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