diff --git a/admin_server.py b/admin_server.py
index ac610b4..1d7c145 100644
--- a/admin_server.py
+++ b/admin_server.py
@@ -11,6 +11,7 @@ Designed to run on a separate port from the main field-worker server.
"""
import csv
import hashlib
+import httpx
import io
import json as _json
import os
@@ -406,7 +407,7 @@ async def auth_middleware(request: Request, call_next):
if os.environ.get("CANTEEN_SKIP_AUTH") == "1":
return await call_next(request)
- if not path.startswith("/api/") or path == "/api/auth/login" or path.startswith("/api/lookup-business"):
+ if not path.startswith("/api/") or path == "/api/auth/login" or path.startswith("/api/lookup-business") or path.startswith("/api/admin/reports/"):
return await call_next(request)
auth_header = request.headers.get("Authorization", "")
@@ -2240,6 +2241,625 @@ def get_replacement_history():
conn.close()
+# ═══════════════════════════════════════════════════════════════════════════
+# WORK ORDER REPORTS — reads extraction DB directly
+# ═══════════════════════════════════════════════════════════════════════════
+
+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_REPORT = {
+ "690970000": "Unscheduled",
+ "690970001": "Scheduled",
+ "690970002": "In Progress",
+ "690970003": "Completed",
+ "690970004": "Posted",
+ "690970005": "Cancelled",
+ "690959000": "Postponed",
+}
+
+
+def _get_extraction_db_report() -> 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
+
+
+def _status_label_report(code) -> str:
+ return STATUS_LABELS_REPORT.get(str(code), f"Unknown ({code})")
+
+
+@app.get("/api/admin/reports/workorder-kpis")
+async def reports_workorder_kpis(
+ date_from: str = Query("", description="Start date ISO (YYYY-MM-DD)"),
+ date_to: str = Query("", description="End date ISO (YYYY-MM-DD)"),
+ technician: str = Query("", description="Filter by technician name"),
+ status: str = Query("", description="Filter by status code (comma-separated)"),
+):
+ """
+ Return aggregated KPI data for field tech work orders.
+ Filters: date_from, date_to (on createdon), technician, status.
+ """
+ conn = _get_extraction_db_report()
+ if not conn:
+ raise HTTPException(503, "Extraction DB not available")
+ try:
+ cur = conn.cursor()
+
+ # ── Build WHERE clause ──
+ where_parts = []
+ params = []
+
+ if date_from:
+ where_parts.append("w.createdon >= ?")
+ params.append(date_from)
+ if date_to:
+ where_parts.append("w.createdon < date(?, '+1 day')")
+ params.append(date_to)
+ if technician:
+ tech_names = [t.strip() for t in technician.split(",") if t.strip()]
+ if tech_names:
+ tech_conditions = []
+ for name in tech_names:
+ like = f"%{name}%"
+ tech_conditions.append(
+ "(COALESCE(w.\"hsl_bookedresource1!name\",'') LIKE ? "
+ "OR COALESCE(w.\"hsl_bookedresource2!name\",'') LIKE ? "
+ "OR COALESCE(w.\"hsl_bookedresource3!name\",'') LIKE ? "
+ "OR EXISTS (SELECT 1 FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" LIKE ?))"
+ )
+ params.extend([like, like, like, like])
+ where_parts.append("(" + " OR ".join(tech_conditions) + ")")
+ if status:
+ codes = [s.strip() for s in status.split(",") if s.strip()]
+ if codes:
+ placeholders = ", ".join(["?"] * len(codes))
+ where_parts.append(f"w.msdyn_systemstatus IN ({placeholders})")
+ params.extend(codes)
+
+ where_sql = " AND ".join(where_parts) if where_parts else "1=1"
+
+ # ── KPI 1: Total WOs ──
+ cur.execute(
+ f"SELECT COUNT(*) FROM msdyn_workorder w WHERE {where_sql}", params
+ )
+ total_workorders = cur.fetchone()[0]
+
+ # ── KPI 2: By Status ──
+ cur.execute(
+ f"""
+ SELECT w.msdyn_systemstatus, COUNT(*) as cnt
+ FROM msdyn_workorder w
+ WHERE {where_sql}
+ GROUP BY w.msdyn_systemstatus
+ ORDER BY cnt DESC
+ """,
+ params,
+ )
+ by_status_raw = cur.fetchall()
+ by_status = []
+ for r in by_status_raw:
+ code = str(r["msdyn_systemstatus"])
+ by_status.append({
+ "code": code,
+ "label": _status_label_report(code),
+ "count": r["cnt"],
+ })
+
+ # ── KPI 3: WOs Over Time (daily) ──
+ cur.execute(
+ f"""
+ SELECT date(w.createdon) as day, COUNT(*) as cnt
+ FROM msdyn_workorder w
+ WHERE {where_sql}
+ GROUP BY day
+ ORDER BY day
+ """,
+ params,
+ )
+ over_time = [{"day": r["day"], "count": r["cnt"]} for r in cur.fetchall()]
+
+ # ── KPI 4: By Technician ──
+ cur.execute(
+ f"""
+ SELECT t.technician, COUNT(*) as cnt
+ FROM (
+ SELECT COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
+ (SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
+ ) AS technician
+ FROM msdyn_workorder w
+ WHERE {where_sql}
+ ) t
+ WHERE t.technician IS NOT NULL AND t.technician != ''
+ GROUP BY t.technician
+ ORDER BY cnt DESC
+ LIMIT 30
+ """,
+ params,
+ )
+ by_technician = [{"name": r["technician"], "count": r["cnt"]} for r in cur.fetchall()]
+
+ # ── KPI 5: By Work Type ──
+ cur.execute(
+ f"""
+ SELECT w.\"msdyn_workordertype!name\" AS work_type, COUNT(*) as cnt
+ FROM msdyn_workorder w
+ WHERE {where_sql}
+ GROUP BY work_type
+ ORDER BY cnt DESC
+ """,
+ params,
+ )
+ by_work_type = [{"name": r["work_type"] or "Unspecified", "count": r["cnt"]} for r in cur.fetchall()]
+
+ # ── KPI 6: By Priority ──
+ cur.execute(
+ f"""
+ SELECT w.\"msdyn_priority!name\" AS priority, COUNT(*) as cnt
+ FROM msdyn_workorder w
+ WHERE {where_sql}
+ GROUP BY priority
+ ORDER BY cnt DESC
+ """,
+ params,
+ )
+ by_priority = [{"name": r["priority"] or "Unspecified", "count": r["cnt"]} for r in cur.fetchall()]
+
+ # ── KPI 7: Technician Performance (detailed) — use simpler subquery ──
+ cur.execute(
+ f"""
+ SELECT
+ t.technician,
+ COUNT(*) as total,
+ SUM(CASE WHEN t.msdyn_systemstatus IN (690970003, 690970004) THEN 1 ELSE 0 END) as completed,
+ SUM(CASE WHEN t.msdyn_systemstatus = 690970002 THEN 1 ELSE 0 END) as in_progress,
+ SUM(CASE WHEN t.msdyn_systemstatus = 690970001 THEN 1 ELSE 0 END) as scheduled
+ FROM (
+ SELECT
+ COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
+ (SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
+ ) AS technician,
+ w.msdyn_systemstatus
+ FROM msdyn_workorder w
+ WHERE {where_sql}
+ ) t
+ WHERE t.technician IS NOT NULL AND t.technician != ''
+ GROUP BY t.technician
+ ORDER BY total DESC
+ LIMIT 30
+ """,
+ params,
+ )
+ tech_perf = []
+ for r in cur.fetchall():
+ tech_perf.append({
+ "name": r["technician"],
+ "total": r["total"],
+ "completed": r["completed"],
+ "in_progress": r["in_progress"],
+ "scheduled": r["scheduled"],
+ })
+
+ # ── KPI 8: By Status per Tech (stacked) ──
+ cur.execute(
+ f"""
+ SELECT
+ t.technician,
+ t.status_code,
+ COUNT(*) as cnt
+ FROM (
+ SELECT
+ COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
+ (SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
+ ) AS technician,
+ w.msdyn_systemstatus AS status_code
+ FROM msdyn_workorder w
+ WHERE {where_sql}
+ ) t
+ WHERE t.technician IS NOT NULL AND t.technician != ''
+ GROUP BY t.technician, t.status_code
+ ORDER BY t.technician, t.status_code
+ """,
+ params,
+ )
+ status_by_tech = {}
+ for r in cur.fetchall():
+ tech = r["technician"]
+ if tech not in status_by_tech:
+ status_by_tech[tech] = {}
+ code = str(r["status_code"])
+ status_by_tech[tech][code] = r["cnt"]
+
+ # ── KPI 9: Summary stats ──
+ cur.execute(
+ f"""
+ SELECT
+ COUNT(*) as total,
+ SUM(CASE WHEN w.msdyn_systemstatus IN (690970003, 690970004) THEN 1 ELSE 0 END) as completed_posted,
+ SUM(CASE WHEN w.msdyn_systemstatus = 690970002 THEN 1 ELSE 0 END) as in_progress,
+ SUM(CASE WHEN w.msdyn_systemstatus = 690970001 THEN 1 ELSE 0 END) as scheduled,
+ COUNT(DISTINCT COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
+ (SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
+ )) as unique_techs
+ FROM msdyn_workorder w
+ WHERE {where_sql}
+ """,
+ params,
+ )
+ summary = dict(cur.fetchone())
+
+ # ── KPI 10: Avg days to complete (for completed/posted) ──
+ cur.execute(
+ f"""
+ SELECT AVG(julianday(w.msdyn_completedon) - julianday(w.createdon)) as avg_days
+ FROM msdyn_workorder w
+ WHERE {where_sql}
+ AND w.msdyn_systemstatus IN (690970003, 690970004)
+ AND w.msdyn_completedon IS NOT NULL
+ """,
+ params,
+ )
+ avg_days = cur.fetchone()[0]
+ summary["avg_days_to_complete"] = round(avg_days, 1) if avg_days else None
+
+ return {
+ "total_workorders": total_workorders,
+ "summary": summary,
+ "by_status": by_status,
+ "by_technician": by_technician,
+ "by_work_type": by_work_type,
+ "by_priority": by_priority,
+ "over_time": over_time,
+ "technician_performance": tech_perf,
+ "status_by_tech": status_by_tech,
+ }
+ finally:
+ conn.close()
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# WORK ORDERS LIST — reads extraction DB directly (same DB as reports)
+# ═══════════════════════════════════════════════════════════════════════════
+
+
+@app.get("/api/admin/workorders/list")
+async def admin_workorders_list(
+ q: str = Query("", description="Search term (WO#, account, city)"),
+ status: str = Query("", description="Comma-separated status codes"),
+ tech: str = Query("", description="Technician name filter"),
+ date_from: str = Query("", description="Date filter start (YYYY-MM-DD)"),
+ date_to: str = Query("", description="Date filter end (YYYY-MM-DD)"),
+ limit: int = Query(50, ge=1, le=500),
+ offset: int = Query(0, ge=0),
+):
+ """Fetch filtered, paginated work orders from the extraction DB."""
+ conn = _get_extraction_db_report()
+ if not conn:
+ raise HTTPException(503, "Extraction DB not available")
+ try:
+ cur = conn.cursor()
+ params: list = []
+ where_clauses: list[str] = []
+
+ if q:
+ like = f"%{q}%"
+ where_clauses.append(
+ "(w.msdyn_name LIKE ? OR a.name LIKE ? OR a.address1_city LIKE ? OR w.\"msdyn_customerasset!name\" LIKE ? OR w.\"msdyn_serviceaccount!name\" LIKE ?)"
+ )
+ params.extend([like, like, like, like, like])
+
+ if status:
+ codes = [s.strip() for s in status.split(",") if s.strip()]
+ if codes:
+ placeholders = ",".join(["?" for _ in codes])
+ where_clauses.append(f"w.msdyn_systemstatus IN ({placeholders})")
+ params.extend(codes)
+
+ if tech:
+ like = f"%{tech}%"
+ where_clauses.append(
+ "(COALESCE(w.\"hsl_bookedresource1!name\",'') LIKE ? "
+ "OR COALESCE(w.\"hsl_bookedresource2!name\",'') LIKE ? "
+ "OR COALESCE(w.\"hsl_bookedresource3!name\",'') LIKE ? "
+ "OR EXISTS (SELECT 1 FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" LIKE ?))"
+ )
+ params.extend([like, like, like, like])
+
+ if date_from:
+ where_clauses.append("w.createdon >= ?")
+ params.append(date_from)
+ if date_to:
+ where_clauses.append("w.createdon < date(?, '+1 day')")
+ params.append(date_to)
+
+ where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
+
+ # Count
+ cur.execute(
+ f"SELECT COUNT(*) FROM msdyn_workorder w LEFT JOIN account a ON w.\"msdyn_serviceaccount!id\" = a.accountid WHERE {where_sql}",
+ params,
+ )
+ total = cur.fetchone()[0]
+
+ # Data
+ data_sql = f"""
+ SELECT
+ w.msdyn_workorderid,
+ w.msdyn_name,
+ w."msdyn_serviceaccount!name" AS account_name,
+ w.msdyn_workordersummary,
+ w.createdon,
+ w.msdyn_completedon,
+ w.msdyn_systemstatus,
+ w."msdyn_priority!name" AS priority,
+ w."msdyn_workordertype!name" AS work_type,
+ w."msdyn_primaryincidenttype!name" AS incident_type,
+ w."msdyn_serviceterritory!name" AS territory,
+ w."msdyn_customerasset!name" AS customer_asset_name,
+ w.msdyn_city,
+ w.msdyn_stateorprovince,
+ (SELECT GROUP_CONCAT(DISTINCT t)
+ FROM (
+ SELECT b2."resource!name" AS t FROM bookableresourcebooking b2 WHERE b2."msdyn_workorder!name" = w.msdyn_name
+ UNION
+ SELECT w2."hsl_bookedresource1!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource1!name" IS NOT NULL
+ UNION
+ SELECT w2."hsl_bookedresource2!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource2!name" IS NOT NULL
+ UNION
+ SELECT w2."hsl_bookedresource3!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource3!name" IS NOT NULL
+ )) AS technicians,
+ a.name AS account_display_name,
+ a.address1_city,
+ a.address1_stateorprovince
+ FROM msdyn_workorder w
+ LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
+ WHERE {where_sql}
+ ORDER BY w.createdon DESC, w.msdyn_name
+ LIMIT ? OFFSET ?
+ """
+ data_params = params + [limit, offset]
+ cur.execute(data_sql, data_params)
+ rows = cur.fetchall()
+
+ workorders = []
+ for r in rows:
+ 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": str(r["msdyn_systemstatus"]),
+ "status": _status_label_report(r["msdyn_systemstatus"]),
+ "priority": r["priority"],
+ "work_type": r["work_type"],
+ "incident_type": r["incident_type"],
+ "territory": r["territory"],
+ "customer_asset_name": r["customer_asset_name"],
+ "technicians": r["technicians"],
+ "created_on": r["createdon"],
+ "completed_on": r["msdyn_completedon"],
+ "city": r["msdyn_city"] or r["address1_city"],
+ "state": r["msdyn_stateorprovince"] or r["address1_stateorprovince"],
+ })
+
+ return {
+ "results": workorders,
+ "total": total,
+ "offset": offset,
+ "limit": limit,
+ }
+ finally:
+ conn.close()
+
+
+@app.get("/api/admin/workorders/lookup")
+async def admin_workorders_lookup(name: str = Query("", description="Work order name/number")):
+ """Return full details for a single work order by name."""
+ if not name:
+ raise HTTPException(400, "Work order name is required")
+ conn = _get_extraction_db_report()
+ if not conn:
+ raise HTTPException(503, "Extraction DB not available")
+ try:
+ cur = conn.cursor()
+ cur.execute("""
+ SELECT
+ w.*,
+ a.name AS account_display_name,
+ a.address1_line1,
+ a.address1_city,
+ a.address1_stateorprovince,
+ a.address1_postalcode,
+ a.telephone1,
+ a.websiteurl,
+ i.title AS incident_title,
+ i.msdyn_servicerequesttype AS incident_type_code,
+ (SELECT GROUP_CONCAT(DISTINCT t)
+ FROM (
+ SELECT b2."resource!name" AS t FROM bookableresourcebooking b2 WHERE b2."msdyn_workorder!name" = w.msdyn_name
+ UNION
+ SELECT w2."hsl_bookedresource1!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource1!name" IS NOT NULL
+ UNION
+ SELECT w2."hsl_bookedresource2!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource2!name" IS NOT NULL
+ UNION
+ SELECT w2."hsl_bookedresource3!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource3!name" IS NOT NULL
+ )) AS technicians
+ FROM msdyn_workorder w
+ LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
+ LEFT JOIN msdyn_workorderincident woi ON woi."msdyn_workorder!id" = w.msdyn_workorderid
+ LEFT JOIN incident i ON i.incidentid = woi."msdyn_incident!id"
+ WHERE w.msdyn_name = ?
+ LIMIT 1
+ """, [name])
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(404, f"Work order '{name}' not found")
+
+ # Map row to dict (all columns)
+ keys = [d[0] for d in cur.description]
+ data = dict(zip(keys, row))
+
+ # Add readable status
+ data["status_label"] = _status_label_report(data.get("msdyn_systemstatus"))
+
+ return {"found": True, "data": data}
+ finally:
+ conn.close()
+
+
+@app.get("/api/admin/workorders/technicians")
+async def admin_workorders_technicians():
+ """Return list of all technicians found in work orders."""
+ conn = _get_extraction_db_report()
+ if not conn:
+ raise HTTPException(503, "Extraction DB not available")
+ try:
+ cur = conn.cursor()
+ cur.execute("""
+ SELECT DISTINCT t FROM (
+ SELECT b."resource!name" AS t FROM bookableresourcebooking b WHERE b."resource!name" IS NOT NULL AND b."resource!name" != ''
+ UNION
+ SELECT w."hsl_bookedresource1!name" FROM msdyn_workorder w WHERE w."hsl_bookedresource1!name" IS NOT NULL AND w."hsl_bookedresource1!name" != ''
+ UNION
+ SELECT w."hsl_bookedresource2!name" FROM msdyn_workorder w WHERE w."hsl_bookedresource2!name" IS NOT NULL AND w."hsl_bookedresource2!name" != ''
+ UNION
+ SELECT w."hsl_bookedresource3!name" FROM msdyn_workorder w WHERE w."hsl_bookedresource3!name" IS NOT NULL AND w."hsl_bookedresource3!name" != ''
+ ) ORDER BY t
+ """)
+ techs = [r[0] for r in cur.fetchall()]
+ return {"technicians": techs}
+ finally:
+ conn.close()
+
+
+@app.get("/api/admin/workorders/by-asset")
+async def admin_workorders_by_asset(
+ name: str = Query("", description="Asset name (msdyn_customerasset!name) to search work orders for"),
+):
+ """Return work orders linked to a specific customer asset by name."""
+ if not name:
+ raise HTTPException(400, "Asset name is required")
+ conn = _get_extraction_db_report()
+ if not conn:
+ raise HTTPException(503, "Extraction DB not available")
+ try:
+ cur = conn.cursor()
+ cur.execute("""
+ SELECT
+ w.msdyn_name, w."msdyn_customerasset!name" AS asset_name,
+ w."msdyn_serviceaccount!name" AS account_name,
+ w.msdyn_workordersummary,
+ w."msdyn_workordertype!name" AS work_type,
+ w."msdyn_priority!name" AS priority,
+ w.msdyn_systemstatus,
+ w.createdon, w.msdyn_completedon,
+ a.name AS account_display_name,
+ a.address1_city, a.address1_stateorprovince
+ FROM msdyn_workorder w
+ LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
+ WHERE w."msdyn_customerasset!name" = ?
+ ORDER BY w.createdon DESC
+ """, [name])
+ rows = cur.fetchall()
+ workorders = []
+ for r in rows:
+ workorders.append({
+ "name": r["msdyn_name"],
+ "asset_name": r["asset_name"],
+ "account_name": r["account_name"] or r["account_display_name"],
+ "summary": r["msdyn_workordersummary"],
+ "work_type": r["work_type"],
+ "priority": r["priority"],
+ "status_code": str(r["msdyn_systemstatus"]),
+ "status": _status_label_report(r["msdyn_systemstatus"]),
+ "created_on": r["createdon"],
+ "completed_on": r["msdyn_completedon"],
+ "city": r["address1_city"],
+ "state": r["address1_stateorprovince"],
+ })
+ return {"results": workorders, "total": len(workorders)}
+ finally:
+ conn.close()
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# ADB MSFS SYNC — proxies to the extraction viewer's sync API on port 8910
+# ═══════════════════════════════════════════════════════════════════════════
+
+EXTRACTION_SYNC_BASE = "http://localhost:8910"
+
+
+@app.get("/api/admin/msfs-sync/info")
+async def admin_msfs_sync_info():
+ """Return ADB connection status, last sync metadata, and DB info."""
+ try:
+ async with httpx.AsyncClient(timeout=10) as client:
+ resp = await client.get(f"{EXTRACTION_SYNC_BASE}/api/sync/info")
+ return resp.json()
+ except httpx.RequestError as e:
+ return {
+ "adb_connected": False,
+ "extraction_server": False,
+ "error": f"Cannot reach extraction server on port 8910: {e}",
+ }
+
+
+@app.post("/api/admin/msfs-sync/start")
+async def admin_msfs_sync_start():
+ """Start a background ADB sync task."""
+ try:
+ async with httpx.AsyncClient(timeout=30) as client:
+ resp = await client.post(f"{EXTRACTION_SYNC_BASE}/api/sync/start")
+ return resp.json()
+ except httpx.RequestError as e:
+ raise HTTPException(502, f"Extraction server unreachable: {e}")
+
+
+@app.get("/api/admin/msfs-sync/task/{task_id}")
+async def admin_msfs_sync_task(task_id: str):
+ """Poll status of a running sync task."""
+ try:
+ async with httpx.AsyncClient(timeout=10) as client:
+ resp = await client.get(f"{EXTRACTION_SYNC_BASE}/api/sync/task/{task_id}")
+ if resp.status_code == 404:
+ raise HTTPException(404, "Task not found")
+ return resp.json()
+ except httpx.RequestError as e:
+ raise HTTPException(502, f"Extraction server unreachable: {e}")
+
+
+@app.post("/api/admin/msfs-sync/launch-app")
+async def admin_msfs_sync_launch():
+ """Launch the MS Field Service app on the connected phone."""
+ try:
+ async with httpx.AsyncClient(timeout=15) as client:
+ resp = await client.post(f"{EXTRACTION_SYNC_BASE}/api/sync/start")
+ # The start endpoint already launches the app
+ return resp.json()
+ except httpx.RequestError as e:
+ raise HTTPException(502, f"Extraction server unreachable: {e}")
+
+
+@app.post("/api/admin/msfs-sync/cancel/{task_id}")
+async def admin_msfs_sync_cancel(task_id: str):
+ """Cancel a running sync task."""
+ try:
+ async with httpx.AsyncClient(timeout=10) as client:
+ resp = await client.post(f"{EXTRACTION_SYNC_BASE}/api/sync/cancel/{task_id}")
+ return resp.json()
+ except httpx.RequestError as e:
+ raise HTTPException(502, f"Extraction server unreachable: {e}")
+
+
# ─── SPA Frontend (catch-all — MUST be last route) ────────────────────────
FRONTEND_INDEX = Path(__file__).parent / "static" / "index.html"
diff --git a/static/index.html b/static/index.html
index 1d946bc..e823898 100644
--- a/static/index.html
+++ b/static/index.html
@@ -768,6 +768,161 @@
border: none;
background: #fff;
}
+ /* ── Reports / Charts ── */
+ .report-filters {
+ display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
+ margin-bottom: 16px;
+ }
+ .report-filters select, .report-filters input {
+ background: var(--card2); color: var(--text);
+ border: 1.5px solid var(--border); border-radius: var(--radius-sm);
+ padding: 8px 10px; font-size: 13px; outline: none;
+ }
+ .report-filters select:focus, .report-filters input:focus { border-color: var(--accent); }
+ .report-filters .rf-chips { display: flex; gap: 4px; flex-wrap: wrap; }
+ .report-filters .tech-opt:hover { background: rgba(108,92,231,.15); }
+ .report-filters .date-nav { display: inline-flex; align-items: center; gap: 2px; }
+ .report-filters .date-nav button { background: var(--card2,#2a2a3e); border: 1.5px solid var(--border,#444); border-radius: var(--radius-sm,6px); color: var(--text,#ddd); cursor: pointer; transition: all .15s; }
+ .report-filters .date-nav button:hover { background: var(--accent,#6c5ce7); border-color: var(--accent,#6c5ce7); }
+ .report-filters .date-nav select { width: 120px; }
+ .rf-chip {
+ padding: 4px 12px; border-radius: 14px; font-size: 11px; font-weight: 600;
+ border: 1.5px solid var(--border); cursor: pointer; transition: all 0.15s;
+ background: transparent; color: var(--text2);
+ }
+ .rf-chip.active { border-color: var(--accent); background: var(--accent-bg); color: var(--accent2); }
+ .rf-chip:hover { border-color: var(--accent2); }
+ .report-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 14px;
+ margin-bottom: 14px;
+ }
+ .report-grid .card { margin-bottom: 0; }
+ .report-card-full { grid-column: 1 / -1; }
+ .stat-bar {
+ display: flex; align-items: center; gap: 8px;
+ padding: 5px 0; font-size: 13px;
+ }
+ .stat-bar .sb-label { width: 140px; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; color: var(--text2); font-weight: 500; }
+ .stat-bar .sb-track { flex: 1; height: 20px; background: var(--border); border-radius: 10px; overflow: hidden; position: relative; }
+ .stat-bar .sb-fill { display: block; height: 100%; border-radius: 10px; background: var(--accent); transition: width 0.4s ease; min-width: 2px; }
+ .stat-bar .sb-fill.green { background: var(--green); }
+ .stat-bar .sb-fill.red { background: var(--red); }
+ .stat-bar .sb-fill.amber { background: var(--amber); }
+ .stat-bar .sb-fill.purple { background: #a78bfa; }
+ .stat-bar .sb-fill.cyan { background: #22d3ee; }
+ .stat-bar .sb-fill.pink { background: #f472b6; }
+ .stat-bar .sb-fill.orange { background: #fb923c; }
+ .stat-bar .sb-count { width: 40px; text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; flex-shrink: 0; }
+ .stat-bar.sb-bar-sm { padding: 3px 0; font-size: 12px; }
+ .stat-bar.sb-bar-sm .sb-label { width: 100px; font-size: 11px; }
+ .stat-bar.sb-bar-sm .sb-track { height: 14px; }
+ .stat-bar.sb-bar-sm .sb-count { width: 30px; font-size: 12px; }
+ .stat-bar.sb-bar-xs { padding: 2px 0; font-size: 11px; }
+ .stat-bar.sb-bar-xs .sb-label { width: 80px; font-size: 10px; }
+ .stat-bar.sb-bar-xs .sb-track { height: 10px; }
+ .stat-bar.sb-bar-xs .sb-count { width: 24px; font-size: 10px; }
+
+ /* ── Donut chart (conic-gradient) ── */
+ .donut-wrapper {
+ display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
+ }
+ .donut-ring {
+ width: 140px; height: 140px; border-radius: 50%;
+ position: relative; flex-shrink: 0;
+ }
+ .donut-ring .donut-hole {
+ position: absolute; inset: 28px; border-radius: 50%;
+ background: var(--card);
+ display: flex; align-items: center; justify-content: center;
+ flex-direction: column;
+ }
+ .donut-ring .donut-hole .dh-value { font-size: 24px; font-weight: 800; color: var(--text); }
+ .donut-ring .donut-hole .dh-label { font-size: 9px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.04em; }
+ .donut-legend { display: flex; flex-direction: column; gap: 4px; min-width: 120px; }
+ .donut-legend .dl-item {
+ display: flex; align-items: center; gap: 6px; font-size: 12px;
+ }
+ .donut-legend .dl-item .dl-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
+ .donut-legend .dl-item .dl-label { color: var(--text2); flex: 1; }
+ .donut-legend .dl-item .dl-count { font-weight: 700; font-variant-numeric: tabular-nums; }
+
+ /* ── Sparkline (SVG) ── */
+ .sparkline-wrap { position: relative; height: 100px; margin: 8px 0; }
+ .sparkline-wrap svg { width: 100%; height: 100%; }
+ .sparkline-wrap .sl-overlay {
+ position: absolute; top: 0; left: 0; right: 0; bottom: 0;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 13px; color: var(--text3);
+ }
+
+ /* ── Technician Performance Table ── */
+ .tech-table { width: 100%; border-collapse: collapse; font-size: 13px; }
+ .tech-table th {
+ text-align: left; padding: 8px 10px;
+ border-bottom: 2px solid var(--border);
+ color: var(--text2); font-weight: 600; font-size: 10px;
+ text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap;
+ }
+ .tech-table td { padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.04); }
+ .tech-table tr:hover td { background: rgba(255,255,255,0.02); }
+ .tech-table .tt-bar { display: inline-block; height: 6px; border-radius: 3px; min-width: 2px; vertical-align: middle; }
+ .tech-table .tt-name { font-weight: 600; }
+
+ /* ── Mini stat cards for report summaries ── */
+ .report-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; margin-bottom: 14px; }
+ .rs-card {
+ background: var(--card); border: 1px solid var(--border);
+ border-radius: var(--radius); padding: 14px; text-align: center;
+ }
+ .rs-card .rs-value { font-size: 26px; font-weight: 800; color: var(--accent2); }
+ .rs-card .rs-value.green { color: var(--green); }
+ .rs-card .rs-value.amber { color: var(--amber); }
+ .rs-card .rs-value.red { color: var(--red); }
+ .rs-card .rs-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text2); margin-top: 2px; }
+
+ /* ── Status badge color classes ── */
+ .rep-status-690970003 { color: var(--green); }
+ .rep-status-690970004 { color: var(--accent2); }
+ .rep-status-690970001 { color: var(--amber); }
+ .rep-status-690970002 { color: #22d3ee; }
+ .rep-status-690970000 { color: var(--text3); }
+ .rep-status-690970005 { color: var(--red); }
+ .rep-status-690959000 { color: var(--text2); }
+
+ /* Status bar colors */
+ .sb-fill.status-690970003 { background: var(--green); }
+ .sb-fill.status-690970004 { background: var(--accent2); }
+ .sb-fill.status-690970001 { background: var(--amber); }
+ .sb-fill.status-690970002 { background: #22d3ee; }
+ .sb-fill.status-690970000 { background: var(--text3); }
+ .sb-fill.status-690970005 { background: var(--red); }
+ .sb-fill.status-690959000 { background: var(--text2); }
+
+ /* ── Stacked bar for technician status per tech ── */
+ .stack-bar {
+ display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 12px;
+ }
+ .stack-bar .st-label { width: 100px; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; color: var(--text2); }
+ .stack-bar .st-track { flex: 1; height: 18px; border-radius: 9px; overflow: hidden; display: flex; background: var(--border); }
+ .stack-bar .st-seg { height: 100%; transition: width 0.4s ease; min-width: 2px; }
+ .stack-bar .st-count { width: 36px; text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; flex-shrink: 0; font-size: 11px; }
+ .stack-legend { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
+ .stack-legend .sl-item { display: flex; align-items: center; gap: 4px; font-size: 10px; color: var(--text2); }
+ .stack-legend .sl-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
+
+ @media (max-width: 1000px) {
+ .report-grid { grid-template-columns: 1fr; }
+ }
+ @media (max-width: 600px) {
+ .report-filters { flex-direction: column; align-items: stretch; }
+ .report-filters select, .report-filters input { width: 100%; }
+ .report-filters .date-nav { width: 100%; justify-content: center; }
+ .report-filters .date-nav select { flex: 1; }
+ .donut-wrapper { flex-direction: column; align-items: center; }
+ .report-stats { grid-template-columns: repeat(2, 1fr); }
+ }
@@ -836,6 +991,15 @@
+
+
+
@@ -1229,6 +1393,216 @@
+
+
+
+
+
+
+
🔧 Work Orders
+
+
+
+
+
+
+
+
+
+
+ Loading...
+
+
+
+
+
+
+
+
+
+
+
+
+
📡 MSFS ADB Sync
+
+
+
+
+
+
+
+
ADB Connection Status
+
+
+
+
+
+
Sync Controls
+
+
+
+
+
+
+
+
+
+
+
+
+
Sync History
+
+
No syncs recorded yet.
+
+
+
+
@@ -1432,6 +1806,9 @@ function switchPage(page) {
else if (page === 'replacements') loadReplacements();
else if (page === 'exifscanner') loadExifScanner();
else if (page === 'route') initRoutePage();
+ else if (page === 'reports') loadReports();
+ else if (page === 'workorders') loadWorkOrders();
+ else if (page === 'msfssync') loadMsfsSync();
}
function closeDetail() {
if (AppState._prevPage) switchPage(AppState._prevPage);
@@ -1870,6 +2247,16 @@ function renderAssetTable() {
deployed: 'Dep', pulled_date: 'Pulled',
};
+ const WO_STATUS_COLORS = {
+ '690970003': '#4ade80',
+ '690970004': '#7c8cf8',
+ '690970001': '#fbbf24',
+ '690970002': '#22d3ee',
+ '690970000': '#5b5f73',
+ '690970005': '#f87171',
+ '690959000': '#8b8fa3',
+ };
+
const rows = sorted.map(a => {
return `
${INLINE_FIELDS.map(f => {
@@ -1879,11 +2266,15 @@ function renderAssetTable() {
const val = a[f];
const isEmpty = val === null || val === undefined || val === '';
if (isEmpty) cls += ' ae-empty-cell';
- return `| ${renderAssetCell(a, f)} | `;
+ const q = JSON.stringify(val == null ? '' : String(val));
+ return `${renderAssetCell(a, f)} | `;
}).join('')}
🔍
|
+
+ 🔧
+ |
`;
}).join('');
@@ -1896,6 +2287,8 @@ function renderAssetTable() {
${INLINE_FIELDS.map(f => `| ${HEADER_LABELS[f]}${sortArrow(f)} | `).join('')}
+ 🔍 |
+ 🔧 |
${rows}
@@ -4464,6 +4857,19 @@ function showAssetDetail(machineId) {
}, 100);
}
+async function showAssetWorkOrders(machineId, assetName) {
+ // Switch to work orders tab and search by asset name
+ document.getElementById('woSearch').value = assetName;
+ document.getElementById('woStatusFilter').value = '';
+ switchPage('workorders');
+ woState.offset = 0;
+ setTimeout(() => {
+ const search = document.getElementById('woSearch');
+ if (search) search.value = assetName;
+ loadWorkOrders();
+ }, 100);
+}
+
// ═════════════════════════════════════════════════════════════════════════════
// ROUTE PLANNER
// ═════════════════════════════════════════════════════════════════════════════
@@ -4476,6 +4882,815 @@ function initRoutePage() {
}
iframe.dataset.loaded = '1';
}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// REPORTS — Work Order KPIs
+// ═════════════════════════════════════════════════════════════════════════════
+
+const STATUS_COLORS = {
+ '690970003': '#4ade80', // Completed
+ '690970004': '#7c8cf8', // Posted
+ '690970001': '#fbbf24', // Scheduled
+ '690970002': '#22d3ee', // In Progress
+ '690970000': '#5b5f73', // Unscheduled
+ '690970005': '#f87171', // Cancelled
+ '690959000': '#8b8fa3', // Postponed
+};
+
+const STATUS_LABELS = {
+ '690970003': 'Completed',
+ '690970004': 'Posted',
+ '690970001': 'Scheduled',
+ '690970002': 'In Progress',
+ '690970000': 'Unscheduled',
+ '690970005': 'Cancelled',
+ '690959000': 'Postponed',
+};
+
+// ── Date navigation state ──
+let dateOffset = 0;
+
+function getRepDateParams() {
+ const range = document.getElementById('repDateRange').value;
+ const offset = dateOffset;
+ const now = new Date();
+
+ // Shift reference point by offset (according to interval)
+ const ref = new Date(now);
+ if (range === 'today') {
+ ref.setDate(ref.getDate() + offset);
+ } else if (range === 'thisweek' || range === 'lastweek') {
+ ref.setDate(ref.getDate() + offset * 7);
+ } else if (range === 'thismonth') {
+ ref.setMonth(ref.getMonth() + offset);
+ } else if (range === 'last30') {
+ ref.setDate(ref.getDate() + offset * 30);
+ } else if (range === 'last90') {
+ ref.setDate(ref.getDate() + offset * 90);
+ }
+
+ function fmt(d) { return d.toISOString().slice(0, 10); }
+
+ let dateFrom = '', dateTo = '';
+ if (range === 'today') {
+ dateFrom = dateTo = fmt(ref);
+ } else if (range === 'thisweek' || range === 'lastweek') {
+ const dayOfWeek = ref.getDay();
+ const mon = new Date(ref);
+ mon.setDate(ref.getDate() - ((dayOfWeek + 6) % 7));
+ dateFrom = fmt(mon);
+ const sun = new Date(mon);
+ sun.setDate(mon.getDate() + 6);
+ dateTo = fmt(sun);
+ } else if (range === 'thismonth') {
+ dateFrom = ref.getFullYear() + '-' + String(ref.getMonth() + 1).padStart(2, '0') + '-01';
+ const lastDay = new Date(ref.getFullYear(), ref.getMonth() + 1, 0);
+ dateTo = fmt(lastDay);
+ } else if (range === 'last30') {
+ const d = new Date(ref);
+ d.setDate(ref.getDate() - 30);
+ dateFrom = fmt(d);
+ dateTo = fmt(ref);
+ } else if (range === 'last90') {
+ const d = new Date(ref);
+ d.setDate(ref.getDate() - 90);
+ dateFrom = fmt(d);
+ dateTo = fmt(ref);
+ } else if (range === 'custom') {
+ dateFrom = document.getElementById('repDateFrom').value;
+ dateTo = document.getElementById('repDateTo').value;
+ }
+ return { dateFrom, dateTo };
+}
+
+function shiftDateRange(delta) {
+ const range = document.getElementById('repDateRange').value;
+ if (range === 'all' || range === 'custom') return;
+ dateOffset += delta;
+ updateDateRangeLabel();
+ loadReports();
+}
+
+function updateDateRangeLabel() {
+ const range = document.getElementById('repDateRange').value;
+ const label = document.getElementById('dateRangeLabel');
+ if (range === 'all' || range === 'custom') {
+ label.textContent = '';
+ return;
+ }
+ const { dateFrom, dateTo } = getRepDateParams();
+ if (dateOffset === 0) {
+ const select = document.getElementById('repDateRange');
+ const text = select.options[select.selectedIndex].text;
+ label.textContent = '— ' + text;
+ } else if (dateFrom === dateTo) {
+ const prefix = dateOffset < 0 ? `${-dateOffset}d ago` : `${dateOffset}d ahead`;
+ label.textContent = `${dateFrom} (${prefix})`;
+ } else {
+ const prefix = dateOffset < 0 ? `${-dateOffset} back` : `${dateOffset} ahead`;
+ label.textContent = `${dateFrom} → ${dateTo} (${prefix})`;
+ }
+}
+
+function onRepFilterChange() {
+ const range = document.getElementById('repDateRange').value;
+ const fromInput = document.getElementById('repDateFrom');
+ const toInput = document.getElementById('repDateTo');
+ // Reset offset when dropdown changes
+ dateOffset = 0;
+ if (range === 'custom') {
+ fromInput.style.display = '';
+ toInput.style.display = '';
+ } else {
+ fromInput.style.display = 'none';
+ toInput.style.display = 'none';
+ }
+ updateDateRangeLabel();
+ loadReports();
+}
+
+// ── Multi-select technician helpers ──
+function toggleTechPanel(event) {
+ event.stopPropagation();
+ const panel = document.getElementById('repTechPanel');
+ panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
+}
+document.addEventListener('click', function(e) {
+ const panel = document.getElementById('repTechPanel');
+ const trigger = document.getElementById('repTechTrigger');
+ if (panel.style.display !== 'none' && !panel.contains(e.target) && !trigger.contains(e.target)) {
+ panel.style.display = 'none';
+ }
+});
+function onRepTechAll(checkbox) {
+ const checks = document.querySelectorAll('#repTechChecks input[type="checkbox"]');
+ checks.forEach(cb => { cb.checked = checkbox.checked; });
+ updateTechLabel();
+ loadReports();
+}
+function onRepTechCheck() {
+ // If all individual checkboxes are checked, also check "All"
+ const checks = document.querySelectorAll('#repTechChecks input[type="checkbox"]');
+ const allCb = document.getElementById('repTechPanel').querySelector('input[value=""]');
+ const allChecked = Array.from(checks).every(cb => cb.checked);
+ if (allCb) allCb.checked = allChecked;
+ updateTechLabel();
+ loadReports();
+}
+function updateTechLabel() {
+ const allCb = document.getElementById('repTechPanel').querySelector('input[value=""]');
+ const label = document.getElementById('repTechLabel');
+ if (allCb && allCb.checked) {
+ label.textContent = 'All Technicians';
+ } else {
+ const checked = document.querySelectorAll('#repTechChecks input:checked');
+ const names = Array.from(checked).map(cb => cb.value);
+ label.textContent = names.length ? names.join(', ') : 'None';
+ }
+}
+
+async function loadReports() {
+ updateDateRangeLabel();
+ const statsEl = document.getElementById('repStats');
+ const otEl = document.getElementById('repOverTime');
+ const bsEl = document.getElementById('repByStatus');
+ const wtEl = document.getElementById('repByWorkType');
+ const btEl = document.getElementById('repByTechnician');
+ const ttEl = document.getElementById('repTechTableWrap');
+ const prEl = document.getElementById('repByPriority');
+ const sbtEl = document.getElementById('repStatusByTech');
+
+ // Show skeletons
+ statsEl.innerHTML = ''.repeat(5);
+ otEl.innerHTML = '';
+ bsEl.innerHTML = '';
+ wtEl.innerHTML = '';
+ btEl.innerHTML = '';
+ ttEl.innerHTML = '';
+ prEl.innerHTML = '';
+ sbtEl.innerHTML = '';
+
+ // Load technicians list on first visit
+ const techChecks = document.getElementById('repTechChecks');
+ if (!techChecks.dataset.loaded) {
+ try {
+ const techResp = await fetch('/api/admin/reports/workorder-kpis');
+ if (techResp.ok) {
+ const techData = await techResp.json();
+ const techs = (techData.by_technician || []).map(t => t.name).filter(Boolean);
+ techs.forEach(name => {
+ const label = document.createElement('label');
+ label.className = 'tech-opt';
+ label.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 12px;cursor:pointer;font-size:13px;';
+ const cb = document.createElement('input');
+ cb.type = 'checkbox';
+ cb.value = name;
+ cb.style.accentColor = '#6c5ce7';
+ cb.onchange = function() { onRepTechCheck(); };
+ label.appendChild(cb);
+ const span = document.createElement('span');
+ span.textContent = '👤 ' + name;
+ label.appendChild(span);
+ techChecks.appendChild(label);
+ });
+ techChecks.dataset.loaded = '1';
+ }
+ } catch (e) { /* non-fatal */ }
+ }
+
+ try {
+ const params = getRepDateParams();
+ const query = new URLSearchParams();
+ if (params.dateFrom) query.set('date_from', params.dateFrom);
+ if (params.dateTo) query.set('date_to', params.dateTo);
+ const techAll = document.getElementById('repTechPanel').querySelector('input[value=""]');
+ let technician = '';
+ if (techAll && !techAll.checked) {
+ const checked = document.querySelectorAll('#repTechChecks input:checked');
+ technician = Array.from(checked).map(cb => cb.value).join(',');
+ }
+ if (technician) query.set('technician', technician);
+ const status = document.getElementById('repStatus').value;
+ if (status) query.set('status', status);
+
+ const url = '/api/admin/reports/workorder-kpis?' + query.toString();
+ const resp = await fetch(url);
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
+ const data = await resp.json();
+
+ renderRepStats(statsEl, data);
+ renderRepOverTime(otEl, data);
+ renderRepDonut(bsEl, data);
+ renderRepBarCharts(wtEl, data.by_work_type, 'name', 'count', 'Work Type');
+ renderRepBarCharts(btEl, data.by_technician, 'name', 'count', 'Technician');
+ renderTechTable(ttEl, data);
+ renderRepBarCharts(prEl, data.by_priority, 'name', 'count', 'Priority');
+ renderTechStackedBar(sbtEl, data);
+ } catch (e) {
+ const errHtml = `
+
⚠️
+
Failed to load reports
+
${esc(e.message)}
+
`;
+ [otEl, bsEl, wtEl, btEl, ttEl, prEl, sbtEl].forEach(el => el.innerHTML = errHtml);
+ statsEl.innerHTML = errHtml;
+ }
+}
+
+function renderRepStats(el, data) {
+ const s = data.summary || {};
+ const total = s.total || 0;
+ const completedPosted = s.completed_posted || 0;
+ const inProgress = s.in_progress || 0;
+ const uniqueTechs = s.unique_techs || 0;
+ const avgDays = s.avg_days_to_complete != null ? s.avg_days_to_complete + 'd' : '—';
+ const completePct = total > 0 ? Math.round(completedPosted / total * 100) + '%' : '—';
+
+ el.innerHTML = `
+ ${total.toLocaleString()}
Total WOs
+ ${completedPosted.toLocaleString()}
Completed/Posted
+ ${completePct}
Completion Rate
+
+ ${uniqueTechs}
Active Techs
+ ${avgDays}
Avg Days to Complete
+ `;
+}
+
+function renderRepOverTime(el, data) {
+ const items = data.over_time || [];
+ if (!items.length) {
+ el.innerHTML = '📭
No time-series data — try a different date range
';
+ return;
+ }
+
+ const mx = Math.max(...items.map(i => i.count), 1);
+ const padding = 20;
+ const w = Math.max(items.length * 30, 300);
+ const h = 120;
+
+ // Build SVG polyline
+ const points = items.map((i, idx) => {
+ const x = padding + (idx / Math.max(items.length - 1, 1)) * (w - padding * 2);
+ const y = h - ((i.count / mx) * (h - padding));
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ }).join(' ');
+
+ // X-axis labels
+ const labelStep = Math.max(1, Math.floor(items.length / 8));
+ const labels = items.map((i, idx) => {
+ if (idx % labelStep !== 0 && idx !== items.length - 1) return '';
+ const x = padding + (idx / Math.max(items.length - 1, 1)) * (w - padding * 2);
+ return `${i.day.slice(5)}`;
+ }).filter(Boolean).join('');
+
+ el.innerHTML = ``;
+}
+
+function renderRepDonut(el, data) {
+ const items = data.by_status || [];
+ if (!items.length) {
+ el.innerHTML = '';
+ return;
+ }
+
+ const total = items.reduce((s, i) => s + i.count, 0);
+ const colors = items.map(i => STATUS_COLORS[i.code] || '#5b5f73');
+
+ let cumulative = 0;
+ const stops = items.map((item, idx) => {
+ const pct = (item.count / total) * 100;
+ const start = cumulative.toFixed(1);
+ cumulative += pct;
+ const end = Math.min(cumulative, 100).toFixed(1);
+ return `${colors[idx]} ${start}% ${end}%`;
+ }).join(', ');
+
+ const gradient = `conic-gradient(${stops})`;
+
+ el.innerHTML = `
+
+
+ ${total.toLocaleString()}
+ Total
+
+
+
+ ${items.map(i => `
+
+ ${esc(i.label)}
+ ${i.count.toLocaleString()}
+
`).join('')}
+
+
`;
+}
+
+function renderRepBarCharts(el, items, labelKey, valueKey, title) {
+ if (!items || !items.length) {
+ el.innerHTML = '';
+ return;
+ }
+
+ const mx = Math.max(...items.map(i => i[valueKey]), 1);
+ const top = items.slice(0, 15);
+
+ el.innerHTML = top.map(i => {
+ const pct = (i[valueKey] / mx * 100);
+ const barColor = title === 'Technician' ? 'purple' :
+ title === 'Priority' ? 'orange' : 'accent';
+ return `
+ ${esc(i[labelKey] || '')}
+
+ ${i[valueKey]}
+
`;
+ }).join('');
+
+ if (items.length > 15) {
+ el.innerHTML += `+${items.length - 15} more...
`;
+ }
+}
+
+function renderTechTable(el, data) {
+ const techs = data.technician_performance || [];
+ if (!techs.length) {
+ el.innerHTML = '';
+ return;
+ }
+
+ const maxTotal = Math.max(...techs.map(t => t.total), 1);
+
+ el.innerHTML = `
+
+
+
+ | Technician |
+ Total |
+ Completed |
+ In Progress |
+ Scheduled |
+ Completion % |
+
+
+
+ ${techs.map(t => {
+ const pct = t.total > 0 ? Math.round(t.completed / t.total * 100) : 0;
+ const barW = (t.total / maxTotal * 100);
+ return `
+ | ${esc(t.name)} |
+ ${t.total} |
+ ${t.completed} |
+ ${t.in_progress} |
+ ${t.scheduled} |
+ ${pct}% |
+
`;
+ }).join('')}
+
+
+
`;
+}
+
+function renderTechStackedBar(el, data) {
+ const statusByTech = data.status_by_tech || {};
+ const techNames = Object.keys(statusByTech);
+ if (!techNames.length) {
+ el.innerHTML = '';
+ return;
+ }
+
+ const allCodes = new Set();
+ const totals = {};
+ techNames.forEach(tech => {
+ const s = statusByTech[tech];
+ Object.keys(s).forEach(code => allCodes.add(code));
+ totals[tech] = Object.values(s).reduce((a, b) => a + b, 0);
+ });
+
+ const sorted = techNames.sort((a, b) => (totals[b] || 0) - (totals[a] || 0));
+ const maxTotal = Math.max(...Object.values(totals), 1);
+ const codes = Array.from(allCodes).sort((a, b) => (STATUS_LABELS[a] || a).localeCompare(STATUS_LABELS[b] || b));
+
+ el.innerHTML = `
+ ${codes.map(c => `${STATUS_LABELS[c] || c}`).join('')}
+
+
+ ${sorted.map(tech => {
+ const techData = statusByTech[tech];
+ const techTotal = totals[tech] || 0;
+ let segHtml = '';
+ codes.forEach(c => {
+ const cnt = techData[c] || 0;
+ if (cnt > 0) {
+ segHtml += `
`;
+ }
+ });
+ return `
+ ${esc(tech)}
+ ${segHtml}
+ ${techTotal}
+
`;
+ }).join('')}
+
`;
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// WORK ORDERS PAGE
+// ═════════════════════════════════════════════════════════════════════════════
+
+let woSearchTimeout = null;
+let woState = { offset: 0, limit: 50, total: 0 };
+const WO_STATUS_LABELS = {
+ '690970000': 'Unscheduled', '690970001': 'Scheduled',
+ '690970002': 'In Progress', '690970003': 'Completed',
+ '690970004': 'Posted', '690970005': 'Cancelled',
+ '690959000': 'Postponed',
+};
+const WO_STATUS_COLORS = {
+ '690970000': '#8b8fa3', '690970001': '#5b6ef7',
+ '690970002': '#fbbf24', '690970003': '#4ade80',
+ '690970004': '#22d3ee', '690970005': '#f87171',
+ '690959000': '#fb923c',
+};
+
+function scheduleWOSearch() {
+ clearTimeout(woSearchTimeout);
+ woSearchTimeout = setTimeout(() => { woState.offset = 0; loadWorkOrders(); }, 300);
+}
+
+function onWODateRangeChange() {
+ const range = document.getElementById('woDateRange').value;
+ document.getElementById('woDateFrom').style.display = range === 'custom' ? '' : 'none';
+ document.getElementById('woDateTo').style.display = range === 'custom' ? '' : 'none';
+ woState.offset = 0;
+ loadWorkOrders();
+}
+
+function getWODateParams() {
+ const range = document.getElementById('woDateRange').value;
+ const today = new Date();
+ const yyyy = d => d.toISOString().slice(0, 10);
+ let from = '', to = '';
+ if (range === 'today') { from = yyyy(today); to = yyyy(today); }
+ else if (range === 'thisweek') {
+ const start = new Date(today); start.setDate(today.getDate() - today.getDay());
+ from = yyyy(start); to = yyyy(today);
+ }
+ else if (range === 'thismonth') {
+ from = yyyy(new Date(today.getFullYear(), today.getMonth(), 1)); to = yyyy(today);
+ }
+ else if (range === 'custom') {
+ from = document.getElementById('woDateFrom').value;
+ to = document.getElementById('woDateTo').value;
+ }
+ return { from, to };
+}
+
+function renderActiveWOTags() {
+ const q = document.getElementById('woSearch').value.trim();
+ const status = document.getElementById('woStatusFilter').value;
+ const tech = document.getElementById('woTechFilter').value;
+ const range = document.getElementById('woDateRange').value;
+ const el = document.getElementById('woActiveTags');
+ const tags = [];
+ if (q) tags.push(`🔍 "${esc(q)}" ✕`);
+ if (status) tags.push(`📋 ${WO_STATUS_LABELS[status] || status} ✕`);
+ if (tech) tags.push(`👤 ${esc(tech)} ✕`);
+ if (range !== 'all') tags.push(`📅 ${range} ✕`);
+ el.innerHTML = tags.join(' ');
+}
+
+async function loadWorkOrders() {
+ const q = document.getElementById('woSearch').value.trim();
+ const status = document.getElementById('woStatusFilter').value;
+ const tech = document.getElementById('woTechFilter').value;
+ const dates = getWODateParams();
+ renderActiveWOTags();
+
+ const params = new URLSearchParams({
+ q, status, tech,
+ date_from: dates.from, date_to: dates.to,
+ limit: woState.limit, offset: woState.offset,
+ });
+
+ try {
+ const data = await api('/api/admin/workorders/list?' + params.toString());
+ woState.total = data.total;
+ document.getElementById('woTotalCount').textContent = `${data.total} work orders found`;
+ renderWOCards(data.results || []);
+ renderWOPagination();
+
+ // Load tech filter dropdown if not loaded
+ const techSelect = document.getElementById('woTechFilter');
+ if (techSelect.options.length <= 1) {
+ try {
+ const tdata = await api('/api/admin/workorders/technicians');
+ (tdata.technicians || []).forEach(t => {
+ const opt = document.createElement('option');
+ opt.value = t; opt.textContent = t;
+ techSelect.appendChild(opt);
+ });
+ } catch(e) {}
+ }
+ } catch (e) {
+ document.getElementById('woCards').innerHTML =
+ `❌ ${esc(e.message)}
`;
+ }
+}
+
+function renderWOCards(orders) {
+ const el = document.getElementById('woCards');
+ if (!orders.length) {
+ el.innerHTML = '🔧 No work orders found matching your filters.
';
+ return;
+ }
+ el.innerHTML = orders.map(wo => {
+ const statusColor = WO_STATUS_COLORS[wo.status_code] || '#5b5f73';
+ const techs = (wo.technicians || '').split(',').filter(Boolean);
+ return `
+
+
${esc(wo.name)}
+
${esc(wo.status)}
+
+
+ ${wo.account_display_name ? `🏢 ${esc(wo.account_display_name)}` : ''}
+ ${wo.city ? `📍 ${esc(wo.city)}${wo.state ? ', ' + esc(wo.state) : ''}` : ''}
+ ${wo.work_type ? `🔧 ${esc(wo.work_type)}` : ''}
+ ${wo.priority ? `⚡ ${esc(wo.priority)}` : ''}
+
+
+ ${techs.length ? `👤 ${techs.join(', ')}` : ''}
+ ${wo.created_on ? `📅 Created: ${wo.created_on.slice(0,10)}` : ''}
+
+
`;
+ }).join('');
+}
+
+function renderWOPagination() {
+ const el = document.getElementById('woPagination');
+ const total = woState.total;
+ const limit = woState.limit;
+ const current = woState.offset;
+ const pages = Math.ceil(total / limit);
+ if (pages <= 1) { el.innerHTML = ''; return; }
+ const html = [];
+ if (current > 0) html.push(``);
+ if (current > 0) html.push(``);
+ html.push(`${current/limit+1}/${pages}`);
+ if (current + limit < total) html.push(``);
+ if (current + limit < total) html.push(``);
+ el.innerHTML = html.join('');
+}
+
+async function showWODetail(name) {
+ try {
+ const d = await api(`/api/admin/workorders/lookup?name=${encodeURIComponent(name)}`);
+ if (!d.found) { showToast('Work order not found', true); return; }
+ renderWODetailModal(d.data);
+ } catch (e) {
+ showToast(e.message, true);
+ }
+}
+
+function renderWODetailModal(data) {
+ const statusColor = WO_STATUS_COLORS[data.msdyn_systemstatus] || '#5b5f73';
+ const techs = (data.technicians || '').split(',').filter(Boolean);
+ const body = `
+
+
${esc(data.msdyn_name)}
+
${esc(data.status_label)}
+
+
+ ${data['msdyn_serviceaccount!name'] ? `| 🏢 Account | ${esc(data['msdyn_serviceaccount!name'])} |
` : ''}
+ ${data.account_display_name && data.account_display_name !== data['msdyn_serviceaccount!name'] ? `| 🏢 Display | ${esc(data.account_display_name)} |
` : ''}
+ ${techs.length ? `| 👤 Technicians | ${esc(techs.join(', '))} |
` : ''}
+ ${data['msdyn_priority!name'] ? `| ⚡ Priority | ${esc(data['msdyn_priority!name'])} |
` : ''}
+ ${data['msdyn_workordertype!name'] ? `| 🔧 Work Type | ${esc(data['msdyn_workordertype!name'])} |
` : ''}
+ ${data['msdyn_primaryincidenttype!name'] ? `| 🛠 Incident Type | ${esc(data['msdyn_primaryincidenttype!name'])} |
` : ''}
+ ${data['msdyn_serviceterritory!name'] ? `| 📍 Territory | ${esc(data['msdyn_serviceterritory!name'])} |
` : ''}
+ ${data.msdyn_city ? `| 🏙 City | ${esc(data.msdyn_city)}${data.msdyn_stateorprovince ? ', ' + esc(data.msdyn_stateorprovince) : ''} |
` : ''}
+ ${data.createdon ? `| 📅 Created | ${esc(data.createdon)} |
` : ''}
+ ${data.msdyn_completedon ? `| ✅ Completed | ${esc(data.msdyn_completedon)} |
` : ''}
+ ${data['msdyn_customerasset!name'] ? `| 🏭 Asset | ${esc(data['msdyn_customerasset!name'])} |
` : ''}
+ ${data.msdyn_workordersummary ? `| 📝 Summary | ${esc(data.msdyn_workordersummary)} |
` : ''}
+ ${data.msdyn_instructions ? `| 📋 Instructions | ${esc(data.msdyn_instructions)} |
` : ''}
+ ${data.address1_line1 ? `| 📍 Address | ${esc(data.address1_line1)}${data.address1_city ? ', ' + esc(data.address1_city) : ''}${data.address1_stateorprovince ? ', ' + esc(data.address1_stateorprovince) : ''} |
` : ''}
+ ${data.address1_postalcode ? `| 📮 ZIP | ${esc(data.address1_postalcode)} |
` : ''}
+ ${data.telephone1 ? `| 📞 Phone | ${esc(data.telephone1)} |
` : ''}
+
+ `;
+ showModal(data.msdyn_name, body, 'Close', 'btn-outline');
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// MSFS ADB SYNC PAGE
+// ═════════════════════════════════════════════════════════════════════════════
+
+let syncPollInterval = null;
+let syncTaskId = null;
+
+async function loadMsfsSync() {
+ document.getElementById('msfsConnStatus').innerHTML =
+ ' Checking ADB connection...
';
+ // Reset progress display
+ document.getElementById('msfsProgressCard').style.display = 'none';
+ document.getElementById('msfsProgressResult').style.display = 'none';
+
+ try {
+ const info = await api('/api/admin/msfs-sync/info');
+ renderMsfsConnStatus(info);
+ renderMsfsHistory(info);
+ } catch (e) {
+ document.getElementById('msfsConnStatus').innerHTML =
+ `❌ ${esc(e.message)}
`;
+ }
+}
+
+function fmtDate(iso) {
+ if (!iso) return '';
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return iso;
+ return d.toLocaleDateString('en-US', {
+ month: 'short', day: 'numeric', year: 'numeric',
+ hour: 'numeric', minute: '2-digit'
+ });
+}
+
+function renderMsfsConnStatus(info) {
+ const el = document.getElementById('msfsConnStatus');
+ const adbOk = info.adb_connected;
+ const dbOk = info.db_file_exists;
+ const extOk = info.extraction_server !== false;
+
+ el.innerHTML = `
+
+
+
${extOk ? (adbOk ? '✅' : '⚠️') : '❌'}
+
ADB Device
+
${adbOk ? 'Connected' : (extOk ? 'Not connected' : 'Server offline')}
+
+
+
${info.app_version ? '📱' : '❓'}
+
App Version
+
${info.app_version || 'Unknown'}
+
+
+
${dbOk ? '✅' : '❌'}
+
Extraction DB
+
${dbOk ? info.db_file_size_gb + ' GB' : 'Missing'}
+
+
+
📊
+
Work Orders
+
${info.work_order_count || '?'}
+
+
+
+ ${adbOk ? `📱 Device: ${info.device_serial || 'connected'}` : ''}
+ ${info.last_pull_iso ? ` | 🕐 Last pull: ${fmtDate(info.last_pull_iso)}` : ''}
+ ${info.db_file_modified ? ` | 🗃 DB modified: ${fmtDate(info.db_file_modified)}` : ''}
+
+ `;
+}
+
+function renderMsfsHistory(info) {
+ const el = document.getElementById('msfsHistory');
+ if (info.last_pull_iso) {
+ el.innerHTML = `
+ 🕐 Last successful pull: ${fmtDate(info.last_pull_iso)}
+ ${info.db_file_modified ? `
🗃 DB last modified: ${fmtDate(info.db_file_modified)}` : ''}
+ ${info.db_file_size_gb ? `
💾 DB size: ${info.db_file_size_gb} GB` : ''}
+
`;
+ } else {
+ el.innerHTML = 'No syncs recorded yet.
';
+ }
+}
+
+async function startMsfsSync() {
+ document.getElementById('msfsSyncBtn').disabled = true;
+ document.getElementById('msfsProgressCard').style.display = '';
+ document.getElementById('msfsProgressResult').style.display = 'none';
+ document.getElementById('msfsProgressMsg').textContent = 'Starting sync...';
+ document.getElementById('msfsCancelBtn').style.display = '';
+
+ try {
+ const result = await api('/api/admin/msfs-sync/start', { method: 'POST' });
+ syncTaskId = result.task_id;
+ document.getElementById('msfsProgressMsg').textContent = result.message || 'Sync started';
+ // Start polling
+ if (syncPollInterval) clearInterval(syncPollInterval);
+ syncPollInterval = setInterval(pollMsfsTask, 3000);
+ } catch (e) {
+ document.getElementById('msfsProgressMsg').textContent = `❌ ${esc(e.message)}`;
+ document.getElementById('msfsSyncBtn').disabled = false;
+ document.getElementById('msfsCancelBtn').style.display = 'none';
+ }
+}
+
+async function pollMsfsTask() {
+ if (!syncTaskId) return;
+ try {
+ const task = await api(`/api/admin/msfs-sync/task/${syncTaskId}`);
+ document.getElementById('msfsProgressMsg').textContent =
+ `${task.phase || task.status}: ${task.message || ''}`;
+
+ if (task.status === 'completed') {
+ clearInterval(syncPollInterval);
+ syncPollInterval = null;
+ syncTaskId = null;
+ document.getElementById('msfsSyncBtn').disabled = false;
+ document.getElementById('msfsCancelBtn').style.display = 'none';
+ document.getElementById('msfsProgressResult').style.display = '';
+ document.getElementById('msfsProgressResult').innerHTML =
+ '✅ Sync completed successfully!
';
+ // Refresh status
+ setTimeout(loadMsfsSync, 2000);
+ } else if (task.status === 'failed') {
+ clearInterval(syncPollInterval);
+ syncPollInterval = null;
+ syncTaskId = null;
+ document.getElementById('msfsSyncBtn').disabled = false;
+ document.getElementById('msfsCancelBtn').style.display = 'none';
+ document.getElementById('msfsProgressResult').style.display = '';
+ document.getElementById('msfsProgressResult').innerHTML =
+ `❌ Sync failed: ${esc(task.message || 'Unknown error')}
`;
+ } else if (task.status === 'cancelled') {
+ clearInterval(syncPollInterval);
+ syncPollInterval = null;
+ syncTaskId = null;
+ document.getElementById('msfsSyncBtn').disabled = false;
+ document.getElementById('msfsCancelBtn').style.display = 'none';
+ }
+ } catch (e) {
+ // Keep polling
+ }
+}
+
+async function launchMsfsApp() {
+ try {
+ await api('/api/admin/msfs-sync/launch-app', { method: 'POST' });
+ showToast('📱 Sync started — Field Service app launched on phone, wait for it to complete');
+ } catch (e) {
+ showToast(e.message, true);
+ }
+}
+
+async function cancelMsfsSync() {
+ if (!syncTaskId) return;
+ try {
+ await api(`/api/admin/msfs-sync/cancel/${syncTaskId}`, { method: 'POST' });
+ showToast('Sync cancelled');
+ document.getElementById('msfsSyncBtn').disabled = false;
+ document.getElementById('msfsCancelBtn').style.display = 'none';
+ } catch (e) {
+ showToast(e.message, true);
+ }
+}
+
+function esc(s) { return String(s || '').replace(/[&<>"']/g, function(m) {
+ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]; });
+}