From ed5e3ff9ec09487e0c0fcab77a3099990f8db122 Mon Sep 17 00:00:00 2001 From: Leo Date: Sun, 31 May 2026 23:54:17 -0400 Subject: [PATCH] Add work order details display and asset table WO column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: new /api/admin/workorders/by-asset endpoint for work orders linked to a specific asset name - Backend: enhanced WO search to also match by msdyn_customerasset!name and msdyn_serviceaccount!name - Frontend: added 🔧 column to asset table linking to work orders tab pre-filtered by asset name - Frontend: added showAssetWorkOrders() function - Frontend: made asset name in WO detail modal clickable (navigates to asset search) Closes #21 --- admin_server.py | 622 ++++++++++++++++++++++- static/index.html | 1217 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 1837 insertions(+), 2 deletions(-) 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 @@ + + + + + + + + + @@ -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
+
${inProgress}
In Progress
+
${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 = ` + + + ${labels} + `; +} + +function renderRepDonut(el, data) { + const items = data.by_status || []; + if (!items.length) { + el.innerHTML = '
📭
No status data
'; + 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 = '
📭
No data available
'; + 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 = '
📭
No technician data
'; + return; + } + + const maxTotal = Math.max(...techs.map(t => t.total), 1); + + el.innerHTML = `
+ + + + + + + + + + + + + ${techs.map(t => { + const pct = t.total > 0 ? Math.round(t.completed / t.total * 100) : 0; + const barW = (t.total / maxTotal * 100); + return ` + + + + + + + `; + }).join('')} + +
TechnicianTotalCompletedIn ProgressScheduledCompletion %
${esc(t.name)} ${t.total}${t.completed}${t.in_progress}${t.scheduled}${pct}%
+
`; +} + +function renderTechStackedBar(el, data) { + const statusByTech = data.status_by_tech || {}; + const techNames = Object.keys(statusByTech); + if (!techNames.length) { + el.innerHTML = '
📭
No status-by-tech data
'; + 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'] ? `` : ''} + ${data.account_display_name && data.account_display_name !== data['msdyn_serviceaccount!name'] ? `` : ''} + ${techs.length ? `` : ''} + ${data['msdyn_priority!name'] ? `` : ''} + ${data['msdyn_workordertype!name'] ? `` : ''} + ${data['msdyn_primaryincidenttype!name'] ? `` : ''} + ${data['msdyn_serviceterritory!name'] ? `` : ''} + ${data.msdyn_city ? `` : ''} + ${data.createdon ? `` : ''} + ${data.msdyn_completedon ? `` : ''} + ${data['msdyn_customerasset!name'] ? `` : ''} + ${data.msdyn_workordersummary ? `` : ''} + ${data.msdyn_instructions ? `` : ''} + ${data.address1_line1 ? `` : ''} + ${data.address1_postalcode ? `` : ''} + ${data.telephone1 ? `` : ''} +
🏢 Account${esc(data['msdyn_serviceaccount!name'])}
🏢 Display${esc(data.account_display_name)}
👤 Technicians${esc(techs.join(', '))}
⚡ Priority${esc(data['msdyn_priority!name'])}
🔧 Work Type${esc(data['msdyn_workordertype!name'])}
🛠 Incident Type${esc(data['msdyn_primaryincidenttype!name'])}
📍 Territory${esc(data['msdyn_serviceterritory!name'])}
🏙 City${esc(data.msdyn_city)}${data.msdyn_stateorprovince ? ', ' + esc(data.msdyn_stateorprovince) : ''}
📅 Created${esc(data.createdon)}
✅ Completed${esc(data.msdyn_completedon)}
🏭 Asset${esc(data['msdyn_customerasset!name'])}
📝 Summary${esc(data.msdyn_workordersummary)}
📋 Instructions${esc(data.msdyn_instructions)}
📍 Address${esc(data.address1_line1)}${data.address1_city ? ', ' + esc(data.address1_city) : ''}${data.address1_stateorprovince ? ', ' + esc(data.address1_stateorprovince) : ''}
📮 ZIP${esc(data.address1_postalcode)}
📞 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]; }); +}