Inline route optimization into canteen server (remove proxy to daily-route on port 8912)

- Added extraction DB config and _get_extraction_db() helper
- Added _status_label, _haversine, _solve_tsp (nearest-neighbor + 2-opt)
- Added GET /api/workorders/search (native)
- Added POST /api/workorders/lookup (native)
- Added GET /api/workorders/today (native, reads bookableresourcebooking)
- Added GET /api/workorders/technicians (native)
- Added POST /api/route/optimize (native TSP solver)
- Removed httpx dependency, _proxy_route, _get_route_client
- Removed all proxy endpoints
This commit is contained in:
2026-05-27 18:18:24 -04:00
parent a1022b300e
commit da2a23f6da
3 changed files with 1433 additions and 1 deletions
+785
View File
@@ -836,6 +836,161 @@
.leaflet-draw-toolbar a[title="Edit layers"]::after { content: "Edit"; }
.leaflet-draw-toolbar a[title="Delete layers"]::after { content: "Delete"; }
.leaflet-draw-actions a { width: auto !important; padding: 0 10px !important; font-size: 10px; color: var(--text) !important; }
/* ── Native Route Planner ── */
#tabRoute {
flex-direction: column;
padding: 12px !important;
gap: 10px;
overflow-y: auto;
}
#tabRoute.active {
display: flex !important;
}
#routeSection, #routeResultSection { width: 100%; min-width: 0; }
.route-card {
background: var(--card, #1e1e2e);
border: 1px solid var(--border, #333);
border-radius: 8px;
padding: 14px;
margin-bottom: 10px;
}
.route-card h3 { font-size: 14px; color: var(--heading, #e0e0e0); margin-bottom: 8px; }
.route-card p { font-size: 13px; color: var(--text2, #999); }
.route-card label {
display: block; font-size: 12px; color: var(--text2, #999);
margin-bottom: 4px; font-weight: 600;
}
.route-card .btn-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.route-card .btn-row button { flex: 1; min-width: 120px; }
/* Tech checkboxes */
.tech-grid {
display: flex; gap: 6px; flex-wrap: wrap; margin: 6px 0;
}
.tech-grid label {
display: flex; align-items: center; gap: 4px; cursor: pointer;
font-size: 13px; background: var(--bg, #111); padding: 4px 10px;
border-radius: 6px; border: 1px solid var(--border, #333);
}
/* Input tabs */
.rp-tabs {
display: flex; border-bottom: 1px solid var(--border, #333);
margin-bottom: 10px;
}
.rp-tab {
padding: 8px 16px; font-size: 13px; cursor: pointer;
color: var(--text2, #999); border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.rp-tab:hover { color: var(--text, #ccc); }
.rp-tab.active { color: var(--accent, #58a6ff); border-bottom-color: var(--accent, #58a6ff); }
/* Origin presets */
.origin-presets { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
.origin-presets button { font-size: 11px; padding: 4px 8px; }
/* Search results */
.search-results { max-height: 250px; overflow-y: auto; }
.search-item {
padding: 8px 10px; border-bottom: 1px solid var(--border, #333);
cursor: pointer; font-size: 13px; transition: background 0.1s;
}
.search-item:hover { background: var(--bg, #111); }
.search-item .si-name { font-weight: 600; color: var(--heading, #e0e0e0); }
.search-item .si-addr { color: var(--text2, #999); font-size: 11px; }
.search-item.selected { background: rgba(88,166,255,0.1); border-left: 3px solid var(--accent, #58a6ff); }
/* Chips (selected WOs) */
.chip-list { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
.chip {
display: inline-flex; align-items: center; gap: 4px;
background: var(--bg, #111); border: 1px solid var(--border, #333);
border-radius: 4px; padding: 2px 6px; font-size: 11px;
}
.chip .remove { color: var(--red, #f85149); cursor: pointer; font-weight: 700; margin-left: 2px; }
/* Stats */
.rp-stats { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.rp-stat {
background: var(--card, #1e1e2e); border: 1px solid var(--border, #333);
border-radius: 6px; padding: 10px 14px; flex: 1; min-width: 80px;
}
.rp-stat-val { font-size: 20px; font-weight: 700; color: var(--heading, #e0e0e0); }
.rp-stat-lbl { font-size: 11px; color: var(--text2, #999); margin-top: 2px; }
/* Route map */
#routeMap {
height: 350px; border-radius: 8px;
border: 1px solid var(--border, #333); margin-bottom: 10px;
}
@media (max-width: 600px) { #routeMap { height: 250px; } }
/* Stop list */
.stop-list { margin-top: 6px; }
.stop-item {
background: var(--card, #1e1e2e); border: 1px solid var(--border, #333);
border-radius: 6px; padding: 10px 12px; margin-bottom: 6px;
display: flex; align-items: flex-start; gap: 10px;
}
.stop-num {
background: var(--accent, #58a6ff); color: #fff; width: 26px; height: 26px;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 700; flex-shrink: 0; margin-top: 1px;
}
.stop-body { flex: 1; min-width: 0; }
.stop-name { font-weight: 600; font-size: 13px; color: var(--heading, #e0e0e0); }
.stop-addr { font-size: 12px; color: var(--text, #ccc); }
.stop-meta { font-size: 11px; color: var(--text2, #999); margin-top: 2px; }
.stop-drive { font-size: 11px; color: var(--orange, #d29922); margin-top: 2px; }
.stop-cumulative { font-size: 11px; color: var(--green, #3fb950); margin-top: 2px; }
.stop-actions { display: flex; gap: 4px; flex-shrink: 0; align-items: center; }
/* Loading */
.route-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 200px; }
.route-loading .spinner { width: 32px; height: 32px; border: 3px solid var(--border, #333); border-top-color: var(--accent, #58a6ff); border-radius: 50%; animation: rp-spin 0.7s linear infinite; margin: 0 auto; }
@keyframes rp-spin { to { transform: rotate(360deg); } }
/* Asset modal (reuse existing modal pattern) */
.asset-overlay {
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.65); z-index: 2000;
}
.asset-modal {
display: none; position: fixed; top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 90%; max-width: 500px; max-height: 85vh; z-index: 2001;
background: var(--card, #1e1e2e); border: 1px solid var(--border, #333);
border-radius: 12px; overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
.asset-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid var(--border, #333);
background: var(--bg, #111); position: sticky; top: 0; z-index: 1;
}
.asset-modal-content { padding: 16px; overflow-y: auto; max-height: calc(85vh - 60px); }
.asset-section-title {
font-size: 12px; font-weight: 600; color: var(--accent, #58a6ff);
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px;
}
.asset-row {
display: flex; justify-content: space-between; align-items: baseline;
padding: 4px 0; font-size: 13px; border-bottom: 1px solid var(--border, #333);
}
.asset-row:last-child { border-bottom: none; }
.asset-label { color: var(--text2, #999); white-space: nowrap; margin-right: 8px; }
.asset-val { color: var(--heading, #e0e0e0); text-align: right; word-break: break-word; max-width: 60%; }
/* Print */
@media print {
body * { visibility: hidden; }
#tabRoute, #tabRoute * { visibility: visible; }
#tabRoute { position: absolute; left: 0; top: 0; width: 100%; }
.no-print { display: none !important; }
.stop-num { background: #333 !important; }
}
</style>
</head>
<body>
@@ -890,6 +1045,9 @@
<button class="dn-item" data-tab="tabNavigate" onclick="navFromDrawer('tabNavigate')">
<span class="dn-icon">🧭</span> Navigate
</button>
<button class="dn-item" data-tab="tabRoute" onclick="navFromDrawer('tabRoute')">
<span class="dn-icon">🗺️</span> Route
</button>
<button class="dn-item" id="logoutBtn" onclick="doLogout()" style="display:none;">
<span class="dn-icon">🚪</span> Logout
</button>
@@ -1391,6 +1549,128 @@
<a id="navGoogleMapsLink" href="#" target="_blank" rel="noopener" class="btn btn-primary" style="display:none;text-decoration:none;text-align:center;margin-top:8px;">🧭 Open in Google Maps</a>
</div>
<!-- ── ROUTE PLANNER ── -->
<div id="tabRoute" class="tab-panel">
<!-- Input Section -->
<div id="routeSection">
<!-- Load Today's Route -->
<div class="route-card" style="text-align:center;">
<div style="text-align:left;margin-bottom:10px;">
<label>👤 Technicians</label>
<div id="rpTechCheckboxes" class="tech-grid">
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px;background:var(--bg,#111);padding:4px 10px;border-radius:6px;border:1px solid var(--border,#333);">
<input type="checkbox" id="rpTechAll" checked onchange="rpToggleAllTechs()"> All
</label>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" style="font-size:15px;padding:12px 24px;flex:1;" onclick="rpLoadToday()">
🚀 Load Today's Route
</button>
</div>
<p style="font-size:11px;color:var(--text2,#999);margin-top:6px;">Finds today's scheduled/active work orders and plans the optimal route</p>
</div>
<!-- Starting Point -->
<div class="route-card">
<h3>📍 Starting Point</h3>
<div style="display:flex;gap:8px;">
<input type="text" id="rpOriginInput" placeholder="28.5383, -81.3792" class="search-input" style="flex:1;">
<button class="btn btn-secondary btn-sm" onclick="rpUseMyLocation()">📍 My Location</button>
</div>
<div class="origin-presets">
<button class="btn btn-secondary btn-sm" onclick="rpSetOrigin(28.5383,-81.3792)">🏢 Orlando</button>
<button class="btn btn-secondary btn-sm" onclick="rpSetOrigin(28.6272,-81.2343)">🔧 Oviedo Shop</button>
<button class="btn btn-secondary btn-sm" onclick="rpSetOrigin(28.4945,-81.5774)">🏭 Ocoee</button>
</div>
<label style="margin-top:6px;display:flex;align-items:center;gap:6px;font-size:12px;">
<input type="checkbox" id="rpUseOrigin" checked> Start from this location
</label>
</div>
<!-- Work Order Input -->
<div class="route-card">
<div class="rp-tabs" id="rpInputTabs">
<div class="rp-tab active" onclick="rpSwitchInputTab('paste')">📋 Paste WOs</div>
<div class="rp-tab" onclick="rpSwitchInputTab('search')">🔍 Search</div>
</div>
<!-- Paste tab -->
<div id="rpTabPaste">
<label>Paste work order numbers (one per line, or comma-separated)</label>
<textarea id="rpWoInput" class="search-input" style="width:100%;min-height:80px;resize:vertical;font-family:monospace;font-size:13px;padding:8px 10px;background:var(--bg,#111);border:1px solid var(--border,#333);border-radius:6px;color:var(--text,#ccc);" placeholder="WO-00958990&#10;WO-00959256"></textarea>
<button class="btn btn-primary mt-8" onclick="rpPlanFromPaste()" style="margin-top:8px;">🚀 Plan Route</button>
<span style="font-size:11px;color:var(--text2,#999);margin-left:8px;">Looks up WO numbers from the extraction DB</span>
</div>
<!-- Search tab -->
<div id="rpTabSearch" style="display:none;">
<label>Search work orders</label>
<input type="text" id="rpSearchInput" class="search-input" style="width:100%;" placeholder="Search by WO#, account name, or city..." oninput="rpDebounceSearch()">
<div id="rpSearchResults" class="search-results" style="margin-top:6px;"></div>
<div id="rpSelectedWOs" style="margin-top:8px;">
<label>Selected WOs (<span id="rpSelectedCount">0</span>)</label>
<div id="rpSelectedWOList" class="chip-list"></div>
</div>
<button class="btn btn-primary mt-8" onclick="rpPlanFromSelected()" id="rpPlanSelectedBtn" disabled style="margin-top:8px;">🚀 Plan Route (<span id="rpPlanSelectedCount">0</span>)</button>
</div>
</div>
</div>
<!-- Loading -->
<div id="rpLoadingSection" style="display:none;">
<div class="route-loading">
<div class="spinner"></div>
<div style="margin-top:12px;color:var(--text2,#999);font-size:14px;" id="rpLoadingMsg">Planning route...</div>
</div>
</div>
<!-- Result Section -->
<div id="rpResultSection" style="display:none;width:100%;">
<!-- Action bar -->
<div class="btn-row no-print" style="margin-bottom:10px;">
<button class="btn btn-secondary btn-sm" onclick="rpResetAll()">← New Route</button>
<button class="btn btn-secondary btn-sm" onclick="window.print()">🖨️ Print</button>
<button class="btn btn-secondary btn-sm" onclick="rpCopyText()">📋 Copy</button>
</div>
<!-- Stats -->
<div id="rpRouteStats" class="rp-stats"></div>
<!-- Map -->
<div id="routeMap"></div>
<!-- Stop List -->
<div class="route-card no-print">
<div style="display:flex;justify-content:space-between;align-items:center;">
<h3>📍 Stops</h3>
<button class="btn btn-secondary btn-sm" onclick="rpToggleReorder()" id="rpReorderToggle">↕️ Reorder</button>
</div>
<div id="rpStopList" class="stop-list"></div>
</div>
<!-- No GPS warning -->
<div id="rpNoGpsWarning" style="display:none;" class="route-card">
<h3 style="color:var(--orange,#d29922);">⚠️ Stops without GPS</h3>
<div id="rpNoGpsList"></div>
</div>
</div>
</div>
<!-- Asset Detail Modal -->
<div id="rpAssetOverlay" class="asset-overlay" onclick="rpCloseAssetModal()"></div>
<div id="rpAssetModal" class="asset-modal">
<div class="asset-modal-header">
<span style="font-size:18px;font-weight:700;color:var(--heading,#e0e0e0);">🔧 Machine Details</span>
<button onclick="rpCloseAssetModal()" style="background:none;border:none;color:var(--text2,#999);font-size:20px;cursor:pointer;padding:4px 8px;"></button>
</div>
<div id="rpAssetModalContent" class="asset-modal-content"></div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
BOTTOM TABS
═══════════════════════════════════════════════════════════════════════ -->
@@ -1407,6 +1687,9 @@
<button class="tab-btn" data-tab="tabNavigate" onclick="switchTab('tabNavigate')">
<span class="tab-icon">🧭</span> Nav
</button>
<button class="tab-btn" data-tab="tabRoute" onclick="switchTab('tabRoute')">
<span class="tab-icon">🗺️</span> Route
</button>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
@@ -1835,6 +2118,7 @@
tabAssets: 'tabAssets',
tabMap: 'tabMap',
tabNavigate: 'tabNavigate',
tabRoute: 'tabRoute',
};
const bottomTab = tabMap[tabId] || 'tabMore';
document.querySelectorAll('.tab-btn').forEach(b => {
@@ -1865,6 +2149,7 @@
zoomControl: true,
});
L.tileLayer(tileUrl, { attribution: tileAttr, maxZoom: 19 }).addTo(map);
// Geofence drawing — only if L.Draw loaded
if (typeof L.Draw !== 'undefined') {
const drawnItems = new L.FeatureGroup();
@@ -1899,6 +2184,11 @@
loadAssetPins(); // Refresh pins on every tab visit
}
// Route tab — dispatch event so route planner can initialize
if (tabId === 'tabRoute') {
document.dispatchEvent(new CustomEvent('rpTabActivated'));
}
// Dispatch event so child tab implementations can hook in
document.dispatchEvent(new CustomEvent('tabChange', { detail: { tabId } }));
}
@@ -4722,6 +5012,501 @@
}
// Boot auth at page load
// ══════════════════════════════════════════════════════════════════════
// ROUTE PLANNER — native integration
// ══════════════════════════════════════════════════════════════════════
const rpState = {
selectedWOs: new Map(),
currentRoute: null,
map: null,
markers: [],
polyline: null,
searchTimer: null,
isReordering: false,
};
// ── Init on tab visit ────────────────────────────────────────────────
document.addEventListener('rpTabActivated', () => {
if (!rpState.map) {
rpInitMap();
} else {
setTimeout(() => rpState.map.invalidateSize(), 200);
}
rpLoadTechs();
rpLoadSavedOrigin();
});
function rpInitMap() {
const el = document.getElementById('routeMap');
if (!el) return;
rpState.map = L.map('routeMap', {
center: [28.5383, -81.3792],
zoom: 10,
zoomControl: true,
attributionControl: false,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
}).addTo(rpState.map);
setTimeout(() => rpState.map.invalidateSize(), 300);
}
// ── Tech Checkboxes ──────────────────────────────────────────────────
async function rpLoadTechs() {
const savedTechs = JSON.parse(localStorage.getItem('rpTechs') || '[]');
try {
const r = await fetch('/api/workorders/technicians');
const data = await r.json();
const container = document.getElementById('rpTechCheckboxes');
// Keep the "All" checkbox, remove existing tech checkboxes
container.querySelectorAll('label:not(:first-child)').forEach(el => el.remove());
data.technicians.forEach(tech => {
const label = document.createElement('label');
label.style.cssText = 'display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px;background:var(--bg,#111);padding:4px 10px;border-radius:6px;border:1px solid var(--border,#333);';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = tech;
cb.checked = savedTechs.length ? savedTechs.includes(tech) : true;
cb.onchange = rpSaveTechs;
label.appendChild(cb);
label.append(' ' + tech);
container.appendChild(label);
});
rpSyncAllCheckbox();
} catch (e) { console.warn('Could not load technicians:', e); }
}
function rpSaveTechs() {
rpSyncAllCheckbox();
const techs = [];
document.querySelectorAll('#rpTechCheckboxes input[type="checkbox"]:not(#rpTechAll):checked').forEach(cb => techs.push(cb.value));
localStorage.setItem('rpTechs', JSON.stringify(techs));
}
function rpToggleAllTechs() {
const allChecked = document.getElementById('rpTechAll').checked;
document.querySelectorAll('#rpTechCheckboxes input[type="checkbox"]:not(#rpTechAll)').forEach(cb => cb.checked = allChecked);
rpSaveTechs();
}
function rpSyncAllCheckbox() {
const individual = document.querySelectorAll('#rpTechCheckboxes input[type="checkbox"]:not(#rpTechAll)');
const allChecked = individual.length > 0 && Array.from(individual).every(cb => cb.checked);
const allCb = document.getElementById('rpTechAll');
if (allCb) allCb.checked = allChecked;
}
// ── Origin ────────────────────────────────────────────────────────────
function rpLoadSavedOrigin() {
const saved = localStorage.getItem('rpOrigin');
if (saved) document.getElementById('rpOriginInput').value = saved;
}
function rpSetOrigin(lat, lng) {
const val = `${lat}, ${lng}`;
document.getElementById('rpOriginInput').value = val;
localStorage.setItem('rpOrigin', val);
}
function rpUseMyLocation() {
if (!navigator.geolocation) return alert('Geolocation not available');
navigator.geolocation.getCurrentPosition(
pos => rpSetOrigin(pos.coords.latitude, pos.coords.longitude),
() => alert('Could not get location')
);
}
function rpGetOrigin() {
const val = document.getElementById('rpOriginInput').value.trim();
if (!val || !document.getElementById('rpUseOrigin').checked) return null;
const parts = val.split(',').map(s => parseFloat(s.trim()));
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
return { lat: parts[0], lng: parts[1] };
}
return null;
}
// ── Input Tabs ────────────────────────────────────────────────────────
function rpSwitchInputTab(tab) {
document.querySelectorAll('#rpInputTabs .rp-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('#rpInputTabs .rp-tab').forEach(t => {
if (t.textContent.includes(tab === 'paste' ? 'Paste' : 'Search')) t.classList.add('active');
});
document.getElementById('rpTabPaste').style.display = tab === 'paste' ? '' : 'none';
document.getElementById('rpTabSearch').style.display = tab === 'search' ? '' : 'none';
}
// ── Search ────────────────────────────────────────────────────────────
function rpDebounceSearch() {
clearTimeout(rpState.searchTimer);
rpState.searchTimer = setTimeout(rpDoSearch, 300);
}
async function rpDoSearch() {
const q = document.getElementById('rpSearchInput').value.trim();
const container = document.getElementById('rpSearchResults');
if (q.length < 2) { container.innerHTML = ''; return; }
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text2,#999);">Searching...</div>';
try {
const r = await fetch('/api/workorders/search?q=' + encodeURIComponent(q) + '&limit=30');
const data = await r.json();
if (!data.results || data.results.length === 0) {
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text2,#999);">No results</div>';
return;
}
let html = '';
data.results.forEach(wo => {
const sel = rpState.selectedWOs.has(wo.id) ? ' selected' : '';
const addr = [wo.address.line1, wo.address.city].filter(Boolean).join(', ');
html += '<div class="search-item' + sel + '" onclick="rpToggleSelect(\'' + wo.id + '\')">' +
'<div class="si-name">' + esc(wo.name) + '</div>' +
'<div class="si-addr">' + esc(wo.account_name || '') + (addr ? ' — ' + esc(addr) : '') + '</div>' +
'</div>';
});
container.innerHTML = html;
} catch (e) {
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--red,#f85149);">Error searching</div>';
}
}
function rpToggleSelect(woId) {
if (rpState.selectedWOs.has(woId)) {
rpState.selectedWOs.delete(woId);
} else if (rpState.selectedWOs.size < 50) {
rpState.selectedWOs.set(woId, {});
}
rpUpdateSelectedUI();
rpDoSearch(); // re-render search results to show selection
}
function rpUpdateSelectedUI() {
const count = rpState.selectedWOs.size;
document.getElementById('rpSelectedCount').textContent = count;
document.getElementById('rpPlanSelectedCount').textContent = count;
document.getElementById('rpPlanSelectedBtn').disabled = count === 0;
const container = document.getElementById('rpSelectedWOList');
if (count === 0) { container.innerHTML = ''; return; }
let html = '';
rpState.selectedWOs.forEach((_, id) => {
html += '<span class="chip">' + esc(id) + ' <span class="remove" onclick="rpRemoveSelected(\'' + id + '\')"></span></span>';
});
container.innerHTML = html;
}
function rpRemoveSelected(woId) {
rpState.selectedWOs.delete(woId);
rpUpdateSelectedUI();
rpDoSearch();
}
// ── Load Today's Route ────────────────────────────────────────────────
async function rpLoadToday() {
const techs = [];
document.querySelectorAll('#rpTechCheckboxes input[type="checkbox"]:not(#rpTechAll):checked').forEach(cb => techs.push(cb.value));
if (techs.length === 0) { return alert('Select at least one technician'); }
rpShowLoading('Loading today\u2019s work orders...');
try {
const params = techs.map(t => 'tech=' + encodeURIComponent(t)).join('&');
const r = await fetch('/api/workorders/today?' + params);
const data = await r.json();
if (!data.workorders || data.workorders.length === 0) {
rpHideLoading();
return alert('No work orders found for today');
}
const woIds = data.workorders.map(wo => wo.name);
await rpOptimizeRoute(woIds, data.workorders);
} catch (e) {
rpHideLoading();
alert('Error loading today\u2019s route: ' + e.message);
}
}
// ── Plan from Paste ───────────────────────────────────────────────────
function rpPlanFromPaste() {
const raw = document.getElementById('rpWoInput').value.trim();
if (!raw) return alert('Paste some work order numbers first');
const ids = raw.split(/[,\n\r]+/).map(s => s.trim()).filter(Boolean);
if (ids.length === 0) return alert('No valid work order numbers found');
rpOptimizeRoute(ids);
}
// ── Plan from Selected ────────────────────────────────────────────────
function rpPlanFromSelected() {
const ids = Array.from(rpState.selectedWOs.keys());
if (ids.length === 0) return;
rpOptimizeRoute(ids);
}
// ── Route Optimization ────────────────────────────────────────────────
async function rpOptimizeRoute(woIds, preloaded) {
rpShowLoading('Optimizing route...');
// Lookup WO details if not preloaded
let woDetails = preloaded || null;
if (!woDetails) {
try {
const r = await fetch('/api/workorders/lookup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: woIds }),
});
const data = await r.json();
woDetails = data.found || [];
} catch (e) {
rpHideLoading();
return alert('Error looking up work orders: ' + e.message);
}
}
const origin = rpGetOrigin();
const payload = {
work_order_ids: woIds,
origin: origin,
};
try {
const r = await fetch('/api/route/optimize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await r.json();
rpHideLoading();
rpDisplayRoute(data, woDetails);
} catch (e) {
rpHideLoading();
alert('Error optimizing route: ' + e.message);
}
}
// ── Display Route ─────────────────────────────────────────────────────
function rpDisplayRoute(data, woDetails) {
rpState.currentRoute = data;
document.getElementById('routeSection').style.display = 'none';
document.getElementById('rpResultSection').style.display = '';
// Stats
const s = data.summary || {};
const statsHtml =
'<div class="rp-stat"><div class="rp-stat-val">' + (s.total_stops || 0) + '</div><div class="rp-stat-lbl">Stops</div></div>' +
'<div class="rp-stat"><div class="rp-stat-val">' + (s.total_distance_km || 0) + ' km</div><div class="rp-stat-lbl">Distance</div></div>' +
'<div class="rp-stat"><div class="rp-stat-val">' + (s.total_drive_min || 0) + ' min</div><div class="rp-stat-lbl">Drive</div></div>' +
'<div class="rp-stat"><div class="rp-stat-val">' + (s.total_estimated_min || 0) + ' min</div><div class="rp-stat-lbl">Est. Total</div></div>';
document.getElementById('rpRouteStats').innerHTML = statsHtml;
// Map
if (!rpState.map) rpInitMap();
const map = rpState.map;
// Clear old layers
rpState.markers.forEach(m => map.removeLayer(m));
if (rpState.polyline) map.removeLayer(rpState.polyline);
rpState.markers = [];
const route = data.route || [];
if (route.length === 0) {
document.getElementById('rpStopList').innerHTML = '<p style="color:var(--text2,#999);">No stops with GPS coordinates</p>';
return;
}
// Plot markers
const bounds = [];
const markerColors = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#79c0ff'];
route.forEach((stop, i) => {
if (!stop.gps) return;
const color = markerColors[i % markerColors.length];
const marker = L.circleMarker([stop.gps.lat, stop.gps.lng], {
radius: 10,
fillColor: color,
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8,
});
marker.bindTooltip((i + 1).toString(), { permanent: true, direction: 'center', className: 'rp-marker-label' });
marker.bindPopup(
'<b>#' + (i + 1) + ' ' + esc(stop.name) + '</b><br>' +
esc(stop.account_name || '') + '<br>' +
esc(stop.address ? [stop.address.line1, stop.address.city].filter(Boolean).join(', ') : '') +
(stop.distance_to_next_km ? '<br>→ ' + stop.distance_to_next_km + ' km' : '')
);
marker.on('click', () => rpShowAsset(stop.name));
marker.addTo(map);
rpState.markers.push(marker);
bounds.push([stop.gps.lat, stop.gps.lng]);
});
// Polyline
const latlngs = route.filter(s => s.gps).map(s => [s.gps.lat, s.gps.lng]);
if (latlngs.length > 1) {
rpState.polyline = L.polyline(latlngs, {
color: '#58a6ff', weight: 3, opacity: 0.7, dashArray: '8, 8',
}).addTo(map);
}
if (bounds.length > 0) {
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 14 });
}
// Stop list
let stopHtml = '';
route.forEach((stop, i) => {
const addr = stop.address ? [stop.address.line1, stop.address.city].filter(Boolean).join(', ') : '';
const drive = stop.drive_min_to_next ? '🚗 ' + stop.drive_min_to_next + ' min (' + stop.distance_to_next_km + ' km)' : '';
const cum = stop.cumulative_min != null ? '⏱ ' + stop.cumulative_min + ' min from start' : '';
const statusBadge = stop.booking_status ? '<span class="badge badge-info">' + esc(stop.booking_status) + '</span>' : '';
const woLink = '<span style="color:var(--accent,#58a6ff);cursor:pointer;" onclick="rpShowAsset(\'' + stop.name + '\')">' + esc(stop.name) + '</span>';
stopHtml += '<div class="stop-item">' +
'<div class="stop-num">' + (i + 1) + '</div>' +
'<div class="stop-body">' +
'<div class="stop-name">' + woLink + ' ' + statusBadge + '</div>' +
'<div class="stop-addr">' + esc(stop.account_name || '') + (addr ? ' — ' + esc(addr) : '') + '</div>' +
'<div class="stop-meta">' + esc(stop.work_type || '') + (stop.priority ? ' · ' + esc(stop.priority) : '') + '</div>' +
(drive ? '<div class="stop-drive">' + drive + '</div>' : '') +
(cum ? '<div class="stop-cumulative">' + cum + '</div>' : '') +
'</div></div>';
});
document.getElementById('rpStopList').innerHTML = stopHtml;
// No-GPS warning
const noGps = data.stops_without_gps || [];
const noGpsContainer = document.getElementById('rpNoGpsWarning');
const noGpsList = document.getElementById('rpNoGpsList');
if (noGps.length > 0) {
noGpsContainer.style.display = '';
noGpsList.innerHTML = noGps.map(s => '<div style="font-size:12px;padding:4px 0;">' + esc(s.name) + ' — ' + esc(s.account_name || '') + '</div>').join('');
} else {
noGpsContainer.style.display = 'none';
}
setTimeout(() => map.invalidateSize(), 300);
}
// ── Asset Detail ──────────────────────────────────────────────────────
async function rpShowAsset(woName) {
const overlay = document.getElementById('rpAssetOverlay');
const modal = document.getElementById('rpAssetModal');
const content = document.getElementById('rpAssetModalContent');
content.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text2,#999);">Loading...</div>';
overlay.style.display = '';
modal.style.display = '';
try {
const r = await fetch('/api/workorders/' + encodeURIComponent(woName) + '/asset');
const data = await r.json();
const d = data.details || {};
let html = '<div class="asset-header"><div class="asset-title">' + esc(data.asset_name || woName) + '</div></div>';
const sections = {};
Object.entries(d).forEach(([key, val]) => {
const section = key.includes('_') ? key.split('_')[0] : 'details';
if (!sections[section]) sections[section] = '';
sections[section] += '<div class="asset-row"><span class="asset-label">' + key.replace(/_/g, ' ') + '</span><span class="asset-val">' + esc(String(val)) + '</span></div>';
});
Object.entries(sections).forEach(([name, content]) => {
html += '<div class="asset-section-title" style="margin-top:12px;">' + name.toUpperCase() + '</div>' + content;
});
content.innerHTML = html;
} catch (e) {
content.innerHTML = '<p style="color:var(--red);">Could not load asset details: ' + e.message + '</p>';
}
}
function rpCloseAssetModal() {
document.getElementById('rpAssetOverlay').style.display = 'none';
document.getElementById('rpAssetModal').style.display = 'none';
}
// ── Reorder ───────────────────────────────────────────────────────────
function rpToggleReorder() {
rpState.isReordering = !rpState.isReordering;
document.getElementById('rpReorderToggle').textContent = rpState.isReordering ? '✅ Done' : '↕️ Reorder';
const stops = document.getElementById('rpStopList');
if (rpState.isReordering) {
stops.querySelectorAll('.stop-item').forEach((el, i) => {
el.style.cursor = 'grab';
el.draggable = true;
el.ondragstart = (e) => { e.dataTransfer.setData('text/plain', i); };
el.ondragover = (e) => e.preventDefault();
el.ondrop = (e) => {
e.preventDefault();
const from = parseInt(e.dataTransfer.getData('text/plain'));
const to = i;
if (from === to) return;
const route = rpState.currentRoute.route;
const item = route.splice(from, 1)[0];
route.splice(to, 0, item);
rpDisplayRoute(rpState.currentRoute, null);
rpState.isReordering = true;
document.getElementById('rpReorderToggle').textContent = '✅ Done';
stops.querySelectorAll('.stop-item').forEach(el => el.draggable = true);
};
});
} else {
stops.querySelectorAll('.stop-item').forEach(el => { el.draggable = false; el.ondragstart = null; el.ondragover = null; el.ondrop = null; el.style.cursor = ''; });
}
}
// ── Copy as Text ──────────────────────────────────────────────────────
function rpCopyText() {
const route = rpState.currentRoute?.route || [];
const lines = ['🗺️ Daily Route Planner — ' + new Date().toLocaleDateString(), ''];
route.forEach((stop, i) => {
const addr = stop.address ? [stop.address.line1, stop.address.city].filter(Boolean).join(', ') : '';
lines.push((i + 1) + '. ' + stop.name + ' — ' + (stop.account_name || '') + (addr ? ' (' + addr + ')' : ''));
if (stop.drive_min_to_next) lines.push(' 🚗 ' + stop.drive_min_to_next + ' min (' + stop.distance_to_next_km + ' km)');
});
if (rpState.currentRoute?.summary) {
const s = rpState.currentRoute.summary;
lines.push('', '📊 Total: ' + s.total_stops + ' stops, ' + s.total_distance_km + ' km, ~' + s.total_estimated_min + ' min');
}
const text = lines.join('\\n');
navigator.clipboard.writeText(text).then(() => {
// brief flash feedback
const btn = document.querySelector('[onclick="rpCopyText()"]');
if (btn) { btn.textContent = '✅ Copied!'; setTimeout(() => btn.textContent = '📋 Copy', 2000); }
}).catch(() => {
// fallback
const ta = document.createElement('textarea');
ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
});
}
// ── Loading State ─────────────────────────────────────────────────────
function rpShowLoading(msg) {
document.getElementById('routeSection').style.display = 'none';
document.getElementById('rpResultSection').style.display = 'none';
document.getElementById('rpLoadingSection').style.display = '';
document.getElementById('rpLoadingMsg').textContent = msg || 'Working...';
}
function rpHideLoading() {
document.getElementById('rpLoadingSection').style.display = 'none';
}
// ── Reset ─────────────────────────────────────────────────────────────
function rpResetAll() {
rpState.currentRoute = null;
document.getElementById('rpResultSection').style.display = 'none';
document.getElementById('rpLoadingSection').style.display = 'none';
document.getElementById('routeSection').style.display = '';
if (rpState.map) {
rpState.markers.forEach(m => rpState.map.removeLayer(m));
if (rpState.polyline) rpState.map.removeLayer(rpState.polyline);
rpState.markers = [];
rpState.polyline = null;
}
}
// ── Helper ────────────────────────────────────────────────────────────
function esc(s) {
if (!s) return '';
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
document.addEventListener('DOMContentLoaded', () => {
initAuth();
initGPS();