| 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());
|
|
|
57 |
$('#bp-user-label').text($('#bp-auth-user option:selected').text());
|
|
|
58 |
|
|
|
59 |
// Check home location
|
|
|
60 |
$.ajax({
|
|
|
61 |
url: context + "/beatPlan/getBaseLocation", type: "GET", dataType: "json",
|
|
|
62 |
data: {authUserId: uid},
|
|
|
63 |
success: function (r) {
|
|
|
64 |
var data = r.response || r;
|
|
|
65 |
if (data.locationName) {
|
|
|
66 |
state.homeLat = parseFloat(data.latitude);
|
|
|
67 |
state.homeLng = parseFloat(data.longitude);
|
|
|
68 |
state.homeName = data.locationName;
|
|
|
69 |
loadPartners();
|
|
|
70 |
} else {
|
|
|
71 |
showHomeModal();
|
|
|
72 |
}
|
|
|
73 |
}
|
|
|
74 |
});
|
|
|
75 |
});
|
|
|
76 |
|
|
|
77 |
// ============ HOME LOCATION MODAL ============
|
|
|
78 |
var homeMap, homeMapMarker;
|
|
|
79 |
|
|
|
80 |
function showHomeModal() {
|
|
|
81 |
$('#modal-home').addClass('show');
|
|
|
82 |
setTimeout(function () {
|
|
|
83 |
if (!homeMap) {
|
|
|
84 |
homeMap = new google.maps.Map(document.getElementById('home-map'), {
|
|
|
85 |
center: {lat: 28.6, lng: 77.2},
|
|
|
86 |
zoom: 6
|
|
|
87 |
});
|
|
|
88 |
var ac = new google.maps.places.Autocomplete(document.getElementById('home-search'), {componentRestrictions: {country: 'in'}});
|
|
|
89 |
homeMapMarker = new google.maps.Marker({map: homeMap, draggable: true});
|
|
|
90 |
|
|
|
91 |
ac.addListener('place_changed', function () {
|
|
|
92 |
var p = ac.getPlace();
|
|
|
93 |
if (!p.geometry) return;
|
|
|
94 |
homeMap.setCenter(p.geometry.location);
|
|
|
95 |
homeMap.setZoom(14);
|
|
|
96 |
homeMapMarker.setPosition(p.geometry.location);
|
|
|
97 |
state.homeLat = p.geometry.location.lat();
|
|
|
98 |
state.homeLng = p.geometry.location.lng();
|
|
|
99 |
state.homeName = p.name || p.formatted_address || '';
|
|
|
100 |
});
|
|
|
101 |
homeMapMarker.addListener('dragend', function () {
|
|
|
102 |
state.homeLat = homeMapMarker.getPosition().lat();
|
|
|
103 |
state.homeLng = homeMapMarker.getPosition().lng();
|
|
|
104 |
});
|
|
|
105 |
homeMap.addListener('click', function (e) {
|
|
|
106 |
homeMapMarker.setPosition(e.latLng);
|
|
|
107 |
state.homeLat = e.latLng.lat();
|
|
|
108 |
state.homeLng = e.latLng.lng();
|
|
|
109 |
});
|
|
|
110 |
}
|
|
|
111 |
}, 200);
|
|
|
112 |
}
|
|
|
113 |
|
|
|
114 |
$('#home-cancel').on('click', function () {
|
|
|
115 |
$('#modal-home').removeClass('show');
|
|
|
116 |
});
|
|
|
117 |
$('#home-confirm').on('click', function () {
|
|
|
118 |
if (!state.homeLat) {
|
|
|
119 |
alert('Please select a location');
|
|
|
120 |
return;
|
|
|
121 |
}
|
|
|
122 |
$('#modal-home').removeClass('show');
|
|
|
123 |
$.ajax({
|
|
|
124 |
url: context + "/beatPlan/saveBaseLocation", type: "POST",
|
|
|
125 |
data: {
|
|
|
126 |
authUserId: state.authUserId,
|
|
|
127 |
locationName: state.homeName || 'Home',
|
|
|
128 |
latitude: state.homeLat,
|
|
|
129 |
longitude: state.homeLng,
|
|
|
130 |
address: $('#home-search').val()
|
|
|
131 |
}
|
|
|
132 |
});
|
|
|
133 |
loadPartners();
|
|
|
134 |
});
|
|
|
135 |
|
|
|
136 |
// ============ LOAD PARTNERS + INIT MAP ============
|
|
|
137 |
function loadPartners() {
|
|
|
138 |
$.ajax({
|
|
|
139 |
url: context + "/beatPlan/getPartners", type: "GET", dataType: "json",
|
|
|
140 |
data: {
|
|
|
141 |
authUserId: state.authUserId,
|
|
|
142 |
categoryId: state.categoryId,
|
|
|
143 |
startLat: state.homeLat,
|
|
|
144 |
startLng: state.homeLng
|
|
|
145 |
},
|
|
|
146 |
success: function (r) {
|
|
|
147 |
var data = r.response || r;
|
|
|
148 |
state.partners = data.partners || [];
|
|
|
149 |
initDay1();
|
|
|
150 |
initMap();
|
|
|
151 |
}
|
|
|
152 |
});
|
|
|
153 |
}
|
|
|
154 |
|
|
|
155 |
function initDay1() {
|
|
|
156 |
state.days = [{
|
|
|
157 |
dayNumber: 1, startLat: state.homeLat, startLng: state.homeLng, startName: state.homeName,
|
|
|
158 |
visits: [], endAction: null, stayName: null, stayLat: null, stayLng: null, totalKm: 0, totalMins: 0
|
|
|
159 |
}];
|
|
|
160 |
state.currentDay = 1;
|
|
|
161 |
$('#bottom-bar').show();
|
|
|
162 |
renderRoutePanel();
|
|
|
163 |
}
|
|
|
164 |
|
|
|
165 |
function initMap() {
|
|
|
166 |
var center = {lat: state.homeLat, lng: state.homeLng};
|
|
|
167 |
map = new google.maps.Map(document.getElementById('beat-map'), {
|
|
|
168 |
center: center, zoom: 9,
|
|
|
169 |
styles: [
|
|
|
170 |
{elementType: 'geometry', stylers: [{color: '#1d2c4d'}]},
|
|
|
171 |
{elementType: 'labels.text.fill', stylers: [{color: '#8ec3b9'}]},
|
|
|
172 |
{elementType: 'labels.text.stroke', stylers: [{color: '#1a3646'}]},
|
|
|
173 |
{featureType: 'road', elementType: 'geometry', stylers: [{color: '#304a7d'}]},
|
|
|
174 |
{featureType: 'water', elementType: 'geometry', stylers: [{color: '#0e1626'}]}
|
|
|
175 |
]
|
|
|
176 |
});
|
|
|
177 |
infoWindow = new google.maps.InfoWindow();
|
|
|
178 |
|
|
|
179 |
// Home marker
|
|
|
180 |
homeMarker = new google.maps.Marker({
|
|
|
181 |
map: map, position: center, title: 'Home: ' + state.homeName,
|
|
|
182 |
icon: {
|
|
|
183 |
url: 'https://maps.google.com/mapfiles/kml/shapes/homegardenbusiness.png',
|
|
|
184 |
scaledSize: new google.maps.Size(32, 32)
|
|
|
185 |
},
|
|
|
186 |
zIndex: 100
|
|
|
187 |
});
|
|
|
188 |
|
|
|
189 |
// Partner markers with name labels
|
|
|
190 |
for (var key in markers) {
|
|
|
191 |
markers[key].setMap(null);
|
|
|
192 |
}
|
|
|
193 |
markers = {};
|
|
|
194 |
|
|
|
195 |
state.partners.forEach(function (p) {
|
|
|
196 |
if (!p.latitude || !p.longitude) return;
|
|
|
197 |
var lat = parseFloat(p.latitude), lng = parseFloat(p.longitude);
|
|
|
198 |
if (isNaN(lat) || isNaN(lng)) return;
|
|
|
199 |
|
|
|
200 |
var m = new google.maps.Marker({
|
|
|
201 |
map: map, position: {lat: lat, lng: lng},
|
|
|
202 |
title: p.code + ' - ' + (p.outletName || p.businessName || ''),
|
|
|
203 |
label: {text: p.code || '', color: '#fff', fontSize: '10px', fontWeight: '600'},
|
|
|
204 |
icon: {
|
|
|
205 |
path: google.maps.SymbolPath.CIRCLE,
|
|
|
206 |
scale: 14,
|
|
|
207 |
fillColor: '#ef4444',
|
|
|
208 |
fillOpacity: 0.9,
|
|
|
209 |
strokeColor: '#fff',
|
|
|
210 |
strokeWeight: 1
|
|
|
211 |
}
|
|
|
212 |
});
|
|
|
213 |
|
|
|
214 |
m.addListener('click', function () {
|
|
|
215 |
showMarkerPopup(p, m);
|
|
|
216 |
});
|
|
|
217 |
m.addListener('mouseover', function () {
|
|
|
218 |
showPreviewLine(p);
|
|
|
219 |
});
|
|
|
220 |
m.addListener('mouseout', function () {
|
|
|
221 |
hidePreviewLine();
|
|
|
222 |
});
|
|
|
223 |
|
|
|
224 |
markers[p.fofoId] = m;
|
|
|
225 |
});
|
|
|
226 |
|
|
|
227 |
fitBounds();
|
|
|
228 |
}
|
|
|
229 |
|
|
|
230 |
// ============ HOVER PREVIEW ============
|
|
|
231 |
function showPreviewLine(partner) {
|
|
|
232 |
if (!partner.latitude || !partner.longitude) return;
|
|
|
233 |
var day = curDay();
|
|
|
234 |
var lastPt = day.visits.length > 0
|
|
|
235 |
? {lat: day.visits[day.visits.length - 1].lat, lng: day.visits[day.visits.length - 1].lng}
|
|
|
236 |
: {lat: day.startLat, lng: day.startLng};
|
|
|
237 |
if (!lastPt.lat) return;
|
|
|
238 |
|
|
|
239 |
var endPt = {lat: parseFloat(partner.latitude), lng: parseFloat(partner.longitude)};
|
|
|
240 |
if (previewLine) previewLine.setMap(null);
|
|
|
241 |
previewLine = new google.maps.Polyline({
|
|
|
242 |
path: [lastPt, endPt],
|
|
|
243 |
geodesic: true,
|
|
|
244 |
strokeColor: '#60a5fa',
|
|
|
245 |
strokeOpacity: 0.5,
|
|
|
246 |
strokeWeight: 2,
|
|
|
247 |
strokePattern: [10, 5],
|
|
|
248 |
icons: [{icon: {path: 'M 0,-1 0,1', strokeOpacity: 1, scale: 3}, offset: '0', repeat: '15px'}],
|
|
|
249 |
map: map
|
|
|
250 |
});
|
|
|
251 |
}
|
|
|
252 |
|
|
|
253 |
function hidePreviewLine() {
|
|
|
254 |
if (previewLine) {
|
|
|
255 |
previewLine.setMap(null);
|
|
|
256 |
previewLine = null;
|
|
|
257 |
}
|
|
|
258 |
}
|
|
|
259 |
|
|
|
260 |
// ============ MARKER CLICK POPUP ============
|
|
|
261 |
function showMarkerPopup(partner, marker) {
|
|
|
262 |
var day = curDay();
|
|
|
263 |
var alreadyAdded = day.visits.some(function (v) {
|
|
|
264 |
return v.id === partner.fofoId;
|
|
|
265 |
});
|
|
|
266 |
if (alreadyAdded) {
|
|
|
267 |
infoWindow.setContent('<div class="marker-popup"><h4>' + partner.code + '</h4><p>Already in today\'s route</p></div>');
|
|
|
268 |
infoWindow.open(map, marker);
|
|
|
269 |
return;
|
|
|
270 |
}
|
|
|
271 |
|
|
|
272 |
var lastPt = day.visits.length > 0
|
|
|
273 |
? {lat: day.visits[day.visits.length - 1].lat, lng: day.visits[day.visits.length - 1].lng}
|
|
|
274 |
: {lat: day.startLat, lng: day.startLng};
|
|
|
275 |
|
|
|
276 |
var dist = 0, travelMins = 0;
|
|
|
277 |
if (lastPt.lat && partner.latitude) {
|
|
|
278 |
dist = haversine(lastPt.lat, lastPt.lng, parseFloat(partner.latitude), parseFloat(partner.longitude)) * ROAD_FACTOR;
|
|
|
279 |
travelMins = (dist / AVG_SPEED) * 60;
|
|
|
280 |
}
|
|
|
281 |
|
|
|
282 |
var name = partner.outletName || partner.businessName || '';
|
|
|
283 |
var addr = partner.address || '';
|
|
|
284 |
|
|
|
285 |
var html = '<div class="marker-popup">';
|
|
|
286 |
html += '<h4>' + partner.code + ' - ' + name + '</h4>';
|
|
|
287 |
html += '<div class="popup-meta">' + addr + '</div>';
|
|
|
288 |
html += '<div class="popup-distance">~' + dist.toFixed(1) + ' km | ~' + fmtMins(travelMins) + ' travel + ' + VISIT_MINS + 'min visit</div>';
|
|
|
289 |
html += '<button class="popup-next" onclick="addVisit(' + partner.fofoId + ',\'next\')">Next Visit</button>';
|
|
|
290 |
html += '<button class="popup-last" onclick="addVisit(' + partner.fofoId + ',\'last\')">Last Visit</button>';
|
|
|
291 |
html += '<button class="popup-nextday" onclick="addVisit(' + partner.fofoId + ',\'nextday\')">Continue Next Day</button>';
|
|
|
292 |
html += '</div>';
|
|
|
293 |
|
|
|
294 |
infoWindow.setContent(html);
|
|
|
295 |
infoWindow.open(map, marker);
|
|
|
296 |
}
|
|
|
297 |
|
|
|
298 |
// ============ ADD VISIT ============
|
|
|
299 |
function addVisit(fofoId, action) {
|
|
|
300 |
infoWindow.close();
|
|
|
301 |
var p = state.partners.find(function (x) {
|
|
|
302 |
return x.fofoId === fofoId;
|
|
|
303 |
});
|
|
|
304 |
if (!p) return;
|
|
|
305 |
|
|
|
306 |
var day = curDay();
|
|
|
307 |
var visit = {
|
|
|
308 |
id: p.fofoId, type: 'partner',
|
|
|
309 |
name: p.code + ' - ' + (p.outletName || p.businessName || ''),
|
|
|
310 |
code: p.code,
|
|
|
311 |
lat: p.latitude ? parseFloat(p.latitude) : null,
|
|
|
312 |
lng: p.longitude ? parseFloat(p.longitude) : null
|
|
|
313 |
};
|
|
|
314 |
|
|
|
315 |
day.visits.push(visit);
|
|
|
316 |
|
|
|
317 |
// Update marker to green
|
|
|
318 |
if (markers[fofoId]) {
|
|
|
319 |
markers[fofoId].setIcon({
|
|
|
320 |
path: google.maps.SymbolPath.CIRCLE, scale: 14,
|
|
|
321 |
fillColor: '#22c55e', fillOpacity: 0.9, strokeColor: '#fff', strokeWeight: 2
|
|
|
322 |
});
|
|
|
323 |
markers[fofoId].setLabel({text: '' + day.visits.length, color: '#fff', fontSize: '11px', fontWeight: '700'});
|
|
|
324 |
}
|
|
|
325 |
|
|
|
326 |
recalcDay();
|
|
|
327 |
drawRoute();
|
|
|
328 |
renderRoutePanel();
|
|
|
329 |
|
|
|
330 |
if (action === 'last') {
|
|
|
331 |
// Last visit — end day, go home
|
|
|
332 |
day.endAction = 'HOME';
|
|
|
333 |
day.stayLat = state.homeLat;
|
|
|
334 |
day.stayLng = state.homeLng;
|
|
|
335 |
day.stayName = state.homeName;
|
|
|
336 |
drawReturnHome();
|
|
|
337 |
renderRoutePanel();
|
|
|
338 |
} else if (action === 'nextday') {
|
|
|
339 |
// Continue next day — end current day, start new day from here
|
|
|
340 |
day.endAction = 'CONTINUE';
|
|
|
341 |
day.stayLat = visit.lat;
|
|
|
342 |
day.stayLng = visit.lng;
|
|
|
343 |
day.stayName = visit.name;
|
|
|
344 |
startNextDay(visit.lat, visit.lng, visit.name);
|
|
|
345 |
}
|
|
|
346 |
// 'next' — just added, continue on same day
|
|
|
347 |
}
|
|
|
348 |
|
|
|
349 |
function drawReturnHome() {
|
|
|
350 |
var day = curDay();
|
|
|
351 |
if (day.visits.length === 0) return;
|
|
|
352 |
var lastVisit = day.visits[day.visits.length - 1];
|
|
|
353 |
if (!lastVisit.lat || !state.homeLat) return;
|
|
|
354 |
|
|
|
355 |
var line = new google.maps.Polyline({
|
|
|
356 |
path: [{lat: lastVisit.lat, lng: lastVisit.lng}, {lat: state.homeLat, lng: state.homeLng}],
|
|
|
357 |
geodesic: true, strokeColor: '#22c55e', strokeOpacity: 0.6, strokeWeight: 2,
|
|
|
358 |
icons: [{icon: {path: 'M 0,-1 0,1', strokeOpacity: 1, scale: 3}, offset: '0', repeat: '15px'}],
|
|
|
359 |
map: map
|
|
|
360 |
});
|
|
|
361 |
routeLines.push(line);
|
|
|
362 |
|
|
|
363 |
// Add return distance to day total
|
|
|
364 |
var retDist = haversine(lastVisit.lat, lastVisit.lng, state.homeLat, state.homeLng) * ROAD_FACTOR;
|
|
|
365 |
day.totalKm += retDist;
|
|
|
366 |
day.totalMins += (retDist / AVG_SPEED) * 60;
|
|
|
367 |
updateBottomBar();
|
|
|
368 |
}
|
|
|
369 |
|
|
|
370 |
function startNextDay(startLat, startLng, startName) {
|
|
|
371 |
var nextNum = state.days.length + 1;
|
|
|
372 |
state.days.push({
|
|
|
373 |
dayNumber: nextNum, startLat: startLat, startLng: startLng, startName: startName,
|
|
|
374 |
visits: [], endAction: null, stayName: null, stayLat: null, stayLng: null, totalKm: 0, totalMins: 0
|
|
|
375 |
});
|
|
|
376 |
state.currentDay = nextNum;
|
|
|
377 |
updateBottomBar();
|
|
|
378 |
renderRoutePanel();
|
|
|
379 |
// Reset marker colors for unvisited
|
|
|
380 |
resetMarkerColors();
|
|
|
381 |
}
|
|
|
382 |
|
|
|
383 |
function resetMarkerColors() {
|
|
|
384 |
var allVisited = {};
|
|
|
385 |
state.days.forEach(function (d) {
|
|
|
386 |
d.visits.forEach(function (v) {
|
|
|
387 |
allVisited[v.id] = true;
|
|
|
388 |
});
|
|
|
389 |
});
|
|
|
390 |
|
|
|
391 |
state.partners.forEach(function (p) {
|
|
|
392 |
if (!markers[p.fofoId]) return;
|
|
|
393 |
if (allVisited[p.fofoId]) {
|
|
|
394 |
markers[p.fofoId].setIcon({
|
|
|
395 |
path: google.maps.SymbolPath.CIRCLE,
|
|
|
396 |
scale: 14,
|
|
|
397 |
fillColor: '#22c55e',
|
|
|
398 |
fillOpacity: 0.9,
|
|
|
399 |
strokeColor: '#fff',
|
|
|
400 |
strokeWeight: 1
|
|
|
401 |
});
|
|
|
402 |
} else {
|
|
|
403 |
markers[p.fofoId].setIcon({
|
|
|
404 |
path: google.maps.SymbolPath.CIRCLE,
|
|
|
405 |
scale: 14,
|
|
|
406 |
fillColor: '#ef4444',
|
|
|
407 |
fillOpacity: 0.9,
|
|
|
408 |
strokeColor: '#fff',
|
|
|
409 |
strokeWeight: 1
|
|
|
410 |
});
|
|
|
411 |
markers[p.fofoId].setLabel({text: p.code || '', color: '#fff', fontSize: '10px', fontWeight: '600'});
|
|
|
412 |
}
|
|
|
413 |
});
|
|
|
414 |
|
|
|
415 |
// Re-label current day visits
|
|
|
416 |
var day = curDay();
|
|
|
417 |
day.visits.forEach(function (v, i) {
|
|
|
418 |
if (markers[v.id]) markers[v.id].setLabel({
|
|
|
419 |
text: '' + (i + 1),
|
|
|
420 |
color: '#fff',
|
|
|
421 |
fontSize: '11px',
|
|
|
422 |
fontWeight: '700'
|
|
|
423 |
});
|
|
|
424 |
});
|
|
|
425 |
}
|
|
|
426 |
|
|
|
427 |
// ============ ROUTE DRAWING ============
|
|
|
428 |
function clearRoute() {
|
|
|
429 |
routeLines.forEach(function (l) {
|
|
|
430 |
l.setMap(null);
|
|
|
431 |
});
|
|
|
432 |
routeLines = [];
|
|
|
433 |
distLabels.forEach(function (l) {
|
|
|
434 |
l.setMap(null);
|
|
|
435 |
});
|
|
|
436 |
distLabels = [];
|
|
|
437 |
}
|
|
|
438 |
|
|
|
439 |
function drawRoute() {
|
|
|
440 |
clearRoute();
|
|
|
441 |
var day = curDay();
|
|
|
442 |
var pts = [{lat: day.startLat, lng: day.startLng}];
|
|
|
443 |
day.visits.forEach(function (v) {
|
|
|
444 |
if (v.lat && v.lng) pts.push({lat: v.lat, lng: v.lng});
|
|
|
445 |
});
|
|
|
446 |
|
|
|
447 |
for (var i = 0; i < pts.length - 1; i++) {
|
|
|
448 |
var line = new google.maps.Polyline({
|
|
|
449 |
path: [pts[i], pts[i + 1]],
|
|
|
450 |
geodesic: true,
|
|
|
451 |
strokeColor: '#3b82f6',
|
|
|
452 |
strokeOpacity: 0.9,
|
|
|
453 |
strokeWeight: 3,
|
|
|
454 |
map: map
|
|
|
455 |
});
|
|
|
456 |
routeLines.push(line);
|
|
|
457 |
|
|
|
458 |
var d = haversine(pts[i].lat, pts[i].lng, pts[i + 1].lat, pts[i + 1].lng) * ROAD_FACTOR;
|
|
|
459 |
var mid = {lat: (pts[i].lat + pts[i + 1].lat) / 2, lng: (pts[i].lng + pts[i + 1].lng) / 2};
|
|
|
460 |
var lbl = new google.maps.Marker({
|
|
|
461 |
position: mid, map: map, icon: {path: google.maps.SymbolPath.CIRCLE, scale: 0},
|
|
|
462 |
label: {text: d.toFixed(1) + 'km', color: '#93c5fd', fontSize: '10px', fontWeight: '600'}
|
|
|
463 |
});
|
|
|
464 |
distLabels.push(lbl);
|
|
|
465 |
}
|
|
|
466 |
}
|
|
|
467 |
|
|
|
468 |
function recalcDay() {
|
|
|
469 |
var day = curDay();
|
|
|
470 |
var pts = [{lat: day.startLat, lng: day.startLng}];
|
|
|
471 |
day.visits.forEach(function (v) {
|
|
|
472 |
if (v.lat && v.lng) pts.push({lat: v.lat, lng: v.lng});
|
|
|
473 |
});
|
|
|
474 |
|
|
|
475 |
var km = 0;
|
|
|
476 |
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;
|
|
|
477 |
day.totalKm = km;
|
|
|
478 |
day.totalMins = (km / AVG_SPEED) * 60 + day.visits.length * VISIT_MINS;
|
|
|
479 |
updateBottomBar();
|
|
|
480 |
}
|
|
|
481 |
|
|
|
482 |
function updateBottomBar() {
|
|
|
483 |
var day = curDay();
|
|
|
484 |
$('#bb-day-label').text('Day ' + day.dayNumber);
|
|
|
485 |
$('#bb-stops').text(day.visits.length + ' stops');
|
|
|
486 |
$('#bb-distance').text(day.totalKm.toFixed(1) + ' km');
|
|
|
487 |
$('#bb-time').text(fmtMins(day.totalMins));
|
|
|
488 |
|
|
|
489 |
var pct = Math.min((day.totalMins / DAY_LIMIT) * 100, 100);
|
|
|
490 |
var col = pct < 78 ? '#22c55e' : pct < 89 ? '#f59e0b' : '#ef4444';
|
|
|
491 |
$('#bb-progress').css({width: pct + '%', background: col});
|
|
|
492 |
}
|
|
|
493 |
|
|
|
494 |
// ============ ROUTE PANEL ============
|
|
|
495 |
function renderRoutePanel() {
|
|
|
496 |
var html = '';
|
|
|
497 |
state.days.forEach(function (day) {
|
|
|
498 |
var isCurrent = day.dayNumber === state.currentDay;
|
|
|
499 |
html += '<div class="route-day">';
|
|
|
500 |
html += '<div class="route-day-header">';
|
|
|
501 |
html += '<span>Day ' + day.dayNumber + (isCurrent ? ' (current)' : '') + '</span>';
|
|
|
502 |
html += '<span>' + fmtMins(day.totalMins) + '</span>';
|
|
|
503 |
html += '</div>';
|
|
|
504 |
|
|
|
505 |
// Start point
|
|
|
506 |
html += '<div class="route-stop route-stop-home"><span class="stop-num">H</span><div class="stop-info"><div class="stop-name">' + (day.startName || 'Start') + '</div></div></div>';
|
|
|
507 |
|
|
|
508 |
// Visits with distance between each
|
|
|
509 |
var prevLat = day.startLat, prevLng = day.startLng;
|
|
|
510 |
day.visits.forEach(function (v, i) {
|
|
|
511 |
// Distance connector from previous point
|
|
|
512 |
var segDist = 0, segTime = 0;
|
|
|
513 |
if (prevLat && prevLng && v.lat && v.lng) {
|
|
|
514 |
segDist = haversine(prevLat, prevLng, v.lat, v.lng) * ROAD_FACTOR;
|
|
|
515 |
segTime = (segDist / AVG_SPEED) * 60;
|
|
|
516 |
}
|
|
|
517 |
if (segDist > 0) {
|
|
|
518 |
html += '<div class="route-segment"><span class="seg-line"></span><span class="seg-dist">' + segDist.toFixed(1) + ' km | ' + fmtMins(segTime) + '</span></div>';
|
|
|
519 |
}
|
|
|
520 |
|
|
|
521 |
html += '<div class="route-stop route-stop-clickable" data-fofoid="' + v.id + '" data-daynum="' + day.dayNumber + '" style="cursor:pointer;">';
|
|
|
522 |
html += '<span class="stop-num">' + (i + 1) + '</span>';
|
|
|
523 |
html += '<div class="stop-info"><div class="stop-name">' + (v.code || '') + '</div>';
|
|
|
524 |
html += '<div class="stop-meta">' + (v.name || '') + '</div></div>';
|
|
|
525 |
html += '<span class="stop-remove" data-fofoid="' + v.id + '" data-daynum="' + day.dayNumber + '" title="Remove">×</span>';
|
|
|
526 |
html += '</div>';
|
|
|
527 |
|
|
|
528 |
prevLat = v.lat;
|
|
|
529 |
prevLng = v.lng;
|
|
|
530 |
});
|
|
|
531 |
|
|
|
532 |
// Return home distance
|
|
|
533 |
if (day.endAction === 'HOME' && day.visits.length > 0) {
|
|
|
534 |
var lastV = day.visits[day.visits.length - 1];
|
|
|
535 |
var retDist = 0, retTime = 0;
|
|
|
536 |
if (lastV.lat && lastV.lng && state.homeLat && state.homeLng) {
|
|
|
537 |
retDist = haversine(lastV.lat, lastV.lng, state.homeLat, state.homeLng) * ROAD_FACTOR;
|
|
|
538 |
retTime = (retDist / AVG_SPEED) * 60;
|
|
|
539 |
}
|
|
|
540 |
if (retDist > 0) {
|
|
|
541 |
html += '<div class="route-segment"><span class="seg-line seg-line-home"></span><span class="seg-dist">' + retDist.toFixed(1) + ' km | ' + fmtMins(retTime) + '</span></div>';
|
|
|
542 |
}
|
|
|
543 |
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>';
|
|
|
544 |
}
|
|
|
545 |
|
|
|
546 |
html += '<div class="day-summary">' + day.visits.length + ' stops | ' + day.totalKm.toFixed(1) + ' km</div>';
|
|
|
547 |
html += '</div>';
|
|
|
548 |
});
|
|
|
549 |
|
|
|
550 |
$('#route-list').html(html);
|
|
|
551 |
}
|
|
|
552 |
|
|
|
553 |
// Click on stop name in route panel → show popup on map
|
|
|
554 |
$(document).on('click', '.route-stop-clickable', function (e) {
|
|
|
555 |
if ($(e.target).hasClass('stop-remove')) return; // let remove handler handle it
|
|
|
556 |
var fofoId = parseInt($(this).data('fofoid'));
|
|
|
557 |
if (!fofoId || !markers[fofoId]) return;
|
|
|
558 |
|
|
|
559 |
var marker = markers[fofoId];
|
|
|
560 |
var p = state.partners.find(function (x) {
|
|
|
561 |
return x.fofoId === fofoId;
|
|
|
562 |
});
|
|
|
563 |
if (!p) return;
|
|
|
564 |
|
|
|
565 |
map.panTo(marker.getPosition());
|
|
|
566 |
map.setZoom(12);
|
|
|
567 |
|
|
|
568 |
var name = p.outletName || p.businessName || '';
|
|
|
569 |
var addr = p.address || '';
|
|
|
570 |
infoWindow.setContent('<div class="marker-popup"><h4>' + p.code + ' - ' + name + '</h4><div class="popup-meta">' + addr + '</div></div>');
|
|
|
571 |
infoWindow.open(map, marker);
|
|
|
572 |
});
|
|
|
573 |
|
|
|
574 |
// Remove stop from route panel
|
|
|
575 |
$(document).on('click', '.stop-remove', function (e) {
|
|
|
576 |
e.stopPropagation();
|
|
|
577 |
var fofoId = parseInt($(this).data('fofoid'));
|
|
|
578 |
var dayNum = parseInt($(this).data('daynum'));
|
|
|
579 |
|
|
|
580 |
var day = state.days[dayNum - 1];
|
|
|
581 |
if (!day) return;
|
|
|
582 |
|
|
|
583 |
// Remove visit from day
|
|
|
584 |
day.visits = day.visits.filter(function (v) {
|
|
|
585 |
return v.id !== fofoId;
|
|
|
586 |
});
|
|
|
587 |
|
|
|
588 |
// Reset marker back to red (unvisited)
|
|
|
589 |
if (markers[fofoId]) {
|
|
|
590 |
var p = state.partners.find(function (x) {
|
|
|
591 |
return x.fofoId === fofoId;
|
|
|
592 |
});
|
|
|
593 |
markers[fofoId].setIcon({
|
|
|
594 |
path: google.maps.SymbolPath.CIRCLE, scale: 14,
|
|
|
595 |
fillColor: '#ef4444', fillOpacity: 0.9, strokeColor: '#fff', strokeWeight: 1
|
|
|
596 |
});
|
|
|
597 |
markers[fofoId].setLabel({text: (p ? p.code : '') || '', color: '#fff', fontSize: '10px', fontWeight: '600'});
|
|
|
598 |
}
|
|
|
599 |
|
|
|
600 |
// Re-number remaining markers for this day
|
|
|
601 |
day.visits.forEach(function (v, i) {
|
|
|
602 |
if (markers[v.id]) {
|
|
|
603 |
markers[v.id].setLabel({text: '' + (i + 1), color: '#fff', fontSize: '11px', fontWeight: '700'});
|
|
|
604 |
}
|
|
|
605 |
});
|
|
|
606 |
|
|
|
607 |
recalcDay();
|
|
|
608 |
drawRoute();
|
|
|
609 |
renderRoutePanel();
|
|
|
610 |
updateBottomBar();
|
|
|
611 |
});
|
|
|
612 |
|
|
|
613 |
// ============ END DAY / FINISH ============
|
|
|
614 |
$('#btn-end-day').on('click', function () {
|
|
|
615 |
var day = curDay();
|
|
|
616 |
if (day.visits.length === 0) {
|
|
|
617 |
alert('Add at least one stop');
|
|
|
618 |
return;
|
|
|
619 |
}
|
|
|
620 |
|
|
|
621 |
$('#daybreak-info').text('Day ' + day.dayNumber + ': ' + day.visits.length + ' stops, ' + day.totalKm.toFixed(1) + ' km, ' + fmtMins(day.totalMins));
|
|
|
622 |
$('#modal-daybreak').addClass('show');
|
|
|
623 |
});
|
|
|
624 |
|
|
|
625 |
$('input[name="stay"]').on('change', function () {
|
|
|
626 |
$('#hotel-fields').toggle($(this).val() === 'HOTEL');
|
|
|
627 |
if ($(this).val() === 'HOTEL') {
|
|
|
628 |
setTimeout(function () {
|
|
|
629 |
if (!window._hotelMap) {
|
|
|
630 |
window._hotelMap = new google.maps.Map(document.getElementById('hotel-map'), {
|
|
|
631 |
center: {
|
|
|
632 |
lat: 28.6,
|
|
|
633 |
lng: 77.2
|
|
|
634 |
}, zoom: 6
|
|
|
635 |
});
|
|
|
636 |
var hm = new google.maps.Marker({map: window._hotelMap, draggable: true});
|
|
|
637 |
var hac = new google.maps.places.Autocomplete(document.getElementById('hotel-search'), {componentRestrictions: {country: 'in'}});
|
|
|
638 |
hac.addListener('place_changed', function () {
|
|
|
639 |
var p = hac.getPlace();
|
|
|
640 |
if (!p.geometry) return;
|
|
|
641 |
window._hotelMap.setCenter(p.geometry.location);
|
|
|
642 |
window._hotelMap.setZoom(14);
|
|
|
643 |
hm.setPosition(p.geometry.location);
|
|
|
644 |
state._hotelLat = p.geometry.location.lat();
|
|
|
645 |
state._hotelLng = p.geometry.location.lng();
|
|
|
646 |
state._hotelName = p.name || p.formatted_address;
|
|
|
647 |
});
|
|
|
648 |
hm.addListener('dragend', function () {
|
|
|
649 |
state._hotelLat = hm.getPosition().lat();
|
|
|
650 |
state._hotelLng = hm.getPosition().lng();
|
|
|
651 |
});
|
|
|
652 |
}
|
|
|
653 |
}, 200);
|
|
|
654 |
}
|
|
|
655 |
});
|
|
|
656 |
|
|
|
657 |
$('#daybreak-cancel').on('click', function () {
|
|
|
658 |
$('#modal-daybreak').removeClass('show');
|
|
|
659 |
});
|
|
|
660 |
$('#daybreak-confirm').on('click', function () {
|
|
|
661 |
var day = curDay();
|
|
|
662 |
var opt = $('input[name="stay"]:checked').val();
|
|
|
663 |
day.endAction = opt;
|
|
|
664 |
|
|
|
665 |
if (opt === 'HOME') {
|
|
|
666 |
day.stayLat = state.homeLat;
|
|
|
667 |
day.stayLng = state.homeLng;
|
|
|
668 |
day.stayName = state.homeName;
|
|
|
669 |
drawReturnHome();
|
|
|
670 |
startNextDay(state.homeLat, state.homeLng, state.homeName);
|
|
|
671 |
} else {
|
|
|
672 |
if (!state._hotelLat) {
|
|
|
673 |
alert('Select hotel location');
|
|
|
674 |
return;
|
|
|
675 |
}
|
|
|
676 |
day.stayLat = state._hotelLat;
|
|
|
677 |
day.stayLng = state._hotelLng;
|
|
|
678 |
day.stayName = state._hotelName;
|
|
|
679 |
startNextDay(state._hotelLat, state._hotelLng, state._hotelName);
|
|
|
680 |
state._hotelLat = null;
|
|
|
681 |
state._hotelLng = null;
|
|
|
682 |
}
|
|
|
683 |
$('#modal-daybreak').removeClass('show');
|
|
|
684 |
renderRoutePanel();
|
|
|
685 |
});
|
|
|
686 |
|
|
|
687 |
$('#btn-finish').on('click', function () {
|
|
|
688 |
if (state.days.every(function (d) {
|
|
|
689 |
return d.visits.length === 0;
|
|
|
690 |
})) {
|
|
|
691 |
alert('No visits added');
|
|
|
692 |
return;
|
|
|
693 |
}
|
|
|
694 |
|
|
|
695 |
// Ask for beat title
|
|
|
696 |
var beatTitle = prompt('Enter a name for this beat:', '');
|
|
|
697 |
if (beatTitle === null) return; // cancelled
|
|
|
698 |
if (!beatTitle.trim()) {
|
|
|
699 |
alert('Please enter a beat name');
|
|
|
700 |
return;
|
|
|
701 |
}
|
|
|
702 |
|
|
|
703 |
// Build plan data and save to DB first
|
|
|
704 |
var daysData = [];
|
|
|
705 |
state.days.forEach(function (day) {
|
|
|
706 |
if (day.visits.length === 0) return;
|
|
|
707 |
daysData.push({
|
|
|
708 |
dayNumber: day.dayNumber,
|
|
|
709 |
startLocationName: day.startName,
|
|
|
710 |
startLatitude: day.startLat ? day.startLat.toString() : null,
|
|
|
711 |
startLongitude: day.startLng ? day.startLng.toString() : null,
|
|
|
712 |
endAction: day.endAction,
|
|
|
713 |
stayLocationName: day.stayName,
|
|
|
714 |
stayLatitude: day.stayLat ? day.stayLat.toString() : null,
|
|
|
715 |
stayLongitude: day.stayLng ? day.stayLng.toString() : null,
|
|
|
716 |
totalDistanceKm: day.totalKm,
|
|
|
717 |
totalTimeMins: Math.round(day.totalMins),
|
|
|
718 |
visits: day.visits.map(function (v) {
|
|
|
719 |
return {id: v.id, type: v.type || 'partner'};
|
|
|
720 |
})
|
|
|
721 |
});
|
|
|
722 |
});
|
|
|
723 |
|
|
|
724 |
// Dates will be assigned later via calendar — pass nulls for now
|
|
|
725 |
var dates = daysData.map(function () {
|
|
|
726 |
return null;
|
|
|
727 |
});
|
|
|
728 |
var planData = JSON.stringify({days: daysData, dates: dates, beatName: beatTitle.trim()});
|
|
|
729 |
|
|
|
730 |
$('#btn-finish').prop('disabled', true).text('Saving...');
|
|
|
731 |
|
|
|
732 |
$.ajax({
|
|
|
733 |
url: context + "/beatPlan/submitPlan",
|
|
|
734 |
type: "POST",
|
|
|
735 |
data: {authUserId: state.authUserId, planData: planData},
|
|
|
736 |
success: function (response) {
|
|
|
737 |
var data = (typeof response === 'string' ? JSON.parse(response) : response);
|
|
|
738 |
var result = data.response || data;
|
|
|
739 |
$('#btn-finish').prop('disabled', false).text('Finish Plan');
|
|
|
740 |
|
|
|
741 |
if (result.status) {
|
|
|
742 |
state.savedPlanGroupId = result.planGroupId;
|
|
|
743 |
if (result.duplicate) {
|
|
|
744 |
alert('This beat already exists (same visits). Switching to calendar.');
|
|
|
745 |
}
|
|
|
746 |
|
|
|
747 |
// Switch to calendar tab
|
|
|
748 |
$('.panel-tab').removeClass('active');
|
|
|
749 |
$('.panel-tab[data-tab="calendar"]').addClass('active');
|
|
|
750 |
$('#panel-route').hide();
|
|
|
751 |
$('#panel-calendar').show();
|
|
|
752 |
$('#cal-grid-container').show();
|
|
|
753 |
$('#bottom-bar').hide();
|
|
|
754 |
|
|
|
755 |
var now = new Date();
|
|
|
756 |
state.calMonth = now.getFullYear() + '-' + ('0' + (now.getMonth() + 1)).slice(-2);
|
|
|
757 |
loadCalendarData();
|
|
|
758 |
} else {
|
|
|
759 |
alert('Error saving beat plan');
|
|
|
760 |
}
|
|
|
761 |
},
|
|
|
762 |
error: function (xhr) {
|
|
|
763 |
$('#btn-finish').prop('disabled', false).text('Finish Plan');
|
|
|
764 |
alert('Error: ' + (xhr.responseText || xhr.statusText));
|
|
|
765 |
}
|
|
|
766 |
});
|
|
|
767 |
});
|
|
|
768 |
|
|
|
769 |
// ============ PANEL TABS ============
|
|
|
770 |
$('.panel-tab').on('click', function () {
|
|
|
771 |
var tab = $(this).data('tab');
|
|
|
772 |
$('.panel-tab').removeClass('active');
|
|
|
773 |
$(this).addClass('active');
|
|
|
774 |
$('#panel-route').toggle(tab === 'route');
|
|
|
775 |
$('#panel-calendar').toggle(tab === 'calendar');
|
|
|
776 |
$('#cal-grid-container').toggle(tab === 'calendar');
|
|
|
777 |
$('#bottom-bar').toggle(tab === 'route' && state.days.length > 0);
|
|
|
778 |
|
|
|
779 |
if (tab === 'calendar' && !state.calMonth) {
|
|
|
780 |
var now = new Date();
|
|
|
781 |
state.calMonth = now.getFullYear() + '-' + ('0' + (now.getMonth() + 1)).slice(-2);
|
|
|
782 |
loadCalendarData();
|
|
|
783 |
}
|
|
|
784 |
});
|
|
|
785 |
|
|
|
786 |
// ============ CALENDAR ============
|
|
|
787 |
$('#cal-prev').on('click', function () {
|
|
|
788 |
navMonth(-1);
|
|
|
789 |
});
|
|
|
790 |
$('#cal-next').on('click', function () {
|
|
|
791 |
navMonth(1);
|
|
|
792 |
});
|
|
|
793 |
|
|
|
794 |
function navMonth(delta) {
|
|
|
795 |
var p = state.calMonth.split('-');
|
|
|
796 |
var d = new Date(parseInt(p[0]), parseInt(p[1]) - 1 + delta, 1);
|
|
|
797 |
state.calMonth = d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2);
|
|
|
798 |
loadCalendarData();
|
|
|
799 |
}
|
|
|
800 |
|
|
|
801 |
function loadCalendarData() {
|
|
|
802 |
if (!state.authUserId) return;
|
|
|
803 |
$('#cal-month-label').text(state.calMonth);
|
|
|
804 |
|
|
|
805 |
$.ajax({
|
|
|
806 |
url: context + "/beatPlan/calendar", type: "GET", dataType: "json",
|
|
|
807 |
data: {authUserId: state.authUserId, month: state.calMonth},
|
|
|
808 |
success: function (r) {
|
|
|
809 |
var data = r.response || r;
|
|
|
810 |
state.calData = data;
|
|
|
811 |
renderCalGrid(data);
|
|
|
812 |
renderBeatCards(data.scheduledBeats);
|
|
|
813 |
}
|
|
|
814 |
});
|
|
|
815 |
}
|
|
|
816 |
|
|
|
817 |
function renderCalGrid(data) {
|
|
|
818 |
var p = state.calMonth.split('-');
|
|
|
819 |
var year = parseInt(p[0]), month = parseInt(p[1]) - 1;
|
|
|
820 |
var first = new Date(year, month, 1);
|
|
|
821 |
var last = new Date(year, month + 1, 0);
|
|
|
822 |
var months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
|
823 |
$('#cal-month-label').text(months[month] + ' ' + year);
|
|
|
824 |
|
|
|
825 |
var holidayMap = {};
|
|
|
826 |
(data.holidays || []).forEach(function (h) {
|
|
|
827 |
holidayMap[h.date] = h.occasion;
|
|
|
828 |
});
|
|
|
829 |
var blockedSet = {};
|
|
|
830 |
(data.blockedDates || []).forEach(function (d) {
|
|
|
831 |
blockedSet[d] = true;
|
|
|
832 |
});
|
|
|
833 |
|
|
|
834 |
var beatDateMap = {};
|
|
|
835 |
(data.scheduledBeats || []).forEach(function (b) {
|
|
|
836 |
(b.days || []).forEach(function (d) {
|
|
|
837 |
if (d.planDate) {
|
|
|
838 |
if (!beatDateMap[d.planDate]) beatDateMap[d.planDate] = [];
|
|
|
839 |
beatDateMap[d.planDate].push({
|
|
|
840 |
name: b.beatName,
|
|
|
841 |
color: b.beatColor,
|
|
|
842 |
day: d.dayNumber,
|
|
|
843 |
status: b.status,
|
|
|
844 |
visits: d.visitCount
|
|
|
845 |
});
|
|
|
846 |
}
|
|
|
847 |
});
|
|
|
848 |
});
|
|
|
849 |
|
|
|
850 |
var today = fmtDate(new Date());
|
|
|
851 |
var startOff = (first.getDay() + 6) % 7;
|
|
|
852 |
var calStart = new Date(year, month, 1 - startOff);
|
|
|
853 |
|
|
|
854 |
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>';
|
|
|
855 |
var d = new Date(calStart);
|
|
|
856 |
for (var w = 0; w < 6; w++) {
|
|
|
857 |
var hasDay = false;
|
|
|
858 |
var row = '<tr>';
|
|
|
859 |
for (var wd = 0; wd < 7; wd++) {
|
|
|
860 |
var ds = fmtDate(d);
|
|
|
861 |
var inMonth = d.getMonth() === month;
|
|
|
862 |
var sun = d.getDay() === 0;
|
|
|
863 |
var hol = !!holidayMap[ds];
|
|
|
864 |
var isToday = ds === today;
|
|
|
865 |
var beats = beatDateMap[ds] || [];
|
|
|
866 |
var isPicked = state.calPickedDates.indexOf(ds) > -1;
|
|
|
867 |
|
|
|
868 |
var cls = [];
|
|
|
869 |
if (sun) cls.push('blocked');
|
|
|
870 |
else if (hol) cls.push('holiday');
|
|
|
871 |
if (isToday) cls.push('today');
|
|
|
872 |
if (!inMonth) cls.push('blocked');
|
|
|
873 |
|
|
|
874 |
var style = inMonth ? '' : 'opacity:0.3;';
|
|
|
875 |
|
|
|
876 |
row += '<td class="' + cls.join(' ') + '" data-date="' + ds + '" style="' + style + '">';
|
|
|
877 |
row += '<div class="cal-date">' + d.getDate() + '</div>';
|
|
|
878 |
if (hol) row += '<div class="cal-holiday-label">' + holidayMap[ds] + '</div>';
|
|
|
879 |
|
|
|
880 |
beats.forEach(function (b) {
|
|
|
881 |
var chipCls = 'cal-chip';
|
|
|
882 |
if (b.status === 'running') chipCls += ' running';
|
|
|
883 |
if (b.status === 'completed') chipCls += ' completed';
|
|
|
884 |
row += '<span class="' + chipCls + '" style="background:' + b.color + '">' + b.name + ' D' + b.day + (b.status === 'running' ? ' [live]' : '') + ' (' + b.visits + ')</span>';
|
|
|
885 |
});
|
|
|
886 |
|
|
|
887 |
if (isPicked) {
|
|
|
888 |
var idx = state.calPickedDates.indexOf(ds) + 1;
|
|
|
889 |
row += '<span class="cal-chip" style="background:#3b82f6;border:1px dashed #93c5fd;">Day ' + idx + '</span>';
|
|
|
890 |
}
|
|
|
891 |
|
|
|
892 |
row += '</td>';
|
|
|
893 |
if (inMonth) hasDay = true;
|
|
|
894 |
d.setDate(d.getDate() + 1);
|
|
|
895 |
}
|
|
|
896 |
row += '</tr>';
|
|
|
897 |
if (hasDay) html += row;
|
|
|
898 |
}
|
|
|
899 |
html += '</table>';
|
|
|
900 |
$('#cal-grid-content').html(html);
|
|
|
901 |
}
|
|
|
902 |
|
|
|
903 |
function renderBeatCards(beats) {
|
|
|
904 |
var html = '';
|
|
|
905 |
if (!beats || beats.length === 0) {
|
|
|
906 |
html = '<p style="color:#64748b;font-size:12px;padding:20px;">No beats scheduled. Create a beat first.</p>';
|
|
|
907 |
}
|
|
|
908 |
(beats || []).forEach(function (b) {
|
|
|
909 |
var badge = b.status === 'running' ? '<span class="status-badge status-running">Live</span>' :
|
|
|
910 |
b.status === 'completed' ? '<span class="status-badge status-completed">Done</span>' :
|
|
|
911 |
b.status === 'unscheduled' ? '<span class="status-badge" style="background:#f59e0b;color:#0f172a;">Unscheduled</span>' :
|
|
|
912 |
'<span class="status-badge status-scheduled">Scheduled</span>';
|
|
|
913 |
|
|
|
914 |
var totalV = 0, totalKm = 0;
|
|
|
915 |
b.days.forEach(function (d) {
|
|
|
916 |
totalV += d.visitCount || 0;
|
|
|
917 |
totalKm += d.totalKm || 0;
|
|
|
918 |
});
|
|
|
919 |
|
|
|
920 |
html += '<div class="beat-card" data-plangroup="' + b.planGroupId + '">';
|
|
|
921 |
html += '<div class="beat-name"><span style="display:inline-block;width:12px;height:12px;border-radius:2px;background:' + b.beatColor + ';"></span> ' + b.beatName + ' ' + badge + '</div>';
|
|
|
922 |
html += '<div class="beat-meta">' + b.days.length + ' days | ' + totalV + ' visits | ' + totalKm.toFixed(0) + ' km</div>';
|
|
|
923 |
if (b.status !== 'running') {
|
|
|
924 |
var isScheduling = state.calSelectedBeat === b.planGroupId && state.calPickedDates.length > 0;
|
|
|
925 |
html += '<div class="beat-actions">';
|
|
|
926 |
if (isScheduling) {
|
|
|
927 |
html += '<button class="btn-sm btn-confirm-schedule" data-pg="' + b.planGroupId + '" data-days="' + b.days.length + '" style="background:#22c55e;color:#fff;">Confirm (' + state.calPickedDates.length + ' dates)</button>';
|
|
|
928 |
html += '<button class="btn-sm btn-cancel-schedule" style="background:#475569;color:#e2e8f0;">Cancel</button>';
|
|
|
929 |
} else {
|
|
|
930 |
html += '<button class="btn-sm btn-schedule" data-pg="' + b.planGroupId + '" data-days="' + b.days.length + '">Schedule</button>';
|
|
|
931 |
html += '<button class="btn-sm btn-repeat" data-pg="' + b.planGroupId + '" data-days="' + b.days.length + '">Repeat</button>';
|
|
|
932 |
}
|
|
|
933 |
html += '<button class="btn-sm" style="background:#ef4444;color:#fff;" data-pg="' + b.planGroupId + '" onclick="deleteBeat(\'' + b.planGroupId + '\')">Delete</button>';
|
|
|
934 |
html += '</div>';
|
|
|
935 |
if (isScheduling) {
|
|
|
936 |
html += '<div style="font-size:10px;color:#60a5fa;margin-top:4px;">Dates: ' + state.calPickedDates.join(', ') + '</div>';
|
|
|
937 |
html += '<div style="font-size:10px;color:#94a3b8;">Click calendar cells to adjust</div>';
|
|
|
938 |
}
|
|
|
939 |
}
|
|
|
940 |
html += '</div>';
|
|
|
941 |
});
|
|
|
942 |
$('#cal-beat-cards').html(html);
|
|
|
943 |
}
|
|
|
944 |
|
|
|
945 |
// Schedule button — suggest dates, show Confirm button in beat card
|
|
|
946 |
$(document).on('click', '.btn-schedule', function (e) {
|
|
|
947 |
e.stopPropagation();
|
|
|
948 |
var pg = $(this).data('pg');
|
|
|
949 |
var days = parseInt($(this).data('days'));
|
|
|
950 |
state.calSelectedBeat = pg;
|
|
|
951 |
state.calPickedDates = [];
|
|
|
952 |
|
|
|
953 |
$.ajax({
|
|
|
954 |
url: context + "/beatPlan/availableSlots", type: "GET", dataType: "json",
|
|
|
955 |
data: {authUserId: state.authUserId, month: state.calMonth, daysNeeded: days},
|
|
|
956 |
success: function (r) {
|
|
|
957 |
var data = r.response || r;
|
|
|
958 |
state.calPickedDates = data.suggestedDates || [];
|
|
|
959 |
renderCalGrid(state.calData);
|
|
|
960 |
renderBeatCards(state.calData.scheduledBeats);
|
|
|
961 |
if (state.calPickedDates.length < days) {
|
|
|
962 |
alert('Only ' + state.calPickedDates.length + ' slots available this month. Need ' + days + '.');
|
|
|
963 |
}
|
|
|
964 |
}
|
|
|
965 |
});
|
|
|
966 |
});
|
|
|
967 |
|
|
|
968 |
// Click calendar cell to toggle picked date
|
|
|
969 |
$(document).on('click', '#cal-grid-content td:not(.blocked)', function () {
|
|
|
970 |
if (!state.calSelectedBeat) return;
|
|
|
971 |
var ds = $(this).data('date');
|
|
|
972 |
var idx = state.calPickedDates.indexOf(ds);
|
|
|
973 |
if (idx > -1) state.calPickedDates.splice(idx, 1);
|
|
|
974 |
else state.calPickedDates.push(ds);
|
|
|
975 |
state.calPickedDates.sort();
|
|
|
976 |
renderCalGrid(state.calData);
|
|
|
977 |
renderBeatCards(state.calData.scheduledBeats);
|
|
|
978 |
});
|
|
|
979 |
|
|
|
980 |
// Cancel scheduling mode
|
|
|
981 |
$(document).on('click', '.btn-cancel-schedule', function (e) {
|
|
|
982 |
e.stopPropagation();
|
|
|
983 |
state.calSelectedBeat = null;
|
|
|
984 |
state.calPickedDates = [];
|
|
|
985 |
renderCalGrid(state.calData);
|
|
|
986 |
renderBeatCards(state.calData.scheduledBeats);
|
|
|
987 |
});
|
|
|
988 |
|
|
|
989 |
// Confirm schedule — save to server
|
|
|
990 |
$(document).on('click', '.btn-confirm-schedule', function (e) {
|
|
|
991 |
e.stopPropagation();
|
|
|
992 |
var pg = $(this).data('pg');
|
|
|
993 |
var daysNeeded = parseInt($(this).data('days'));
|
|
|
994 |
|
|
|
995 |
if (state.calPickedDates.length < daysNeeded) {
|
|
|
996 |
alert('Need ' + daysNeeded + ' dates but only ' + state.calPickedDates.length + ' selected.');
|
|
|
997 |
return;
|
|
|
998 |
}
|
|
|
999 |
|
|
|
1000 |
var beat = (state.calData.scheduledBeats || []).find(function (b) {
|
|
|
1001 |
return b.planGroupId === pg;
|
|
|
1002 |
});
|
|
|
1003 |
if (!beat) return;
|
|
|
1004 |
|
|
|
1005 |
$.ajax({
|
|
|
1006 |
url: context + "/beatPlan/scheduleOnCalendar", type: "POST",
|
|
|
1007 |
data: {
|
|
|
1008 |
planGroupId: pg,
|
|
|
1009 |
dates: JSON.stringify(state.calPickedDates.slice(0, daysNeeded)),
|
|
|
1010 |
beatName: beat.beatName,
|
|
|
1011 |
beatColor: beat.beatColor
|
|
|
1012 |
},
|
|
|
1013 |
success: function () {
|
|
|
1014 |
state.calSelectedBeat = null;
|
|
|
1015 |
state.calPickedDates = [];
|
|
|
1016 |
loadCalendarData();
|
|
|
1017 |
},
|
|
|
1018 |
error: function (xhr) {
|
|
|
1019 |
alert('Error: ' + (xhr.responseText || xhr.statusText));
|
|
|
1020 |
}
|
|
|
1021 |
});
|
|
|
1022 |
});
|
|
|
1023 |
|
|
|
1024 |
// Repeat
|
|
|
1025 |
$(document).on('click', '.btn-repeat', function (e) {
|
|
|
1026 |
e.stopPropagation();
|
|
|
1027 |
var pg = $(this).data('pg');
|
|
|
1028 |
var days = parseInt($(this).data('days'));
|
|
|
1029 |
|
|
|
1030 |
$.ajax({
|
|
|
1031 |
url: context + "/beatPlan/availableSlots", type: "GET", dataType: "json",
|
|
|
1032 |
data: {authUserId: state.authUserId, month: state.calMonth, daysNeeded: days},
|
|
|
1033 |
success: function (r) {
|
|
|
1034 |
var data = r.response || r;
|
|
|
1035 |
if ((data.suggestedDates || []).length < days) {
|
|
|
1036 |
alert('Not enough slots this month');
|
|
|
1037 |
return;
|
|
|
1038 |
}
|
|
|
1039 |
if (!confirm('Repeat beat on: ' + data.suggestedDates.join(', ') + '?')) return;
|
|
|
1040 |
|
|
|
1041 |
$.ajax({
|
|
|
1042 |
url: context + "/beatPlan/repeatBeat", type: "POST",
|
|
|
1043 |
data: {sourcePlanGroupId: pg, authUserId: state.authUserId, dates: JSON.stringify(data.suggestedDates)},
|
|
|
1044 |
success: function () {
|
|
|
1045 |
loadCalendarData();
|
|
|
1046 |
alert('Beat repeated!');
|
|
|
1047 |
}
|
|
|
1048 |
});
|
|
|
1049 |
}
|
|
|
1050 |
});
|
|
|
1051 |
});
|
|
|
1052 |
|
|
|
1053 |
// ============ DELETE BEAT ============
|
|
|
1054 |
function deleteBeat(planGroupId) {
|
|
|
1055 |
if (!confirm('Delete this beat? This cannot be undone.')) return;
|
|
|
1056 |
$.ajax({
|
|
|
1057 |
url: context + "/beatPlan/delete", type: "POST",
|
|
|
1058 |
data: {planGroupId: planGroupId},
|
|
|
1059 |
success: function () {
|
|
|
1060 |
loadCalendarData();
|
|
|
1061 |
},
|
|
|
1062 |
error: function (xhr) {
|
|
|
1063 |
alert('Error: ' + (xhr.responseText || xhr.statusText));
|
|
|
1064 |
}
|
|
|
1065 |
});
|
|
|
1066 |
}
|
|
|
1067 |
|
|
|
1068 |
// ============ HELPERS ============
|
|
|
1069 |
function curDay() {
|
|
|
1070 |
return state.days[state.currentDay - 1];
|
|
|
1071 |
}
|
|
|
1072 |
|
|
|
1073 |
function haversine(lat1, lng1, lat2, lng2) {
|
|
|
1074 |
var R = 6371, dLat = (lat2 - lat1) * Math.PI / 180, dLng = (lng2 - lng1) * Math.PI / 180;
|
|
|
1075 |
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);
|
|
|
1076 |
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
|
1077 |
}
|
|
|
1078 |
|
|
|
1079 |
function fmtMins(m) {
|
|
|
1080 |
return Math.floor(m / 60) + 'h ' + Math.round(m % 60) + 'm';
|
|
|
1081 |
}
|
|
|
1082 |
|
|
|
1083 |
function fmtDate(d) {
|
|
|
1084 |
return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2);
|
|
|
1085 |
}
|
|
|
1086 |
|
|
|
1087 |
function fitBounds() {
|
|
|
1088 |
if (Object.keys(markers).length > 0) {
|
|
|
1089 |
var b = new google.maps.LatLngBounds();
|
|
|
1090 |
if (homeMarker) b.extend(homeMarker.getPosition());
|
|
|
1091 |
for (var k in markers) b.extend(markers[k].getPosition());
|
|
|
1092 |
map.fitBounds(b);
|
|
|
1093 |
}
|
|
|
1094 |
}
|