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:
@@ -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 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();
|
||||
|
||||
Reference in New Issue
Block a user