Rev 36632 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | RSS feed
// ============================================================// BEAT PLAN APP — Map-First Route Planner// ============================================================var map, infoWindow, homeMarker, previewLine;var markers = {};var routeLines = [];var distLabels = [];var state = {authUserId: 0,categoryId: 4,homeLat: null, homeLng: null, homeName: '',currentDay: 1,days: [], // [{dayNumber, startLat, startLng, startName, visits:[], endAction, stayName, stayLat, stayLng, totalKm, totalMins}]partners: [],calMonth: null,calData: null,calSelectedBeat: null,calPickedDates: []};var VISIT_MINS = 30;var AVG_SPEED = 30;var DAY_LIMIT = 540;var ROAD_FACTOR = 1.3;var COLORS = ['#3b82f6', '#ef4444', '#22c55e', '#a855f7', '#f59e0b', '#06b6d4', '#ec4899', '#334155', '#14b8a6', '#dc2626'];// ============ TOP BAR ============$('#bp-level').on('change', function () {var level = $(this).val();$('#bp-auth-user').html('<option value="">Select User</option>');if (!level) return;$.ajax({url: context + "/beatPlan/getAuthUsers", type: "GET", dataType: "json",data: {categoryId: $('#bp-category').val(), escalationType: level},success: function (r) {var data = r.response || r;var html = '<option value="">Select User</option>';data.forEach(function (u) {html += '<option value="' + u.id + '">' + u.name + '</option>';});$('#bp-auth-user').html(html);}});});$('#bp-load').on('click', function () {var uid = $('#bp-auth-user').val();if (!uid) {alert('Select a user');return;}state.authUserId = parseInt(uid);state.categoryId = parseInt($('#bp-category').val());$('#bp-user-label').text($('#bp-auth-user option:selected').text());// Check home location$.ajax({url: context + "/beatPlan/getBaseLocation", type: "GET", dataType: "json",data: {authUserId: uid},success: function (r) {var data = r.response || r;if (data.locationName) {state.homeLat = parseFloat(data.latitude);state.homeLng = parseFloat(data.longitude);state.homeName = data.locationName;loadPartners();} else {showHomeModal();}}});});// ============ HOME LOCATION MODAL ============var homeMap, homeMapMarker;function showHomeModal() {$('#modal-home').addClass('show');setTimeout(function () {if (!homeMap) {homeMap = new google.maps.Map(document.getElementById('home-map'), {center: {lat: 28.6, lng: 77.2},zoom: 6});var ac = new google.maps.places.Autocomplete(document.getElementById('home-search'), {componentRestrictions: {country: 'in'}});homeMapMarker = new google.maps.Marker({map: homeMap, draggable: true});ac.addListener('place_changed', function () {var p = ac.getPlace();if (!p.geometry) return;homeMap.setCenter(p.geometry.location);homeMap.setZoom(14);homeMapMarker.setPosition(p.geometry.location);state.homeLat = p.geometry.location.lat();state.homeLng = p.geometry.location.lng();state.homeName = p.name || p.formatted_address || '';});homeMapMarker.addListener('dragend', function () {state.homeLat = homeMapMarker.getPosition().lat();state.homeLng = homeMapMarker.getPosition().lng();});homeMap.addListener('click', function (e) {homeMapMarker.setPosition(e.latLng);state.homeLat = e.latLng.lat();state.homeLng = e.latLng.lng();});}}, 200);}$('#home-cancel').on('click', function () {$('#modal-home').removeClass('show');});$('#home-confirm').on('click', function () {if (!state.homeLat) {alert('Please select a location');return;}$('#modal-home').removeClass('show');$.ajax({url: context + "/beatPlan/saveBaseLocation", type: "POST",data: {authUserId: state.authUserId,locationName: state.homeName || 'Home',latitude: state.homeLat,longitude: state.homeLng,address: $('#home-search').val()}});loadPartners();});// ============ LOAD PARTNERS + INIT MAP ============function loadPartners() {$.ajax({url: context + "/beatPlan/getPartners", type: "GET", dataType: "json",data: {authUserId: state.authUserId,categoryId: state.categoryId,startLat: state.homeLat,startLng: state.homeLng},success: function (r) {var data = r.response || r;state.partners = data.partners || [];initDay1();initMap();}});}function initDay1() {state.days = [{dayNumber: 1, startLat: state.homeLat, startLng: state.homeLng, startName: state.homeName,visits: [], endAction: null, stayName: null, stayLat: null, stayLng: null, totalKm: 0, totalMins: 0}];state.currentDay = 1;$('#bottom-bar').show();renderRoutePanel();}function initMap() {var center = {lat: state.homeLat, lng: state.homeLng};map = new google.maps.Map(document.getElementById('beat-map'), {center: center, zoom: 9,styles: [{elementType: 'geometry', stylers: [{color: '#1d2c4d'}]},{elementType: 'labels.text.fill', stylers: [{color: '#8ec3b9'}]},{elementType: 'labels.text.stroke', stylers: [{color: '#1a3646'}]},{featureType: 'road', elementType: 'geometry', stylers: [{color: '#304a7d'}]},{featureType: 'water', elementType: 'geometry', stylers: [{color: '#0e1626'}]}]});infoWindow = new google.maps.InfoWindow();// Home markerhomeMarker = new google.maps.Marker({map: map, position: center, title: 'Home: ' + state.homeName,icon: {url: 'https://maps.google.com/mapfiles/kml/shapes/homegardenbusiness.png',scaledSize: new google.maps.Size(32, 32)},zIndex: 100});// Partner markers with name labelsfor (var key in markers) {markers[key].setMap(null);}markers = {};state.partners.forEach(function (p) {if (!p.latitude || !p.longitude) return;var lat = parseFloat(p.latitude), lng = parseFloat(p.longitude);if (isNaN(lat) || isNaN(lng)) return;var m = new google.maps.Marker({map: map, position: {lat: lat, lng: lng},title: p.code + ' - ' + (p.outletName || p.businessName || ''),label: {text: p.code || '', color: '#fff', fontSize: '10px', fontWeight: '600'},icon: {path: google.maps.SymbolPath.CIRCLE,scale: 14,fillColor: '#ef4444',fillOpacity: 0.9,strokeColor: '#fff',strokeWeight: 1}});m.addListener('click', function () {showMarkerPopup(p, m);});m.addListener('mouseover', function () {showPreviewLine(p);});m.addListener('mouseout', function () {hidePreviewLine();});markers[p.fofoId] = m;});fitBounds();}// ============ HOVER PREVIEW ============function showPreviewLine(partner) {if (!partner.latitude || !partner.longitude) return;var day = curDay();var lastPt = day.visits.length > 0? {lat: day.visits[day.visits.length - 1].lat, lng: day.visits[day.visits.length - 1].lng}: {lat: day.startLat, lng: day.startLng};if (!lastPt.lat) return;var endPt = {lat: parseFloat(partner.latitude), lng: parseFloat(partner.longitude)};if (previewLine) previewLine.setMap(null);previewLine = new google.maps.Polyline({path: [lastPt, endPt],geodesic: true,strokeColor: '#60a5fa',strokeOpacity: 0.5,strokeWeight: 2,strokePattern: [10, 5],icons: [{icon: {path: 'M 0,-1 0,1', strokeOpacity: 1, scale: 3}, offset: '0', repeat: '15px'}],map: map});}function hidePreviewLine() {if (previewLine) {previewLine.setMap(null);previewLine = null;}}// ============ MARKER CLICK POPUP ============function showMarkerPopup(partner, marker) {var day = curDay();var alreadyAdded = day.visits.some(function (v) {return v.id === partner.fofoId;});if (alreadyAdded) {infoWindow.setContent('<div class="marker-popup"><h4>' + partner.code + '</h4><p>Already in today\'s route</p></div>');infoWindow.open(map, marker);return;}var lastPt = day.visits.length > 0? {lat: day.visits[day.visits.length - 1].lat, lng: day.visits[day.visits.length - 1].lng}: {lat: day.startLat, lng: day.startLng};var dist = 0, travelMins = 0;if (lastPt.lat && partner.latitude) {dist = haversine(lastPt.lat, lastPt.lng, parseFloat(partner.latitude), parseFloat(partner.longitude)) * ROAD_FACTOR;travelMins = (dist / AVG_SPEED) * 60;}var name = partner.outletName || partner.businessName || '';var addr = partner.address || '';var html = '<div class="marker-popup">';html += '<h4>' + partner.code + ' - ' + name + '</h4>';html += '<div class="popup-meta">' + addr + '</div>';html += '<div class="popup-distance">~' + dist.toFixed(1) + ' km | ~' + fmtMins(travelMins) + ' travel + ' + VISIT_MINS + 'min visit</div>';html += '<button class="popup-next" onclick="addVisit(' + partner.fofoId + ',\'next\')">Next Visit</button>';html += '<button class="popup-last" onclick="addVisit(' + partner.fofoId + ',\'last\')">Last Visit</button>';html += '<button class="popup-nextday" onclick="addVisit(' + partner.fofoId + ',\'nextday\')">Continue Next Day</button>';html += '</div>';infoWindow.setContent(html);infoWindow.open(map, marker);}// ============ ADD VISIT ============function addVisit(fofoId, action) {infoWindow.close();var p = state.partners.find(function (x) {return x.fofoId === fofoId;});if (!p) return;var day = curDay();var visit = {id: p.fofoId, type: 'partner',name: p.code + ' - ' + (p.outletName || p.businessName || ''),code: p.code,lat: p.latitude ? parseFloat(p.latitude) : null,lng: p.longitude ? parseFloat(p.longitude) : null};day.visits.push(visit);// Update marker to greenif (markers[fofoId]) {markers[fofoId].setIcon({path: google.maps.SymbolPath.CIRCLE, scale: 14,fillColor: '#22c55e', fillOpacity: 0.9, strokeColor: '#fff', strokeWeight: 2});markers[fofoId].setLabel({text: '' + day.visits.length, color: '#fff', fontSize: '11px', fontWeight: '700'});}recalcDay();drawRoute();renderRoutePanel();if (action === 'last') {// Last visit — end day, go homeday.endAction = 'HOME';day.stayLat = state.homeLat;day.stayLng = state.homeLng;day.stayName = state.homeName;drawReturnHome();renderRoutePanel();} else if (action === 'nextday') {// Continue next day — end current day, start new day from hereday.endAction = 'CONTINUE';day.stayLat = visit.lat;day.stayLng = visit.lng;day.stayName = visit.name;startNextDay(visit.lat, visit.lng, visit.name);}// 'next' — just added, continue on same day}function drawReturnHome() {var day = curDay();if (day.visits.length === 0) return;var lastVisit = day.visits[day.visits.length - 1];if (!lastVisit.lat || !state.homeLat) return;var line = new google.maps.Polyline({path: [{lat: lastVisit.lat, lng: lastVisit.lng}, {lat: state.homeLat, lng: state.homeLng}],geodesic: true, strokeColor: '#22c55e', strokeOpacity: 0.6, strokeWeight: 2,icons: [{icon: {path: 'M 0,-1 0,1', strokeOpacity: 1, scale: 3}, offset: '0', repeat: '15px'}],map: map});routeLines.push(line);// Add return distance to day totalvar retDist = haversine(lastVisit.lat, lastVisit.lng, state.homeLat, state.homeLng) * ROAD_FACTOR;day.totalKm += retDist;day.totalMins += (retDist / AVG_SPEED) * 60;updateBottomBar();}function startNextDay(startLat, startLng, startName) {var nextNum = state.days.length + 1;state.days.push({dayNumber: nextNum, startLat: startLat, startLng: startLng, startName: startName,visits: [], endAction: null, stayName: null, stayLat: null, stayLng: null, totalKm: 0, totalMins: 0});state.currentDay = nextNum;updateBottomBar();renderRoutePanel();// Reset marker colors for unvisitedresetMarkerColors();}function resetMarkerColors() {var allVisited = {};state.days.forEach(function (d) {d.visits.forEach(function (v) {allVisited[v.id] = true;});});state.partners.forEach(function (p) {if (!markers[p.fofoId]) return;if (allVisited[p.fofoId]) {markers[p.fofoId].setIcon({path: google.maps.SymbolPath.CIRCLE,scale: 14,fillColor: '#22c55e',fillOpacity: 0.9,strokeColor: '#fff',strokeWeight: 1});} else {markers[p.fofoId].setIcon({path: google.maps.SymbolPath.CIRCLE,scale: 14,fillColor: '#ef4444',fillOpacity: 0.9,strokeColor: '#fff',strokeWeight: 1});markers[p.fofoId].setLabel({text: p.code || '', color: '#fff', fontSize: '10px', fontWeight: '600'});}});// Re-label current day visitsvar day = curDay();day.visits.forEach(function (v, i) {if (markers[v.id]) markers[v.id].setLabel({text: '' + (i + 1),color: '#fff',fontSize: '11px',fontWeight: '700'});});}// ============ ROUTE DRAWING ============function clearRoute() {routeLines.forEach(function (l) {l.setMap(null);});routeLines = [];distLabels.forEach(function (l) {l.setMap(null);});distLabels = [];}function drawRoute() {clearRoute();var day = curDay();var pts = [{lat: day.startLat, lng: day.startLng}];day.visits.forEach(function (v) {if (v.lat && v.lng) pts.push({lat: v.lat, lng: v.lng});});for (var i = 0; i < pts.length - 1; i++) {var line = new google.maps.Polyline({path: [pts[i], pts[i + 1]],geodesic: true,strokeColor: '#3b82f6',strokeOpacity: 0.9,strokeWeight: 3,map: map});routeLines.push(line);var d = haversine(pts[i].lat, pts[i].lng, pts[i + 1].lat, pts[i + 1].lng) * ROAD_FACTOR;var mid = {lat: (pts[i].lat + pts[i + 1].lat) / 2, lng: (pts[i].lng + pts[i + 1].lng) / 2};var lbl = new google.maps.Marker({position: mid, map: map, icon: {path: google.maps.SymbolPath.CIRCLE, scale: 0},label: {text: d.toFixed(1) + 'km', color: '#93c5fd', fontSize: '10px', fontWeight: '600'}});distLabels.push(lbl);}}function recalcDay() {var day = curDay();var pts = [{lat: day.startLat, lng: day.startLng}];day.visits.forEach(function (v) {if (v.lat && v.lng) pts.push({lat: v.lat, lng: v.lng});});var km = 0;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;day.totalKm = km;day.totalMins = (km / AVG_SPEED) * 60 + day.visits.length * VISIT_MINS;updateBottomBar();}function updateBottomBar() {var day = curDay();$('#bb-day-label').text('Day ' + day.dayNumber);$('#bb-stops').text(day.visits.length + ' stops');$('#bb-distance').text(day.totalKm.toFixed(1) + ' km');$('#bb-time').text(fmtMins(day.totalMins));var pct = Math.min((day.totalMins / DAY_LIMIT) * 100, 100);var col = pct < 78 ? '#22c55e' : pct < 89 ? '#f59e0b' : '#ef4444';$('#bb-progress').css({width: pct + '%', background: col});}// ============ ROUTE PANEL ============function renderRoutePanel() {var html = '';state.days.forEach(function (day) {var isCurrent = day.dayNumber === state.currentDay;html += '<div class="route-day">';html += '<div class="route-day-header">';html += '<span>Day ' + day.dayNumber + (isCurrent ? ' (current)' : '') + '</span>';html += '<span>' + fmtMins(day.totalMins) + '</span>';html += '</div>';// Start pointhtml += '<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>';// Visits with distance between eachvar prevLat = day.startLat, prevLng = day.startLng;day.visits.forEach(function (v, i) {// Distance connector from previous pointvar segDist = 0, segTime = 0;if (prevLat && prevLng && v.lat && v.lng) {segDist = haversine(prevLat, prevLng, v.lat, v.lng) * ROAD_FACTOR;segTime = (segDist / AVG_SPEED) * 60;}if (segDist > 0) {html += '<div class="route-segment"><span class="seg-line"></span><span class="seg-dist">' + segDist.toFixed(1) + ' km | ' + fmtMins(segTime) + '</span></div>';}html += '<div class="route-stop route-stop-clickable" data-fofoid="' + v.id + '" data-daynum="' + day.dayNumber + '" style="cursor:pointer;">';html += '<span class="stop-num">' + (i + 1) + '</span>';html += '<div class="stop-info"><div class="stop-name">' + (v.code || '') + '</div>';html += '<div class="stop-meta">' + (v.name || '') + '</div></div>';html += '<span class="stop-remove" data-fofoid="' + v.id + '" data-daynum="' + day.dayNumber + '" title="Remove">×</span>';html += '</div>';prevLat = v.lat;prevLng = v.lng;});// Return home distanceif (day.endAction === 'HOME' && day.visits.length > 0) {var lastV = day.visits[day.visits.length - 1];var retDist = 0, retTime = 0;if (lastV.lat && lastV.lng && state.homeLat && state.homeLng) {retDist = haversine(lastV.lat, lastV.lng, state.homeLat, state.homeLng) * ROAD_FACTOR;retTime = (retDist / AVG_SPEED) * 60;}if (retDist > 0) {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>';}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>';}html += '<div class="day-summary">' + day.visits.length + ' stops | ' + day.totalKm.toFixed(1) + ' km</div>';html += '</div>';});$('#route-list').html(html);}// Click on stop name in route panel → show popup on map$(document).on('click', '.route-stop-clickable', function (e) {if ($(e.target).hasClass('stop-remove')) return; // let remove handler handle itvar fofoId = parseInt($(this).data('fofoid'));if (!fofoId || !markers[fofoId]) return;var marker = markers[fofoId];var p = state.partners.find(function (x) {return x.fofoId === fofoId;});if (!p) return;map.panTo(marker.getPosition());map.setZoom(12);var name = p.outletName || p.businessName || '';var addr = p.address || '';infoWindow.setContent('<div class="marker-popup"><h4>' + p.code + ' - ' + name + '</h4><div class="popup-meta">' + addr + '</div></div>');infoWindow.open(map, marker);});// Remove stop from route panel$(document).on('click', '.stop-remove', function (e) {e.stopPropagation();var fofoId = parseInt($(this).data('fofoid'));var dayNum = parseInt($(this).data('daynum'));var day = state.days[dayNum - 1];if (!day) return;// Remove visit from dayday.visits = day.visits.filter(function (v) {return v.id !== fofoId;});// Reset marker back to red (unvisited)if (markers[fofoId]) {var p = state.partners.find(function (x) {return x.fofoId === fofoId;});markers[fofoId].setIcon({path: google.maps.SymbolPath.CIRCLE, scale: 14,fillColor: '#ef4444', fillOpacity: 0.9, strokeColor: '#fff', strokeWeight: 1});markers[fofoId].setLabel({text: (p ? p.code : '') || '', color: '#fff', fontSize: '10px', fontWeight: '600'});}// Re-number remaining markers for this dayday.visits.forEach(function (v, i) {if (markers[v.id]) {markers[v.id].setLabel({text: '' + (i + 1), color: '#fff', fontSize: '11px', fontWeight: '700'});}});recalcDay();drawRoute();renderRoutePanel();updateBottomBar();});// ============ END DAY / FINISH ============$('#btn-end-day').on('click', function () {var day = curDay();if (day.visits.length === 0) {alert('Add at least one stop');return;}$('#daybreak-info').text('Day ' + day.dayNumber + ': ' + day.visits.length + ' stops, ' + day.totalKm.toFixed(1) + ' km, ' + fmtMins(day.totalMins));$('#modal-daybreak').addClass('show');});$('input[name="stay"]').on('change', function () {$('#hotel-fields').toggle($(this).val() === 'HOTEL');if ($(this).val() === 'HOTEL') {setTimeout(function () {if (!window._hotelMap) {window._hotelMap = new google.maps.Map(document.getElementById('hotel-map'), {center: {lat: 28.6,lng: 77.2}, zoom: 6});var hm = new google.maps.Marker({map: window._hotelMap, draggable: true});var hac = new google.maps.places.Autocomplete(document.getElementById('hotel-search'), {componentRestrictions: {country: 'in'}});hac.addListener('place_changed', function () {var p = hac.getPlace();if (!p.geometry) return;window._hotelMap.setCenter(p.geometry.location);window._hotelMap.setZoom(14);hm.setPosition(p.geometry.location);state._hotelLat = p.geometry.location.lat();state._hotelLng = p.geometry.location.lng();state._hotelName = p.name || p.formatted_address;});hm.addListener('dragend', function () {state._hotelLat = hm.getPosition().lat();state._hotelLng = hm.getPosition().lng();});}}, 200);}});$('#daybreak-cancel').on('click', function () {$('#modal-daybreak').removeClass('show');});$('#daybreak-confirm').on('click', function () {var day = curDay();var opt = $('input[name="stay"]:checked').val();day.endAction = opt;if (opt === 'HOME') {day.stayLat = state.homeLat;day.stayLng = state.homeLng;day.stayName = state.homeName;drawReturnHome();startNextDay(state.homeLat, state.homeLng, state.homeName);} else {if (!state._hotelLat) {alert('Select hotel location');return;}day.stayLat = state._hotelLat;day.stayLng = state._hotelLng;day.stayName = state._hotelName;startNextDay(state._hotelLat, state._hotelLng, state._hotelName);state._hotelLat = null;state._hotelLng = null;}$('#modal-daybreak').removeClass('show');renderRoutePanel();});$('#btn-finish').on('click', function () {if (state.days.every(function (d) {return d.visits.length === 0;})) {alert('No visits added');return;}// Ask for beat titlevar beatTitle = prompt('Enter a name for this beat:', '');if (beatTitle === null) return; // cancelledif (!beatTitle.trim()) {alert('Please enter a beat name');return;}// Build plan data and save to DB firstvar daysData = [];state.days.forEach(function (day) {if (day.visits.length === 0) return;daysData.push({dayNumber: day.dayNumber,startLocationName: day.startName,startLatitude: day.startLat ? day.startLat.toString() : null,startLongitude: day.startLng ? day.startLng.toString() : null,endAction: day.endAction,stayLocationName: day.stayName,stayLatitude: day.stayLat ? day.stayLat.toString() : null,stayLongitude: day.stayLng ? day.stayLng.toString() : null,totalDistanceKm: day.totalKm,totalTimeMins: Math.round(day.totalMins),visits: day.visits.map(function (v) {return {id: v.id, type: v.type || 'partner'};})});});// Dates will be assigned later via calendar — pass nulls for nowvar dates = daysData.map(function () {return null;});var planData = JSON.stringify({days: daysData, dates: dates, beatName: beatTitle.trim()});$('#btn-finish').prop('disabled', true).text('Saving...');$.ajax({url: context + "/beatPlan/submitPlan",type: "POST",data: {authUserId: state.authUserId, planData: planData},success: function (response) {var data = (typeof response === 'string' ? JSON.parse(response) : response);var result = data.response || data;$('#btn-finish').prop('disabled', false).text('Finish Plan');if (result.status) {state.savedPlanGroupId = result.planGroupId;if (result.duplicate) {alert('This beat already exists (same visits). Switching to calendar.');}// Switch to calendar tab$('.panel-tab').removeClass('active');$('.panel-tab[data-tab="calendar"]').addClass('active');$('#panel-route').hide();$('#panel-calendar').show();$('#cal-grid-container').show();$('#bottom-bar').hide();var now = new Date();state.calMonth = now.getFullYear() + '-' + ('0' + (now.getMonth() + 1)).slice(-2);loadCalendarData();} else {alert('Error saving beat plan');}},error: function (xhr) {$('#btn-finish').prop('disabled', false).text('Finish Plan');alert('Error: ' + (xhr.responseText || xhr.statusText));}});});// ============ PANEL TABS ============$('.panel-tab').on('click', function () {var tab = $(this).data('tab');$('.panel-tab').removeClass('active');$(this).addClass('active');$('#panel-route').toggle(tab === 'route');$('#panel-calendar').toggle(tab === 'calendar');$('#cal-grid-container').toggle(tab === 'calendar');$('#bottom-bar').toggle(tab === 'route' && state.days.length > 0);if (tab === 'calendar' && !state.calMonth) {var now = new Date();state.calMonth = now.getFullYear() + '-' + ('0' + (now.getMonth() + 1)).slice(-2);loadCalendarData();}});// ============ CALENDAR ============$('#cal-prev').on('click', function () {navMonth(-1);});$('#cal-next').on('click', function () {navMonth(1);});function navMonth(delta) {var p = state.calMonth.split('-');var d = new Date(parseInt(p[0]), parseInt(p[1]) - 1 + delta, 1);state.calMonth = d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2);loadCalendarData();}function loadCalendarData() {if (!state.authUserId) return;$('#cal-month-label').text(state.calMonth);$.ajax({url: context + "/beatPlan/calendar", type: "GET", dataType: "json",data: {authUserId: state.authUserId, month: state.calMonth},success: function (r) {var data = r.response || r;state.calData = data;renderCalGrid(data);renderBeatCards(data.scheduledBeats);}});}function renderCalGrid(data) {var p = state.calMonth.split('-');var year = parseInt(p[0]), month = parseInt(p[1]) - 1;var first = new Date(year, month, 1);var last = new Date(year, month + 1, 0);var months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];$('#cal-month-label').text(months[month] + ' ' + year);var holidayMap = {};(data.holidays || []).forEach(function (h) {holidayMap[h.date] = h.occasion;});var blockedSet = {};(data.blockedDates || []).forEach(function (d) {blockedSet[d] = true;});var beatDateMap = {};(data.scheduledBeats || []).forEach(function (b) {(b.days || []).forEach(function (d) {if (d.planDate) {if (!beatDateMap[d.planDate]) beatDateMap[d.planDate] = [];beatDateMap[d.planDate].push({name: b.beatName,color: b.beatColor,day: d.dayNumber,status: b.status,visits: d.visitCount});}});});var today = fmtDate(new Date());var startOff = (first.getDay() + 6) % 7;var calStart = new Date(year, month, 1 - startOff);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>';var d = new Date(calStart);for (var w = 0; w < 6; w++) {var hasDay = false;var row = '<tr>';for (var wd = 0; wd < 7; wd++) {var ds = fmtDate(d);var inMonth = d.getMonth() === month;var sun = d.getDay() === 0;var hol = !!holidayMap[ds];var isToday = ds === today;var beats = beatDateMap[ds] || [];var isPicked = state.calPickedDates.indexOf(ds) > -1;var cls = [];if (sun) cls.push('blocked');else if (hol) cls.push('holiday');if (isToday) cls.push('today');if (!inMonth) cls.push('blocked');var style = inMonth ? '' : 'opacity:0.3;';row += '<td class="' + cls.join(' ') + '" data-date="' + ds + '" style="' + style + '">';row += '<div class="cal-date">' + d.getDate() + '</div>';if (hol) row += '<div class="cal-holiday-label">' + holidayMap[ds] + '</div>';beats.forEach(function (b) {var chipCls = 'cal-chip';if (b.status === 'running') chipCls += ' running';if (b.status === 'completed') chipCls += ' completed';row += '<span class="' + chipCls + '" style="background:' + b.color + '">' + b.name + ' D' + b.day + (b.status === 'running' ? ' [live]' : '') + ' (' + b.visits + ')</span>';});if (isPicked) {var idx = state.calPickedDates.indexOf(ds) + 1;row += '<span class="cal-chip" style="background:#3b82f6;border:1px dashed #93c5fd;">Day ' + idx + '</span>';}row += '</td>';if (inMonth) hasDay = true;d.setDate(d.getDate() + 1);}row += '</tr>';if (hasDay) html += row;}html += '</table>';$('#cal-grid-content').html(html);}function renderBeatCards(beats) {var html = '';if (!beats || beats.length === 0) {html = '<p style="color:#64748b;font-size:12px;padding:20px;">No beats scheduled. Create a beat first.</p>';}(beats || []).forEach(function (b) {var badge = b.status === 'running' ? '<span class="status-badge status-running">Live</span>' :b.status === 'completed' ? '<span class="status-badge status-completed">Done</span>' :b.status === 'unscheduled' ? '<span class="status-badge" style="background:#f59e0b;color:#0f172a;">Unscheduled</span>' :'<span class="status-badge status-scheduled">Scheduled</span>';var totalV = 0, totalKm = 0;b.days.forEach(function (d) {totalV += d.visitCount || 0;totalKm += d.totalKm || 0;});html += '<div class="beat-card" data-plangroup="' + b.planGroupId + '">';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>';html += '<div class="beat-meta">' + b.days.length + ' days | ' + totalV + ' visits | ' + totalKm.toFixed(0) + ' km</div>';if (b.status !== 'running') {var isScheduling = state.calSelectedBeat === b.planGroupId && state.calPickedDates.length > 0;html += '<div class="beat-actions">';if (isScheduling) {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>';html += '<button class="btn-sm btn-cancel-schedule" style="background:#475569;color:#e2e8f0;">Cancel</button>';} else {html += '<button class="btn-sm btn-schedule" data-pg="' + b.planGroupId + '" data-days="' + b.days.length + '">Schedule</button>';html += '<button class="btn-sm btn-repeat" data-pg="' + b.planGroupId + '" data-days="' + b.days.length + '">Repeat</button>';}html += '<button class="btn-sm" style="background:#ef4444;color:#fff;" data-pg="' + b.planGroupId + '" onclick="deleteBeat(\'' + b.planGroupId + '\')">Delete</button>';html += '</div>';if (isScheduling) {html += '<div style="font-size:10px;color:#60a5fa;margin-top:4px;">Dates: ' + state.calPickedDates.join(', ') + '</div>';html += '<div style="font-size:10px;color:#94a3b8;">Click calendar cells to adjust</div>';}}html += '</div>';});$('#cal-beat-cards').html(html);}// Schedule button — suggest dates, show Confirm button in beat card$(document).on('click', '.btn-schedule', function (e) {e.stopPropagation();var pg = $(this).data('pg');var days = parseInt($(this).data('days'));state.calSelectedBeat = pg;state.calPickedDates = [];$.ajax({url: context + "/beatPlan/availableSlots", type: "GET", dataType: "json",data: {authUserId: state.authUserId, month: state.calMonth, daysNeeded: days},success: function (r) {var data = r.response || r;state.calPickedDates = data.suggestedDates || [];renderCalGrid(state.calData);renderBeatCards(state.calData.scheduledBeats);if (state.calPickedDates.length < days) {alert('Only ' + state.calPickedDates.length + ' slots available this month. Need ' + days + '.');}}});});// Click calendar cell to toggle picked date$(document).on('click', '#cal-grid-content td:not(.blocked)', function () {if (!state.calSelectedBeat) return;var ds = $(this).data('date');var idx = state.calPickedDates.indexOf(ds);if (idx > -1) state.calPickedDates.splice(idx, 1);else state.calPickedDates.push(ds);state.calPickedDates.sort();renderCalGrid(state.calData);renderBeatCards(state.calData.scheduledBeats);});// Cancel scheduling mode$(document).on('click', '.btn-cancel-schedule', function (e) {e.stopPropagation();state.calSelectedBeat = null;state.calPickedDates = [];renderCalGrid(state.calData);renderBeatCards(state.calData.scheduledBeats);});// Confirm schedule — save to server$(document).on('click', '.btn-confirm-schedule', function (e) {e.stopPropagation();var pg = $(this).data('pg');var daysNeeded = parseInt($(this).data('days'));if (state.calPickedDates.length < daysNeeded) {alert('Need ' + daysNeeded + ' dates but only ' + state.calPickedDates.length + ' selected.');return;}var beat = (state.calData.scheduledBeats || []).find(function (b) {return b.planGroupId === pg;});if (!beat) return;$.ajax({url: context + "/beatPlan/scheduleOnCalendar", type: "POST",data: {planGroupId: pg,dates: JSON.stringify(state.calPickedDates.slice(0, daysNeeded)),beatName: beat.beatName,beatColor: beat.beatColor},success: function () {state.calSelectedBeat = null;state.calPickedDates = [];loadCalendarData();},error: function (xhr) {alert('Error: ' + (xhr.responseText || xhr.statusText));}});});// Repeat$(document).on('click', '.btn-repeat', function (e) {e.stopPropagation();var pg = $(this).data('pg');var days = parseInt($(this).data('days'));$.ajax({url: context + "/beatPlan/availableSlots", type: "GET", dataType: "json",data: {authUserId: state.authUserId, month: state.calMonth, daysNeeded: days},success: function (r) {var data = r.response || r;if ((data.suggestedDates || []).length < days) {alert('Not enough slots this month');return;}if (!confirm('Repeat beat on: ' + data.suggestedDates.join(', ') + '?')) return;$.ajax({url: context + "/beatPlan/repeatBeat", type: "POST",data: {sourcePlanGroupId: pg, authUserId: state.authUserId, dates: JSON.stringify(data.suggestedDates)},success: function () {loadCalendarData();alert('Beat repeated!');}});}});});// ============ DELETE BEAT ============function deleteBeat(planGroupId) {if (!confirm('Delete this beat? This cannot be undone.')) return;$.ajax({url: context + "/beatPlan/delete", type: "POST",data: {planGroupId: planGroupId},success: function () {loadCalendarData();},error: function (xhr) {alert('Error: ' + (xhr.responseText || xhr.statusText));}});}// ============ HELPERS ============function curDay() {return state.days[state.currentDay - 1];}function haversine(lat1, lng1, lat2, lng2) {var R = 6371, dLat = (lat2 - lat1) * Math.PI / 180, dLng = (lng2 - lng1) * Math.PI / 180;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);return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));}function fmtMins(m) {return Math.floor(m / 60) + 'h ' + Math.round(m % 60) + 'm';}function fmtDate(d) {return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2);}function fitBounds() {if (Object.keys(markers).length > 0) {var b = new google.maps.LatLngBounds();if (homeMarker) b.extend(homeMarker.getPosition());for (var k in markers) b.extend(markers[k].getPosition());map.fitBounds(b);}}