| 36621 |
ranu |
1 |
// ============================================================
|
|
|
2 |
// BEAT PLAN APP — Map-First Route Planner
|
|
|
3 |
// ============================================================
|
|
|
4 |
|
|
|
5 |
var map, infoWindow, homeMarker, previewLine;
|
|
|
6 |
var markers = {};
|
|
|
7 |
var routeLines = [];
|
|
|
8 |
var distLabels = [];
|
|
|
9 |
|
|
|
10 |
var state = {
|
|
|
11 |
authUserId: 0,
|
|
|
12 |
categoryId: 4,
|
|
|
13 |
homeLat: null, homeLng: null, homeName: '',
|
|
|
14 |
currentDay: 1,
|
|
|
15 |
days: [], // [{dayNumber, startLat, startLng, startName, visits:[], endAction, stayName, stayLat, stayLng, totalKm, totalMins}]
|
|
|
16 |
partners: [],
|
|
|
17 |
calMonth: null,
|
|
|
18 |
calData: null,
|
|
|
19 |
calSelectedBeat: null,
|
|
|
20 |
calPickedDates: []
|
|
|
21 |
};
|
|
|
22 |
|
|
|
23 |
var VISIT_MINS = 30;
|
|
|
24 |
var AVG_SPEED = 30;
|
|
|
25 |
var DAY_LIMIT = 540;
|
|
|
26 |
var ROAD_FACTOR = 1.3;
|
|
|
27 |
var COLORS = ['#3b82f6', '#ef4444', '#22c55e', '#a855f7', '#f59e0b', '#06b6d4', '#ec4899', '#334155', '#14b8a6', '#dc2626'];
|
|
|
28 |
|
|
|
29 |
// ============ TOP BAR ============
|
|
|
30 |
$('#bp-level').on('change', function () {
|
|
|
31 |
var level = $(this).val();
|
|
|
32 |
$('#bp-auth-user').html('<option value="">Select User</option>');
|
|
|
33 |
if (!level) return;
|
|
|
34 |
|
|
|
35 |
$.ajax({
|
|
|
36 |
url: context + "/beatPlan/getAuthUsers", type: "GET", dataType: "json",
|
|
|
37 |
data: {categoryId: $('#bp-category').val(), escalationType: level},
|
|
|
38 |
success: function (r) {
|
|
|
39 |
var data = r.response || r;
|
|
|
40 |
var html = '<option value="">Select User</option>';
|
|
|
41 |
data.forEach(function (u) {
|
|
|
42 |
html += '<option value="' + u.id + '">' + u.name + '</option>';
|
|
|
43 |
});
|
|
|
44 |
$('#bp-auth-user').html(html);
|
|
|
45 |
}
|
|
|
46 |
});
|
|
|
47 |
});
|
|
|
48 |
|
|
|
49 |
$('#bp-load').on('click', function () {
|
|
|
50 |
var uid = $('#bp-auth-user').val();
|
|
|
51 |
if (!uid) {
|
|
|
52 |
alert('Select a user');
|
|
|
53 |
return;
|
|
|
54 |
}
|
|
|
55 |
state.authUserId = parseInt(uid);
|
|
|
56 |
state.categoryId = parseInt($('#bp-category').val());
|
| 36632 |
ranu |
57 |
state.mode = 'list'; // 'list' or 'create' or 'view'
|
| 36621 |
ranu |
58 |
$('#bp-user-label').text($('#bp-auth-user option:selected').text());
|
|
|
59 |
|
| 36632 |
ranu |
60 |
// Load home location
|
| 36621 |
ranu |
61 |
$.ajax({
|
|
|
62 |
url: context + "/beatPlan/getBaseLocation", type: "GET", dataType: "json",
|
|
|
63 |
data: {authUserId: uid},
|
|
|
64 |
success: function (r) {
|
|
|
65 |
var data = r.response || r;
|
|
|
66 |
if (data.locationName) {
|
|
|
67 |
state.homeLat = parseFloat(data.latitude);
|
|
|
68 |
state.homeLng = parseFloat(data.longitude);
|
|
|
69 |
state.homeName = data.locationName;
|
| 36632 |
ranu |
70 |
}
|
|
|
71 |
showBeatList();
|
|
|
72 |
}
|
|
|
73 |
});
|
| 36621 |
ranu |
74 |
});
|
|
|
75 |
|
| 36632 |
ranu |
76 |
// ============ BEAT LIST VIEW ============
|
|
|
77 |
function showBeatList() {
|
|
|
78 |
state.mode = 'list';
|
|
|
79 |
$('#bottom-bar').hide();
|
|
|
80 |
|
|
|
81 |
// Load all beats for this user
|
|
|
82 |
var now = new Date();
|
|
|
83 |
var month = now.getFullYear() + '-' + ('0' + (now.getMonth() + 1)).slice(-2);
|
|
|
84 |
|
|
|
85 |
$.ajax({
|
|
|
86 |
url: context + "/beatPlan/calendar", type: "GET", dataType: "json",
|
|
|
87 |
data: {authUserId: state.authUserId, month: month},
|
|
|
88 |
success: function (r) {
|
|
|
89 |
var data = r.response || r;
|
|
|
90 |
var beats = data.scheduledBeats || [];
|
|
|
91 |
|
|
|
92 |
var html = '<div style="margin-bottom:10px;">';
|
|
|
93 |
html += '<button id="btn-new-beat" style="width:100%;padding:10px;border:2px dashed #475569;border-radius:8px;background:none;color:#60a5fa;cursor:pointer;font-size:13px;font-weight:600;font-family:inherit;">+ New Beat</button>';
|
|
|
94 |
html += '</div>';
|
|
|
95 |
|
|
|
96 |
if (beats.length === 0) {
|
|
|
97 |
html += '<p style="color:#64748b;font-size:12px;text-align:center;padding:20px;">No beats created yet.</p>';
|
|
|
98 |
}
|
|
|
99 |
|
|
|
100 |
// Deduplicate by beatName
|
|
|
101 |
var seen = {};
|
|
|
102 |
beats.forEach(function (b) {
|
|
|
103 |
var key = b.beatName || b.planGroupId;
|
|
|
104 |
if (!seen[key]) {
|
|
|
105 |
seen[key] = {beat: b, count: 0};
|
|
|
106 |
}
|
|
|
107 |
seen[key].count++;
|
|
|
108 |
});
|
|
|
109 |
|
|
|
110 |
Object.keys(seen).forEach(function (key) {
|
|
|
111 |
var item = seen[key];
|
|
|
112 |
var b = item.beat;
|
|
|
113 |
var totalV = 0, totalKm = 0;
|
|
|
114 |
b.days.forEach(function (d) {
|
|
|
115 |
totalV += d.visitCount || 0;
|
|
|
116 |
totalKm += d.totalKm || 0;
|
|
|
117 |
});
|
|
|
118 |
|
|
|
119 |
var statusCls = b.status === 'running' ? 'status-running' : b.status === 'completed' ? 'status-completed' : 'status-scheduled';
|
|
|
120 |
var statusLabel = b.status === 'running' ? 'Live' : b.status === 'completed' ? 'Done' : b.status === 'unscheduled' ? 'Unscheduled' : 'Scheduled';
|
|
|
121 |
|
|
|
122 |
html += '<div class="beat-card beat-list-item" data-plangroup="' + b.planGroupId + '" style="cursor:pointer;">';
|
|
|
123 |
html += '<div class="beat-name"><span style="display:inline-block;width:12px;height:12px;border-radius:2px;background:' + b.beatColor + ';"></span> ' + b.beatName + ' <span class="status-badge ' + statusCls + '">' + statusLabel + '</span></div>';
|
|
|
124 |
html += '<div class="beat-meta">' + b.days.length + ' days | ' + totalV + ' visits | ' + totalKm.toFixed(0) + ' km';
|
|
|
125 |
if (item.count > 1) html += ' | ' + item.count + 'x scheduled';
|
|
|
126 |
html += '</div>';
|
|
|
127 |
html += '</div>';
|
|
|
128 |
});
|
|
|
129 |
|
|
|
130 |
$('#route-list').html(html);
|
|
|
131 |
}
|
|
|
132 |
});
|
|
|
133 |
}
|
|
|
134 |
|
|
|
135 |
// Click on existing beat → view its route on map
|
|
|
136 |
$(document).on('click', '.beat-list-item', function () {
|
|
|
137 |
var pg = $(this).data('plangroup');
|
|
|
138 |
viewBeat(pg);
|
|
|
139 |
});
|
|
|
140 |
|
|
|
141 |
function viewBeat(planGroupId) {
|
|
|
142 |
state.mode = 'view';
|
|
|
143 |
$('#bottom-bar').hide();
|
|
|
144 |
|
|
|
145 |
// Load partners first (for coordinates), then load beat visits
|
|
|
146 |
$.ajax({
|
|
|
147 |
url: context + "/beatPlan/getPartners", type: "GET", dataType: "json",
|
|
|
148 |
data: {
|
|
|
149 |
authUserId: state.authUserId,
|
|
|
150 |
categoryId: state.categoryId,
|
|
|
151 |
startLat: state.homeLat,
|
|
|
152 |
startLng: state.homeLng
|
|
|
153 |
},
|
|
|
154 |
success: function (r) {
|
|
|
155 |
var pData = r.response || r;
|
|
|
156 |
state.partners = pData.partners || [];
|
|
|
157 |
|
|
|
158 |
// Build partner lookup by fofoId
|
|
|
159 |
var partnerMap = {};
|
|
|
160 |
state.partners.forEach(function (p) {
|
|
|
161 |
partnerMap[p.fofoId] = p;
|
|
|
162 |
});
|
|
|
163 |
|
|
|
164 |
// Load beat visits
|
|
|
165 |
$.ajax({
|
|
|
166 |
url: context + "/beatPlan/calendar", type: "GET", dataType: "json",
|
|
|
167 |
data: {authUserId: state.authUserId, month: '2020-01'},
|
|
|
168 |
success: function (r2) {
|
|
|
169 |
var calData = r2.response || r2;
|
|
|
170 |
var beat = (calData.scheduledBeats || []).find(function (b) {
|
|
|
171 |
return b.planGroupId === planGroupId;
|
|
|
172 |
});
|
|
|
173 |
if (!beat) {
|
|
|
174 |
alert('Beat not found');
|
|
|
175 |
return;
|
|
|
176 |
}
|
|
|
177 |
|
|
|
178 |
// Reconstruct state.days from beat data for map drawing
|
|
|
179 |
// We need actual visit fofoIds — fetch from beat_plan records
|
|
|
180 |
$.ajax({
|
|
|
181 |
url: context + "/beatPlan/getBeatVisits", type: "GET", dataType: "json",
|
|
|
182 |
data: {planGroupId: planGroupId},
|
|
|
183 |
success: function (r3) {
|
|
|
184 |
var visits = (r3.response || r3) || [];
|
|
|
185 |
|
|
|
186 |
// Group visits by dayNumber
|
|
|
187 |
var dayVisitsMap = {};
|
|
|
188 |
visits.forEach(function (v) {
|
|
|
189 |
if (!dayVisitsMap[v.dayNumber]) dayVisitsMap[v.dayNumber] = [];
|
|
|
190 |
dayVisitsMap[v.dayNumber].push(v);
|
|
|
191 |
});
|
|
|
192 |
|
|
|
193 |
// Build days for route display
|
|
|
194 |
state.days = [];
|
|
|
195 |
beat.days.forEach(function (d) {
|
|
|
196 |
var dayVisits = (dayVisitsMap[d.dayNumber] || []).sort(function (a, b) {
|
|
|
197 |
return a.sequenceOrder - b.sequenceOrder;
|
|
|
198 |
});
|
|
|
199 |
var startLat = state.homeLat, startLng = state.homeLng, startName = state.homeName;
|
|
|
200 |
|
|
|
201 |
// If not first day, start from previous day's last visit
|
|
|
202 |
if (state.days.length > 0) {
|
|
|
203 |
var prevDay = state.days[state.days.length - 1];
|
|
|
204 |
if (prevDay.visits.length > 0 && prevDay.endAction === 'DAYBREAK') {
|
|
|
205 |
var lastV = prevDay.visits[prevDay.visits.length - 1];
|
|
|
206 |
startLat = lastV.lat;
|
|
|
207 |
startLng = lastV.lng;
|
|
|
208 |
startName = lastV.code || lastV.name;
|
|
|
209 |
}
|
|
|
210 |
}
|
|
|
211 |
|
|
|
212 |
var builtVisits = [];
|
|
|
213 |
dayVisits.forEach(function (v) {
|
| 36642 |
ranu |
214 |
if (v.visitType === 'lead') {
|
|
|
215 |
// Lead visit — get lead geo from API (sync for simplicity)
|
|
|
216 |
var leadVisit = {
|
|
|
217 |
id: v.fofoId, type: 'lead',
|
|
|
218 |
name: 'LEAD #' + v.fofoId,
|
|
|
219 |
code: 'LEAD',
|
|
|
220 |
lat: null, lng: null,
|
|
|
221 |
isLead: true
|
|
|
222 |
};
|
|
|
223 |
$.ajax({
|
|
|
224 |
url: context + '/lead-geo/check/' + v.fofoId,
|
|
|
225 |
method: 'GET',
|
|
|
226 |
async: false,
|
|
|
227 |
success: function (resp) {
|
|
|
228 |
var geoData = resp.response || resp;
|
|
|
229 |
if (geoData && geoData.hasApprovedGeo) {
|
|
|
230 |
leadVisit.lat = geoData.latitude;
|
|
|
231 |
leadVisit.lng = geoData.longitude;
|
|
|
232 |
}
|
|
|
233 |
}
|
| 36632 |
ranu |
234 |
});
|
| 36642 |
ranu |
235 |
// Also get lead name
|
|
|
236 |
$.ajax({
|
|
|
237 |
url: context + '/getLead?leadId=' + v.fofoId,
|
|
|
238 |
method: 'GET',
|
|
|
239 |
async: false,
|
|
|
240 |
success: function (resp) {
|
|
|
241 |
var lead = resp.response || resp;
|
|
|
242 |
if (lead && lead.firstName) {
|
|
|
243 |
leadVisit.name = 'LEAD - ' + lead.firstName + ' ' + (lead.lastName || '');
|
|
|
244 |
}
|
|
|
245 |
}
|
|
|
246 |
});
|
|
|
247 |
builtVisits.push(leadVisit);
|
|
|
248 |
} else {
|
|
|
249 |
var p = partnerMap[v.fofoId];
|
|
|
250 |
if (p) {
|
|
|
251 |
builtVisits.push({
|
|
|
252 |
id: p.fofoId, type: 'partner',
|
|
|
253 |
name: p.code + ' - ' + (p.outletName || p.businessName || ''),
|
|
|
254 |
code: p.code,
|
|
|
255 |
lat: p.latitude ? parseFloat(p.latitude) : null,
|
|
|
256 |
lng: p.longitude ? parseFloat(p.longitude) : null
|
|
|
257 |
});
|
|
|
258 |
}
|
| 36632 |
ranu |
259 |
}
|
|
|
260 |
});
|
|
|
261 |
|
|
|
262 |
state.days.push({
|
|
|
263 |
dayNumber: d.dayNumber,
|
|
|
264 |
startLat: startLat,
|
|
|
265 |
startLng: startLng,
|
|
|
266 |
startName: startName,
|
|
|
267 |
visits: builtVisits,
|
|
|
268 |
endAction: d.dayNumber < beat.days.length ? 'DAYBREAK' : 'HOME',
|
|
|
269 |
totalKm: d.totalKm || 0,
|
|
|
270 |
totalMins: d.totalMins || 0
|
|
|
271 |
});
|
|
|
272 |
});
|
|
|
273 |
|
|
|
274 |
state.currentDay = 1;
|
|
|
275 |
|
|
|
276 |
// Render left panel
|
|
|
277 |
var html = '<div style="margin-bottom:8px;"><button class="btn-back-to-list" style="font-size:11px;padding:4px 10px;border:1px solid #475569;border-radius:4px;background:none;color:#94a3b8;cursor:pointer;font-family:inherit;">← Back to list</button></div>';
|
|
|
278 |
html += '<div style="font-size:14px;font-weight:600;color:#60a5fa;margin-bottom:10px;">' + beat.beatName + '</div>';
|
|
|
279 |
$('#route-list').html(html);
|
|
|
280 |
|
|
|
281 |
// Use renderRoutePanel for consistent display (but without remove/daybreak buttons)
|
|
|
282 |
state.mode = 'view';
|
|
|
283 |
renderRoutePanel();
|
|
|
284 |
// Prepend the back button + title before route days
|
|
|
285 |
$('#route-list').prepend('<div style="margin-bottom:8px;"><button class="btn-back-to-list" style="font-size:11px;padding:4px 10px;border:1px solid #475569;border-radius:4px;background:none;color:#94a3b8;cursor:pointer;font-family:inherit;">← Back to list</button></div><div style="font-size:14px;font-weight:600;color:#60a5fa;margin-bottom:10px;">' + beat.beatName + '</div>');
|
|
|
286 |
|
|
|
287 |
// Init map with all partners + highlight beat route
|
|
|
288 |
initMap();
|
| 36642 |
ranu |
289 |
// Add lead markers to map
|
|
|
290 |
state.days.forEach(function (d) {
|
|
|
291 |
d.visits.forEach(function (v, i) {
|
|
|
292 |
if (v.isLead && v.lat && v.lng) {
|
|
|
293 |
var m = new google.maps.Marker({
|
|
|
294 |
map: map,
|
|
|
295 |
position: {lat: v.lat, lng: v.lng},
|
|
|
296 |
title: v.name,
|
|
|
297 |
label: {text: 'L', color: '#fff', fontSize: '11px', fontWeight: '700'},
|
|
|
298 |
icon: {
|
|
|
299 |
path: google.maps.SymbolPath.CIRCLE,
|
|
|
300 |
scale: 16,
|
|
|
301 |
fillColor: '#e67e22',
|
|
|
302 |
fillOpacity: 0.95,
|
|
|
303 |
strokeColor: '#fff',
|
|
|
304 |
strokeWeight: 2
|
|
|
305 |
}
|
|
|
306 |
});
|
|
|
307 |
markers[v.id] = m;
|
|
|
308 |
}
|
|
|
309 |
});
|
|
|
310 |
});
|
| 36632 |
ranu |
311 |
resetMarkerColors();
|
|
|
312 |
drawRoute();
|
|
|
313 |
}
|
|
|
314 |
});
|
|
|
315 |
}
|
|
|
316 |
});
|
|
|
317 |
}
|
|
|
318 |
});
|
|
|
319 |
}
|
|
|
320 |
|
|
|
321 |
// Back to list
|
|
|
322 |
$(document).on('click', '.btn-back-to-list', function () {
|
|
|
323 |
showBeatList();
|
|
|
324 |
// Clear map
|
|
|
325 |
if (map) {
|
|
|
326 |
for (var key in markers) {
|
|
|
327 |
markers[key].setMap(null);
|
|
|
328 |
}
|
|
|
329 |
markers = {};
|
|
|
330 |
clearRoute();
|
|
|
331 |
}
|
|
|
332 |
});
|
|
|
333 |
|
|
|
334 |
// New Beat button
|
|
|
335 |
$(document).on('click', '#btn-new-beat', function () {
|
|
|
336 |
var beatTitle = prompt('Enter beat name:', '');
|
|
|
337 |
if (!beatTitle || !beatTitle.trim()) return;
|
|
|
338 |
|
|
|
339 |
state.beatTitle = beatTitle.trim();
|
|
|
340 |
state.mode = 'create';
|
|
|
341 |
|
|
|
342 |
if (!state.homeLat) {
|
|
|
343 |
showHomeModal();
|
|
|
344 |
} else {
|
|
|
345 |
loadPartners();
|
|
|
346 |
}
|
|
|
347 |
});
|
|
|
348 |
|
| 36621 |
ranu |
349 |
// ============ HOME LOCATION MODAL ============
|
|
|
350 |
var homeMap, homeMapMarker;
|
|
|
351 |
|
|
|
352 |
function showHomeModal() {
|
|
|
353 |
$('#modal-home').addClass('show');
|
|
|
354 |
setTimeout(function () {
|
|
|
355 |
if (!homeMap) {
|
|
|
356 |
homeMap = new google.maps.Map(document.getElementById('home-map'), {
|
|
|
357 |
center: {lat: 28.6, lng: 77.2},
|
|
|
358 |
zoom: 6
|
|
|
359 |
});
|
|
|
360 |
var ac = new google.maps.places.Autocomplete(document.getElementById('home-search'), {componentRestrictions: {country: 'in'}});
|
|
|
361 |
homeMapMarker = new google.maps.Marker({map: homeMap, draggable: true});
|
|
|
362 |
|
|
|
363 |
ac.addListener('place_changed', function () {
|
|
|
364 |
var p = ac.getPlace();
|
|
|
365 |
if (!p.geometry) return;
|
|
|
366 |
homeMap.setCenter(p.geometry.location);
|
|
|
367 |
homeMap.setZoom(14);
|
|
|
368 |
homeMapMarker.setPosition(p.geometry.location);
|
|
|
369 |
state.homeLat = p.geometry.location.lat();
|
|
|
370 |
state.homeLng = p.geometry.location.lng();
|
|
|
371 |
state.homeName = p.name || p.formatted_address || '';
|
|
|
372 |
});
|
|
|
373 |
homeMapMarker.addListener('dragend', function () {
|
|
|
374 |
state.homeLat = homeMapMarker.getPosition().lat();
|
|
|
375 |
state.homeLng = homeMapMarker.getPosition().lng();
|
|
|
376 |
});
|
|
|
377 |
homeMap.addListener('click', function (e) {
|
|
|
378 |
homeMapMarker.setPosition(e.latLng);
|
|
|
379 |
state.homeLat = e.latLng.lat();
|
|
|
380 |
state.homeLng = e.latLng.lng();
|
|
|
381 |
});
|
|
|
382 |
}
|
|
|
383 |
}, 200);
|
|
|
384 |
}
|
|
|
385 |
|
|
|
386 |
$('#home-cancel').on('click', function () {
|
|
|
387 |
$('#modal-home').removeClass('show');
|
|
|
388 |
});
|
|
|
389 |
$('#home-confirm').on('click', function () {
|
|
|
390 |
if (!state.homeLat) {
|
|
|
391 |
alert('Please select a location');
|
|
|
392 |
return;
|
|
|
393 |
}
|
|
|
394 |
$('#modal-home').removeClass('show');
|
|
|
395 |
$.ajax({
|
|
|
396 |
url: context + "/beatPlan/saveBaseLocation", type: "POST",
|
|
|
397 |
data: {
|
|
|
398 |
authUserId: state.authUserId,
|
|
|
399 |
locationName: state.homeName || 'Home',
|
|
|
400 |
latitude: state.homeLat,
|
|
|
401 |
longitude: state.homeLng,
|
|
|
402 |
address: $('#home-search').val()
|
|
|
403 |
}
|
|
|
404 |
});
|
|
|
405 |
loadPartners();
|
|
|
406 |
});
|
|
|
407 |
|
|
|
408 |
// ============ LOAD PARTNERS + INIT MAP ============
|
|
|
409 |
function loadPartners() {
|
|
|
410 |
$.ajax({
|
|
|
411 |
url: context + "/beatPlan/getPartners", type: "GET", dataType: "json",
|
|
|
412 |
data: {
|
|
|
413 |
authUserId: state.authUserId,
|
|
|
414 |
categoryId: state.categoryId,
|
|
|
415 |
startLat: state.homeLat,
|
|
|
416 |
startLng: state.homeLng
|
|
|
417 |
},
|
|
|
418 |
success: function (r) {
|
|
|
419 |
var data = r.response || r;
|
|
|
420 |
state.partners = data.partners || [];
|
|
|
421 |
initDay1();
|
|
|
422 |
initMap();
|
|
|
423 |
}
|
|
|
424 |
});
|
|
|
425 |
}
|
|
|
426 |
|
|
|
427 |
function initDay1() {
|
|
|
428 |
state.days = [{
|
|
|
429 |
dayNumber: 1, startLat: state.homeLat, startLng: state.homeLng, startName: state.homeName,
|
|
|
430 |
visits: [], endAction: null, stayName: null, stayLat: null, stayLng: null, totalKm: 0, totalMins: 0
|
|
|
431 |
}];
|
|
|
432 |
state.currentDay = 1;
|
| 36632 |
ranu |
433 |
if (state.mode === 'create') $('#bottom-bar').show();
|
| 36621 |
ranu |
434 |
renderRoutePanel();
|
|
|
435 |
}
|
|
|
436 |
|
|
|
437 |
function initMap() {
|
|
|
438 |
var center = {lat: state.homeLat, lng: state.homeLng};
|
|
|
439 |
map = new google.maps.Map(document.getElementById('beat-map'), {
|
|
|
440 |
center: center, zoom: 9,
|
|
|
441 |
styles: [
|
|
|
442 |
{elementType: 'geometry', stylers: [{color: '#1d2c4d'}]},
|
|
|
443 |
{elementType: 'labels.text.fill', stylers: [{color: '#8ec3b9'}]},
|
|
|
444 |
{elementType: 'labels.text.stroke', stylers: [{color: '#1a3646'}]},
|
|
|
445 |
{featureType: 'road', elementType: 'geometry', stylers: [{color: '#304a7d'}]},
|
|
|
446 |
{featureType: 'water', elementType: 'geometry', stylers: [{color: '#0e1626'}]}
|
|
|
447 |
]
|
|
|
448 |
});
|
|
|
449 |
infoWindow = new google.maps.InfoWindow();
|
|
|
450 |
|
|
|
451 |
// Home marker
|
|
|
452 |
homeMarker = new google.maps.Marker({
|
|
|
453 |
map: map, position: center, title: 'Home: ' + state.homeName,
|
|
|
454 |
icon: {
|
|
|
455 |
url: 'https://maps.google.com/mapfiles/kml/shapes/homegardenbusiness.png',
|
|
|
456 |
scaledSize: new google.maps.Size(32, 32)
|
|
|
457 |
},
|
|
|
458 |
zIndex: 100
|
|
|
459 |
});
|
|
|
460 |
|
|
|
461 |
// Partner markers with name labels
|
|
|
462 |
for (var key in markers) {
|
|
|
463 |
markers[key].setMap(null);
|
|
|
464 |
}
|
|
|
465 |
markers = {};
|
|
|
466 |
|
|
|
467 |
state.partners.forEach(function (p) {
|
|
|
468 |
if (!p.latitude || !p.longitude) return;
|
|
|
469 |
var lat = parseFloat(p.latitude), lng = parseFloat(p.longitude);
|
|
|
470 |
if (isNaN(lat) || isNaN(lng)) return;
|
|
|
471 |
|
|
|
472 |
var m = new google.maps.Marker({
|
|
|
473 |
map: map, position: {lat: lat, lng: lng},
|
|
|
474 |
title: p.code + ' - ' + (p.outletName || p.businessName || ''),
|
|
|
475 |
label: {text: p.code || '', color: '#fff', fontSize: '10px', fontWeight: '600'},
|
|
|
476 |
icon: {
|
|
|
477 |
path: google.maps.SymbolPath.CIRCLE,
|
|
|
478 |
scale: 14,
|
|
|
479 |
fillColor: '#ef4444',
|
|
|
480 |
fillOpacity: 0.9,
|
|
|
481 |
strokeColor: '#fff',
|
|
|
482 |
strokeWeight: 1
|
|
|
483 |
}
|
|
|
484 |
});
|
|
|
485 |
|
|
|
486 |
m.addListener('click', function () {
|
|
|
487 |
showMarkerPopup(p, m);
|
|
|
488 |
});
|
|
|
489 |
m.addListener('mouseover', function () {
|
|
|
490 |
showPreviewLine(p);
|
|
|
491 |
});
|
|
|
492 |
m.addListener('mouseout', function () {
|
|
|
493 |
hidePreviewLine();
|
|
|
494 |
});
|
|
|
495 |
|
|
|
496 |
markers[p.fofoId] = m;
|
|
|
497 |
});
|
|
|
498 |
|
|
|
499 |
fitBounds();
|
|
|
500 |
}
|
|
|
501 |
|
|
|
502 |
// ============ HOVER PREVIEW ============
|
|
|
503 |
function showPreviewLine(partner) {
|
|
|
504 |
if (!partner.latitude || !partner.longitude) return;
|
|
|
505 |
var day = curDay();
|
|
|
506 |
var lastPt = day.visits.length > 0
|
|
|
507 |
? {lat: day.visits[day.visits.length - 1].lat, lng: day.visits[day.visits.length - 1].lng}
|
|
|
508 |
: {lat: day.startLat, lng: day.startLng};
|
|
|
509 |
if (!lastPt.lat) return;
|
|
|
510 |
|
|
|
511 |
var endPt = {lat: parseFloat(partner.latitude), lng: parseFloat(partner.longitude)};
|
|
|
512 |
if (previewLine) previewLine.setMap(null);
|
|
|
513 |
previewLine = new google.maps.Polyline({
|
|
|
514 |
path: [lastPt, endPt],
|
|
|
515 |
geodesic: true,
|
|
|
516 |
strokeColor: '#60a5fa',
|
|
|
517 |
strokeOpacity: 0.5,
|
|
|
518 |
strokeWeight: 2,
|
|
|
519 |
strokePattern: [10, 5],
|
|
|
520 |
icons: [{icon: {path: 'M 0,-1 0,1', strokeOpacity: 1, scale: 3}, offset: '0', repeat: '15px'}],
|
|
|
521 |
map: map
|
|
|
522 |
});
|
|
|
523 |
}
|
|
|
524 |
|
|
|
525 |
function hidePreviewLine() {
|
|
|
526 |
if (previewLine) {
|
|
|
527 |
previewLine.setMap(null);
|
|
|
528 |
previewLine = null;
|
|
|
529 |
}
|
|
|
530 |
}
|
|
|
531 |
|
|
|
532 |
// ============ MARKER CLICK POPUP ============
|
|
|
533 |
function showMarkerPopup(partner, marker) {
|
|
|
534 |
var day = curDay();
|
|
|
535 |
var alreadyAdded = day.visits.some(function (v) {
|
|
|
536 |
return v.id === partner.fofoId;
|
|
|
537 |
});
|
|
|
538 |
if (alreadyAdded) {
|
|
|
539 |
infoWindow.setContent('<div class="marker-popup"><h4>' + partner.code + '</h4><p>Already in today\'s route</p></div>');
|
|
|
540 |
infoWindow.open(map, marker);
|
|
|
541 |
return;
|
|
|
542 |
}
|
|
|
543 |
|
|
|
544 |
var lastPt = day.visits.length > 0
|
|
|
545 |
? {lat: day.visits[day.visits.length - 1].lat, lng: day.visits[day.visits.length - 1].lng}
|
|
|
546 |
: {lat: day.startLat, lng: day.startLng};
|
|
|
547 |
|
|
|
548 |
var dist = 0, travelMins = 0;
|
|
|
549 |
if (lastPt.lat && partner.latitude) {
|
|
|
550 |
dist = haversine(lastPt.lat, lastPt.lng, parseFloat(partner.latitude), parseFloat(partner.longitude)) * ROAD_FACTOR;
|
|
|
551 |
travelMins = (dist / AVG_SPEED) * 60;
|
|
|
552 |
}
|
|
|
553 |
|
|
|
554 |
var name = partner.outletName || partner.businessName || '';
|
|
|
555 |
var addr = partner.address || '';
|
|
|
556 |
|
|
|
557 |
var html = '<div class="marker-popup">';
|
|
|
558 |
html += '<h4>' + partner.code + ' - ' + name + '</h4>';
|
|
|
559 |
html += '<div class="popup-meta">' + addr + '</div>';
|
|
|
560 |
html += '<div class="popup-distance">~' + dist.toFixed(1) + ' km | ~' + fmtMins(travelMins) + ' travel + ' + VISIT_MINS + 'min visit</div>';
|
|
|
561 |
html += '<button class="popup-next" onclick="addVisit(' + partner.fofoId + ',\'next\')">Next Visit</button>';
|
|
|
562 |
html += '<button class="popup-last" onclick="addVisit(' + partner.fofoId + ',\'last\')">Last Visit</button>';
|
|
|
563 |
html += '</div>';
|
|
|
564 |
|
|
|
565 |
infoWindow.setContent(html);
|
|
|
566 |
infoWindow.open(map, marker);
|
|
|
567 |
}
|
|
|
568 |
|
|
|
569 |
// ============ ADD VISIT ============
|
|
|
570 |
function addVisit(fofoId, action) {
|
|
|
571 |
infoWindow.close();
|
|
|
572 |
var p = state.partners.find(function (x) {
|
|
|
573 |
return x.fofoId === fofoId;
|
|
|
574 |
});
|
|
|
575 |
if (!p) return;
|
|
|
576 |
|
|
|
577 |
var day = curDay();
|
|
|
578 |
var visit = {
|
|
|
579 |
id: p.fofoId, type: 'partner',
|
|
|
580 |
name: p.code + ' - ' + (p.outletName || p.businessName || ''),
|
|
|
581 |
code: p.code,
|
|
|
582 |
lat: p.latitude ? parseFloat(p.latitude) : null,
|
|
|
583 |
lng: p.longitude ? parseFloat(p.longitude) : null
|
|
|
584 |
};
|
|
|
585 |
|
|
|
586 |
day.visits.push(visit);
|
|
|
587 |
|
|
|
588 |
// Update marker to green
|
|
|
589 |
if (markers[fofoId]) {
|
|
|
590 |
markers[fofoId].setIcon({
|
|
|
591 |
path: google.maps.SymbolPath.CIRCLE, scale: 14,
|
|
|
592 |
fillColor: '#22c55e', fillOpacity: 0.9, strokeColor: '#fff', strokeWeight: 2
|
|
|
593 |
});
|
|
|
594 |
markers[fofoId].setLabel({text: '' + day.visits.length, color: '#fff', fontSize: '11px', fontWeight: '700'});
|
|
|
595 |
}
|
|
|
596 |
|
|
|
597 |
recalcDay();
|
|
|
598 |
drawRoute();
|
|
|
599 |
renderRoutePanel();
|
|
|
600 |
|
|
|
601 |
if (action === 'last') {
|
|
|
602 |
// Last visit — end day, go home
|
|
|
603 |
day.endAction = 'HOME';
|
|
|
604 |
day.stayLat = state.homeLat;
|
|
|
605 |
day.stayLng = state.homeLng;
|
|
|
606 |
day.stayName = state.homeName;
|
|
|
607 |
drawReturnHome();
|
|
|
608 |
renderRoutePanel();
|
|
|
609 |
} else if (action === 'nextday') {
|
|
|
610 |
// Continue next day — end current day, start new day from here
|
|
|
611 |
day.endAction = 'CONTINUE';
|
|
|
612 |
day.stayLat = visit.lat;
|
|
|
613 |
day.stayLng = visit.lng;
|
|
|
614 |
day.stayName = visit.name;
|
|
|
615 |
startNextDay(visit.lat, visit.lng, visit.name);
|
|
|
616 |
}
|
|
|
617 |
// 'next' — just added, continue on same day
|
|
|
618 |
}
|
|
|
619 |
|
|
|
620 |
function drawReturnHome() {
|
|
|
621 |
var day = curDay();
|
|
|
622 |
if (day.visits.length === 0) return;
|
|
|
623 |
var lastVisit = day.visits[day.visits.length - 1];
|
|
|
624 |
if (!lastVisit.lat || !state.homeLat) return;
|
|
|
625 |
|
|
|
626 |
var line = new google.maps.Polyline({
|
|
|
627 |
path: [{lat: lastVisit.lat, lng: lastVisit.lng}, {lat: state.homeLat, lng: state.homeLng}],
|
|
|
628 |
geodesic: true, strokeColor: '#22c55e', strokeOpacity: 0.6, strokeWeight: 2,
|
|
|
629 |
icons: [{icon: {path: 'M 0,-1 0,1', strokeOpacity: 1, scale: 3}, offset: '0', repeat: '15px'}],
|
|
|
630 |
map: map
|
|
|
631 |
});
|
|
|
632 |
routeLines.push(line);
|
|
|
633 |
|
|
|
634 |
// Add return distance to day total
|
|
|
635 |
var retDist = haversine(lastVisit.lat, lastVisit.lng, state.homeLat, state.homeLng) * ROAD_FACTOR;
|
|
|
636 |
day.totalKm += retDist;
|
|
|
637 |
day.totalMins += (retDist / AVG_SPEED) * 60;
|
|
|
638 |
updateBottomBar();
|
|
|
639 |
}
|
|
|
640 |
|
|
|
641 |
function startNextDay(startLat, startLng, startName) {
|
|
|
642 |
var nextNum = state.days.length + 1;
|
|
|
643 |
state.days.push({
|
|
|
644 |
dayNumber: nextNum, startLat: startLat, startLng: startLng, startName: startName,
|
|
|
645 |
visits: [], endAction: null, stayName: null, stayLat: null, stayLng: null, totalKm: 0, totalMins: 0
|
|
|
646 |
});
|
|
|
647 |
state.currentDay = nextNum;
|
|
|
648 |
updateBottomBar();
|
|
|
649 |
renderRoutePanel();
|
|
|
650 |
// Reset marker colors for unvisited
|
|
|
651 |
resetMarkerColors();
|
|
|
652 |
}
|
|
|
653 |
|
|
|
654 |
function resetMarkerColors() {
|
|
|
655 |
var allVisited = {};
|
|
|
656 |
state.days.forEach(function (d) {
|
|
|
657 |
d.visits.forEach(function (v) {
|
|
|
658 |
allVisited[v.id] = true;
|
|
|
659 |
});
|
|
|
660 |
});
|
|
|
661 |
|
|
|
662 |
state.partners.forEach(function (p) {
|
|
|
663 |
if (!markers[p.fofoId]) return;
|
|
|
664 |
if (allVisited[p.fofoId]) {
|
|
|
665 |
markers[p.fofoId].setIcon({
|
|
|
666 |
path: google.maps.SymbolPath.CIRCLE,
|
|
|
667 |
scale: 14,
|
|
|
668 |
fillColor: '#22c55e',
|
|
|
669 |
fillOpacity: 0.9,
|
|
|
670 |
strokeColor: '#fff',
|
|
|
671 |
strokeWeight: 1
|
|
|
672 |
});
|
|
|
673 |
} else {
|
|
|
674 |
markers[p.fofoId].setIcon({
|
|
|
675 |
path: google.maps.SymbolPath.CIRCLE,
|
|
|
676 |
scale: 14,
|
|
|
677 |
fillColor: '#ef4444',
|
|
|
678 |
fillOpacity: 0.9,
|
|
|
679 |
strokeColor: '#fff',
|
|
|
680 |
strokeWeight: 1
|
|
|
681 |
});
|
|
|
682 |
markers[p.fofoId].setLabel({text: p.code || '', color: '#fff', fontSize: '10px', fontWeight: '600'});
|
|
|
683 |
}
|
|
|
684 |
});
|
|
|
685 |
|
|
|
686 |
// Re-label current day visits
|
|
|
687 |
var day = curDay();
|
|
|
688 |
day.visits.forEach(function (v, i) {
|
|
|
689 |
if (markers[v.id]) markers[v.id].setLabel({
|
|
|
690 |
text: '' + (i + 1),
|
|
|
691 |
color: '#fff',
|
|
|
692 |
fontSize: '11px',
|
|
|
693 |
fontWeight: '700'
|
|
|
694 |
});
|
|
|
695 |
});
|
|
|
696 |
}
|
|
|
697 |
|
|
|
698 |
// ============ ROUTE DRAWING ============
|
|
|
699 |
function clearRoute() {
|
|
|
700 |
routeLines.forEach(function (l) {
|
|
|
701 |
l.setMap(null);
|
|
|
702 |
});
|
|
|
703 |
routeLines = [];
|
|
|
704 |
distLabels.forEach(function (l) {
|
|
|
705 |
l.setMap(null);
|
|
|
706 |
});
|
|
|
707 |
distLabels = [];
|
|
|
708 |
}
|
|
|
709 |
|
| 36632 |
ranu |
710 |
var DAY_ROUTE_COLORS = ['#3b82f6', '#a855f7', '#06b6d4', '#ec4899', '#f59e0b', '#14b8a6'];
|
|
|
711 |
|
| 36621 |
ranu |
712 |
function drawRoute() {
|
|
|
713 |
clearRoute();
|
|
|
714 |
|
| 36632 |
ranu |
715 |
// Draw routes for ALL days, not just current
|
|
|
716 |
state.days.forEach(function (day, dayIdx) {
|
|
|
717 |
var isCurrent = day.dayNumber === state.currentDay;
|
|
|
718 |
var routeColor = DAY_ROUTE_COLORS[dayIdx % DAY_ROUTE_COLORS.length];
|
|
|
719 |
var opacity = isCurrent ? 0.9 : 0.5;
|
|
|
720 |
var weight = isCurrent ? 3 : 2;
|
| 36621 |
ranu |
721 |
|
| 36632 |
ranu |
722 |
var pts = [{lat: day.startLat, lng: day.startLng}];
|
|
|
723 |
day.visits.forEach(function (v) {
|
|
|
724 |
if (v.lat && v.lng) pts.push({lat: v.lat, lng: v.lng});
|
|
|
725 |
});
|
|
|
726 |
|
|
|
727 |
// Draw visit route lines for this day
|
|
|
728 |
for (var i = 0; i < pts.length - 1; i++) {
|
|
|
729 |
var line = new google.maps.Polyline({
|
|
|
730 |
path: [pts[i], pts[i + 1]], geodesic: true,
|
|
|
731 |
strokeColor: routeColor, strokeOpacity: opacity, strokeWeight: weight, map: map
|
|
|
732 |
});
|
|
|
733 |
routeLines.push(line);
|
|
|
734 |
|
|
|
735 |
// Only show distance labels on current day
|
|
|
736 |
if (isCurrent) {
|
|
|
737 |
var d = haversine(pts[i].lat, pts[i].lng, pts[i + 1].lat, pts[i + 1].lng) * ROAD_FACTOR;
|
|
|
738 |
var mid = {lat: (pts[i].lat + pts[i + 1].lat) / 2, lng: (pts[i].lng + pts[i + 1].lng) / 2};
|
|
|
739 |
var lbl = new google.maps.Marker({
|
|
|
740 |
position: mid, map: map, icon: {path: google.maps.SymbolPath.CIRCLE, scale: 0},
|
|
|
741 |
label: {text: d.toFixed(1) + 'km', color: '#93c5fd', fontSize: '10px', fontWeight: '600'}
|
|
|
742 |
});
|
|
|
743 |
distLabels.push(lbl);
|
|
|
744 |
}
|
|
|
745 |
}
|
|
|
746 |
|
|
|
747 |
// Draw return-to-home line (not on DAYBREAK days)
|
|
|
748 |
if (day.visits.length > 0 && day.endAction !== 'DAYBREAK') {
|
|
|
749 |
var lastV = day.visits[day.visits.length - 1];
|
|
|
750 |
if (lastV.lat && lastV.lng && state.homeLat && state.homeLng) {
|
|
|
751 |
var retLine = new google.maps.Polyline({
|
|
|
752 |
path: [{lat: lastV.lat, lng: lastV.lng}, {lat: state.homeLat, lng: state.homeLng}],
|
|
|
753 |
geodesic: true, strokeColor: '#22c55e', strokeOpacity: isCurrent ? 0.4 : 0.2, strokeWeight: 2,
|
|
|
754 |
icons: [{icon: {path: 'M 0,-1 0,1', strokeOpacity: 1, scale: 3}, offset: '0', repeat: '15px'}],
|
|
|
755 |
map: map
|
|
|
756 |
});
|
|
|
757 |
routeLines.push(retLine);
|
|
|
758 |
}
|
|
|
759 |
}
|
|
|
760 |
});
|
| 36621 |
ranu |
761 |
}
|
|
|
762 |
|
|
|
763 |
function recalcDay() {
|
|
|
764 |
var day = curDay();
|
|
|
765 |
var pts = [{lat: day.startLat, lng: day.startLng}];
|
|
|
766 |
day.visits.forEach(function (v) {
|
|
|
767 |
if (v.lat && v.lng) pts.push({lat: v.lat, lng: v.lng});
|
|
|
768 |
});
|
|
|
769 |
|
|
|
770 |
var km = 0;
|
|
|
771 |
for (var i = 0; i < pts.length - 1; i++) km += haversine(pts[i].lat, pts[i].lng, pts[i + 1].lat, pts[i + 1].lng) * ROAD_FACTOR;
|
|
|
772 |
day.totalKm = km;
|
|
|
773 |
day.totalMins = (km / AVG_SPEED) * 60 + day.visits.length * VISIT_MINS;
|
|
|
774 |
updateBottomBar();
|
|
|
775 |
}
|
|
|
776 |
|
|
|
777 |
function updateBottomBar() {
|
|
|
778 |
var day = curDay();
|
|
|
779 |
$('#bb-day-label').text('Day ' + day.dayNumber);
|
|
|
780 |
$('#bb-stops').text(day.visits.length + ' stops');
|
|
|
781 |
$('#bb-distance').text(day.totalKm.toFixed(1) + ' km');
|
|
|
782 |
$('#bb-time').text(fmtMins(day.totalMins));
|
|
|
783 |
|
|
|
784 |
var pct = Math.min((day.totalMins / DAY_LIMIT) * 100, 100);
|
|
|
785 |
var col = pct < 78 ? '#22c55e' : pct < 89 ? '#f59e0b' : '#ef4444';
|
|
|
786 |
$('#bb-progress').css({width: pct + '%', background: col});
|
|
|
787 |
}
|
|
|
788 |
|
|
|
789 |
// ============ ROUTE PANEL ============
|
|
|
790 |
function renderRoutePanel() {
|
|
|
791 |
var html = '';
|
| 36632 |
ranu |
792 |
if (state.mode === 'create' && state.beatTitle) {
|
|
|
793 |
html += '<div style="margin-bottom:8px;"><button class="btn-back-to-list" style="font-size:11px;padding:4px 10px;border:1px solid #475569;border-radius:4px;background:none;color:#94a3b8;cursor:pointer;font-family:inherit;">← Back</button></div>';
|
|
|
794 |
html += '<div style="font-size:14px;font-weight:600;color:#60a5fa;margin-bottom:10px;">' + state.beatTitle + '</div>';
|
|
|
795 |
}
|
| 36621 |
ranu |
796 |
state.days.forEach(function (day) {
|
|
|
797 |
var isCurrent = day.dayNumber === state.currentDay;
|
| 36632 |
ranu |
798 |
var isLastDay = day.dayNumber === state.days.length;
|
| 36621 |
ranu |
799 |
html += '<div class="route-day">';
|
|
|
800 |
html += '<div class="route-day-header">';
|
|
|
801 |
html += '<span>Day ' + day.dayNumber + (isCurrent ? ' (current)' : '') + '</span>';
|
|
|
802 |
html += '<span>' + fmtMins(day.totalMins) + '</span>';
|
|
|
803 |
html += '</div>';
|
|
|
804 |
|
|
|
805 |
// Start point
|
| 36632 |
ranu |
806 |
html += '<div class="route-stop route-stop-home"><span class="stop-num">H</span><div class="stop-info"><div class="stop-name">' + (day.startName || 'Home') + '</div></div></div>';
|
| 36621 |
ranu |
807 |
|
|
|
808 |
// Visits with distance between each
|
|
|
809 |
var prevLat = day.startLat, prevLng = day.startLng;
|
| 36632 |
ranu |
810 |
var isLastStop;
|
| 36621 |
ranu |
811 |
day.visits.forEach(function (v, i) {
|
| 36632 |
ranu |
812 |
isLastStop = (i === day.visits.length - 1);
|
|
|
813 |
|
| 36621 |
ranu |
814 |
var segDist = 0, segTime = 0;
|
|
|
815 |
if (prevLat && prevLng && v.lat && v.lng) {
|
|
|
816 |
segDist = haversine(prevLat, prevLng, v.lat, v.lng) * ROAD_FACTOR;
|
|
|
817 |
segTime = (segDist / AVG_SPEED) * 60;
|
|
|
818 |
}
|
|
|
819 |
if (segDist > 0) {
|
|
|
820 |
html += '<div class="route-segment"><span class="seg-line"></span><span class="seg-dist">' + segDist.toFixed(1) + ' km | ' + fmtMins(segTime) + '</span></div>';
|
|
|
821 |
}
|
|
|
822 |
|
| 36642 |
ranu |
823 |
var isLeadVisit = v.isLead || v.type === 'lead';
|
|
|
824 |
html += '<div class="route-stop route-stop-clickable" data-fofoid="' + v.id + '" data-daynum="' + day.dayNumber + '" style="cursor:pointer;' + (isLeadVisit ? 'background:#fff3cd;border-left:3px solid #e67e22;' : '') + '">';
|
|
|
825 |
html += '<span class="stop-num" style="' + (isLeadVisit ? 'background:#e67e22;' : '') + '">' + (i + 1) + '</span>';
|
|
|
826 |
html += '<div class="stop-info"><div class="stop-name">' + (v.code || '') + (isLeadVisit ? ' <span style="color:#e67e22;font-size:10px;">LEAD VISIT</span>' : '') + '</div>';
|
| 36632 |
ranu |
827 |
html += '<div class="stop-meta">' + (v.name || '') + '</div>';
|
|
|
828 |
|
|
|
829 |
// Show "Last Visit" + "Day Break" buttons on last stop of current day
|
|
|
830 |
if (isLastStop && isCurrent && !day.endAction) {
|
|
|
831 |
html += '<div style="margin-top:4px;display:flex;gap:4px;">';
|
|
|
832 |
html += '<button class="btn-last-visit" data-daynum="' + day.dayNumber + '" style="font-size:10px;padding:2px 8px;border:none;border-radius:3px;background:#22c55e;color:#fff;cursor:pointer;font-weight:600;">Last Visit</button>';
|
|
|
833 |
html += '<button class="btn-day-break" data-daynum="' + day.dayNumber + '" style="font-size:10px;padding:2px 8px;border:none;border-radius:3px;background:#f59e0b;color:#0f172a;cursor:pointer;font-weight:600;">Day Break</button>';
|
|
|
834 |
html += '</div>';
|
|
|
835 |
}
|
|
|
836 |
|
|
|
837 |
html += '</div>';
|
|
|
838 |
if (state.mode === 'create') {
|
|
|
839 |
html += '<span class="stop-remove" data-fofoid="' + v.id + '" data-daynum="' + day.dayNumber + '" title="Remove">×</span>';
|
|
|
840 |
}
|
| 36621 |
ranu |
841 |
html += '</div>';
|
|
|
842 |
|
|
|
843 |
prevLat = v.lat;
|
|
|
844 |
prevLng = v.lng;
|
|
|
845 |
});
|
|
|
846 |
|
| 36632 |
ranu |
847 |
// Show return-to-home only when day is complete (Last Visit) or still in progress
|
|
|
848 |
if (day.visits.length > 0 && day.endAction !== 'DAYBREAK') {
|
| 36621 |
ranu |
849 |
var lastV = day.visits[day.visits.length - 1];
|
|
|
850 |
var retDist = 0, retTime = 0;
|
|
|
851 |
if (lastV.lat && lastV.lng && state.homeLat && state.homeLng) {
|
|
|
852 |
retDist = haversine(lastV.lat, lastV.lng, state.homeLat, state.homeLng) * ROAD_FACTOR;
|
|
|
853 |
retTime = (retDist / AVG_SPEED) * 60;
|
|
|
854 |
}
|
|
|
855 |
if (retDist > 0) {
|
| 36632 |
ranu |
856 |
html += '<div class="route-segment"><span class="seg-line seg-line-home"></span><span class="seg-dist">' + retDist.toFixed(1) + ' km | ' + fmtMins(retTime) + ' (return)</span></div>';
|
| 36621 |
ranu |
857 |
}
|
|
|
858 |
html += '<div class="route-stop route-stop-home"><span class="stop-num">H</span><div class="stop-info"><div class="stop-name">Home</div></div></div>';
|
|
|
859 |
}
|
|
|
860 |
|
| 36632 |
ranu |
861 |
// Day break indicator
|
|
|
862 |
if (day.endAction === 'DAYBREAK' && day.visits.length > 0) {
|
|
|
863 |
html += '<div style="margin-left:12px;padding:4px 8px;font-size:11px;color:#f59e0b;font-weight:600;">-- Day Break (stays near last visit) --</div>';
|
|
|
864 |
}
|
|
|
865 |
|
| 36621 |
ranu |
866 |
html += '<div class="day-summary">' + day.visits.length + ' stops | ' + day.totalKm.toFixed(1) + ' km</div>';
|
|
|
867 |
html += '</div>';
|
|
|
868 |
});
|
|
|
869 |
|
|
|
870 |
$('#route-list').html(html);
|
|
|
871 |
}
|
|
|
872 |
|
|
|
873 |
// Click on stop name in route panel → show popup on map
|
|
|
874 |
$(document).on('click', '.route-stop-clickable', function (e) {
|
|
|
875 |
if ($(e.target).hasClass('stop-remove')) return; // let remove handler handle it
|
|
|
876 |
var fofoId = parseInt($(this).data('fofoid'));
|
|
|
877 |
if (!fofoId || !markers[fofoId]) return;
|
|
|
878 |
|
|
|
879 |
var marker = markers[fofoId];
|
|
|
880 |
var p = state.partners.find(function (x) {
|
|
|
881 |
return x.fofoId === fofoId;
|
|
|
882 |
});
|
|
|
883 |
if (!p) return;
|
|
|
884 |
|
|
|
885 |
map.panTo(marker.getPosition());
|
|
|
886 |
map.setZoom(12);
|
|
|
887 |
|
|
|
888 |
var name = p.outletName || p.businessName || '';
|
|
|
889 |
var addr = p.address || '';
|
|
|
890 |
infoWindow.setContent('<div class="marker-popup"><h4>' + p.code + ' - ' + name + '</h4><div class="popup-meta">' + addr + '</div></div>');
|
|
|
891 |
infoWindow.open(map, marker);
|
|
|
892 |
});
|
|
|
893 |
|
|
|
894 |
// Remove stop from route panel
|
|
|
895 |
$(document).on('click', '.stop-remove', function (e) {
|
|
|
896 |
e.stopPropagation();
|
|
|
897 |
var fofoId = parseInt($(this).data('fofoid'));
|
|
|
898 |
var dayNum = parseInt($(this).data('daynum'));
|
|
|
899 |
|
|
|
900 |
var day = state.days[dayNum - 1];
|
|
|
901 |
if (!day) return;
|
|
|
902 |
|
|
|
903 |
// Remove visit from day
|
|
|
904 |
day.visits = day.visits.filter(function (v) {
|
|
|
905 |
return v.id !== fofoId;
|
|
|
906 |
});
|
|
|
907 |
|
|
|
908 |
// Reset marker back to red (unvisited)
|
|
|
909 |
if (markers[fofoId]) {
|
|
|
910 |
var p = state.partners.find(function (x) {
|
|
|
911 |
return x.fofoId === fofoId;
|
|
|
912 |
});
|
|
|
913 |
markers[fofoId].setIcon({
|
|
|
914 |
path: google.maps.SymbolPath.CIRCLE, scale: 14,
|
|
|
915 |
fillColor: '#ef4444', fillOpacity: 0.9, strokeColor: '#fff', strokeWeight: 1
|
|
|
916 |
});
|
|
|
917 |
markers[fofoId].setLabel({text: (p ? p.code : '') || '', color: '#fff', fontSize: '10px', fontWeight: '600'});
|
|
|
918 |
}
|
|
|
919 |
|
|
|
920 |
// Re-number remaining markers for this day
|
|
|
921 |
day.visits.forEach(function (v, i) {
|
|
|
922 |
if (markers[v.id]) {
|
|
|
923 |
markers[v.id].setLabel({text: '' + (i + 1), color: '#fff', fontSize: '11px', fontWeight: '700'});
|
|
|
924 |
}
|
|
|
925 |
});
|
|
|
926 |
|
|
|
927 |
recalcDay();
|
|
|
928 |
drawRoute();
|
|
|
929 |
renderRoutePanel();
|
|
|
930 |
updateBottomBar();
|
|
|
931 |
});
|
|
|
932 |
|
| 36632 |
ranu |
933 |
// ============ LAST VISIT (from sidebar) — marks beat as complete, return home ============
|
|
|
934 |
$(document).on('click', '.btn-last-visit', function (e) {
|
|
|
935 |
e.stopPropagation();
|
|
|
936 |
var dayNum = parseInt($(this).data('daynum'));
|
|
|
937 |
var day = state.days[dayNum - 1];
|
|
|
938 |
if (!day || day.visits.length === 0) return;
|
| 36621 |
ranu |
939 |
|
| 36632 |
ranu |
940 |
var lastVisit = day.visits[day.visits.length - 1];
|
|
|
941 |
if (!confirm('Mark ' + (lastVisit.code || lastVisit.name) + ' as last visit? Beat will end here and return home.')) return;
|
| 36621 |
ranu |
942 |
|
| 36632 |
ranu |
943 |
day.endAction = 'HOME';
|
|
|
944 |
drawRoute();
|
|
|
945 |
renderRoutePanel();
|
|
|
946 |
updateBottomBar();
|
| 36621 |
ranu |
947 |
});
|
|
|
948 |
|
| 36632 |
ranu |
949 |
// ============ DAY BREAK (from sidebar) — end day, next day starts from last visit ============
|
|
|
950 |
$(document).on('click', '.btn-day-break', function (e) {
|
|
|
951 |
e.stopPropagation();
|
|
|
952 |
var dayNum = parseInt($(this).data('daynum'));
|
|
|
953 |
var day = state.days[dayNum - 1];
|
|
|
954 |
if (!day || day.visits.length === 0) return;
|
| 36621 |
ranu |
955 |
|
| 36632 |
ranu |
956 |
var lastVisit = day.visits[day.visits.length - 1];
|
|
|
957 |
if (!confirm('Day Break at ' + (lastVisit.code || lastVisit.name) + '? Next day will start from this location.')) return;
|
|
|
958 |
|
|
|
959 |
day.endAction = 'DAYBREAK';
|
|
|
960 |
|
|
|
961 |
// Next day starts from LAST VISIT location (not home)
|
|
|
962 |
var nextNum = state.days.length + 1;
|
|
|
963 |
state.days.push({
|
|
|
964 |
dayNumber: nextNum,
|
|
|
965 |
startLat: lastVisit.lat, startLng: lastVisit.lng, startName: lastVisit.code || lastVisit.name,
|
|
|
966 |
visits: [], endAction: null,
|
|
|
967 |
stayName: null, stayLat: null, stayLng: null,
|
|
|
968 |
totalKm: 0, totalMins: 0
|
|
|
969 |
});
|
|
|
970 |
state.currentDay = nextNum;
|
|
|
971 |
|
|
|
972 |
resetMarkerColors();
|
|
|
973 |
drawRoute();
|
| 36621 |
ranu |
974 |
renderRoutePanel();
|
| 36632 |
ranu |
975 |
updateBottomBar();
|
| 36621 |
ranu |
976 |
});
|
|
|
977 |
|
|
|
978 |
$('#btn-finish').on('click', function () {
|
|
|
979 |
if (state.days.every(function (d) {
|
|
|
980 |
return d.visits.length === 0;
|
|
|
981 |
})) {
|
|
|
982 |
alert('No visits added');
|
|
|
983 |
return;
|
|
|
984 |
}
|
|
|
985 |
|
| 36632 |
ranu |
986 |
var beatTitle = state.beatTitle || 'Beat';
|
| 36621 |
ranu |
987 |
|
|
|
988 |
// Build plan data and save to DB first
|
|
|
989 |
var daysData = [];
|
|
|
990 |
state.days.forEach(function (day) {
|
|
|
991 |
if (day.visits.length === 0) return;
|
|
|
992 |
daysData.push({
|
|
|
993 |
dayNumber: day.dayNumber,
|
|
|
994 |
startLocationName: day.startName,
|
|
|
995 |
startLatitude: day.startLat ? day.startLat.toString() : null,
|
|
|
996 |
startLongitude: day.startLng ? day.startLng.toString() : null,
|
|
|
997 |
endAction: day.endAction,
|
|
|
998 |
stayLocationName: day.stayName,
|
|
|
999 |
stayLatitude: day.stayLat ? day.stayLat.toString() : null,
|
|
|
1000 |
stayLongitude: day.stayLng ? day.stayLng.toString() : null,
|
|
|
1001 |
totalDistanceKm: day.totalKm,
|
|
|
1002 |
totalTimeMins: Math.round(day.totalMins),
|
|
|
1003 |
visits: day.visits.map(function (v) {
|
|
|
1004 |
return {id: v.id, type: v.type || 'partner'};
|
|
|
1005 |
})
|
|
|
1006 |
});
|
|
|
1007 |
});
|
|
|
1008 |
|
|
|
1009 |
// Dates will be assigned later via calendar — pass nulls for now
|
|
|
1010 |
var dates = daysData.map(function () {
|
|
|
1011 |
return null;
|
|
|
1012 |
});
|
|
|
1013 |
var planData = JSON.stringify({days: daysData, dates: dates, beatName: beatTitle.trim()});
|
|
|
1014 |
|
|
|
1015 |
$('#btn-finish').prop('disabled', true).text('Saving...');
|
|
|
1016 |
|
|
|
1017 |
$.ajax({
|
|
|
1018 |
url: context + "/beatPlan/submitPlan",
|
|
|
1019 |
type: "POST",
|
|
|
1020 |
data: {authUserId: state.authUserId, planData: planData},
|
|
|
1021 |
success: function (response) {
|
|
|
1022 |
var data = (typeof response === 'string' ? JSON.parse(response) : response);
|
|
|
1023 |
var result = data.response || data;
|
|
|
1024 |
$('#btn-finish').prop('disabled', false).text('Finish Plan');
|
|
|
1025 |
|
|
|
1026 |
if (result.status) {
|
|
|
1027 |
state.savedPlanGroupId = result.planGroupId;
|
|
|
1028 |
$('#bottom-bar').hide();
|
|
|
1029 |
|
| 36632 |
ranu |
1030 |
if (result.duplicate) {
|
|
|
1031 |
alert('This beat already exists (same visits).');
|
|
|
1032 |
} else {
|
|
|
1033 |
alert('Beat "' + beatTitle + '" saved successfully!');
|
|
|
1034 |
}
|
|
|
1035 |
|
|
|
1036 |
// Go back to beat list
|
|
|
1037 |
showBeatList();
|
| 36621 |
ranu |
1038 |
} else {
|
|
|
1039 |
alert('Error saving beat plan');
|
|
|
1040 |
}
|
|
|
1041 |
},
|
|
|
1042 |
error: function (xhr) {
|
|
|
1043 |
$('#btn-finish').prop('disabled', false).text('Finish Plan');
|
|
|
1044 |
alert('Error: ' + (xhr.responseText || xhr.statusText));
|
|
|
1045 |
}
|
|
|
1046 |
});
|
|
|
1047 |
});
|
|
|
1048 |
|
|
|
1049 |
// ============ PANEL TABS ============
|
|
|
1050 |
$('.panel-tab').on('click', function () {
|
|
|
1051 |
var tab = $(this).data('tab');
|
|
|
1052 |
$('.panel-tab').removeClass('active');
|
|
|
1053 |
$(this).addClass('active');
|
|
|
1054 |
$('#panel-route').toggle(tab === 'route');
|
|
|
1055 |
$('#panel-calendar').toggle(tab === 'calendar');
|
|
|
1056 |
$('#cal-grid-container').toggle(tab === 'calendar');
|
| 36632 |
ranu |
1057 |
$('#bottom-bar').toggle(tab === 'route' && state.mode === 'create' && state.days && state.days.length > 0);
|
| 36621 |
ranu |
1058 |
|
|
|
1059 |
if (tab === 'calendar' && !state.calMonth) {
|
|
|
1060 |
var now = new Date();
|
|
|
1061 |
state.calMonth = now.getFullYear() + '-' + ('0' + (now.getMonth() + 1)).slice(-2);
|
|
|
1062 |
loadCalendarData();
|
|
|
1063 |
}
|
|
|
1064 |
});
|
|
|
1065 |
|
|
|
1066 |
// ============ CALENDAR ============
|
|
|
1067 |
$('#cal-prev').on('click', function () {
|
|
|
1068 |
navMonth(-1);
|
|
|
1069 |
});
|
|
|
1070 |
$('#cal-next').on('click', function () {
|
|
|
1071 |
navMonth(1);
|
|
|
1072 |
});
|
|
|
1073 |
|
|
|
1074 |
function navMonth(delta) {
|
|
|
1075 |
var p = state.calMonth.split('-');
|
|
|
1076 |
var d = new Date(parseInt(p[0]), parseInt(p[1]) - 1 + delta, 1);
|
|
|
1077 |
state.calMonth = d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2);
|
|
|
1078 |
loadCalendarData();
|
|
|
1079 |
}
|
|
|
1080 |
|
|
|
1081 |
function loadCalendarData() {
|
|
|
1082 |
if (!state.authUserId) return;
|
|
|
1083 |
$('#cal-month-label').text(state.calMonth);
|
|
|
1084 |
|
|
|
1085 |
$.ajax({
|
|
|
1086 |
url: context + "/beatPlan/calendar", type: "GET", dataType: "json",
|
|
|
1087 |
data: {authUserId: state.authUserId, month: state.calMonth},
|
|
|
1088 |
success: function (r) {
|
|
|
1089 |
var data = r.response || r;
|
|
|
1090 |
state.calData = data;
|
|
|
1091 |
renderCalGrid(data);
|
|
|
1092 |
renderBeatCards(data.scheduledBeats);
|
|
|
1093 |
}
|
|
|
1094 |
});
|
|
|
1095 |
}
|
|
|
1096 |
|
|
|
1097 |
function renderCalGrid(data) {
|
|
|
1098 |
var p = state.calMonth.split('-');
|
|
|
1099 |
var year = parseInt(p[0]), month = parseInt(p[1]) - 1;
|
|
|
1100 |
var first = new Date(year, month, 1);
|
|
|
1101 |
var last = new Date(year, month + 1, 0);
|
|
|
1102 |
var months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
|
1103 |
$('#cal-month-label').text(months[month] + ' ' + year);
|
|
|
1104 |
|
|
|
1105 |
var holidayMap = {};
|
|
|
1106 |
(data.holidays || []).forEach(function (h) {
|
|
|
1107 |
holidayMap[h.date] = h.occasion;
|
|
|
1108 |
});
|
|
|
1109 |
var blockedSet = {};
|
|
|
1110 |
(data.blockedDates || []).forEach(function (d) {
|
|
|
1111 |
blockedSet[d] = true;
|
|
|
1112 |
});
|
|
|
1113 |
|
|
|
1114 |
var beatDateMap = {};
|
|
|
1115 |
(data.scheduledBeats || []).forEach(function (b) {
|
|
|
1116 |
(b.days || []).forEach(function (d) {
|
|
|
1117 |
if (d.planDate) {
|
|
|
1118 |
if (!beatDateMap[d.planDate]) beatDateMap[d.planDate] = [];
|
|
|
1119 |
beatDateMap[d.planDate].push({
|
|
|
1120 |
name: b.beatName,
|
|
|
1121 |
color: b.beatColor,
|
|
|
1122 |
day: d.dayNumber,
|
|
|
1123 |
status: b.status,
|
| 36632 |
ranu |
1124 |
visits: d.visitCount,
|
|
|
1125 |
planGroupId: b.planGroupId
|
| 36621 |
ranu |
1126 |
});
|
|
|
1127 |
}
|
|
|
1128 |
});
|
|
|
1129 |
});
|
|
|
1130 |
|
|
|
1131 |
var today = fmtDate(new Date());
|
|
|
1132 |
var startOff = (first.getDay() + 6) % 7;
|
|
|
1133 |
var calStart = new Date(year, month, 1 - startOff);
|
|
|
1134 |
|
|
|
1135 |
var html = '<table class="cal-grid"><tr><th>Mon</th><th>Tue</th><th>Wed</th><th>Thu</th><th>Fri</th><th>Sat</th><th>Sun</th></tr>';
|
|
|
1136 |
var d = new Date(calStart);
|
|
|
1137 |
for (var w = 0; w < 6; w++) {
|
|
|
1138 |
var hasDay = false;
|
|
|
1139 |
var row = '<tr>';
|
|
|
1140 |
for (var wd = 0; wd < 7; wd++) {
|
|
|
1141 |
var ds = fmtDate(d);
|
|
|
1142 |
var inMonth = d.getMonth() === month;
|
|
|
1143 |
var sun = d.getDay() === 0;
|
|
|
1144 |
var hol = !!holidayMap[ds];
|
|
|
1145 |
var isToday = ds === today;
|
|
|
1146 |
var beats = beatDateMap[ds] || [];
|
|
|
1147 |
var isPicked = state.calPickedDates.indexOf(ds) > -1;
|
|
|
1148 |
|
| 36632 |
ranu |
1149 |
var isPast = ds < today;
|
|
|
1150 |
var hasBeats = beats.length > 0;
|
|
|
1151 |
|
| 36621 |
ranu |
1152 |
var cls = [];
|
| 36632 |
ranu |
1153 |
if (hol && !sun) cls.push('holiday', 'blocked');
|
|
|
1154 |
else if (isPast) cls.push('blocked', 'past');
|
|
|
1155 |
if (sun && !isPast) cls.push('sunday'); // Sundays styled but not blocked
|
|
|
1156 |
if (sun && isPast) cls.push('blocked', 'past');
|
| 36621 |
ranu |
1157 |
if (isToday) cls.push('today');
|
|
|
1158 |
if (!inMonth) cls.push('blocked');
|
|
|
1159 |
|
|
|
1160 |
var style = inMonth ? '' : 'opacity:0.3;';
|
| 36632 |
ranu |
1161 |
if (isPast && inMonth) style += 'opacity:0.5;';
|
| 36621 |
ranu |
1162 |
|
| 36632 |
ranu |
1163 |
row += '<td class="' + cls.join(' ') + '" data-date="' + ds + '" data-hasbeat="' + (hasBeats ? '1' : '0') + '" style="' + style + '">';
|
| 36621 |
ranu |
1164 |
row += '<div class="cal-date">' + d.getDate() + '</div>';
|
|
|
1165 |
if (hol) row += '<div class="cal-holiday-label">' + holidayMap[ds] + '</div>';
|
|
|
1166 |
|
|
|
1167 |
beats.forEach(function (b) {
|
|
|
1168 |
var chipCls = 'cal-chip';
|
|
|
1169 |
if (b.status === 'running') chipCls += ' running';
|
|
|
1170 |
if (b.status === 'completed') chipCls += ' completed';
|
| 36632 |
ranu |
1171 |
var removeBtn = (!isPast && b.status !== 'running' && b.status !== 'completed')
|
|
|
1172 |
? '<span class="cal-chip-remove" data-date="' + ds + '" data-plangroup="' + (b.planGroupId || '') + '" title="Remove">×</span>'
|
|
|
1173 |
: '';
|
|
|
1174 |
row += '<span class="' + chipCls + '" style="background:' + b.color + ';position:relative;">'
|
|
|
1175 |
+ b.name + ' D' + b.day + (b.status === 'running' ? ' [live]' : '') + ' (' + b.visits + ')'
|
|
|
1176 |
+ removeBtn + '</span>';
|
| 36621 |
ranu |
1177 |
});
|
|
|
1178 |
|
|
|
1179 |
if (isPicked) {
|
|
|
1180 |
var idx = state.calPickedDates.indexOf(ds) + 1;
|
|
|
1181 |
row += '<span class="cal-chip" style="background:#3b82f6;border:1px dashed #93c5fd;">Day ' + idx + '</span>';
|
|
|
1182 |
}
|
|
|
1183 |
|
|
|
1184 |
row += '</td>';
|
|
|
1185 |
if (inMonth) hasDay = true;
|
|
|
1186 |
d.setDate(d.getDate() + 1);
|
|
|
1187 |
}
|
|
|
1188 |
row += '</tr>';
|
|
|
1189 |
if (hasDay) html += row;
|
|
|
1190 |
}
|
|
|
1191 |
html += '</table>';
|
|
|
1192 |
$('#cal-grid-content').html(html);
|
|
|
1193 |
}
|
|
|
1194 |
|
|
|
1195 |
function renderBeatCards(beats) {
|
|
|
1196 |
var html = '';
|
|
|
1197 |
if (!beats || beats.length === 0) {
|
|
|
1198 |
html = '<p style="color:#64748b;font-size:12px;padding:20px;">No beats scheduled. Create a beat first.</p>';
|
| 36632 |
ranu |
1199 |
$('#cal-beat-cards').html(html);
|
|
|
1200 |
return;
|
| 36621 |
ranu |
1201 |
}
|
| 36632 |
ranu |
1202 |
|
|
|
1203 |
// Deduplicate by beatName — group beats with same name, show count
|
|
|
1204 |
var beatGroups = {};
|
| 36621 |
ranu |
1205 |
(beats || []).forEach(function (b) {
|
| 36632 |
ranu |
1206 |
var key = b.beatName || b.planGroupId;
|
|
|
1207 |
if (!beatGroups[key]) {
|
|
|
1208 |
beatGroups[key] = {
|
|
|
1209 |
beatName: b.beatName,
|
|
|
1210 |
beatColor: b.beatColor,
|
|
|
1211 |
instances: []
|
|
|
1212 |
};
|
|
|
1213 |
}
|
|
|
1214 |
beatGroups[key].instances.push(b);
|
|
|
1215 |
});
|
| 36621 |
ranu |
1216 |
|
| 36632 |
ranu |
1217 |
Object.keys(beatGroups).forEach(function (key) {
|
|
|
1218 |
var group = beatGroups[key];
|
|
|
1219 |
var first = group.instances[0];
|
|
|
1220 |
var scheduledCount = group.instances.filter(function (b) {
|
|
|
1221 |
return b.status === 'scheduled';
|
|
|
1222 |
}).length;
|
|
|
1223 |
var unscheduledCount = group.instances.filter(function (b) {
|
|
|
1224 |
return b.status === 'unscheduled';
|
|
|
1225 |
}).length;
|
|
|
1226 |
var totalInstances = group.instances.length;
|
|
|
1227 |
|
| 36621 |
ranu |
1228 |
var totalV = 0, totalKm = 0;
|
| 36632 |
ranu |
1229 |
first.days.forEach(function (d) {
|
|
|
1230 |
totalV += d.visitCount || 0;
|
|
|
1231 |
totalKm += d.totalKm || 0;
|
|
|
1232 |
});
|
| 36621 |
ranu |
1233 |
|
| 36632 |
ranu |
1234 |
// Use first unscheduled instance for scheduling, or first overall
|
|
|
1235 |
var primaryPg = first.planGroupId;
|
|
|
1236 |
var unscheduledInstance = group.instances.find(function (b) {
|
|
|
1237 |
return b.status === 'unscheduled';
|
|
|
1238 |
});
|
|
|
1239 |
if (unscheduledInstance) primaryPg = unscheduledInstance.planGroupId;
|
|
|
1240 |
|
|
|
1241 |
var statusText = '';
|
|
|
1242 |
if (scheduledCount > 0) statusText += scheduledCount + ' scheduled';
|
|
|
1243 |
if (unscheduledCount > 0) statusText += (statusText ? ', ' : '') + unscheduledCount + ' unscheduled';
|
|
|
1244 |
|
|
|
1245 |
var isScheduling = state.calSelectedBeat === primaryPg && state.calPickedDates.length > 0;
|
|
|
1246 |
|
|
|
1247 |
html += '<div class="beat-card" data-plangroup="' + primaryPg + '" data-beatname="' + (group.beatName || '') + '" draggable="true">';
|
|
|
1248 |
html += '<div class="beat-name"><span style="display:inline-block;width:12px;height:12px;border-radius:2px;background:' + group.beatColor + ';"></span> ' + group.beatName + '</div>';
|
|
|
1249 |
html += '<div class="beat-meta">' + first.days.length + ' days | ' + totalV + ' visits | ' + totalKm.toFixed(0) + ' km</div>';
|
|
|
1250 |
html += '<div class="beat-meta" style="color:#94a3b8;">' + statusText + ' (' + totalInstances + ' total)</div>';
|
|
|
1251 |
|
|
|
1252 |
html += '<div class="beat-actions">';
|
|
|
1253 |
if (isScheduling) {
|
|
|
1254 |
html += '<button class="btn-sm btn-confirm-schedule" data-pg="' + primaryPg + '" data-days="' + first.days.length + '" style="background:#22c55e;color:#fff;">Confirm (' + state.calPickedDates.length + ' dates)</button>';
|
|
|
1255 |
html += '<button class="btn-sm btn-cancel-schedule" style="background:#475569;color:#e2e8f0;">Cancel</button>';
|
|
|
1256 |
} else {
|
|
|
1257 |
html += '<button class="btn-sm btn-schedule" data-pg="' + primaryPg + '" data-days="' + first.days.length + '">Schedule</button>';
|
|
|
1258 |
}
|
|
|
1259 |
html += '<button class="btn-sm" style="background:#ef4444;color:#fff;" onclick="deleteBeat(\'' + primaryPg + '\')">Delete</button>';
|
|
|
1260 |
html += '</div>';
|
|
|
1261 |
|
|
|
1262 |
if (isScheduling) {
|
|
|
1263 |
html += '<div style="font-size:10px;color:#60a5fa;margin-top:4px;">Dates: ' + state.calPickedDates.join(', ') + '</div>';
|
|
|
1264 |
html += '<div style="font-size:10px;color:#94a3b8;">Click calendar cells to adjust</div>';
|
|
|
1265 |
}
|
|
|
1266 |
|
|
|
1267 |
html += '<div style="font-size:9px;color:#475569;margin-top:4px;">Drag onto calendar to schedule</div>';
|
| 36621 |
ranu |
1268 |
html += '</div>';
|
|
|
1269 |
});
|
| 36632 |
ranu |
1270 |
|
| 36621 |
ranu |
1271 |
$('#cal-beat-cards').html(html);
|
|
|
1272 |
}
|
|
|
1273 |
|
|
|
1274 |
// Schedule button — suggest dates, show Confirm button in beat card
|
|
|
1275 |
$(document).on('click', '.btn-schedule', function (e) {
|
|
|
1276 |
e.stopPropagation();
|
|
|
1277 |
var pg = $(this).data('pg');
|
|
|
1278 |
var days = parseInt($(this).data('days'));
|
|
|
1279 |
state.calSelectedBeat = pg;
|
|
|
1280 |
state.calPickedDates = [];
|
|
|
1281 |
|
|
|
1282 |
$.ajax({
|
|
|
1283 |
url: context + "/beatPlan/availableSlots", type: "GET", dataType: "json",
|
|
|
1284 |
data: {authUserId: state.authUserId, month: state.calMonth, daysNeeded: days},
|
|
|
1285 |
success: function (r) {
|
|
|
1286 |
var data = r.response || r;
|
|
|
1287 |
state.calPickedDates = data.suggestedDates || [];
|
|
|
1288 |
renderCalGrid(state.calData);
|
|
|
1289 |
renderBeatCards(state.calData.scheduledBeats);
|
|
|
1290 |
if (state.calPickedDates.length < days) {
|
|
|
1291 |
alert('Only ' + state.calPickedDates.length + ' slots available this month. Need ' + days + '.');
|
|
|
1292 |
}
|
|
|
1293 |
}
|
|
|
1294 |
});
|
|
|
1295 |
});
|
|
|
1296 |
|
| 36632 |
ranu |
1297 |
// Click calendar cell during Schedule flow — auto-fill consecutive days from clicked date
|
|
|
1298 |
$(document).on('click', '#cal-grid-content td', function () {
|
| 36621 |
ranu |
1299 |
if (!state.calSelectedBeat) return;
|
|
|
1300 |
var ds = $(this).data('date');
|
| 36632 |
ranu |
1301 |
if (!ds || isDateBlocked(ds)) return;
|
|
|
1302 |
|
|
|
1303 |
// Find the beat to get days needed
|
|
|
1304 |
var beat = (state.calData.scheduledBeats || []).find(function (b) {
|
|
|
1305 |
return b.planGroupId === state.calSelectedBeat;
|
|
|
1306 |
});
|
|
|
1307 |
var daysNeeded = beat ? beat.days.length : 1;
|
|
|
1308 |
|
|
|
1309 |
// Auto-fill consecutive slots from clicked date
|
|
|
1310 |
var slots = findConsecutiveSlots(ds, daysNeeded);
|
|
|
1311 |
if (slots) {
|
|
|
1312 |
state.calPickedDates = slots;
|
|
|
1313 |
renderCalGrid(state.calData);
|
|
|
1314 |
renderBeatCards(state.calData.scheduledBeats);
|
|
|
1315 |
}
|
| 36621 |
ranu |
1316 |
});
|
|
|
1317 |
|
|
|
1318 |
// Cancel scheduling mode
|
|
|
1319 |
$(document).on('click', '.btn-cancel-schedule', function (e) {
|
|
|
1320 |
e.stopPropagation();
|
|
|
1321 |
state.calSelectedBeat = null;
|
|
|
1322 |
state.calPickedDates = [];
|
|
|
1323 |
renderCalGrid(state.calData);
|
|
|
1324 |
renderBeatCards(state.calData.scheduledBeats);
|
|
|
1325 |
});
|
|
|
1326 |
|
|
|
1327 |
// Confirm schedule — save to server
|
|
|
1328 |
$(document).on('click', '.btn-confirm-schedule', function (e) {
|
|
|
1329 |
e.stopPropagation();
|
|
|
1330 |
var pg = $(this).data('pg');
|
|
|
1331 |
var daysNeeded = parseInt($(this).data('days'));
|
|
|
1332 |
|
|
|
1333 |
if (state.calPickedDates.length < daysNeeded) {
|
|
|
1334 |
alert('Need ' + daysNeeded + ' dates but only ' + state.calPickedDates.length + ' selected.');
|
|
|
1335 |
return;
|
|
|
1336 |
}
|
|
|
1337 |
|
|
|
1338 |
var beat = (state.calData.scheduledBeats || []).find(function (b) {
|
|
|
1339 |
return b.planGroupId === pg;
|
|
|
1340 |
});
|
|
|
1341 |
if (!beat) return;
|
|
|
1342 |
|
|
|
1343 |
$.ajax({
|
|
|
1344 |
url: context + "/beatPlan/scheduleOnCalendar", type: "POST",
|
|
|
1345 |
data: {
|
|
|
1346 |
planGroupId: pg,
|
|
|
1347 |
dates: JSON.stringify(state.calPickedDates.slice(0, daysNeeded)),
|
|
|
1348 |
beatName: beat.beatName,
|
|
|
1349 |
beatColor: beat.beatColor
|
|
|
1350 |
},
|
|
|
1351 |
success: function () {
|
|
|
1352 |
state.calSelectedBeat = null;
|
|
|
1353 |
state.calPickedDates = [];
|
|
|
1354 |
loadCalendarData();
|
|
|
1355 |
},
|
|
|
1356 |
error: function (xhr) {
|
|
|
1357 |
alert('Error: ' + (xhr.responseText || xhr.statusText));
|
|
|
1358 |
}
|
|
|
1359 |
});
|
|
|
1360 |
});
|
|
|
1361 |
|
| 36632 |
ranu |
1362 |
// ============ DRAG & DROP BEAT TO CALENDAR ============
|
|
|
1363 |
$(document).on('dragstart', '.beat-card', function (e) {
|
|
|
1364 |
var pg = $(this).data('plangroup');
|
|
|
1365 |
var beatName = $(this).data('beatname');
|
|
|
1366 |
e.originalEvent.dataTransfer.setData('text/plain', pg);
|
|
|
1367 |
e.originalEvent.dataTransfer.setData('beatname', beatName || '');
|
|
|
1368 |
$(this).css('opacity', '0.5');
|
|
|
1369 |
});
|
| 36621 |
ranu |
1370 |
|
| 36632 |
ranu |
1371 |
$(document).on('dragend', '.beat-card', function () {
|
|
|
1372 |
$(this).css('opacity', '1');
|
|
|
1373 |
});
|
| 36621 |
ranu |
1374 |
|
| 36632 |
ranu |
1375 |
// Calendar cells accept drops
|
|
|
1376 |
$(document).on('dragover', '#cal-grid-content td', function (e) {
|
|
|
1377 |
var ds = $(this).data('date');
|
|
|
1378 |
if (!ds) return;
|
|
|
1379 |
if (isDateBlocked(ds)) return; // don't allow drop indicator on blocked dates
|
|
|
1380 |
e.preventDefault();
|
|
|
1381 |
$(this).css('background', '#1e3a5f');
|
|
|
1382 |
});
|
|
|
1383 |
|
|
|
1384 |
$(document).on('dragleave', '#cal-grid-content td', function () {
|
|
|
1385 |
$(this).css('background', '');
|
|
|
1386 |
});
|
|
|
1387 |
|
|
|
1388 |
$(document).on('drop', '#cal-grid-content td', function (e) {
|
|
|
1389 |
e.preventDefault();
|
|
|
1390 |
$(this).css('background', '');
|
|
|
1391 |
|
|
|
1392 |
var pg = e.originalEvent.dataTransfer.getData('text/plain');
|
|
|
1393 |
var dropDate = $(this).data('date');
|
|
|
1394 |
if (!pg || !dropDate) return;
|
|
|
1395 |
|
|
|
1396 |
if (isDateBlocked(dropDate)) {
|
|
|
1397 |
alert('Cannot schedule on this date (past, Sunday, or holiday)');
|
|
|
1398 |
return;
|
|
|
1399 |
}
|
|
|
1400 |
|
|
|
1401 |
var beat = (state.calData.scheduledBeats || []).find(function (b) {
|
|
|
1402 |
return b.planGroupId === pg;
|
|
|
1403 |
});
|
|
|
1404 |
if (!beat) return;
|
|
|
1405 |
var beatName = beat.beatName || 'Beat';
|
|
|
1406 |
var daysNeeded = beat.days ? beat.days.length : 1;
|
|
|
1407 |
|
|
|
1408 |
// Auto-fill consecutive available days starting from drop date
|
|
|
1409 |
var scheduleDates = findConsecutiveSlots(dropDate, daysNeeded);
|
|
|
1410 |
|
|
|
1411 |
if (!scheduleDates) return; // alerts already shown inside findConsecutiveSlots
|
|
|
1412 |
|
|
|
1413 |
var dateStr = scheduleDates.map(function (d, i) {
|
|
|
1414 |
return 'Day ' + (i + 1) + ': ' + d;
|
|
|
1415 |
}).join('\n');
|
|
|
1416 |
if (!confirm('Schedule "' + beatName + '" (' + daysNeeded + ' days):\n\n' + dateStr + '\n\nConfirm?')) return;
|
|
|
1417 |
|
|
|
1418 |
$.ajax({
|
|
|
1419 |
url: context + "/beatPlan/repeatBeat", type: "POST",
|
|
|
1420 |
data: {
|
|
|
1421 |
sourcePlanGroupId: pg,
|
|
|
1422 |
authUserId: state.authUserId,
|
|
|
1423 |
dates: JSON.stringify(scheduleDates)
|
|
|
1424 |
},
|
|
|
1425 |
success: function () {
|
|
|
1426 |
loadCalendarData();
|
|
|
1427 |
},
|
|
|
1428 |
error: function (xhr) {
|
|
|
1429 |
alert('Error: ' + (xhr.responseText || xhr.statusText));
|
|
|
1430 |
}
|
|
|
1431 |
});
|
|
|
1432 |
});
|
|
|
1433 |
|
|
|
1434 |
// Find N consecutive available days starting from startDate, skipping Sundays/holidays
|
|
|
1435 |
// Returns array of date strings, or null if can't fit
|
|
|
1436 |
function findConsecutiveSlots(startDateStr, daysNeeded) {
|
|
|
1437 |
var startDate = new Date(startDateStr);
|
|
|
1438 |
var startMonth = startDate.getMonth();
|
|
|
1439 |
var dates = [];
|
|
|
1440 |
var d = new Date(startDate);
|
|
|
1441 |
var sundayAsked = false;
|
|
|
1442 |
var includeSundays = false;
|
|
|
1443 |
|
|
|
1444 |
while (dates.length < daysNeeded) {
|
|
|
1445 |
var ds = fmtDate(d);
|
|
|
1446 |
|
|
|
1447 |
// Rule 3: must stay within same month
|
|
|
1448 |
if (d.getMonth() !== startMonth) {
|
|
|
1449 |
alert('Beat has ' + daysNeeded + ' days but only ' + dates.length + ' available days left in this month starting from ' + startDateStr + '. Try an earlier date.');
|
|
|
1450 |
return null;
|
| 36621 |
ranu |
1451 |
}
|
| 36632 |
ranu |
1452 |
|
|
|
1453 |
// Past dates — skip
|
|
|
1454 |
var today = fmtDate(new Date());
|
|
|
1455 |
if (ds <= today) {
|
|
|
1456 |
d.setDate(d.getDate() + 1);
|
|
|
1457 |
continue;
|
|
|
1458 |
}
|
|
|
1459 |
|
|
|
1460 |
// Holidays — always skip
|
|
|
1461 |
if (isHoliday(ds)) {
|
|
|
1462 |
d.setDate(d.getDate() + 1);
|
|
|
1463 |
continue;
|
|
|
1464 |
}
|
|
|
1465 |
|
|
|
1466 |
// Sunday — ask once whether to include
|
|
|
1467 |
if (isSunday(ds)) {
|
|
|
1468 |
if (!sundayAsked) {
|
|
|
1469 |
sundayAsked = true;
|
|
|
1470 |
includeSundays = confirm('This beat falls on a Sunday (' + ds + '). Include Sundays in the schedule?');
|
|
|
1471 |
}
|
|
|
1472 |
if (!includeSundays) {
|
|
|
1473 |
d.setDate(d.getDate() + 1);
|
|
|
1474 |
continue;
|
|
|
1475 |
}
|
|
|
1476 |
}
|
|
|
1477 |
|
|
|
1478 |
// Already occupied — block
|
|
|
1479 |
if (getBeatsOnDate(ds).length > 0) {
|
|
|
1480 |
alert('Cannot schedule: ' + ds + ' already has a beat scheduled. Consecutive days not available from ' + startDateStr + '.');
|
|
|
1481 |
return null;
|
|
|
1482 |
}
|
|
|
1483 |
|
|
|
1484 |
dates.push(ds);
|
|
|
1485 |
d.setDate(d.getDate() + 1);
|
|
|
1486 |
}
|
|
|
1487 |
|
|
|
1488 |
return dates;
|
|
|
1489 |
}
|
|
|
1490 |
|
|
|
1491 |
function isDateBlocked(ds) {
|
|
|
1492 |
var today = fmtDate(new Date());
|
|
|
1493 |
if (ds <= today) return true; // past or today
|
|
|
1494 |
// Sundays NOT blocked — handled via confirm dialog instead
|
|
|
1495 |
// Only block holidays
|
|
|
1496 |
if (state.calData && state.calData.blockedDates) {
|
|
|
1497 |
var blocked = state.calData.blockedDates;
|
|
|
1498 |
// blockedDates from server includes Sundays+holidays, but we only block holidays now
|
|
|
1499 |
var d = new Date(ds);
|
|
|
1500 |
if (d.getDay() !== 0) { // not Sunday — check holidays
|
|
|
1501 |
if (Array.isArray(blocked) && blocked.indexOf(ds) > -1) return true;
|
|
|
1502 |
if (typeof blocked === 'object' && blocked[ds]) return true;
|
|
|
1503 |
}
|
|
|
1504 |
}
|
|
|
1505 |
return false;
|
|
|
1506 |
}
|
|
|
1507 |
|
|
|
1508 |
function isSunday(ds) {
|
|
|
1509 |
return new Date(ds).getDay() === 0;
|
|
|
1510 |
}
|
|
|
1511 |
|
|
|
1512 |
function isHoliday(ds) {
|
|
|
1513 |
if (!state.calData || !state.calData.holidays) return false;
|
|
|
1514 |
return state.calData.holidays.some(function (h) {
|
|
|
1515 |
return h.date === ds;
|
|
|
1516 |
});
|
|
|
1517 |
}
|
|
|
1518 |
|
|
|
1519 |
function getBeatsOnDate(ds) {
|
|
|
1520 |
var result = [];
|
|
|
1521 |
if (!state.calData || !state.calData.scheduledBeats) return result;
|
|
|
1522 |
state.calData.scheduledBeats.forEach(function (b) {
|
|
|
1523 |
(b.days || []).forEach(function (d) {
|
|
|
1524 |
if (d.planDate === ds) result.push({planGroupId: b.planGroupId, beatName: b.beatName});
|
|
|
1525 |
});
|
|
|
1526 |
});
|
|
|
1527 |
return result;
|
|
|
1528 |
}
|
|
|
1529 |
|
|
|
1530 |
// ============ REMOVE BEAT FROM CALENDAR DATE ============
|
|
|
1531 |
$(document).on('click', '.cal-chip-remove', function (e) {
|
|
|
1532 |
e.stopPropagation();
|
|
|
1533 |
var date = $(this).data('date');
|
|
|
1534 |
var planGroupId = $(this).data('plangroup');
|
|
|
1535 |
if (!planGroupId || !date) return;
|
|
|
1536 |
|
|
|
1537 |
if (!confirm('Remove this beat from ' + date + '?')) return;
|
|
|
1538 |
|
|
|
1539 |
// Delete the entire beat instance (planGroupId)
|
|
|
1540 |
$.ajax({
|
|
|
1541 |
url: context + "/beatPlan/delete", type: "POST",
|
|
|
1542 |
data: {planGroupId: planGroupId},
|
|
|
1543 |
success: function () {
|
|
|
1544 |
loadCalendarData();
|
|
|
1545 |
},
|
|
|
1546 |
error: function (xhr) {
|
|
|
1547 |
alert('Error: ' + (xhr.responseText || xhr.statusText));
|
|
|
1548 |
}
|
| 36621 |
ranu |
1549 |
});
|
|
|
1550 |
});
|
|
|
1551 |
|
|
|
1552 |
// ============ DELETE BEAT ============
|
|
|
1553 |
function deleteBeat(planGroupId) {
|
|
|
1554 |
if (!confirm('Delete this beat? This cannot be undone.')) return;
|
|
|
1555 |
$.ajax({
|
|
|
1556 |
url: context + "/beatPlan/delete", type: "POST",
|
|
|
1557 |
data: {planGroupId: planGroupId},
|
|
|
1558 |
success: function () {
|
|
|
1559 |
loadCalendarData();
|
|
|
1560 |
},
|
|
|
1561 |
error: function (xhr) {
|
|
|
1562 |
alert('Error: ' + (xhr.responseText || xhr.statusText));
|
|
|
1563 |
}
|
|
|
1564 |
});
|
|
|
1565 |
}
|
|
|
1566 |
|
|
|
1567 |
// ============ HELPERS ============
|
|
|
1568 |
function curDay() {
|
|
|
1569 |
return state.days[state.currentDay - 1];
|
|
|
1570 |
}
|
|
|
1571 |
|
|
|
1572 |
function haversine(lat1, lng1, lat2, lng2) {
|
|
|
1573 |
var R = 6371, dLat = (lat2 - lat1) * Math.PI / 180, dLng = (lng2 - lng1) * Math.PI / 180;
|
|
|
1574 |
var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
|
|
1575 |
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
|
1576 |
}
|
|
|
1577 |
|
|
|
1578 |
function fmtMins(m) {
|
|
|
1579 |
return Math.floor(m / 60) + 'h ' + Math.round(m % 60) + 'm';
|
|
|
1580 |
}
|
|
|
1581 |
|
|
|
1582 |
function fmtDate(d) {
|
|
|
1583 |
return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2);
|
|
|
1584 |
}
|
|
|
1585 |
|
|
|
1586 |
function fitBounds() {
|
|
|
1587 |
if (Object.keys(markers).length > 0) {
|
|
|
1588 |
var b = new google.maps.LatLngBounds();
|
|
|
1589 |
if (homeMarker) b.extend(homeMarker.getPosition());
|
|
|
1590 |
for (var k in markers) b.extend(markers[k].getPosition());
|
|
|
1591 |
map.fitBounds(b);
|
|
|
1592 |
}
|
|
|
1593 |
}
|