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