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
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
export CANTEEN_SKIP_AUTH=1
exec uvicorn server:app --host 0.0.0.0 --port 8915
+645 -1
View File
@@ -9,6 +9,7 @@ import csv
import hashlib import hashlib
import io import io
import json as _json import json as _json
import math
import os import os
import re import re
import secrets import secrets
@@ -17,6 +18,7 @@ import urllib.error
import urllib.request import urllib.request
import uuid import uuid
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import date
from pathlib import Path from pathlib import Path
import pytesseract import pytesseract
@@ -515,7 +517,6 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# ─── Auth Middleware ────────────────────────────────────────────────────────── # ─── Auth Middleware ──────────────────────────────────────────────────────────
@@ -2438,6 +2439,649 @@ def list_all_service_entrances():
return [dict(r) for r in rows] return [dict(r) for r in rows]
# ═══════════════════════════════════════════════════════════════════════════
# WORK ORDER & ROUTE OPTIMIZATION — reads extraction DB directly
# ═══════════════════════════════════════════════════════════════════════════
# ── Extraction DB (MS Field Service sync) ────────────────────────────────
EXTRACTION_PROJECT = Path.home() / "projects" / "ms-field-service-extraction"
EXTRACTION_DB = (
EXTRACTION_PROJECT
/ "data-samples"
/ "msfs_backup"
/ "fieldservicecanteen.crm.dynamics.com_9fc6c50c-b097-f011-b4cb-7ced8d1b15a2_data.db"
)
STATUS_LABELS = {
"690970000": "Unscheduled",
"690970001": "Scheduled",
"690970002": "In Progress",
"690970003": "Completed",
"690970004": "Posted",
"690970005": "Cancelled",
"690959000": "Postponed",
}
def _status_label(code) -> str:
"""Map a status code integer to a human-readable label."""
return STATUS_LABELS.get(str(code), f"Unknown ({code})")
def _get_extraction_db() -> sqlite3.Connection | None:
"""Return read-only connection to the MS Field Service extraction DB."""
if not EXTRACTION_DB.exists():
return None
conn = sqlite3.connect(str(EXTRACTION_DB))
conn.row_factory = sqlite3.Row
return conn
# ── TSP Solver ───────────────────────────────────────────────────────────
def _haversine(lat1, lon1, lat2, lon2):
"""Haversine distance in km between two lat/lng points."""
R = 6371
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (
math.sin(dlat / 2) ** 2
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2
)
return R * 2 * math.asin(math.sqrt(a))
def _solve_tsp(points, origin_idx=0):
"""Nearest-neighbor TSP with 2-opt improvement.
Args:
points: list of (lat, lng) tuples.
origin_idx: index of the starting point (default 0).
Returns list of indices in TSP-optimized order.
"""
n = len(points)
if n <= 2:
return list(range(n))
# Precompute distance matrix
dist = [
[_haversine(points[i][0], points[i][1], points[j][0], points[j][1]) for j in range(n)]
for i in range(n)
]
# Nearest-neighbor
unvisited = set(range(n))
unvisited.remove(origin_idx)
route = [origin_idx]
current = origin_idx
while unvisited:
nearest = min(unvisited, key=lambda j: dist[current][j])
route.append(nearest)
unvisited.remove(nearest)
current = nearest
# 2-opt improvement
improved = True
while improved:
improved = False
for i in range(n - 1):
for j in range(i + 2, n):
j1 = (j + 1) % n
old_dist = dist[route[i]][route[i + 1]] + dist[route[j]][route[j1]]
new_dist = dist[route[i]][route[j]] + dist[route[i + 1]][route[j1]]
if new_dist < old_dist - 0.001:
route[i + 1 : j + 1] = reversed(route[i + 1 : j + 1])
improved = True
return route
# ── Work Order Search ──────────────────────────────────────────────────────
@app.get("/api/workorders/search")
async def workorders_search(
q: str = Query("", description="Search term"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
"""Search work orders by name, account, or city."""
conn = _get_extraction_db()
if not conn:
raise HTTPException(503, "Database not available — sync extraction DB missing")
try:
cur = conn.cursor()
like = f"%{q}%"
cur.execute(
"""
SELECT
w.msdyn_workorderid, w.msdyn_name,
w."msdyn_serviceaccount!name" AS account_name,
w.msdyn_workordersummary,
w.msdyn_datewindowstart, w.msdyn_timewindowstart, w.msdyn_timewindowend,
w.hsl_onsiteduration,
w."msdyn_priority!name" AS priority,
w.msdyn_systemstatus,
a.address1_line1, a.address1_city, a.address1_stateorprovince, a.address1_postalcode,
a.address1_latitude, a.address1_longitude
FROM msdyn_workorder w
LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
WHERE w.msdyn_name LIKE ?
OR a.name LIKE ?
OR a.address1_city LIKE ?
ORDER BY w.msdyn_name
LIMIT ? OFFSET ?
""",
(like, like, like, limit, offset),
)
rows = cur.fetchall()
cur.execute(
"""
SELECT COUNT(*) FROM msdyn_workorder w
LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
WHERE w.msdyn_name LIKE ?
OR a.name LIKE ?
OR a.address1_city LIKE ?
""",
(like, like, like),
)
total = cur.fetchone()[0]
return {
"results": [
{
"id": r["msdyn_workorderid"],
"name": r["msdyn_name"],
"account_name": r["account_name"],
"summary": r["msdyn_workordersummary"],
"status_code": r["msdyn_systemstatus"],
"status": _status_label(r["msdyn_systemstatus"]),
"priority": r["priority"],
"onsite_duration": r["hsl_onsiteduration"],
"address": {
"line1": r["address1_line1"],
"city": r["address1_city"],
"state": r["address1_stateorprovince"],
"postal": r["address1_postalcode"],
},
"gps": (
{
"lat": r["address1_latitude"],
"lng": r["address1_longitude"],
}
if r["address1_latitude"] and r["address1_longitude"]
else None
),
}
for r in rows
],
"total": total,
"offset": offset,
"limit": limit,
}
finally:
conn.close()
# ── Work Order Lookup (by ID) ──────────────────────────────────────────────
class WorkorderLookupRequest(BaseModel):
ids: list[str]
@app.post("/api/workorders/lookup")
async def workorders_lookup(body: WorkorderLookupRequest):
"""Lookup work orders by ID or name."""
ids = body.ids
if not ids:
raise HTTPException(400, "No work order IDs provided")
if len(ids) > 100:
raise HTTPException(400, "Max 100 work orders per request")
conn = _get_extraction_db()
if not conn:
raise HTTPException(503, "Database not available — sync extraction DB missing")
try:
cur = conn.cursor()
placeholders = ",".join(["?"] * len(ids))
cur.execute(
f"""
SELECT
w.msdyn_workorderid, w.msdyn_name,
w."msdyn_serviceaccount!name" AS account_name,
w."msdyn_serviceaccount!id" AS account_id,
w.msdyn_workordersummary,
w.msdyn_datewindowstart, w.msdyn_timewindowstart, w.msdyn_timewindowend,
w.hsl_onsiteduration,
w."msdyn_priority!name" AS priority,
w.msdyn_systemstatus,
w."msdyn_workordertype!name" AS work_type,
w."msdyn_primaryincidenttype!name" AS incident_type,
a.address1_line1, a.address1_city, a.address1_stateorprovince, a.address1_postalcode,
a.address1_latitude, a.address1_longitude, a.name AS account_display_name
FROM msdyn_workorder w
LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
WHERE w.msdyn_workorderid IN ({placeholders})
OR w.msdyn_name IN ({placeholders})
""",
ids + ids,
)
rows = cur.fetchall()
found = []
id_set = set(ids)
found_set = set()
for r in rows:
rid = r["msdyn_workorderid"]
rname = r["msdyn_name"]
matched = rid if rid in id_set else rname if rname in id_set else None
if matched:
found_set.add(matched)
lat = r["address1_latitude"]
lng = r["address1_longitude"]
gps = None
if lat and lng:
try:
gps = {"lat": float(lat), "lng": float(lng)}
except (ValueError, TypeError):
pass
found.append(
{
"id": rid,
"name": rname,
"account_name": r["account_name"],
"account_display_name": r["account_display_name"],
"summary": r["msdyn_workordersummary"],
"status_code": r["msdyn_systemstatus"],
"status": _status_label(r["msdyn_systemstatus"]),
"priority": r["priority"],
"work_type": r["work_type"],
"incident_type": r["incident_type"],
"onsite_duration": r["hsl_onsiteduration"],
"address": {
"line1": r["address1_line1"],
"city": r["address1_city"],
"state": r["address1_stateorprovince"],
"postal": r["address1_postalcode"],
},
"gps": gps,
}
)
not_found = [uid for uid in ids if uid not in found_set and uid not in {f["id"] for f in found}]
return {
"found": found,
"not_found": not_found,
"total_found": len(found),
"total_not_found": len(not_found),
}
finally:
conn.close()
# ── Today's Active Work Orders ──────────────────────────────────────────────
@app.get("/api/workorders/today")
async def workorders_today(
tech: list[str] = Query([], description="Filter by technician names"),
limit: int = Query(100, ge=1, le=500),
):
"""Fetch today's active work orders via bookableresourcebooking.
Pass ?tech=Shawn+Canada&tech=John+Doe to filter by one or more technicians.
Omit tech entirely to return all technicians.
"""
conn = _get_extraction_db()
if not conn:
raise HTTPException(503, "Database not available — sync extraction DB missing")
try:
cur = conn.cursor()
today = date.today().isoformat()
today_start = f"{today}T00:00:00Z"
today_end = f"{today}T23:59:59Z"
active_booking_statuses = ",".join(
f"'{s}'"
for s in ["Scheduled", "Unscheduled", "On Site", "Traveling", "On Break", "Hold", "Committed"]
)
params = [today_start, today_end]
tech_clause = ""
if tech:
placeholders_t = ",".join(["?" for _ in tech])
tech_clause = f'AND b."resource!name" IN ({placeholders_t})'
params.extend(tech)
params.append(limit)
cur.execute(
f"""
SELECT
w.msdyn_workorderid,
COALESCE(b."msdyn_workorder!name", w.msdyn_name) AS msdyn_name,
COALESCE(b."hsl_serviceaccountid!name", w."msdyn_serviceaccount!name") AS account_name,
COALESCE(b."hsl_serviceaccountid!id", w."msdyn_serviceaccount!id") AS account_id,
b."bookingstatus!name" AS booking_status,
w.msdyn_systemstatus,
w.msdyn_workordersummary,
b.starttime, b.endtime,
w.hsl_onsiteduration,
w."msdyn_priority!name" AS priority,
w."msdyn_workordertype!name" AS work_type,
w."msdyn_primaryincidenttype!name" AS incident_type,
b."resource!name" AS technician,
w."msdyn_serviceterritory!name" AS territory,
a.address1_line1, a.address1_city, a.address1_stateorprovince, a.address1_postalcode,
a.address1_latitude, a.address1_longitude, a.name AS account_display_name
FROM bookableresourcebooking b
LEFT JOIN msdyn_workorder w ON b."msdyn_workorder!name" = w.msdyn_name
LEFT JOIN account a ON COALESCE(b."hsl_serviceaccountid!id", w."msdyn_serviceaccount!id") = a.accountid
WHERE b.starttime >= ?
AND b.starttime <= ?
AND b."bookingstatus!name" IN ({active_booking_statuses})
{tech_clause}
ORDER BY b.starttime ASC
LIMIT ?
""",
params,
)
rows = cur.fetchall()
workorders = []
for r in rows:
lat = r["address1_latitude"]
lng = r["address1_longitude"]
gps = None
if lat and lng:
try:
gps = {"lat": float(lat), "lng": float(lng)}
except (ValueError, TypeError):
pass
workorders.append(
{
"id": r["msdyn_workorderid"],
"name": r["msdyn_name"],
"account_name": r["account_name"],
"account_display_name": r["account_display_name"],
"summary": r["msdyn_workordersummary"],
"status_code": r["msdyn_systemstatus"],
"status": _status_label(r["msdyn_systemstatus"]),
"booking_status": r["booking_status"],
"priority": r["priority"],
"work_type": r["work_type"],
"incident_type": r["incident_type"],
"technician": r["technician"],
"territory": r["territory"],
"onsite_duration": r["hsl_onsiteduration"],
"starttime": r["starttime"],
"endtime": r["endtime"],
"address": {
"line1": r["address1_line1"],
"city": r["address1_city"],
"state": r["address1_stateorprovince"],
"postal": r["address1_postalcode"],
},
"gps": gps,
}
)
return {
"date": today,
"total": len(workorders),
"with_gps": sum(1 for w in workorders if w["gps"]),
"without_gps": sum(1 for w in workorders if not w["gps"]),
"workorders": workorders,
"booking_statuses": ["Scheduled", "Unscheduled", "On Site", "Traveling", "On Break", "Hold", "Committed"],
}
finally:
conn.close()
# ── List Technicians ──────────────────────────────────────────────────────
@app.get("/api/workorders/technicians")
async def workorders_technicians():
"""Return distinct technician names from bookings."""
conn = _get_extraction_db()
if not conn:
raise HTTPException(503, "Database not available — sync extraction DB missing")
try:
cur = conn.cursor()
cur.execute(
"""
SELECT DISTINCT b."resource!name" AS technician
FROM bookableresourcebooking b
WHERE b."resource!name" IS NOT NULL
AND b."resource!name" != ''
ORDER BY technician
"""
)
techs = [r["technician"] for r in cur.fetchall()]
return {"technicians": techs, "total": len(techs)}
finally:
conn.close()
# ── Route Optimization ──────────────────────────────────────────────────────
def _lookup_asset_gps_by_machine_id(machine_id: str) -> dict | None:
"""Lookup GPS from the canteen-asset-tracker assets table by machine_id."""
try:
conn = get_db()
row = conn.execute(
"SELECT latitude, longitude FROM assets WHERE machine_id = ? "
"AND latitude IS NOT NULL AND latitude != 0 "
"AND longitude IS NOT NULL AND longitude != 0",
(machine_id,),
).fetchone()
conn.close()
if row:
return {"lat": float(row["latitude"]), "lng": float(row["longitude"])}
except Exception:
pass
return None
class RouteOptimizeRequest(BaseModel):
work_order_ids: list[str]
origin: dict | None = None # {"lat": 28.5, "lng": -81.3} or null
start_at: str | None = None # optional name/label for origin stop
@app.post("/api/route/optimize")
async def route_optimize(body: RouteOptimizeRequest):
"""Optimize a route for a set of work orders using TSP (nearest-neighbor + 2-opt)."""
wo_ids = body.work_order_ids
origin = body.origin
if not wo_ids:
raise HTTPException(400, "No work order IDs provided")
if len(wo_ids) > 50:
raise HTTPException(400, "Max 50 work orders per route")
conn = _get_extraction_db()
if not conn:
raise HTTPException(503, "Database not available — sync extraction DB missing")
try:
cur = conn.cursor()
placeholders = ",".join(["?"] * len(wo_ids))
cur.execute(
f"""
SELECT
w.msdyn_workorderid, w.msdyn_name,
w."msdyn_serviceaccount!name" AS account_name,
w."msdyn_serviceaccount!id" AS account_id,
w.msdyn_workordersummary,
w.msdyn_datewindowstart, w.msdyn_timewindowstart, w.msdyn_timewindowend,
w.hsl_onsiteduration,
w."msdyn_priority!name" AS priority,
w.msdyn_systemstatus,
w."msdyn_workordertype!name" AS work_type,
w."msdyn_primaryincidenttype!name" AS incident_type,
w."msdyn_serviceterritory!name" AS territory,
w."msdyn_customerasset!id" AS asset_id,
w."msdyn_customerasset!name" AS asset_name,
ca.msdyn_latitude AS asset_lat, ca.msdyn_longitude AS asset_lng, ca.hsl_lobassetnumber,
ca."msdyn_functionallocation!name" AS asset_location_in_facility,
ca."msdyn_account!name" AS asset_account_name,
a.address1_line1, a.address1_city, a.address1_stateorprovince, a.address1_postalcode,
a.address1_latitude, a.address1_longitude, a.name AS account_display_name
FROM msdyn_workorder w
LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
LEFT JOIN msdyn_customerasset ca ON w."msdyn_customerasset!id" = ca.msdyn_customerassetid
WHERE w.msdyn_name IN ({placeholders})
OR w.msdyn_workorderid IN ({placeholders})
""",
wo_ids + wo_ids,
)
rows = cur.fetchall()
finally:
conn.close()
if not rows:
raise HTTPException(404, "No matching work orders found in DB")
# Build stop list
stops = []
for r in rows:
addr_lat = r["address1_latitude"]
addr_lng = r["address1_longitude"]
address_gps = None
if addr_lat and addr_lng:
try:
address_gps = {"lat": float(addr_lat), "lng": float(addr_lng)}
except (ValueError, TypeError):
pass
# Machine GPS — prefer msdyn_customerasset GPS, fall back to canteen assets table
machine_gps = None
asset_lat = r["asset_lat"]
asset_lng = r["asset_lng"]
if asset_lat and asset_lng:
try:
machine_gps = {"lat": float(asset_lat), "lng": float(asset_lng)}
except (ValueError, TypeError):
pass
if not machine_gps:
lob = r["hsl_lobassetnumber"]
if lob:
machine_gps = _lookup_asset_gps_by_machine_id(str(lob).strip())
gps = machine_gps or address_gps
stops.append(
{
"work_order_id": r["msdyn_workorderid"],
"name": r["msdyn_name"],
"account_name": r["account_name"],
"account_display_name": r["account_display_name"],
"summary": r["msdyn_workordersummary"],
"address": {
"line1": r["address1_line1"],
"city": r["address1_city"],
"state": r["address1_stateorprovince"],
"postal": r["address1_postalcode"],
},
"gps": gps,
"address_gps": address_gps,
"date_window": r["msdyn_datewindowstart"],
"time_window_start": r["msdyn_timewindowstart"],
"time_window_end": r["msdyn_timewindowend"],
"onsite_duration": r["hsl_onsiteduration"],
"priority": r["priority"],
"status_code": r["msdyn_systemstatus"],
"status": _status_label(r["msdyn_systemstatus"]),
"work_type": r["work_type"],
"incident_type": r["incident_type"],
"territory": r["territory"],
"asset_id": r["asset_id"],
"asset_name": r["asset_name"],
"asset_location_in_facility": r["asset_location_in_facility"],
"asset_account_name": r["asset_account_name"],
}
)
# Filter stops with GPS
stops_with_gps = [s for s in stops if s["gps"]]
stops_without_gps = [s for s in stops if not s["gps"]]
if not stops_with_gps:
return {
"route": [],
"error": "No stops have GPS coordinates",
"stops_without_gps": stops_without_gps,
"summary": {"total_stops": 0, "with_gps": 0, "without_gps": len(stops_without_gps)},
}
# TSP
points = [(s["gps"]["lat"], s["gps"]["lng"]) for s in stops_with_gps]
has_origin = origin and "lat" in origin and "lng" in origin
if has_origin:
points.insert(0, (origin["lat"], origin["lng"]))
route_indices = _solve_tsp(points, origin_idx=0)
route_stops = [stops_with_gps[i - 1] for i in route_indices if i > 0]
else:
route_indices = _solve_tsp(points, origin_idx=0)
route_stops = [stops_with_gps[i] for i in route_indices]
# Calculate distances and drive times
total_distance_km = 0
total_drive_min = 0
for i in range(len(route_stops) - 1):
p1 = (route_stops[i]["gps"]["lat"], route_stops[i]["gps"]["lng"])
p2 = (route_stops[i + 1]["gps"]["lat"], route_stops[i + 1]["gps"]["lng"])
d = _haversine(*p1, *p2)
drive_min = (d / 50) * 60 # avg 50 km/h
route_stops[i]["distance_to_next_km"] = round(d, 1)
route_stops[i]["drive_min_to_next"] = round(drive_min)
total_distance_km += d
total_drive_min += drive_min
if route_stops:
route_stops[-1]["distance_to_next_km"] = 0
route_stops[-1]["drive_min_to_next"] = 0
# Cumulative time from start
cumulative_min = 0
if has_origin and route_stops:
o_lat, o_lng = origin["lat"], origin["lng"]
f_lat, f_lng = route_stops[0]["gps"]["lat"], route_stops[0]["gps"]["lng"]
first_leg = _haversine(o_lat, o_lng, f_lat, f_lng)
cumulative_min = round((first_leg / 50) * 60)
for stop in route_stops:
stop["cumulative_min"] = cumulative_min
onsite = round((stop.get("onsite_duration") or 0) / 60)
cumulative_min += (stop.get("drive_min_to_next") or 0) + onsite
total_onsite_min = sum(round((s.get("onsite_duration") or 0) / 60) for s in route_stops)
result = {
"route": route_stops,
"stops_without_gps": stops_without_gps,
"origin": origin,
"summary": {
"total_stops": len(route_stops),
"with_gps": len(stops_with_gps),
"without_gps": len(stops_without_gps),
"total_distance_km": round(total_distance_km, 1),
"total_drive_min": round(total_drive_min),
"total_onsite_min": total_onsite_min,
"total_estimated_min": round(total_drive_min + total_onsite_min),
"origin": origin,
},
}
return result
# ─── Static Files (mounted last to not shadow routes) ────────────────────── # ─── Static Files (mounted last to not shadow routes) ──────────────────────
app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads") app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads")
+785
View File
@@ -836,6 +836,161 @@
.leaflet-draw-toolbar a[title="Edit layers"]::after { content: "Edit"; } .leaflet-draw-toolbar a[title="Edit layers"]::after { content: "Edit"; }
.leaflet-draw-toolbar a[title="Delete layers"]::after { content: "Delete"; } .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; } .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> </style>
</head> </head>
<body> <body>
@@ -890,6 +1045,9 @@
<button class="dn-item" data-tab="tabNavigate" onclick="navFromDrawer('tabNavigate')"> <button class="dn-item" data-tab="tabNavigate" onclick="navFromDrawer('tabNavigate')">
<span class="dn-icon">🧭</span> Navigate <span class="dn-icon">🧭</span> Navigate
</button> </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;"> <button class="dn-item" id="logoutBtn" onclick="doLogout()" style="display:none;">
<span class="dn-icon">🚪</span> Logout <span class="dn-icon">🚪</span> Logout
</button> </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> <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> </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 BOTTOM TABS
═══════════════════════════════════════════════════════════════════════ --> ═══════════════════════════════════════════════════════════════════════ -->
@@ -1407,6 +1687,9 @@
<button class="tab-btn" data-tab="tabNavigate" onclick="switchTab('tabNavigate')"> <button class="tab-btn" data-tab="tabNavigate" onclick="switchTab('tabNavigate')">
<span class="tab-icon">🧭</span> Nav <span class="tab-icon">🧭</span> Nav
</button> </button>
<button class="tab-btn" data-tab="tabRoute" onclick="switchTab('tabRoute')">
<span class="tab-icon">🗺️</span> Route
</button>
</div> </div>
<!-- ═══════════════════════════════════════════════════════════════════════ <!-- ═══════════════════════════════════════════════════════════════════════
@@ -1835,6 +2118,7 @@
tabAssets: 'tabAssets', tabAssets: 'tabAssets',
tabMap: 'tabMap', tabMap: 'tabMap',
tabNavigate: 'tabNavigate', tabNavigate: 'tabNavigate',
tabRoute: 'tabRoute',
}; };
const bottomTab = tabMap[tabId] || 'tabMore'; const bottomTab = tabMap[tabId] || 'tabMore';
document.querySelectorAll('.tab-btn').forEach(b => { document.querySelectorAll('.tab-btn').forEach(b => {
@@ -1865,6 +2149,7 @@
zoomControl: true, zoomControl: true,
}); });
L.tileLayer(tileUrl, { attribution: tileAttr, maxZoom: 19 }).addTo(map); L.tileLayer(tileUrl, { attribution: tileAttr, maxZoom: 19 }).addTo(map);
// Geofence drawing — only if L.Draw loaded // Geofence drawing — only if L.Draw loaded
if (typeof L.Draw !== 'undefined') { if (typeof L.Draw !== 'undefined') {
const drawnItems = new L.FeatureGroup(); const drawnItems = new L.FeatureGroup();
@@ -1899,6 +2184,11 @@
loadAssetPins(); // Refresh pins on every tab visit 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 // Dispatch event so child tab implementations can hook in
document.dispatchEvent(new CustomEvent('tabChange', { detail: { tabId } })); document.dispatchEvent(new CustomEvent('tabChange', { detail: { tabId } }));
} }
@@ -4722,6 +5012,501 @@
} }
// Boot auth at page load // 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', () => { document.addEventListener('DOMContentLoaded', () => {
initAuth(); initAuth();
initGPS(); initGPS();