From da2a23f6da13ebbcd4bf5a2cd38cc003e4af36af Mon Sep 17 00:00:00 2001 From: Shawn Date: Wed, 27 May 2026 18:18:24 -0400 Subject: [PATCH] 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 --- run-test.sh | 3 + server.py | 646 +++++++++++++++++++++++++++++++++++++- static/index.html | 785 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1433 insertions(+), 1 deletion(-) create mode 100644 run-test.sh diff --git a/run-test.sh b/run-test.sh new file mode 100644 index 0000000..e07404b --- /dev/null +++ b/run-test.sh @@ -0,0 +1,3 @@ +#!/bin/bash +export CANTEEN_SKIP_AUTH=1 +exec uvicorn server:app --host 0.0.0.0 --port 8915 diff --git a/server.py b/server.py index ddafa15..3d4631b 100644 --- a/server.py +++ b/server.py @@ -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") diff --git a/static/index.html b/static/index.html index 7099463..8c56e95 100644 --- a/static/index.html +++ b/static/index.html @@ -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; } + } @@ -890,6 +1045,9 @@ + @@ -1391,6 +1549,128 @@ + +
+ + +
+ + +
+
+ +
+ +
+
+
+ +
+

Finds today's scheduled/active work orders and plans the optimal route

+
+ + +
+

📍 Starting Point

+
+ + +
+
+ + + +
+ +
+ + +
+
+
📋 Paste WOs
+
🔍 Search
+
+ + +
+ + + + Looks up WO numbers from the extraction DB +
+ + + +
+ +
+ + + + + + +
+ + +
+
+
+ 🔧 Machine Details + +
+
+
+ @@ -1407,6 +1687,9 @@ +