Rev 37060 | 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: [],// L4+ operators may schedule a beat on today's date (set from the page).canScheduleToday: (typeof canScheduleToday !== 'undefined' && !!canScheduleToday)};var VISIT_MINS = 30;var AVG_SPEED = 30;var DAY_LIMIT = 540;var ROAD_FACTOR = 1.3;var isSubmittingBeat = false; // guard against double-submitvar 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>';// Show "Name (#authUserId)" so the head can quickly match identities// (useful when grabbing an id for the Visit-Request Reassign prompt etc).data.forEach(function (u) {html += '<option value="' + u.id + '">' + u.name + ' (#' + u.id + ')</option>';});$('#bp-auth-user').html(html);}});});$('#bp-load').on('click', function () {var uid = $('#bp-auth-user').val();if (!uid) {alert('Select a user');return;}// Hard reset of any previous user's cached state so calendar chips,// route lines, partner markers, and the route-detail view don't leak.state.authUserId = parseInt(uid);state.categoryId = parseInt($('#bp-category').val());state.mode = 'list';state.days = [];state.partners = [];state.currentDay = 1;state.calMonth = null;state.calData = null;state.calSelectedBeat = null;state.calPickedDates = [];state.homeLat = null;state.homeLng = null;state.homeName = '';state.activeBaseLocationId = null;state.baseLocations = [];state.viewDate = null;state.originalDayCount = 0;state.visitRequests = [];state.assignVisitRequest = null;state.assignedLeads = [];state.assignLead = null;state.deferredItems = [];$('#visit-request-panel').hide();$('#assigned-leads-panel').hide();// A fresh load starts both collapsibles expanded.$('#vr-body, #al-body').show();$('#vr-caret, #al-caret').html('▾');// #deferred-items-panel now sits inside its own tab pane (#panel-deferred)// which controls visibility — no need to hide the inner panel here.$('#di-list').empty();$('#di-count').text('');$('#di-tab-count').text('');// Clear the rendered calendar grid + route map / panel so nothing from// the previous user is briefly visible while the new fetch is in flight.$('#cal-grid-content').empty();$('#route-list').empty();if (typeof clearRoute === 'function') {try {clearRoute();} catch (e) {}}if (typeof markers === 'object' && markers) {for (var k in markers) {try {markers[k].setMap(null);} catch (e) {}}markers = {};}if (typeof homeMarker !== 'undefined' && homeMarker) {try {homeMarker.setMap(null);} catch (e) {}homeMarker = null;}$('#bp-user-label').text($('#bp-auth-user option:selected').text());// Load all saved base locations — default goes in state.home*, the rest is// exposed via a picker in the route panel so the planner can pick a// different starting point for this one beat without changing the default.$.ajax({url: context + "/beatPlan/listBaseLocations", type: "GET", dataType: "json",data: {authUserId: uid},success: function (r) {var data = r.response || r;state.baseLocations = data.locations || [];var def = state.baseLocations.find(function (l) {return l.isDefault;}) || state.baseLocations[0];if (def) {state.homeLat = parseFloat(def.latitude);state.homeLng = parseFloat(def.longitude);state.homeName = def.locationName;state.activeBaseLocationId = def.id;} else {state.activeBaseLocationId = null;}showBeatList();// If the user is currently on the Calendar tab when Load is clicked,// re-fetch immediately. Otherwise the calendar grid stays blank// until they click the tab (which would auto-fetch on its own).if ($('.panel-tab[data-tab="calendar"]').hasClass('active')) {var now = new Date();state.calMonth = now.getFullYear() + '-' + ('0' + (now.getMonth() + 1)).slice(-2);loadCalendarData();}// Pending visit requests for this user's hierarchy. Hidden when empty.if (typeof loadVisitRequests === 'function') {loadVisitRequests();}// This user's active assigned leads (routable onto a beat). Hidden when empty.if (typeof loadAssignedLeads === 'function') {loadAssignedLeads();}// Deferred items (partners + leads) for this user. Hidden when empty.if (typeof loadDeferredItems === 'function') {loadDeferredItems();}}});});// Called from the picker → swap the start location for the beat being// created/edited. Updates state.home* + day 1's start point, then re-draws.// Inline typeahead inside the route sidebar — same data source as the// Partners tab (state.partners, fed by /beatPlan/getPartners, which mirrors// the /getPartnerReadonlyInfo pattern used by the Partner Access tab).// User types 2+ chars → suggestions appear → click adds to the current day.function renderPartnerQuickAdd() {if (!state.partners || state.partners.length === 0) return '';return ''+ '<div style="position:relative; margin-bottom:10px;">'+ ' <input type="text" id="bp-partner-quick" autocomplete="off"'+ ' placeholder="Search & add a partner..."'+ ' style="width:100%; padding:8px 10px; border:1px solid #475569; border-radius:6px;'+ ' background:#0f172a; color:#e2e8f0; font-size:12px; font-family:inherit;">'+ ' <div id="bp-partner-suggest"'+ ' style="display:none; position:absolute; left:0; right:0; top:100%; z-index:50;'+ ' background:#1e293b; border:1px solid #334155; border-radius:6px;'+ ' margin-top:4px; max-height:260px; overflow-y:auto;'+ ' box-shadow:0 6px 16px rgba(0,0,0,0.4);"></div>'+ '</div>';}function refreshPartnerSuggestions() {var $input = $('#bp-partner-quick');if (!$input.length) return;var q = ($input.val() || '').trim().toLowerCase();var $sug = $('#bp-partner-suggest');if (q.length < 2) {$sug.hide().empty();return;}// Lookup of fofoId → day number for partners already on the routevar inRoute = {};(state.days || []).forEach(function (d) {(d.visits || []).forEach(function (v) {if (!v.isLead && v.type !== 'lead') inRoute[v.id] = d.dayNumber;});});var matches = (state.partners || []).filter(function (p) {var hay = ((p.code || '') + ' '+ (p.outletName || '') + ' '+ (p.businessName || '') + ' '+ (p.city || '')).toLowerCase();return hay.indexOf(q) !== -1;}).slice(0, 10);if (matches.length === 0) {$sug.html('<div style="padding:10px; color:#94a3b8; font-size:11px; text-align:center;">No partners found</div>').show();return;}var html = '';matches.forEach(function (p) {var day = inRoute[p.fofoId];var nameLine = (p.outletName || p.businessName || '');if (p.city) nameLine += (nameLine ? ' - ' : '') + p.city;var inactiveBadge = (p.active === false)? ' <span style="background:#7c2d12;color:#fed7aa;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;">Inactive</span>': '';if (day) {html += '<div class="bp-partner-row in-route" style="padding:8px 10px; font-size:11px;'+ ' color:#94a3b8; border-bottom:1px solid #0f172a; cursor:not-allowed;">'+ '<div style="font-weight:600;">' + (p.code || '') + inactiveBadge + '</div>'+ '<div>' + nameLine + ' <span style="color:#22c55e;">(in D' + day + ')</span></div>'+ '</div>';} else {html += '<div class="bp-partner-row" data-fofoid="' + p.fofoId + '"'+ ' style="padding:8px 10px; font-size:12px; color:#e2e8f0;'+ ' border-bottom:1px solid #0f172a; cursor:pointer;">'+ '<div style="font-weight:600;">' + (p.code || '') + inactiveBadge + '</div>'+ '<div style="font-size:11px; color:#94a3b8;">' + nameLine + '</div>'+ '</div>';}});$sug.html(html).show();}$(document).on('input', '#bp-partner-quick', refreshPartnerSuggestions);$(document).on('focus', '#bp-partner-quick', function () {if (($(this).val() || '').trim().length >= 2) refreshPartnerSuggestions();});// Hide on outside click$(document).on('click', function (e) {if (!$(e.target).closest('#bp-partner-quick, #bp-partner-suggest').length) {$('#bp-partner-suggest').hide();}});$(document).on('mousedown', '#bp-partner-suggest .bp-partner-row', function (e) {if ($(this).hasClass('in-route')) return;e.preventDefault(); // keep input from losing focus before we read fofoidvar fofoId = parseInt($(this).data('fofoid'));if (!fofoId) return;if (typeof addVisit === 'function') addVisit(fofoId, 'next');$('#bp-partner-quick').val('').focus();$('#bp-partner-suggest').hide().empty();});function renderBaseLocationPicker() {var locs = state.baseLocations || [];if (locs.length === 0) return '';var activeId = state.activeBaseLocationId;if (locs.length === 1) {var only = locs[0];return '<div style="font-size:11px;color:#94a3b8;margin-bottom:10px;">'+ 'Starting from: <strong style="color:#e2e8f0;">' + (only.locationName || 'Home') + '</strong>'+ (only.isDefault ? ' <span style="color:#22c55e;">(default)</span>' : '')+ '</div>';}var html = '<div style="margin-bottom:10px;">'+ '<label style="font-size:11px;color:#94a3b8;display:block;margin-bottom:4px;">Start this beat from:</label>'+ '<select id="bp-base-picker" style="width:100%;padding:6px 8px;border:1px solid #475569;border-radius:4px;background:#0f172a;color:#e2e8f0;font-size:12px;font-family:inherit;">';locs.forEach(function (l) {var sel = (l.id === activeId) ? ' selected' : '';var label = (l.locationName || 'Location ' + l.id) + (l.isDefault ? ' (default)' : '');html += '<option value="' + l.id + '"' + sel + '>' + label + '</option>';});html += '</select></div>';return html;}$(document).on('change', '#bp-base-picker', function () {var id = parseInt($(this).val());if (!id) return;applyBaseLocation(id);});function applyBaseLocation(id) {var loc = (state.baseLocations || []).find(function (l) {return l.id === id;});if (!loc) return;state.homeLat = parseFloat(loc.latitude);state.homeLng = parseFloat(loc.longitude);state.homeName = loc.locationName;state.activeBaseLocationId = id;// Only day 1 starts from the chosen base; later days start where the// previous day ended (existing behavior unchanged).if (state.days && state.days.length > 0) {state.days[0].startLat = state.homeLat;state.days[0].startLng = state.homeLng;state.days[0].startName = state.homeName;if (typeof recalcDay === 'function') {var saved = state.currentDay;state.currentDay = 1;recalcDay();state.currentDay = saved;}}// Move the home marker on the map + recenter so the user actually sees it.if (typeof google !== 'undefined' && google.maps && map) {var pos = new google.maps.LatLng(state.homeLat, state.homeLng);if (homeMarker) {homeMarker.setPosition(pos);homeMarker.setTitle('Home: ' + state.homeName);} else {homeMarker = new google.maps.Marker({map: map, position: pos, title: 'Home: ' + state.homeName,icon: {url: 'https://maps.google.com/mapfiles/kml/shapes/homegardenbusiness.png',scaledSize: new google.maps.Size(32, 32)},zIndex: 100});}map.panTo(pos);}if (typeof drawRoute === 'function') drawRoute();if (typeof renderRoutePanel === 'function') renderRoutePanel();if (typeof updateBottomBar === 'function') updateBottomBar();}// ============ BEAT LIST VIEW ============function showBeatList() {state.mode = 'list';$('#bottom-bar').hide();// Load all beats for this uservar now = new Date();var month = now.getFullYear() + '-' + ('0' + (now.getMonth() + 1)).slice(-2);$.ajax({url: context + "/beatPlan/calendar", type: "GET", dataType: "json",data: {authUserId: state.authUserId, month: month},success: function (r) {var data = r.response || r;var beats = data.scheduledBeats || [];var html = '<div style="margin-bottom:10px;">';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>';html += '</div>';if (beats.length === 0) {html += '<p style="color:#64748b;font-size:12px;text-align:center;padding:20px;">No beats created yet.</p>';}// Deduplicate by beatNamevar seen = {};beats.forEach(function (b) {var key = b.beatName || b.planGroupId;if (!seen[key]) {seen[key] = {beat: b, count: 0};}seen[key].count++;});Object.keys(seen).forEach(function (key) {var item = seen[key];var b = item.beat;var totalV = 0, totalKm = 0;b.days.forEach(function (d) {totalV += d.visitCount || 0;totalKm += d.totalKm || 0;});var statusCls = b.status === 'running' ? 'status-running' : b.status === 'completed' ? 'status-completed' : 'status-scheduled';var statusLabel = b.status === 'running' ? 'Live' : b.status === 'completed' ? 'Done' : b.status === 'unscheduled' ? 'Unscheduled' : 'Scheduled';html += '<div class="beat-card beat-list-item" data-plangroup="' + b.planGroupId + '" style="cursor:pointer; position:relative;">';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>';html += '<div class="beat-meta">' + b.days.length + ' days | ' + totalV + ' visits | ' + totalKm.toFixed(0) + ' km';if (item.count > 1) html += ' | ' + item.count + 'x scheduled';html += '</div>';html += '<button class="beat-edit-btn" data-plangroup="' + b.planGroupId + '" '+ 'style="position:absolute;top:8px;right:8px;background:#475569;color:#e2e8f0;'+ 'border:none;border-radius:4px;padding:3px 8px;font-size:10px;cursor:pointer;'+ 'font-family:inherit;">Edit</button>';html += '</div>';});$('#route-list').html(html);}});}// Click on existing beat → view its route on map$(document).on('click', '.beat-list-item', function () {var pg = $(this).data('plangroup');viewBeat(pg);});// Edit button on a beat card → load in EDIT mode (modifiable)$(document).on('click', '.beat-edit-btn', function (e) {e.stopPropagation(); // don't also trigger beat-list-item viewvar pg = $(this).data('plangroup');var date = $(this).data('date') || null;editBeat(String(pg), date ? String(date) : null);});// Loads a beat into edit mode — same UI as create but Finish becomes Save.// When planDate is provided: leads scheduled on that date are loaded too and// can be removed (template = partners stays the same across runs).function editBeat(planGroupId, planDate) {state.mode = 'edit';state.editingBeatId = planGroupId;state.editingDate = planDate || null;state.viewDate = null;state.savedPlanGroupId = null;isSubmittingBeat = false;$.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 pData = r.response || r;state.partners = pData.partners || [];var partnerMap = {};state.partners.forEach(function (p) {partnerMap[p.fofoId] = p;});$.ajax({url: context + '/beatPlan/calendar', type: 'GET', dataType: 'json',data: {authUserId: state.authUserId, month: '2020-01'},success: function (r2) {var calData = r2.response || r2;var beat = (calData.scheduledBeats || []).find(function (b) {return String(b.planGroupId) === String(planGroupId);});if (!beat) {alert('Beat not found');return;}state.beatTitle = beat.beatName || 'Beat';// Pass planDate to getBeatVisits so leads for that date are includedvar visitsData = planDate? {planGroupId: planGroupId, planDate: planDate}: {planGroupId: planGroupId};$.ajax({url: context + '/beatPlan/getBeatVisits', type: 'GET', dataType: 'json',data: visitsData,success: function (r3) {var visits = (r3.response || r3) || [];var dayVisitsMap = {};visits.forEach(function (v) {if (!dayVisitsMap[v.dayNumber]) dayVisitsMap[v.dayNumber] = [];dayVisitsMap[v.dayNumber].push(v);});// Collect lead IDs so we can fetch their name/geo in parallelvar leadIdsToFetch = [];visits.forEach(function (v) {if (v.visitType === 'lead') leadIdsToFetch.push(v.fofoId);});state.days = [];var seen = {};beat.days.forEach(function (d) {if (seen[d.dayNumber]) return;seen[d.dayNumber] = true;var dayVisits = (dayVisitsMap[d.dayNumber] || []).sort(function (a, b) {return a.sequenceOrder - b.sequenceOrder;});var builtVisits = [];dayVisits.forEach(function (v) {if (v.visitType === 'lead') {builtVisits.push({id: v.fofoId, type: 'lead',name: 'LEAD #' + v.fofoId, code: 'LEAD',lat: null, lng: null, isLead: true});} else if (v.visitType === 'office') {// Office stops are enriched server-side because the partner// map doesn't cover them. Lat/lng come from logistics.company_office.builtVisits.push({id: v.fofoId, type: 'office',name: (v.code ? v.code + ' - ' : '') + (v.name || 'Office #' + v.fofoId),code: v.code || 'OFFICE',lat: v.latitude ? parseFloat(v.latitude) : null,lng: v.longitude ? parseFloat(v.longitude) : null,isOffice: true});} else {var p = partnerMap[v.fofoId];if (p) builtVisits.push({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 1 starts at the beat's own saved start location when set;// otherwise fall back to the user's default base. Later days// start where the prior day ended (handled by the Day Break flow).var dayStartLat = state.homeLat, dayStartLng = state.homeLng, dayStartName = state.homeName;if (d.dayNumber === 1) {var bLat = beat.startLatitude != null ? parseFloat(beat.startLatitude) : NaN;var bLng = beat.startLongitude != null ? parseFloat(beat.startLongitude) : NaN;if (!isNaN(bLat) && !isNaN(bLng)) {dayStartLat = bLat;dayStartLng = bLng;dayStartName = beat.startLocationName || state.homeName;}}state.days.push({dayNumber: d.dayNumber,startLat: dayStartLat, startLng: dayStartLng,startName: dayStartName,visits: builtVisits,endAction: d.endAction || (d.dayNumber < beat.days.length ? 'DAYBREAK' : 'HOME'),totalKm: 0, totalMins: 0});});state.currentDay = 1;// Snapshot the originals — used during save to detect day-count// increases (blocked) and lead removals (popup with cancel/reschedule).state.originalDayCount = state.days.length;state.originalLeads = [];state.days.forEach(function (d) {d.visits.forEach(function (v) {if (v.type === 'lead' || v.isLead) {state.originalLeads.push({leadId: v.id,dayNumber: d.dayNumber,name: v.name || ('Lead #' + v.id)});}});});// Async enrich each lead with name + geovar leadPromises = [];state.days.forEach(function (day) {day.visits.forEach(function (v) {if (!v.isLead) return;leadPromises.push($.get(context + '/lead-geo/check/' + v.id).then(function (rr) {var g = rr.response || rr;if (g && g.hasApprovedGeo) {v.lat = g.latitude;v.lng = g.longitude;}}),$.get(context + '/getLead?leadId=' + v.id).then(function (rr) {var l = rr.response || rr;if (l && l.firstName) {var nm = 'LEAD - ' + l.firstName + ' ' + (l.lastName || '');v.name = nm;// Keep the originalLeads snapshot label in sync so the// removed-leads popup shows the real name, not 'LEAD #123'.(state.originalLeads || []).forEach(function (ol) {if (ol.leadId === v.id) ol.name = nm;});}}));});});$.when.apply($, leadPromises).always(function () {// Pre-select whichever saved base location matches the beat's// existing day-1 start (lat/lng), so the picker reflects current state.var day1 = state.days[0];if (day1 && state.baseLocations) {var match = state.baseLocations.find(function (l) {return Math.abs(parseFloat(l.latitude) - day1.startLat) < 1e-4&& Math.abs(parseFloat(l.longitude) - day1.startLng) < 1e-4;});if (match) state.activeBaseLocationId = match.id;}// renderRoutePanel now builds the edit header (back btn, EDITING// title, date label, base picker, partner search) inline — so we// just call it without the one-time prepend that used to live here.renderRoutePanel();initMap();// Add lead markers (orange) on the mapstate.days.forEach(function (day) {day.visits.forEach(function (v) {if (v.isLead && v.lat && v.lng) {var m = new google.maps.Marker({map: map,position: {lat: v.lat, lng: v.lng},title: v.name,label: {text: 'L', color: '#fff', fontSize: '11px', fontWeight: '700'},icon: {path: google.maps.SymbolPath.CIRCLE, scale: 16,fillColor: '#e67e22', fillOpacity: 0.95,strokeColor: '#fff', strokeWeight: 2}});markers[v.id] = m;}});});resetMarkerColors();drawRoute();updateBottomBar();$('#bottom-bar').show(); // make Save bar visible$('#btn-finish').text('Save Changes').prop('disabled', false);});}});}});}});}function viewBeat(planGroupId, planDate) {state.mode = 'view';state.viewDate = planDate || null; // when set, show that run's leads$('#bottom-bar').hide();// Load partners first (for coordinates), then load beat visits$.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 pData = r.response || r;state.partners = pData.partners || [];// Build partner lookup by fofoIdvar partnerMap = {};state.partners.forEach(function (p) {partnerMap[p.fofoId] = p;});// Load beat visits$.ajax({url: context + "/beatPlan/calendar", type: "GET", dataType: "json",data: {authUserId: state.authUserId, month: '2020-01'},success: function (r2) {var calData = r2.response || r2;var beat = (calData.scheduledBeats || []).find(function (b) {return String(b.planGroupId) === String(planGroupId);});if (!beat) {alert('Beat not found');return;}// Reconstruct state.days from beat data for map drawing// We need actual visit fofoIds — fetch from beat_plan records$.ajax({url: context + "/beatPlan/getBeatVisits", type: "GET", dataType: "json",data: planDate? {planGroupId: planGroupId, planDate: planDate}: {planGroupId: planGroupId},success: function (r3) {var visits = (r3.response || r3) || [];// Group visits by dayNumbervar dayVisitsMap = {};visits.forEach(function (v) {if (!dayVisitsMap[v.dayNumber]) dayVisitsMap[v.dayNumber] = [];dayVisitsMap[v.dayNumber].push(v);});// Build days for route display.// A beat scheduled on multiple dates has multiple schedule// rows per day_number — dedupe so each day appears once.state.days = [];var seenDayNumbers = {};beat.days.forEach(function (d) {if (seenDayNumbers[d.dayNumber]) return; // skip duplicate dayseenDayNumbers[d.dayNumber] = true;var dayVisits = (dayVisitsMap[d.dayNumber] || []).sort(function (a, b) {return a.sequenceOrder - b.sequenceOrder;});var startLat = state.homeLat, startLng = state.homeLng, startName = state.homeName;// If not first day, start from previous day's last visitif (state.days.length > 0) {var prevDay = state.days[state.days.length - 1];if (prevDay.visits.length > 0 && prevDay.endAction === 'DAYBREAK') {var lastV = prevDay.visits[prevDay.visits.length - 1];startLat = lastV.lat;startLng = lastV.lng;startName = lastV.code || lastV.name;}}var builtVisits = [];dayVisits.forEach(function (v) {if (v.visitType === 'lead') {builtVisits.push({id: v.fofoId, type: 'lead',name: 'LEAD #' + v.fofoId,code: 'LEAD',lat: null, lng: null,isLead: true});} else if (v.visitType === 'office') {builtVisits.push({id: v.fofoId, type: 'office',name: (v.code ? v.code + ' - ' : '') + (v.name || 'Office #' + v.fofoId),code: v.code || 'OFFICE',lat: v.latitude ? parseFloat(v.latitude) : null,lng: v.longitude ? parseFloat(v.longitude) : null,isOffice: true});} else {var p = partnerMap[v.fofoId];if (p) {builtVisits.push({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});}}});state.days.push({dayNumber: d.dayNumber,startLat: startLat,startLng: startLng,startName: startName,visits: builtVisits,// Use the actual end_action stored on this schedule row.// The dayNumber-based inference is wrong for multi-instance// beats (same beat scheduled on several dates) — every// instance would have day N but only one was treated HOME.endAction: d.endAction || (d.dayNumber < beat.days.length ? 'DAYBREAK' : 'HOME'),totalKm: d.totalKm || 0,totalMins: d.totalMins || 0});});state.currentDay = 1;// Collect all lead data promises across all daysvar allLeadPromises = [];state.days.forEach(function (d) {d.visits.forEach(function (v) {if (v.isLead) {allLeadPromises.push($.get(context + '/lead-geo/check/' + v.id).then(function (resp) {var geoData = resp.response || resp;if (geoData && geoData.hasApprovedGeo) {v.lat = geoData.latitude;v.lng = geoData.longitude;}}));allLeadPromises.push($.get(context + '/getLead?leadId=' + v.id).then(function (resp) {var lead = resp.response || resp;if (lead && lead.firstName) {v.name = 'LEAD - ' + lead.firstName + ' ' + (lead.lastName || '');}}));}});});// Wait for all lead data, then render$.when.apply($, allLeadPromises).always(function () {// Render left panelstate.mode = 'view';renderRoutePanel();var dateLabel = state.viewDate? '<div style="font-size:11px;color:#f59e0b;margin-bottom:8px;">Run date: ' + state.viewDate + ' (showing this day\'s leads)</div>': '<div style="font-size:11px;color:#94a3b8;margin-bottom:8px;">Beat template (partners only)</div>';$('#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);// Init map with all partners + lead markersinitMap();state.days.forEach(function (d) {d.visits.forEach(function (v) {if (v.isLead && v.lat && v.lng) {var m = new google.maps.Marker({map: map,position: {lat: v.lat, lng: v.lng},title: v.name,label: {text: 'L', color: '#fff', fontSize: '11px', fontWeight: '700'},icon: {path: google.maps.SymbolPath.CIRCLE,scale: 16,fillColor: '#e67e22',fillOpacity: 0.95,strokeColor: '#fff',strokeWeight: 2}});markers[v.id] = m;}});});resetMarkerColors();drawRoute();});}});}});}});}// Back to list$(document).on('click', '.btn-back-to-list', function () {showBeatList();// Clear mapif (map) {for (var key in markers) {markers[key].setMap(null);}markers = {};clearRoute();}});// New Beat button// Start the full map-first new-beat planner (shared by the big "+ New Beat"// button and the "+ New beat" buttons on the visit-request / assigned-lead cards).function startNewBeat() {var beatTitle = prompt('Enter beat name:', '');if (!beatTitle || !beatTitle.trim()) return;state.beatTitle = beatTitle.trim();state.mode = 'create';state.savedPlanGroupId = null; // reset — this is a fresh beatstate.days = []; // clear any leftover route dataisSubmittingBeat = false;// Cards live on the Route tab already, but ensure we're there when invoked.$('.panel-tab[data-tab="route"]').click();if (!state.homeLat) {showHomeModal();} else {loadPartners();}}$(document).on('click', '#btn-new-beat', startNewBeat);// ============ 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;if (state.mode === 'create' || state.mode === 'edit') $('#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 + ' Minutes Discussion Time</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 += '</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);// Auto-order the day into a nearest-neighbor route. The just-added stop may// no longer be last, so recolour + renumber every current-day marker by its// new index (resetMarkerColors handles both) rather than labelling this one.sortDayByNearestNeighbor(day);resetMarkerColors();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 = [];}var DAY_ROUTE_COLORS = ['#3b82f6', '#a855f7', '#06b6d4', '#ec4899', '#f59e0b', '#14b8a6'];function drawRoute() {clearRoute();// Draw routes for ALL days, not just currentstate.days.forEach(function (day, dayIdx) {var isCurrent = day.dayNumber === state.currentDay;var routeColor = DAY_ROUTE_COLORS[dayIdx % DAY_ROUTE_COLORS.length];var opacity = isCurrent ? 0.9 : 0.5;var weight = isCurrent ? 3 : 2;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});});// Draw visit route lines for this dayfor (var i = 0; i < pts.length - 1; i++) {var line = new google.maps.Polyline({path: [pts[i], pts[i + 1]], geodesic: true,strokeColor: routeColor, strokeOpacity: opacity, strokeWeight: weight, map: map});routeLines.push(line);// Only show distance labels on current dayif (isCurrent) {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);}}// Draw return-to-home line (not on DAYBREAK days)if (day.visits.length > 0 && day.endAction !== 'DAYBREAK') {var lastV = day.visits[day.visits.length - 1];if (lastV.lat && lastV.lng && state.homeLat && state.homeLng) {var retLine = new google.maps.Polyline({path: [{lat: lastV.lat, lng: lastV.lng}, {lat: state.homeLat, lng: state.homeLng}],geodesic: true, strokeColor: '#22c55e', strokeOpacity: isCurrent ? 0.4 : 0.2, strokeWeight: 2,icons: [{icon: {path: 'M 0,-1 0,1', strokeOpacity: 1, scale: 3}, offset: '0', repeat: '15px'}],map: map});routeLines.push(retLine);}}});}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();}// Auto-order a day's stops into a nearest-neighbor route from the day's start// point (beat start / base for day 1, prior day's end for later days). Reorders// day.visits in place; sequence = array index downstream. Stops without valid// coords (e.g. leads with no captured geo) are parked at the end in their// existing relative order rather than dropped.function sortDayByNearestNeighbor(day) {if (!day || !day.visits || day.visits.length < 2) return;var routable = [], parked = [];day.visits.forEach(function (v) {if (typeof v.lat === 'number' && typeof v.lng === 'number' && !isNaN(v.lat) && !isNaN(v.lng)) routable.push(v);else parked.push(v);});var haveStart = (typeof day.startLat === 'number' && typeof day.startLng === 'number' && !isNaN(day.startLat) && !isNaN(day.startLng));var ordered = [], curLat, curLng;if (haveStart) {curLat = day.startLat;curLng = day.startLng;} else if (routable.length > 0) {var first = routable.shift();ordered.push(first);curLat = first.lat;curLng = first.lng;}while (routable.length > 0) {var bestIdx = 0, bestD = Infinity;for (var i = 0; i < routable.length; i++) {var d = haversine(curLat, curLng, routable[i].lat, routable[i].lng);if (d < bestD) { bestD = d; bestIdx = i; }}var next = routable.splice(bestIdx, 1)[0];ordered.push(next);curLat = next.lat;curLng = next.lng;}day.visits.length = 0;ordered.forEach(function (v) { day.visits.push(v); });parked.forEach(function (v) { day.visits.push(v); });}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 = '';if (state.mode === 'create' && state.beatTitle) {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>';html += '<div style="font-size:14px;font-weight:600;color:#60a5fa;margin-bottom:10px;">' + state.beatTitle + '</div>';html += renderBaseLocationPicker();html += renderPartnerQuickAdd();} else if (state.mode === 'edit' && state.beatTitle) {// Edit header used to be injected via a one-time prepend() in editBeat's// success callback — that meant any addVisit-triggered re-render wiped the// back button / title / base picker / search box. Building it here keeps// them present across every re-render so users can keep adding partners.html += '<div style="margin-bottom:8px;"><button class="btn-back-to-list" style="font-size:11px;padding:4px 10px;border:1px solid #475569;border-radius:4px;background:none;color:#94a3b8;cursor:pointer;font-family:inherit;">← Back to list</button></div>';html += '<div style="font-size:14px;font-weight:600;color:#f59e0b;margin-bottom:4px;">EDITING: ' + state.beatTitle + '</div>';html += state.editingDate? '<div style="font-size:11px;color:#f59e0b;margin-bottom:8px;">Editing run on: ' + state.editingDate + ' (leads on this date can be removed)</div>': '<div style="font-size:11px;color:#94a3b8;margin-bottom:8px;">Editing beat template (partners only)</div>';html += renderBaseLocationPicker();html += renderPartnerQuickAdd();}state.days.forEach(function (day) {var isCurrent = day.dayNumber === state.currentDay;var isLastDay = day.dayNumber === state.days.length;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 || 'Home') + '</div></div></div>';// Visits with distance between eachvar prevLat = day.startLat, prevLng = day.startLng;var isLastStop;day.visits.forEach(function (v, i) {isLastStop = (i === day.visits.length - 1);var 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) {// Show drive ETA and discussion-time dwell separately so the// planner can see where the time goes.html += '<div class="route-segment"><span class="seg-line"></span><span class="seg-dist">'+ segDist.toFixed(1) + ' km | '+ fmtMins(segTime) + ' drive '+ '<span style="color:#94a3b8;">+ ' + VISIT_MINS + ' Minutes Discussion Time</span>'+ '</span></div>';}var isLeadVisit = v.isLead || v.type === 'lead';// Outlet/business name is the primary label; code is the muted subtitle.// v.name is stored as "CODE - Outlet Name" — strip the code prefix.var outletLabel = v.name || '';if (v.code && outletLabel.indexOf(v.code + ' - ') === 0) {outletLabel = outletLabel.substring(v.code.length + 3);}if (!outletLabel) outletLabel = v.code || '';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;' : '') + '">';html += '<span class="stop-num" style="' + (isLeadVisit ? 'background:#e67e22;' : '') + '">' + (i + 1) + '</span>';html += '<div class="stop-info"><div class="stop-name">' + outletLabel + (isLeadVisit ? ' <span style="background:#e67e22;color:#fff;font-size:10px;font-weight:700;padding:2px 6px;border-radius:3px;margin-left:4px;letter-spacing:0.3px;">LEAD VISIT</span>' : '') + '</div>';html += '<div class="stop-meta">' + (v.code || '') + '</div>';// Show "Last Visit" + "Day Break" buttons on last stop of current dayif (isLastStop && isCurrent && !day.endAction) {html += '<div style="margin-top:4px;display:flex;gap:4px;">';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>';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>';html += '</div>';}html += '</div>';if (state.mode === 'create' || state.mode === 'edit') {html += '<span class="stop-remove" data-fofoid="' + v.id + '" data-daynum="' + day.dayNumber + '" title="Remove">×</span>';}html += '</div>';prevLat = v.lat;prevLng = v.lng;});// Show return-to-home only when day is complete (Last Visit) or still in progressif (day.visits.length > 0 && day.endAction !== 'DAYBREAK') {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) {// Return-home leg = travel time only (no discussion at home).html += '<div class="route-segment"><span class="seg-line seg-line-home"></span><span class="seg-dist">'+ retDist.toFixed(1) + ' km | ' + fmtMins(retTime) + ' drive (return)'+ '</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>';}// Day break indicatorif (day.endAction === 'DAYBREAK' && day.visits.length > 0) {html += '<div style="margin-left:12px;padding:4px 8px;font-size:11px;color:#f59e0b;font-weight:600;">-- Day Break (stays near last visit) --</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;});// Re-route the remaining stops (nearest-neighbor) before re-numbering below.sortDayByNearestNeighbor(day);// 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();});// ============ LAST VISIT (from sidebar) — marks beat as complete, return home ============$(document).on('click', '.btn-last-visit', function (e) {e.stopPropagation();var dayNum = parseInt($(this).data('daynum'));var day = state.days[dayNum - 1];if (!day || day.visits.length === 0) return;var lastVisit = day.visits[day.visits.length - 1];if (!confirm('Mark ' + (lastVisit.code || lastVisit.name) + ' as last visit? Beat will end here and return home.')) return;day.endAction = 'HOME';drawRoute();renderRoutePanel();updateBottomBar();});// ============ DAY BREAK (from sidebar) — end day, next day starts from last visit ============$(document).on('click', '.btn-day-break', function (e) {e.stopPropagation();var dayNum = parseInt($(this).data('daynum'));var day = state.days[dayNum - 1];if (!day || day.visits.length === 0) return;var lastVisit = day.visits[day.visits.length - 1];if (!confirm('Day Break at ' + (lastVisit.code || lastVisit.name) + '? Next day will start from this location.')) return;day.endAction = 'DAYBREAK';// Next day starts from LAST VISIT location (not home)var nextNum = state.days.length + 1;state.days.push({dayNumber: nextNum,startLat: lastVisit.lat, startLng: lastVisit.lng, startName: lastVisit.code || lastVisit.name,visits: [], endAction: null,stayName: null, stayLat: null, stayLng: null,totalKm: 0, totalMins: 0});state.currentDay = nextNum;resetMarkerColors();drawRoute();renderRoutePanel();updateBottomBar();});$('#btn-finish').on('click', function () {// Guard 1: already submitting — ignore duplicate clicksif (isSubmittingBeat) {return;}// Guard 2: this beat was already saved — don't re-submitif (state.savedPlanGroupId) {alert('This beat is already saved.');showBeatList();return;}if (state.days.every(function (d) {return d.visits.length === 0;})) {alert('No visits added');return;}var beatTitle = state.beatTitle || 'Beat';var isEdit = state.mode === 'edit' && state.editingBeatId;// EDIT RULE: cannot grow day count. Caught client-side so the modal// doesn't even open; server enforces the same rule.if (isEdit && state.originalDayCount && state.days.length > state.originalDayCount) {alert('Cannot increase the number of days while editing.\n\n'+ 'Original: ' + state.originalDayCount + ' day(s)\n'+ 'Current: ' + state.days.length + ' day(s)\n\n'+ 'Please create a new beat for additional days.');return;}// Build plan data — recompute every day's totals + per-leg distance/time// fresh from the current state. recalcDay() only refreshes the CURRENT day,// so older days could carry stale values; doing it here guarantees DB rows// match what the user sees in the panel.var daysData = [];state.days.forEach(function (day) {if (day.visits.length === 0) return;// Walk the visits in order, computing each leg from the previous point.// Day always starts at (startLat, startLng); for the LAST stop, if// endAction === 'HOME' we ALSO add the return-to-home leg into the day total// (but not stored per-visit, just rolled into totalDistanceKm/totalTimeMins).var prevLat = day.startLat, prevLng = day.startLng;var dayKm = 0, dayMins = 0;var visitsOut = [];day.visits.forEach(function (v) {var legKm = null, legMins = null;if (prevLat != null && prevLng != null && v.lat != null && v.lng != null) {legKm = haversine(prevLat, prevLng, v.lat, v.lng) * ROAD_FACTOR;legMins = Math.round((legKm / AVG_SPEED) * 60);dayKm += legKm;dayMins += legMins;}visitsOut.push({id: v.id,type: v.type || 'partner',distanceFromPrevKm: legKm != null ? Number(legKm.toFixed(3)) : null,timeFromPrevMins: legMins});if (v.lat != null && v.lng != null) {prevLat = v.lat;prevLng = v.lng;}});// Return-home leg if applicableif (day.endAction === 'HOME' && day.visits.length > 0&& state.homeLat != null && state.homeLng != null) {var last = day.visits[day.visits.length - 1];if (last.lat != null && last.lng != null) {var retKm = haversine(last.lat, last.lng, state.homeLat, state.homeLng) * ROAD_FACTOR;dayKm += retKm;dayMins += (retKm / AVG_SPEED) * 60;}}// Add per-stop dwell timedayMins += day.visits.length * VISIT_MINS;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: Number(dayKm.toFixed(3)),totalTimeMins: Math.round(dayMins),visits: visitsOut});});// EDIT RULE: detect removed leads → show popup with cancel/reschedule per leadvar removedLeads = [];if (isEdit && state.originalLeads && state.originalLeads.length) {var currentLeadIds = {};state.days.forEach(function (d) {d.visits.forEach(function (v) {if (v.type === 'lead' || v.isLead) currentLeadIds[v.id] = true;});});removedLeads = state.originalLeads.filter(function (l) {return !currentLeadIds[l.leadId];});}if (removedLeads.length > 0) {openRemovedLeadsModal(removedLeads, function (actions) {doFinishSubmit(daysData, beatTitle, isEdit, actions);});return;}doFinishSubmit(daysData, beatTitle, isEdit, null);});function doFinishSubmit(daysData, beatTitle, isEdit, removedLeadActions) {var planPayload = {days: daysData,dates: daysData.map(function () {return null;}),beatName: beatTitle.trim()};if (state.mode === 'edit' && state.editingDate) planPayload.planDate = state.editingDate;if (removedLeadActions && removedLeadActions.length) {planPayload.removedLeadActions = JSON.stringify(removedLeadActions);}var planData = JSON.stringify(planPayload);isSubmittingBeat = true;$('#btn-finish').prop('disabled', true).text(isEdit ? 'Updating...' : 'Saving...');var url = isEdit ? '/beatPlan/updateBeat' : '/beatPlan/submitPlan';var ajaxData = isEdit? {beatId: state.editingBeatId, planData: planData}: {authUserId: state.authUserId, planData: planData};$.ajax({url: context + url,type: "POST",data: ajaxData,success: function (response) {var data = (typeof response === 'string' ? JSON.parse(response) : response);var result = data.response || data;if (result.status) {state.savedPlanGroupId = result.planGroupId;$('#bottom-bar').hide();if (result.duplicate) {alert('This beat already exists.');} else if (isEdit) {var extras = [];if (result.leadsCancelled) extras.push(result.leadsCancelled + ' lead(s) cancelled');if (result.leadsRescheduled) extras.push(result.leadsRescheduled + ' lead(s) rescheduled');alert('Beat "' + beatTitle + '" updated successfully'+ (extras.length ? '\n\n' + extras.join('\n') : '') + '.');} else {alert('Beat "' + beatTitle + '" saved successfully!');}// Wipe in-progress beat state for this user — the next "+ New Beat"// must start from a clean slate (no leftover days/visits/title/leads).state.editingBeatId = null;state.editingDate = null;state.beatTitle = null;state.days = [];state.currentDay = 1;state.originalDayCount = null;state.originalLeads = [];state.savedPlanGroupId = null;// Clear map artifacts from the just-saved route so the canvas// doesn't show stale polylines/labels behind the beat list.routeLines.forEach(function (l) {l.setMap(null);});routeLines = [];if (previewLine) {previewLine.setMap(null);previewLine = null;}if (typeof resetMarkerColors === 'function') resetMarkerColors();state.mode = 'list';showBeatList();} else {$('#btn-finish').prop('disabled', false).text(isEdit ? 'Save Changes' : 'Finish Plan');alert('Error saving beat plan');}isSubmittingBeat = false;},error: function (xhr) {isSubmittingBeat = false;$('#btn-finish').prop('disabled', false).text(isEdit ? 'Save Changes' : 'Finish Plan');// Surface a clean server message if present (e.g., day-increase block, missing beat on reschedule date)var msg = xhr.responseText || xhr.statusText;try {var err = JSON.parse(xhr.responseText);if (err && err.response) {if (typeof err.response === 'string') msg = err.response;else if (err.response.message) msg = err.response.message;}} catch (_) {}alert(msg);}});}// ============ REMOVED LEADS POPUP ============// Shown when an edit removes one or more leads. Per lead, the user picks:// - Cancel → mark CANCELLED// - Reschedule + date → move to whichever beat the same user has on that date// The date input is validated against /beatPlan/userBeatsOnDate so the user// sees "No beat on this date — pick another" before submit.function openRemovedLeadsModal(removedLeads, onConfirm) {$('#modal-removed-leads').remove(); // clean any prior instancevar rowsHtml = removedLeads.map(function (l) {return ''+ '<div class="rl-row" data-leadid="' + l.leadId + '" style="border:1px solid #334155;border-radius:6px;padding:8px;margin-bottom:8px;">'+ ' <div style="font-weight:600;color:#f1f5f9;margin-bottom:6px;">'+ ' ' + (l.name || ('Lead #' + l.leadId)) + ' <span style="color:#64748b;font-weight:400;font-size:11px;">(was on day ' + l.dayNumber + ')</span>'+ ' </div>'+ ' <label style="display:inline-flex;align-items:center;gap:6px;margin-right:14px;cursor:pointer;font-size:12px;">'+ ' <input type="radio" name="rl-act-' + l.leadId + '" value="cancel" checked> Cancel this lead'+ ' </label>'+ ' <label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;">'+ ' <input type="radio" name="rl-act-' + l.leadId + '" value="reschedule"> Reschedule to another date'+ ' </label>'+ ' <div class="rl-date-wrap" style="display:none;margin-top:6px;">'+ ' <input type="date" class="rl-date" min="' + fmtDate(new Date()) + '" style="width:auto;display:inline-block;margin-bottom:0;">'+ ' <span class="rl-date-status" style="font-size:11px;margin-left:8px;"></span>'+ ' </div>'+ '</div>';}).join('');var html = ''+ '<div class="modal-overlay show" id="modal-removed-leads">'+ ' <div class="modal-box" style="min-width:520px;max-width:680px;">'+ ' <h3>You removed ' + removedLeads.length + ' lead(s) from this beat</h3>'+ ' <p style="font-size:12px;color:#94a3b8;margin-bottom:12px;">'+ ' Choose what to do with each lead before saving. Reschedule moves the lead to whichever beat this user has on the selected date.'+ ' </p>'+ ' <div id="rl-list" style="max-height:55vh;overflow-y:auto;">' + rowsHtml + '</div>'+ ' <div class="modal-actions">'+ ' <button class="modal-cancel" id="rl-cancel">Cancel (keep editing)</button>'+ ' <button class="modal-confirm" id="rl-confirm">Save with these actions</button>'+ ' </div>'+ ' </div>'+ '</div>';$('body').append(html);// Show/hide date picker per row$('#modal-removed-leads').on('change', 'input[type="radio"]', function () {var $row = $(this).closest('.rl-row');var isResched = $row.find('input[type="radio"]:checked').val() === 'reschedule';$row.find('.rl-date-wrap').toggle(isResched);$row.find('.rl-date-status').text('');});// Validate the date as the user types it — show "no beat on this date" warning$('#modal-removed-leads').on('change', '.rl-date', function () {var $row = $(this).closest('.rl-row');var $status = $row.find('.rl-date-status');var d = $(this).val();if (!d) {$status.text('');return;}$status.text('Checking...').css('color', '#94a3b8');$.ajax({url: context + '/beatPlan/userBeatsOnDate', type: 'GET', dataType: 'json',data: {authUserId: state.authUserId, date: d},success: function (r) {var dd = r.response || r;var beats = dd.beats || [];if (beats.length === 0) {$status.text('No beat on this date — pick another date, or Cancel this lead instead.').css('color', '#ef4444');} else {var names = beats.map(function (b) {return '"' + b.beatName + '" (day ' + b.dayNumber + ')';}).join(', ');$status.text('Will attach to: ' + names).css('color', '#22c55e');}},error: function () {$status.text('Could not verify date').css('color', '#f59e0b');}});});$('#modal-removed-leads').on('click', '#rl-cancel', function () {$('#modal-removed-leads').remove();// abort save — user goes back to editing});$('#modal-removed-leads').on('click', '#rl-confirm', function () {var actions = [];var problems = [];$('#modal-removed-leads .rl-row').each(function () {var $row = $(this);var leadId = parseInt($row.data('leadid'));var mode = $row.find('input[type="radio"]:checked').val();if (mode === 'reschedule') {var d = $row.find('.rl-date').val();if (!d) {problems.push('Lead ' + leadId + ': pick a date or switch to Cancel');return;}actions.push({leadId: leadId, action: 'reschedule', toDate: d});} else {actions.push({leadId: leadId, action: 'cancel'});}});if (problems.length) {alert(problems.join('\n'));return;}$('#modal-removed-leads').remove();onConfirm(actions);});}// ============ 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');$('#panel-deferred').toggle(tab === 'deferred');$('#cal-grid-container').toggle(tab === 'calendar');$('#bottom-bar').toggle(tab === 'route' && (state.mode === 'create' || state.mode === 'edit') && state.days && state.days.length > 0);if (tab === 'calendar' && !state.calMonth) {var now = new Date();state.calMonth = now.getFullYear() + '-' + ('0' + (now.getMonth() + 1)).slice(-2);loadCalendarData();}if (tab === 'deferred' && typeof loadDeferredItems === 'function') {loadDeferredItems();}});// Click a beat chip on the calendar → view that specific day's route// (partners + only the leads scheduled for that exact date)$(document).on('click', '.cal-chip-view', function (e) {if ($(e.target).hasClass('cal-chip-remove')) return; // handled separatelye.stopPropagation();var pg = $(this).data('plangroup');var date = $(this).data('date');if (!pg || !date) return;// Deferred-assign mode: clicking a beat chip drops the deferred item into// that beat on that date (instead of opening the route).if (state.assignDeferred) {assignDeferredToBeat(String(pg), String(date));return;}// Visit-request scheduling mode: similar handoff but talks to /visitRequest/{id}/approve-schedule.if (state.assignVisitRequest) {approveVisitRequestOnBeat(String(pg), String(date));return;}// Assigned-lead scheduling mode: drop the picked lead onto this beat+date.if (state.assignLead) {approveLeadOnBeat(String(pg), String(date));return;}// Switch to Route tab and load that day's run$('.panel-tab[data-tab="route"]').click();viewBeat(String(pg), String(date));});// Drop the deferred item (set on entry via URL params) into the chosen beat+date.function assignDeferredToBeat(beatId, date) {var ad = state.assignDeferred;if (!ad) return;// Must be a FUTURE beat — strictly after the deferred day (and not in the past).if (date <= ad.minDate) {alert('Pick a later beat. A deferred ' + (ad.type === 'lead' ? 'lead' : 'partner')+ ' can only move to a date after ' + ad.minDate + ' — not ' + date + '.');return;}if (!confirm('Add deferred ' + (ad.type === 'lead' ? 'lead' : 'partner') + ' "' + ad.name + '" into this beat on ' + date + '?')) return;$.ajax({url: context + '/beatPlan/deferred/assignToBeat',type: 'POST', contentType: 'application/json',data: JSON.stringify({deferredId: ad.id, beatId: parseInt(beatId), date: date}),success: function (r) {var d = r.response || r;var label = (ad.type === 'lead' ? 'Lead' : 'Partner') + ' "' + ad.name + '"';var msg = label + ' rescheduled to ' + date + '.';$('#deferred-assign-banner').html('<span style="color:#bbf7d0;">' + msg + '</span>');// Confirm to the head (name included so they don't have to read the banner),// then auto-close the parent's iframe modal. The parent's `hidden.bs.modal`// handler reloads the deferred list (rescheduled row drops off).alert(msg);// Clear the assign-mode so subsequent chip clicks don't try to// re-schedule the same (now-resolved) deferred row and trip the// "already scheduled" guard. Refresh the in-page deferred panel and// switch back to the route tab.state.assignDeferred = null;if (typeof loadDeferredItems === 'function') loadDeferredItems();$('.panel-tab[data-tab="route"]').click();if (window.parent && window.parent !== window) {try {window.parent.$('#defCalModal').modal('hide');} catch (e) {}}},error: function (xhr) {var msg = 'Failed';try {var err = JSON.parse(xhr.responseText);if (err && err.response) msg = (typeof err.response === 'string') ? err.response : (err.response.message || msg);} catch (_) {}alert(msg);}});}// ============ 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,planGroupId: b.planGroupId});}});});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 isPast = ds < today;var hasBeats = beats.length > 0;var cls = [];if (hol && !sun) cls.push('holiday', 'blocked');else if (isPast) cls.push('blocked', 'past');if (sun && !isPast) cls.push('sunday'); // Sundays styled but not blockedif (sun && isPast) cls.push('blocked', 'past');if (isToday) cls.push('today');if (!inMonth) cls.push('blocked');var style = inMonth ? '' : 'opacity:0.3;';if (isPast && inMonth) style += 'opacity:0.5;';row += '<td class="' + cls.join(' ') + '" data-date="' + ds + '" data-hasbeat="' + (hasBeats ? '1' : '0') + '" 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) {// The "running"/[live] state only applies to TODAY's chip — future// chips of the same beat are still freely editable (drag + ×).// Past chips are always locked.var isLiveChip = b.status === 'running' && isToday;var isLockedChip = isPast || isLiveChip;var chipCls = 'cal-chip';if (isLiveChip) chipCls += ' running';if (b.status === 'completed') chipCls += ' completed';var removeBtn = !isLockedChip? '<span class="cal-chip-remove" data-date="' + ds + '" data-plangroup="' + (b.planGroupId || '') + '" title="Remove">×</span>': '';var draggable = !isLockedChip ? ' draggable="true"' : '';var cursor = draggable ? 'grab' : 'pointer';row += '<span class="' + chipCls + ' cal-chip-view"' + draggable + ' data-plangroup="' + (b.planGroupId || '') + '" data-date="' + ds + '" style="background:' + b.color + ';position:relative;cursor:' + cursor + ';" title="Drag to another date to shuffle">'+ b.name + ' D' + b.day + (isLiveChip ? ' [live]' : '') + ' (' + b.visits + ')'+ removeBtn + '</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);}// Lists the scheduled dates for a beat (grouped by day number) inside a beat card.// Output looks like: "Schedule: D1 May 27 | D2 May 28 | D3 May 29"// Unscheduled days show as "—"; if NO real dates exist, returns empty string.function renderBeatScheduledDates(instances) {if (!instances || !instances.length) return '';// Collect planDates per dayNumber across every instance of this beatvar byDay = {};var today = fmtDate(new Date());instances.forEach(function (inst) {(inst.days || []).forEach(function (d) {if (!d.planDate) return; // skip unscheduled placeholdersif (!byDay[d.dayNumber]) byDay[d.dayNumber] = [];byDay[d.dayNumber].push(d.planDate);});});var dayNums = Object.keys(byDay).map(function (n) {return parseInt(n, 10);}).sort(function (a, b) {return a - b;});if (dayNums.length === 0) {return '<div class="beat-meta" style="color:#475569; margin-top:4px;">Not scheduled yet</div>';}var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];function fmt(ds) {// ds = "yyyy-mm-dd"var p = ds.split('-');if (p.length !== 3) return ds;return MONTHS[parseInt(p[1], 10) - 1] + ' ' + parseInt(p[2], 10);}var parts = dayNums.map(function (n) {// De-dup + sort dates for this day (could repeat if beat scheduled multiple times)var dates = (byDay[n] || []).filter(function (v, i, a) {return a.indexOf(v) === i;}).sort();var label = dates.map(function (ds) {var color = ds === today ? '#ef4444' : (ds < today ? '#64748b' : '#60a5fa');return '<span style="color:' + color + ';">' + fmt(ds) + '</span>';}).join(', ');return '<span style="color:#94a3b8;">D' + n + '</span> ' + label;});return '<div class="beat-meta" style="margin-top:4px; line-height:1.6;">'+ '<span style="color:#64748b;">Schedule:</span> '+ parts.join('<span style="color:#334155;"> | </span>')+ '</div>';}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>';$('#cal-beat-cards').html(html);return;}// Deduplicate by beatName — group beats with same name, show countvar beatGroups = {};(beats || []).forEach(function (b) {var key = b.beatName || b.planGroupId;if (!beatGroups[key]) {beatGroups[key] = {beatName: b.beatName,beatColor: b.beatColor,instances: []};}beatGroups[key].instances.push(b);});Object.keys(beatGroups).forEach(function (key) {var group = beatGroups[key];var first = group.instances[0];var scheduledCount = group.instances.filter(function (b) {return b.status === 'scheduled';}).length;var unscheduledCount = group.instances.filter(function (b) {return b.status === 'unscheduled';}).length;var totalInstances = group.instances.length;var totalV = 0, totalKm = 0;first.days.forEach(function (d) {totalV += d.visitCount || 0;totalKm += d.totalKm || 0;});// Use first unscheduled instance for scheduling, or first overallvar primaryPg = first.planGroupId;var unscheduledInstance = group.instances.find(function (b) {return b.status === 'unscheduled';});if (unscheduledInstance) primaryPg = unscheduledInstance.planGroupId;var statusText = '';if (scheduledCount > 0) statusText += scheduledCount + ' scheduled';if (unscheduledCount > 0) statusText += (statusText ? ', ' : '') + unscheduledCount + ' unscheduled';var isScheduling = state.calSelectedBeat === primaryPg && state.calPickedDates.length > 0;html += '<div class="beat-card" data-plangroup="' + primaryPg + '" data-beatname="' + (group.beatName || '') + '" draggable="true">';html += '<div class="beat-name"><span style="display:inline-block;width:12px;height:12px;border-radius:2px;background:' + group.beatColor + ';"></span> ' + group.beatName + '</div>';html += '<div class="beat-meta">' + (first.totalDays || 1) + ' days | ' + totalV + ' visits | ' + totalKm.toFixed(0) + ' km</div>';html += '<div class="beat-meta" style="color:#94a3b8;">' + statusText + ' (' + totalInstances + ' total)</div>';html += renderBeatScheduledDates(group.instances);html += '<div class="beat-actions">';if (isScheduling) {html += '<button class="btn-sm btn-confirm-schedule" data-pg="' + primaryPg + '" data-days="' + (first.totalDays || 1) + '" 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="' + primaryPg + '" data-days="' + (first.totalDays || 1) + '">Schedule</button>';}html += '<button class="btn-sm" style="background:#ef4444;color:#fff;" onclick="deleteBeat(\'' + primaryPg + '\')">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 style="font-size:9px;color:#475569;margin-top:4px;">Drag onto calendar to schedule</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 during Schedule flow — auto-fill consecutive days from clicked date$(document).on('click', '#cal-grid-content td', function () {if (!state.calSelectedBeat) return;var ds = $(this).data('date');if (!ds || isDateBlocked(ds)) return;// Find the beat to get days neededvar beat = (state.calData.scheduledBeats || []).find(function (b) {return String(b.planGroupId) === String(state.calSelectedBeat);});var daysNeeded = beat ? (beat.totalDays || 1) : 1;// Auto-fill consecutive slots from clicked datevar slots = findConsecutiveSlots(ds, daysNeeded);if (slots) {state.calPickedDates = slots;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 String(b.planGroupId) === String(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));}});});// ============ DRAG & DROP BEAT TO CALENDAR ============$(document).on('dragstart', '.beat-card', function (e) {var pg = $(this).data('plangroup');var beatName = $(this).data('beatname');e.originalEvent.dataTransfer.setData('text/plain', pg);e.originalEvent.dataTransfer.setData('beatname', beatName || '');$(this).css('opacity', '0.5');});$(document).on('dragend', '.beat-card', function () {$(this).css('opacity', '1');});// Calendar cells accept drops$(document).on('dragover', '#cal-grid-content td', function (e) {var ds = $(this).data('date');if (!ds) return;if (isDateBlocked(ds)) return; // don't allow drop indicator on blocked datese.preventDefault();$(this).css('background', '#1e3a5f');});$(document).on('dragleave', '#cal-grid-content td', function () {$(this).css('background', '');});$(document).on('drop', '#cal-grid-content td', function (e) {e.preventDefault();$(this).css('background', '');var pg = e.originalEvent.dataTransfer.getData('text/plain');var dropDate = $(this).data('date');if (!pg || !dropDate) return;if (isDateBlocked(dropDate)) {alert('Cannot schedule on this date (past, Sunday, or holiday)');return;}var beat = (state.calData.scheduledBeats || []).find(function (b) {return String(b.planGroupId) === String(pg);});if (!beat) return;var beatName = beat.beatName || 'Beat';var daysNeeded = beat.totalDays || 1;// Auto-fill consecutive available days starting from drop datevar scheduleDates = findConsecutiveSlots(dropDate, daysNeeded);if (!scheduleDates) return; // alerts already shown inside findConsecutiveSlotsvar dateStr = scheduleDates.map(function (d, i) {return 'Day ' + (i + 1) + ': ' + d;}).join('\n');if (!confirm('Schedule "' + beatName + '" (' + daysNeeded + ' days):\n\n' + dateStr + '\n\nConfirm?')) return;$.ajax({url: context + "/beatPlan/repeatBeat", type: "POST",data: {sourcePlanGroupId: pg,authUserId: state.authUserId,dates: JSON.stringify(scheduleDates)},success: function () {loadCalendarData();},error: function (xhr) {alert('Error: ' + (xhr.responseText || xhr.statusText));}});});// Find N consecutive available days starting from startDate, skipping Sundays/holidays// Returns array of date strings, or null if can't fitfunction findConsecutiveSlots(startDateStr, daysNeeded) {var startDate = new Date(startDateStr);var startMonth = startDate.getMonth();var dates = [];var d = new Date(startDate);var sundayAsked = false;var includeSundays = false;while (dates.length < daysNeeded) {var ds = fmtDate(d);// Rule 3: must stay within same monthif (d.getMonth() !== startMonth) {alert('Beat has ' + daysNeeded + ' days but only ' + dates.length + ' available days left in this month starting from ' + startDateStr + '. Try an earlier date.');return null;}// Past dates — skip. Today is skipped too, unless this is an L4+ operator.var today = fmtDate(new Date());if (ds < today || (ds === today && !state.canScheduleToday)) {d.setDate(d.getDate() + 1);continue;}// Holidays — always skipif (isHoliday(ds)) {d.setDate(d.getDate() + 1);continue;}// Sunday — ask once whether to includeif (isSunday(ds)) {if (!sundayAsked) {sundayAsked = true;includeSundays = confirm('This beat falls on a Sunday (' + ds + '). Include Sundays in the schedule?');}if (!includeSundays) {d.setDate(d.getDate() + 1);continue;}}// Already occupied — blockif (getBeatsOnDate(ds).length > 0) {alert('Cannot schedule: ' + ds + ' already has a beat scheduled. Consecutive days not available from ' + startDateStr + '.');return null;}dates.push(ds);d.setDate(d.getDate() + 1);}return dates;}function isDateBlocked(ds) {var today = fmtDate(new Date());if (ds < today) return true; // pastif (ds === today && !state.canScheduleToday) return true; // today blocked unless L4+// Sundays NOT blocked — handled via confirm dialog instead// Only block holidaysif (state.calData && state.calData.blockedDates) {var blocked = state.calData.blockedDates;// blockedDates from server includes Sundays+holidays, but we only block holidays nowvar d = new Date(ds);if (d.getDay() !== 0) { // not Sunday — check holidaysif (Array.isArray(blocked) && blocked.indexOf(ds) > -1) return true;if (typeof blocked === 'object' && blocked[ds]) return true;}}return false;}function isSunday(ds) {return new Date(ds).getDay() === 0;}function isHoliday(ds) {if (!state.calData || !state.calData.holidays) return false;return state.calData.holidays.some(function (h) {return h.date === ds;});}function getBeatsOnDate(ds) {var result = [];if (!state.calData || !state.calData.scheduledBeats) return result;state.calData.scheduledBeats.forEach(function (b) {(b.days || []).forEach(function (d) {if (d.planDate === ds) result.push({planGroupId: b.planGroupId, beatName: b.beatName});});});return result;}// ============ REMOVE BEAT FROM CALENDAR DATE ============$(document).on('click', '.cal-chip-remove', function (e) {e.stopPropagation();var date = $(this).data('date');var planGroupId = $(this).data('plangroup');if (!planGroupId || !date) return;if (!confirm('Unschedule this beat from ' + date + '?\n\n(The beat stays in the list — only this date is cleared.)')) return;// Unschedule ONLY this date — the beat itself is preserved$.ajax({url: context + "/beatPlan/unscheduleDate", type: "POST",data: {planGroupId: String(planGroupId), date: date},success: function () {loadCalendarData();},error: function (xhr) {alert('Error: ' + (xhr.responseText || xhr.statusText));}});});// ============ CALENDAR SHUFFLE (drag chip to another date) ============$(document).on('dragstart', '.cal-chip-view', function (e) {var pg = $(this).data('plangroup');var date = $(this).data('date');if (!pg || !date) {e.preventDefault();return;}var payload = JSON.stringify({pg: String(pg), fromDate: String(date)});var dt = e.originalEvent.dataTransfer;dt.effectAllowed = 'move';dt.setData('text/plain', payload);$(this).css('opacity', '0.4');});$(document).on('dragend', '.cal-chip-view', function () {$(this).css('opacity', '');});$(document).on('dragover', '.cal-grid td', function (e) {var $td = $(this);// Today is the live/running slot — nothing can be shuffled onto it. The// shuffle is only valid between future dates.if ($td.hasClass('past') || $td.hasClass('blocked') || $td.hasClass('holiday') || $td.hasClass('sunday') || $td.hasClass('today')) return;e.preventDefault();e.originalEvent.dataTransfer.dropEffect = 'move';$td.css('box-shadow', 'inset 0 0 0 2px #60a5fa');});$(document).on('dragleave', '.cal-grid td', function () {$(this).css('box-shadow', '');});$(document).on('drop', '.cal-grid td', function (e) {var $td = $(this);$td.css('box-shadow', '');if ($td.hasClass('past') || $td.hasClass('blocked') || $td.hasClass('holiday') || $td.hasClass('sunday') || $td.hasClass('today')) return;e.preventDefault();var raw;try {raw = e.originalEvent.dataTransfer.getData('text/plain');} catch (_) {return;}if (!raw) return;var data;try {data = JSON.parse(raw);} catch (_) {return;}if (!data.pg || !data.fromDate) return;var toDate = $td.data('date');if (!toDate || toDate === data.fromDate) return;$.ajax({url: context + "/beatPlan/moveScheduleDate", type: "POST",data: {planGroupId: data.pg, fromDate: data.fromDate, toDate: String(toDate)},success: function () {loadCalendarData();},error: function (xhr) {var msg = 'Move failed';try {var err = JSON.parse(xhr.responseText);if (err && err.response) {if (typeof err.response === 'string') msg = err.response;else if (err.response.message) msg = err.response.message;}} catch (_) {}alert(msg);}});});// ============ 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() {var b = new google.maps.LatLngBounds();var any = false;if (homeMarker) {b.extend(homeMarker.getPosition());any = true;}// Prefer the current route's stops — the unselected partners on the map can// span a much wider area than the actual planned visits, which would zoom// the camera all the way out. We want focus on the route the user is viewing.try {var day = curDay();if (day && day.visits && day.visits.length > 0) {day.visits.forEach(function (v) {if (v.lat && v.lng) {b.extend(new google.maps.LatLng(parseFloat(v.lat), parseFloat(v.lng)));any = true;}});}} catch (e) {}// No route yet (e.g., still picking partners) → fit all rendered markers.if (!any) {for (var k in markers) {b.extend(markers[k].getPosition());any = true;}}if (any) map.fitBounds(b);}// ============ AUTO-LOAD FROM URL PARAMS ============// When opened with ?autoUserId=X[&autoUserName=Y], skip the user-picker// dropdowns and load that user's beats directly (and jump to Calendar tab).$(function () {// Use a manual parser so we don't depend on URLSearchParamsfunction getParam(name) {var q = window.location.search.substring(1);var pairs = q.split('&');for (var i = 0; i < pairs.length; i++) {var kv = pairs[i].split('=');if (decodeURIComponent(kv[0]) === name) {return kv.length > 1 ? decodeURIComponent(kv[1].replace(/\+/g, ' ')) : '';}}return null;}var autoUid = getParam('autoUserId');console.log('[BEAT-AUTO] autoUserId =', autoUid);if (!autoUid) return;var autoName = getParam('autoUserName');var editBeatId = getParam('editBeatId');var editDate = getParam('editDate');// Deferred-assign mode (opened from the Deferred Partners panel): the head// picks an upcoming beat on the calendar to drop this deferred item into.var assignDeferredId = getParam('assignDeferredId');if (assignDeferredId) {var d2 = new Date();var todayStr = d2.getFullYear() + '-' + ('0' + (d2.getMonth() + 1)).slice(-2) + '-' + ('0' + d2.getDate()).slice(-2);var deferDate = getParam('deferDate') || '';// A deferral can only move FORWARD — never to the day it was deferred or// earlier. The floor is the later of (deferred date, today).var floor = (deferDate && deferDate > todayStr) ? deferDate : todayStr;state.assignDeferred = {id: parseInt(assignDeferredId),type: getParam('deferType') || 'visit',name: getParam('deferName') || 'item',deferDate: deferDate,minDate: floor // target date must be strictly AFTER this};}state.authUserId = parseInt(autoUid);state.categoryId = parseInt($('#bp-category').val()) || 4;state.mode = 'list';$('#bp-user-label').text(autoName || ('User #' + autoUid));$.ajax({url: context + '/beatPlan/getBaseLocation', type: 'GET', dataType: 'json',data: {authUserId: autoUid},success: function (r) {console.log('[BEAT-AUTO] getBaseLocation ok');var data = r.response || r;if (data.locationName) {state.homeLat = parseFloat(data.latitude);state.homeLng = parseFloat(data.longitude);state.homeName = data.locationName;}if (editBeatId) {console.log('[BEAT-AUTO] entering edit for beat', editBeatId, 'date', editDate);editBeat(String(editBeatId), editDate || null);} else {showBeatList();setTimeout(function () {$('.panel-tab[data-tab="calendar"]').click();}, 100);if (state.assignDeferred) {// Banner instructing the head to click an upcoming beat.var ad = state.assignDeferred;$('#deferred-assign-banner').remove();$('body').prepend('<div id="deferred-assign-banner" style="position:sticky;top:0;z-index:2000;'+ 'background:#0d9488;color:#fff;padding:8px 16px;font-size:13px;">'+ 'Adding deferred ' + (ad.type === 'lead' ? 'lead' : 'partner') + ': '+ '<strong>' + ad.name + '</strong> — click an upcoming beat on the calendar to drop it in.'+ '</div>');}}},error: function (xhr) {console.error('[BEAT-AUTO] getBaseLocation failed:', xhr.status, xhr.responseText);}});});// ============ VISIT REQUEST PANEL ============// Lives on the route tab (sidebar) when a sales user is loaded. Fetches// PENDING requests addressed at this user's hierarchy and shows three actions// per row: Schedule on a beat, Reassign, Reject. Schedule reuses the calendar// chip-click flow by setting state.assignVisitRequest = {…} and switching// the panel to the Calendar tab — clicking a future chip then fires the POST.function loadVisitRequests() {if (!state.authUserId) return;$.ajax({url: context + '/visitRequest/list',type: 'GET', dataType: 'json',// Force a fresh fetch — otherwise the browser HTTP cache can serve the// pre-reassign list and the panel won't reflect the new ownership.cache: false,data: {assigneeAuthId: state.authUserId, status: 'PENDING'},success: function (r) {var data = r.response || r;var rows = (data && data.rows) || [];state.visitRequests = rows;renderVisitRequests(rows);},error: function () {$('#visit-request-panel').hide();}});}function renderVisitRequests(rows) {if (!rows || rows.length === 0) {$('#visit-request-panel').hide();return;}$('#vr-count').text('(' + rows.length + ')');var html = '';rows.forEach(function (r) {var safeName = String(r.leadName || '').replace(/"/g, '"');// Line 1: outlet · mobile · city, statevar top = [];if (r.leadOutlet) top.push(r.leadOutlet);if (r.leadMobile) top.push(r.leadMobile);var loc = [r.leadCity, r.leadState].filter(Boolean).join(', ');if (loc) top.push(loc);var topLine = top.length ? '<div class="beat-meta">' + top.join(' · ') + '</div>' : '';// Line 2: nearest store (only when known)var nearLine = r.nearestStoreCode? '<div class="beat-meta">Near ' + r.nearestStoreCode+ (r.nearestStoreOutlet ? ' — ' + r.nearestStoreOutlet : '') + '</div>': '';// Line 3: by requester · Pref date. Assignee + communication type are intentionally// not shown here (assignee id still travels on the button data for the Reassign prompt).var reqBits = [];if (r.requestedByName) reqBits.push('by ' + r.requestedByName + (r.requestedByAuthId ? ' (#' + r.requestedByAuthId + ')' : ''));if (r.requestedDate) reqBits.push('Pref <span style="color:#fbbf24;">' + r.requestedDate + '</span>');var reqLine = reqBits.length ? '<div class="beat-meta">' + reqBits.join(' · ') + '</div>' : '';html += '<div class="beat-card" data-rid="' + r.id + '" style="cursor:default;">'+ '<div class="beat-name"><span style="display:inline-block;width:12px;height:12px;border-radius:2px;flex:none;background:#fbbf24;"></span> ' + (r.leadName || ('Lead #' + r.leadId)) + '</div>'+ topLine+ nearLine+ reqLine+ '<div class="beat-actions" style="flex-wrap:wrap;">'+ '<button class="vr-schedule btn-sm" data-rid="' + r.id + '" data-lead="' + r.leadId + '" data-leadname="' + safeName + '" data-assignee="' + r.assigneeAuthId + '" style="background:#22c55e;color:#fff;">Schedule</button>'+ '<button class="vr-reassign btn-sm" data-rid="' + r.id + '" style="background:none;border:1px solid #475569;color:#cbd5e1;">Reassign</button>'+ '<button class="vr-reject btn-sm" data-rid="' + r.id + '" style="background:#ef4444;color:#fff;">Reject</button>'+ '</div>'+ '</div>';});$('#vr-list').html(html);$('#visit-request-panel').show();}$(document).on('click', '#vr-refresh', function () {loadVisitRequests();});// Collapse / expand the visit-requests list (accordion header).$(document).on('click', '#vr-toggle', function () {var open = $('#vr-body').is(':visible');$('#vr-body').slideToggle(120);$('#vr-caret').html(open ? '▸' : '▾');});// Enter "schedule a visit request" mode — same handoff to the calendar chip// click that the deferred-assign flow uses.$(document).on('click', '.vr-schedule', function () {var $b = $(this);state.assignVisitRequest = {id: parseInt($b.data('rid')),leadId: parseInt($b.data('lead')),leadName: String($b.data('leadname') || ''),assigneeAuthId: parseInt($b.data('assignee'))};alert('Click a future beat-chip on the calendar to schedule "'+ (state.assignVisitRequest.leadName || ('Lead #' + state.assignVisitRequest.leadId))+ '" onto it.');$('.panel-tab[data-tab="calendar"]').click();});// "+ New beat" — open the full map-first planner, same as the big "+ New Beat"// button (no date prompt / no auto 1-day beat).$(document).on('click', '.vr-new-beat', function () {startNewBeat();});$(document).on('click', '.vr-reassign', function () {var rid = $(this).data('rid');var newId = prompt('New assignee auth user id (must be in your downline):');if (!newId) return;var newIdInt = parseInt(newId);if (!newIdInt) return;$.ajax({url: context + '/visitRequest/' + rid + '/reassign',type: 'POST', contentType: 'application/json',data: JSON.stringify({newAssigneeAuthId: newIdInt}),success: function () {alert('Reassigned to user #' + newIdInt + '. The request will drop off this panel.');loadVisitRequests();},error: function (xhr) {var msg = 'Reassign failed';try {var e = JSON.parse(xhr.responseText);if (e && e.response) msg = (typeof e.response === 'string') ? e.response : (e.response.message || msg);} catch (_) {}alert(msg);}});});$(document).on('click', '.vr-reject', function () {var rid = $(this).data('rid');var reason = prompt('Reject reason (required):');if (!reason || !reason.trim()) return;$.ajax({url: context + '/visitRequest/' + rid + '/reject',type: 'POST', contentType: 'application/json',data: JSON.stringify({rejectReason: reason.trim()}),success: function () {alert('Request rejected. It will drop off this panel.');loadVisitRequests();},error: function (xhr) {var msg = 'Reject failed';try {var e = JSON.parse(xhr.responseText);if (e && e.response) msg = (typeof e.response === 'string') ? e.response : (e.response.message || msg);} catch (_) {}alert(msg);}});});// ============ ASSIGNED LEADS PANEL ============// Lives on the route tab (sidebar) below Pending Visit Requests. Lists the// loaded user's active/open leads (pending + follow-up, yellow/green). Each row// offers "Schedule on a beat" (reuses the calendar-chip handoff, like visit// requests) and "New beat" (a 1-day beat with just this lead). Hidden when empty.function loadAssignedLeads() {if (!state.authUserId) return;$.ajax({url: context + '/beatPlan/assignedLeads',type: 'GET', dataType: 'json',cache: false,data: {authUserId: state.authUserId},success: function (r) {var data = r.response || r;var rows = (data && data.rows) || [];state.assignedLeads = rows;renderAssignedLeads(rows);},error: function () {$('#assigned-leads-panel').hide();}});}function renderAssignedLeads(rows) {if (!rows || rows.length === 0) {$('#assigned-leads-panel').hide();return;}$('#al-count').text('(' + rows.length + ')');$('#al-search').val('');$('#al-list').html(alRowsHtml(rows));$('#assigned-leads-panel').show();}// Lead rows reuse the beat-card look (.beat-card/.beat-name/.beat-meta/.beat-actions)// so leads and routes read as one system. The colour dot mirrors the beat colour// swatch — here it's the lead's yellow/green priority colour.function alRowsHtml(rows) {if (!rows || rows.length === 0) {return '<div style="padding:12px;color:#64748b;font-size:11px;text-align:center;">No matching leads.</div>';}var html = '';rows.forEach(function (r) {var safeName = String(r.leadName || '').replace(/"/g, '"');var dot = r.color === 'green' ? '#22c55e' : (r.color === 'yellow' ? '#eab308' : '#64748b');var metaParts = [];if (r.outletName) metaParts.push(r.outletName);if (r.mobile) metaParts.push(r.mobile);var loc = [r.city, r.state].filter(Boolean).join(', ');if (loc) metaParts.push(loc);if (r.stage) metaParts.push(r.stage);var meta = metaParts.join(' · ')+ (r.hasGeo ? '' : ' <span style="color:#f59e0b;" title="No approved location — the field rep won\'t get a map pin">· no location</span>');html += '<div class="beat-card" data-lid="' + r.leadId + '" style="cursor:default;">'+ '<div class="beat-name"><span style="display:inline-block;width:12px;height:12px;border-radius:2px;flex:none;background:' + dot + ';"></span> ' + (r.leadName || ('Lead #' + r.leadId)) + '</div>'+ '<div class="beat-meta">' + meta + '</div>'+ '<div class="beat-actions">'+ '<button class="al-schedule btn-sm" data-lead="' + r.leadId + '" data-leadname="' + safeName + '" style="background:#22c55e;color:#fff;">Schedule on a beat</button>'+ '<button class="al-new-beat btn-sm" data-lead="' + r.leadId + '" data-leadname="' + safeName + '" style="background:#3b82f6;color:#fff;" title="Use this when the user has no beat to land on">+ New beat</button>'+ '</div>'+ '</div>';});return html;}$(document).on('click', '#al-refresh', function () {loadAssignedLeads();});// Collapse / expand the assigned-leads list (accordion header).$(document).on('click', '#al-toggle', function () {var open = $('#al-body').is(':visible');$('#al-body').slideToggle(120);$('#al-caret').html(open ? '▸' : '▾');});// Client-side filter over the loaded leads — keeps the panel short without a round-trip.$(document).on('input', '#al-search', function () {var q = $(this).val().toLowerCase().trim();var src = state.assignedLeads || [];var list = !q ? src : src.filter(function (p) {return (p.leadName || '').toLowerCase().indexOf(q) !== -1|| (p.outletName || '').toLowerCase().indexOf(q) !== -1|| (p.city || '').toLowerCase().indexOf(q) !== -1|| (p.mobile || '').toLowerCase().indexOf(q) !== -1;});$('#al-list').html(alRowsHtml(list));});// Enter "schedule a lead" mode — same calendar-chip handoff as visit requests.$(document).on('click', '.al-schedule', function () {var $b = $(this);state.assignLead = {leadId: parseInt($b.data('lead')),leadName: String($b.data('leadname') || '')};alert('Click a future beat-chip on the calendar to schedule "'+ (state.assignLead.leadName || ('Lead #' + state.assignLead.leadId))+ '" onto it.');$('.panel-tab[data-tab="calendar"]').click();});// "+ New beat" — open the full map-first planner, same as the big "+ New Beat"// button (no date prompt / no auto 1-day beat).$(document).on('click', '.al-new-beat', function () {startNewBeat();});// Drop the picked lead onto the chosen beat+date (strictly-future chip).function approveLeadOnBeat(pg, date) {var al = state.assignLead;if (!al) return;var today = new Date();var todayStr = today.getFullYear() + '-' + ('0' + (today.getMonth() + 1)).slice(-2) + '-' + ('0' + today.getDate()).slice(-2);if (date <= todayStr) {alert('Pick a FUTURE date (after today).');return;}if (!confirm('Schedule "' + (al.leadName || ('Lead #' + al.leadId)) + '" onto this beat on ' + date + '?')) return;$.ajax({url: context + '/beatPlan/lead/scheduleOnBeat',type: 'POST', contentType: 'application/json',data: JSON.stringify({leadId: al.leadId, beatId: parseInt(pg), date: date}),success: function (resp) {var r = resp && resp.response ? resp.response : resp;state.assignLead = null;alert(r.message || 'Scheduled.');$('.panel-tab[data-tab="route"]').click();loadAssignedLeads();},error: function (xhr) {var msg = 'Schedule failed';try {var er = JSON.parse(xhr.responseText);if (er && er.response) msg = (typeof er.response === 'string') ? er.response : (er.response.message || msg);} catch (_) {}alert(msg);}});}// ============ DEFERRED ITEMS PANEL (inside beat-plan-window) ============// Reuses the existing /beatPlan/deferred read (hierarchy-scoped) and filters// client-side to the currently-loaded sales user. Schedule → switches to the// Calendar tab in deferred-assign mode and reuses assignDeferredToBeat() on// chip click (existing flow). Cancel posts /beatPlan/deferred/action with a// required reason. The standalone Deferred Partners page is untouched.function loadDeferredItems() {if (!state.authUserId) return;// Wide window so recent misses + any not-yet-acted older ones surface.var now = new Date();function fmt(d) {return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2);}var start = new Date();start.setDate(now.getDate() - 30);var end = new Date();end.setDate(now.getDate() + 30);$.ajax({url: context + '/beatPlan/deferred',type: 'GET', dataType: 'json', cache: false,data: {startDate: fmt(start), endDate: fmt(end)},success: function (r) {var data = r.response || r;var all = (data && data.rows) || [];var mine = all.filter(function (row) {return row.authUserId === state.authUserId;});state.deferredItems = mine;renderDeferredItems(mine);},error: function () {$('#di-list').html('<p style="color:#d9534f;font-size:12px;text-align:center;padding:20px 10px;">Could not load deferred items.</p>');}});}function renderDeferredItems(rows) {var n = (rows && rows.length) || 0;$('#di-count').text('(' + n + ')');// Tab pill counter — keeps the head aware of pending items while on other tabs.if (n > 0) {$('#di-tab-count').text('(' + n + ')').css('color', '#fca5a5');} else {$('#di-tab-count').text('').css('color', '');}if (n === 0) {$('#di-list').html('<p style="color:#64748b;font-size:12px;text-align:center;padding:20px 10px;">No deferred items for this user.</p>');return;}var html = '';rows.forEach(function (r) {var safeName = String(r.name || '').replace(/"/g, '"');var typeTag = r.type === 'Lead'? '<span style="background:#fbbf24;color:#0f172a;font-size:10px;padding:1px 5px;border-radius:3px;margin-right:6px;">Lead</span>': '<span style="background:#60a5fa;color:#0f172a;font-size:10px;padding:1px 5px;border-radius:3px;margin-right:6px;">Visit</span>';var nextLine = r.nextScheduledDate? '<div style="color:#22c55e;font-size:11px;">Auto-covered on ' + r.nextScheduledDate + '</div>' : '';html += '<div data-did="' + r.id + '" style="padding:8px;border:1px solid #334155;border-radius:6px;background:#0f172a;margin-bottom:6px;">'+ typeTag+ '<strong style="color:#e2e8f0;">' + (r.name || '#' + r.fofoStoreId) + '</strong>'+ '<div style="color:#94a3b8;font-size:11px;margin-top:2px;">'+ 'Deferred: <span style="color:#fca5a5;">' + (r.deferredDate || '-') + '</span>'+ (r.reason ? ' · ' + r.reason : '')+ '</div>'+ nextLine+ '<div style="margin-top:6px;display:flex;gap:6px;flex-wrap:wrap;">'+ '<button class="di-schedule" data-did="' + r.id + '" data-type="' + (r.type === 'Lead' ? 'lead' : 'visit') + '" data-name="' + safeName + '" data-deferdate="' + (r.deferredDate || '') + '" style="font-size:11px;padding:3px 8px;border:none;border-radius:4px;background:#22c55e;color:#fff;cursor:pointer;">Schedule on a beat</button>'+ '<button class="di-cancel" data-did="' + r.id + '" data-name="' + safeName + '" style="font-size:11px;padding:3px 8px;border:none;border-radius:4px;background:#ef4444;color:#fff;cursor:pointer;">Cancel</button>'+ '</div>'+ '</div>';});$('#di-list').html(html);}$(document).on('click', '#di-refresh', function () {loadDeferredItems();});// Schedule: set state.assignDeferred (existing pattern) and switch to the// Calendar tab. The existing .cal-chip-view handler dispatches into// assignDeferredToBeat() when state.assignDeferred is set.$(document).on('click', '.di-schedule', function () {var $b = $(this);var deferDate = String($b.data('deferdate') || '');var todayObj = new Date();var todayStr = todayObj.getFullYear() + '-' + ('0' + (todayObj.getMonth() + 1)).slice(-2) + '-' + ('0' + todayObj.getDate()).slice(-2);// Floor: later of the deferred-date OR today — same guard the iframe flow uses.var floor = (deferDate && deferDate > todayStr) ? deferDate : todayStr;state.assignDeferred = {id: parseInt($b.data('did')),type: String($b.data('type') || 'visit'),name: String($b.data('name') || 'item'),deferDate: deferDate,minDate: floor};alert('Click a future beat-chip on the calendar to drop "' + (state.assignDeferred.name) + '" onto it (after ' + floor + ').');$('.panel-tab[data-tab="calendar"]').click();});$(document).on('click', '.di-cancel', function () {var did = $(this).data('did');var name = String($(this).data('name') || 'this deferred item');var reason = prompt('Cancel reason (required) — why is "' + name + '" being cancelled?');if (!reason || !reason.trim()) return;$.ajax({url: context + '/beatPlan/deferred/action',type: 'POST', contentType: 'application/json',data: JSON.stringify({deferredId: did, action: 'cancel', reason: reason.trim()}),success: function () {alert('Cancelled. The row will drop off this panel.');loadDeferredItems();},error: function (xhr) {var msg = 'Cancel failed';try {var e = JSON.parse(xhr.responseText);if (e && e.response) msg = (typeof e.response === 'string') ? e.response : (e.response.message || msg);} catch (_) {}alert(msg);}});});function approveVisitRequestOnBeat(pg, date) {var vr = state.assignVisitRequest;if (!vr) return;var today = new Date();var todayStr = today.getFullYear() + '-' + ('0' + (today.getMonth() + 1)).slice(-2) + '-' + ('0' + today.getDate()).slice(-2);if (date <= todayStr) {alert('Pick a FUTURE date (after today).');return;}if (!confirm('Schedule "' + (vr.leadName || ('Lead #' + vr.leadId)) + '" onto this beat on ' + date + '?')) return;$.ajax({url: context + '/visitRequest/' + vr.id + '/approve-schedule',type: 'POST', contentType: 'application/json',data: JSON.stringify({beatId: parseInt(pg), scheduleDate: date}),success: function () {state.assignVisitRequest = null;alert('Scheduled. The request has been resolved.');$('.panel-tab[data-tab="route"]').click();loadVisitRequests();},error: function (xhr) {var msg = 'Schedule failed';try {var er = JSON.parse(xhr.responseText);if (er && er.response) msg = (typeof er.response === 'string') ? er.response : (er.response.message || msg);} catch (_) {}alert(msg);}});}