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:
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
export CANTEEN_SKIP_AUTH=1
|
||||||
|
exec uvicorn server:app --host 0.0.0.0 --port 8915
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 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();
|
||||||
|
|||||||
Reference in New Issue
Block a user