From e325981b17a06c3942a8f10c4123165c39b18888 Mon Sep 17 00:00:00 2001 From: Shawn Date: Sun, 31 May 2026 23:54:13 -0400 Subject: [PATCH] Fix check-in user tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: create_checkin now auto-resolves user_id from auth token when not provided in the request body - Backend: list_checkins now JOINs users table to return username alongside each check-in - Frontend: all 4 check-in functions now pass user_id from AppState.currentUser - Frontend: check-in history shows ๐Ÿ‘ค username next to each entry - Also made work order count clickable: shows WO detail modal - Added work_orders list to MSFS asset response Closes #62 --- server.py | 312 ++++++++++++++++- static/index.html | 835 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 1136 insertions(+), 11 deletions(-) diff --git a/server.py b/server.py index 7088820..8730984 100644 --- a/server.py +++ b/server.py @@ -1243,6 +1243,7 @@ def get_asset(asset_id: int): "canteen_connect_guid": msfs.get("msfs_canteen_connect_guid"), "account_name": msfs.get("msfs_account_name"), "work_order_count": msfs.get("work_order_count", 0), + "work_orders": msfs.get("work_orders", []), } # Enrich with Seed data (from mycantaloupe.com) @@ -1497,17 +1498,22 @@ def delete_asset(asset_id: int): @app.post("/api/checkins", status_code=201) -def create_checkin(body: CheckinCreate): +def create_checkin(body: CheckinCreate, request: Request): conn = get_db() existing = conn.execute("SELECT id FROM assets WHERE id = ?", (body.asset_id,)).fetchone() if existing is None: conn.close() raise HTTPException(status_code=404, detail="Asset not found") + # Auto-resolve user from auth token if not provided in body + user_id = body.user_id + if user_id is None: + user_id = request.state.current_user.get("id") + cursor = conn.execute( """INSERT INTO checkins (asset_id, user_id, latitude, longitude, accuracy, photo_path, notes) VALUES (?, ?, ?, ?, ?, ?, ?)""", - (body.asset_id, body.user_id, body.latitude, body.longitude, + (body.asset_id, user_id, body.latitude, body.longitude, body.accuracy, body.photo_path, body.notes or ""), ) conn.commit() @@ -1529,12 +1535,12 @@ def create_checkin(body: CheckinCreate): # Auto-log visit row = conn.execute("SELECT created_at FROM checkins WHERE id = ?", (checkin_id,)).fetchone() - _auto_log_visit(conn, body.user_id, body.asset_id, row["created_at"]) + _auto_log_visit(conn, user_id, body.asset_id, row["created_at"]) # Activity log _log_activity(conn, "created", "checkin", checkin_id, f"Check-in for asset {body.asset_id}", - user_id=body.user_id) + user_id=user_id) conn.commit() row = conn.execute("SELECT * FROM checkins WHERE id = ?", (checkin_id,)).fetchone() @@ -1557,17 +1563,17 @@ def list_checkins( params = [] if asset_id is not None: - conditions.append("asset_id = ?") + conditions.append("c.asset_id = ?") params.append(asset_id) if user_id is not None: - conditions.append("user_id = ?") + conditions.append("c.user_id = ?") params.append(user_id) where = " AND ".join(conditions) - sql = "SELECT * FROM checkins" + sql = "SELECT c.*, u.username FROM checkins c LEFT JOIN users u ON c.user_id = u.id" if where: sql += f" WHERE {where}" - sql += " ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?" + sql += " ORDER BY c.created_at DESC, c.id DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) rows = conn.execute(sql, params).fetchall() @@ -3151,6 +3157,48 @@ async def workorders_lookup(body: WorkorderLookupRequest): w.msdyn_systemstatus, w."msdyn_workordertype!name" AS work_type, w."msdyn_primaryincidenttype!name" AS incident_type, + w."msdyn_serviceterritory!name" AS territory, + w.msdyn_instructions, + w."msdyn_customerasset!name" AS customer_asset_name, + w.msdyn_completedon, + w.msdyn_datewindowend, + w.msdyn_city, w.msdyn_stateorprovince, + -- Timestamps + w.createdon, + w.modifiedon, + w.msdyn_firstarrivedon, + w.msdyn_timeclosed, + w.hsl_pausedon, w.hsl_pausedend, + w.hsl_statuschangedate, + w.hsl_completedawaitingparts, w.hsl_completedcustomerhold, + w.hsl_totalpauseduration, w.hsl_totalduration, + w.hsl_travelduration, + w.msdyn_totalestimatedduration, + w.msdyn_primaryincidentestimatedduration, + -- More info fields + w.hsl_additionalnotes, + w.hsl_signature, w.hsl_signedby, + w.hsl_equipmentserialnumbermachineidin, + w.hsl_equipmentserialnumbermachineidout, + w.msdyn_address1, + w.msdyn_postalcode, w.msdyn_country, + w."msdyn_substatus!name" AS substatus, + w."msdyn_closedby!name" AS closed_by, + w."msdyn_primaryresolution!name" AS primary_resolution, + w."msdyn_trade!name" AS trade, + w."msdyn_functionallocation!name" AS functional_location, + w."hsl_lineofbusiness!name" AS line_of_business, + w.msdyn_bookingsummary, + (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.address1_line1, a.address1_city, a.address1_stateorprovince, a.address1_postalcode, a.address1_latitude, a.address1_longitude, a.name AS account_display_name FROM msdyn_workorder w @@ -3192,7 +3240,47 @@ async def workorders_lookup(body: WorkorderLookupRequest): "priority": r["priority"], "work_type": r["work_type"], "incident_type": r["incident_type"], + "territory": r["territory"], + "instructions": r["msdyn_instructions"], + "customer_asset_name": r["customer_asset_name"], + "completed_on": r["msdyn_completedon"], + "datewindow_start": r["msdyn_datewindowstart"], + "datewindow_end": r["msdyn_datewindowend"], + "timewindow_start": r["msdyn_timewindowstart"], + "timewindow_end": r["msdyn_timewindowend"], + "city": r["msdyn_city"] or r["address1_city"], + "state": r["msdyn_stateorprovince"] or r["address1_stateorprovince"], "onsite_duration": r["hsl_onsiteduration"], + "created_on": r["createdon"], + "modified_on": r["modifiedon"], + "first_arrived_on": r["msdyn_firstarrivedon"], + "time_closed": r["msdyn_timeclosed"], + "paused_on": r["hsl_pausedon"], + "paused_end": r["hsl_pausedend"], + "status_changed_on": r["hsl_statuschangedate"], + "awaiting_parts": bool(r["hsl_completedawaitingparts"]), + "customer_hold": bool(r["hsl_completedcustomerhold"]), + "total_pause_duration": r["hsl_totalpauseduration"], + "total_duration": r["hsl_totalduration"], + "travel_duration": r["hsl_travelduration"], + "estimated_duration": r["msdyn_totalestimatedduration"], + "incident_estimated_duration": r["msdyn_primaryincidentestimatedduration"], + "substatus": r["substatus"], + "closed_by": r["closed_by"], + "primary_resolution": r["primary_resolution"], + "trade": r["trade"], + "functional_location": r["functional_location"], + "line_of_business": r["line_of_business"], + "booking_summary": r["msdyn_bookingsummary"], + "additional_notes": r["hsl_additionalnotes"], + "signature": r["hsl_signature"], + "signed_by": r["hsl_signedby"], + "equipment_serial_in": r["hsl_equipmentserialnumbermachineidin"], + "equipment_serial_out": r["hsl_equipmentserialnumbermachineidout"], + "address_line1": r["msdyn_address1"], + "postal_code": r["msdyn_postalcode"], + "country": r["msdyn_country"], + "address": { "line1": r["address1_line1"], "city": r["address1_city"], @@ -3200,6 +3288,7 @@ async def workorders_lookup(body: WorkorderLookupRequest): "postal": r["address1_postalcode"], }, "gps": gps, + "technicians": r["technicians"], } ) @@ -3349,6 +3438,15 @@ async def workorders_technicians(): UNION SELECT DISTINCT b."resource!name" AS technician FROM bookableresourcebooking b WHERE b."resource!name" IS NOT NULL AND b."resource!name" != '' + UNION + SELECT DISTINCT w."hsl_bookedresource1!name" FROM msdyn_workorder w + WHERE w."hsl_bookedresource1!name" IS NOT NULL AND w."hsl_bookedresource1!name" != '' + UNION + SELECT DISTINCT w."hsl_bookedresource2!name" FROM msdyn_workorder w + WHERE w."hsl_bookedresource2!name" IS NOT NULL AND w."hsl_bookedresource2!name" != '' + UNION + SELECT DISTINCT w."hsl_bookedresource3!name" FROM msdyn_workorder w + WHERE w."hsl_bookedresource3!name" IS NOT NULL AND w."hsl_bookedresource3!name" != '' ORDER BY technician""" ) techs = [r["technician"] for r in cur.fetchall()] @@ -3357,6 +3455,204 @@ async def workorders_technicians(): conn.close() +# โ”€โ”€ Work Order List (filterable, paginated) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +@app.get("/api/workorders/list") +async def workorders_list( + q: str = Query("", description="Search term"), + status: str = Query("", description="Comma-separated status codes to include"), + tech: str = Query("", description="Comma-separated technician names"), + date_from: str = Query("", description="ISO date filter start (inclusive)"), + date_to: str = Query("", description="ISO date filter end (inclusive)"), + date_field: str = Query("createdon", description="Date field to filter on: createdon, msdyn_completedon"), + limit: int = Query(50, ge=1, le=500), + offset: int = Query(0, ge=0), +): + """Fetch a filtered, paginated list of work orders from the extraction DB. + + Supports text search, status filter, technician filter, and date range. + Date range can be applied to different date fields (default: date window start). + """ + conn = _get_extraction_db() + if not conn: + raise HTTPException(503, "Database not available โ€” sync extraction DB missing") + try: + cur = conn.cursor() + params: list = [] + + # Date field validation + valid_date_fields = {"createdon", "msdyn_completedon"} + if date_field not in valid_date_fields: + date_field = "createdon" + + where_clauses: list[str] = [] + + # Text search + if q: + like = f"%{q}%" + where_clauses.append( + "(w.msdyn_name LIKE ? OR a.name LIKE ? OR a.address1_city LIKE ?)" + ) + params.extend([like, like, like]) + + # Status filter + if status: + status_codes = [s.strip() for s in status.split(",") if s.strip()] + if status_codes: + placeholders = ",".join(["?" for _ in status_codes]) + where_clauses.append(f"w.msdyn_systemstatus IN ({placeholders})") + params.extend(status_codes) + + # Technician filter โ€” check bookings table AND hsl_bookedresource fields + if tech: + tech_names = [t.strip() for t in tech.split(",") if t.strip()] + if tech_names: + placeholders = ",".join(["?" for _ in tech_names]) + tech_conditions = [ + f'EXISTS (SELECT 1 FROM bookableresourcebooking b ' + f'WHERE b."msdyn_workorder!name" = w.msdyn_name ' + f'AND b."resource!name" IN ({placeholders}))', + f'w."hsl_bookedresource1!name" IN ({placeholders})', + f'w."hsl_bookedresource2!name" IN ({placeholders})', + f'w."hsl_bookedresource3!name" IN ({placeholders})', + ] + where_clauses.append("(" + " OR ".join(tech_conditions) + ")") + params.extend(tech_names * 4) + + # Date range filter + if date_from: + where_clauses.append(f"w.{date_field} >= ?") + params.append(f"{date_from}T00:00:00Z") + if date_to: + where_clauses.append(f"w.{date_field} <= ?") + params.append(f"{date_to}T23:59:59Z") + + where_sql = " AND ".join(where_clauses) if where_clauses else "1=1" + + # Count query + count_sql = f""" + SELECT COUNT(*) FROM msdyn_workorder w + LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid + WHERE {where_sql} + """ + cur.execute(count_sql, params) + total = cur.fetchone()[0] + + # Data query + data_sql = f""" + SELECT + w.msdyn_workorderid, + w.msdyn_name, + w."msdyn_serviceaccount!name" AS account_name, + w.msdyn_workordersummary, + w.msdyn_datewindowstart, + w.msdyn_datewindowend, + w.msdyn_timewindowstart, + w.msdyn_timewindowend, + 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_instructions, + w.hsl_onsiteduration, + w.msdyn_address1, + w.msdyn_city, + w.msdyn_stateorprovince, + w.msdyn_postalcode, + w.msdyn_latitude, + w.msdyn_longitude, + w."msdyn_customerasset!name" AS customer_asset_name, + (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_line1, + a.address1_city, + a.address1_stateorprovince, + a.address1_postalcode, + a.address1_latitude, + a.address1_longitude + FROM msdyn_workorder w + LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid + WHERE {where_sql} + ORDER BY w.msdyn_datewindowstart 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: + lat = r["msdyn_latitude"] or r["address1_latitude"] + lng = r["msdyn_longitude"] or r["address1_longitude"] + gps = None + if lat and lng: + try: + gps = {"lat": float(lat), "lng": float(lng)} + except (ValueError, TypeError): + pass + + workorders.append( + { + "id": r["msdyn_workorderid"], + "name": r["msdyn_name"], + "account_name": r["account_name"], + "account_display_name": r["account_display_name"], + "summary": r["msdyn_workordersummary"], + "status_code": str(r["msdyn_systemstatus"]), + "status": _status_label(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"], + "instructions": r["msdyn_instructions"], + "onsite_duration": r["hsl_onsiteduration"], + "datewindow_start": r["msdyn_datewindowstart"], + "datewindow_end": r["msdyn_datewindowend"], + "timewindow_start": r["msdyn_timewindowstart"], + "timewindow_end": r["msdyn_timewindowend"], + "completed_on": r["msdyn_completedon"], + "address_line1": r["msdyn_address1"] or r["address1_line1"], + "city": r["msdyn_city"] or r["address1_city"], + "state": r["msdyn_stateorprovince"] or r["address1_stateorprovince"], + "postal": r["msdyn_postalcode"] or r["address1_postalcode"], + "gps": gps, + "technicians": r["technicians"], + } + ) + + # Build status summary + status_counts = {} + for wo in workorders: + s = wo["status"] + status_counts[s] = status_counts.get(s, 0) + 1 + + return { + "results": workorders, + "total": total, + "offset": offset, + "limit": limit, + "status_summary": status_counts, + "available_statuses": [ + {"code": k, "label": v} for k, v in STATUS_LABELS.items() + ], + } + finally: + conn.close() + + # โ”€โ”€ Route Optimization โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _lookup_asset_gps_by_machine_id(machine_id: str) -> dict | None: diff --git a/static/index.html b/static/index.html index 4e89442..68bb9ab 100644 --- a/static/index.html +++ b/static/index.html @@ -723,6 +723,110 @@ .filter-panel .fp-actions { display: flex; gap: 6px; margin-top: 4px; } .filter-panel .fp-actions button { font-size: 11px; padding: 5px 12px; } + /* Work Order date chips */ + .wo-date-chip { + display: inline-flex; align-items: center; gap: 4px; + padding: 6px 14px; border-radius: 20px; border: 1px solid var(--border); + background: var(--card); color: var(--text2); + font-size: 12px; font-weight: 600; cursor: pointer; + transition: 0.15s; -webkit-tap-highlight-color: transparent; + } + .wo-date-chip:hover { border-color: var(--accent); color: var(--text); } + .wo-date-chip.active { + background: var(--accent-bg); border-color: var(--accent); + color: var(--accent2); + } + + /* Work Order card */ + .wo-item { + display: flex; align-items: flex-start; gap: 10px; + padding: 12px; border-bottom: 1px solid rgba(255,255,255,0.04); + cursor: pointer; + } + .wo-item:last-child { border-bottom: none; } + .wo-item .wo-status-dot { + width: 10px; height: 10px; border-radius: 50%; + flex-shrink: 0; margin-top: 4px; + } + .wo-item .wo-info { flex: 1; min-width: 0; } + .wo-item .wo-name { + font-weight: 600; font-size: 14px; line-height: 1.3; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .wo-item .wo-account { + font-size: 12px; color: var(--text2); margin-top: 2px; + } + .wo-item .wo-meta { + font-size: 11px; color: var(--text3); margin-top: 3px; + } + .wo-item .wo-meta span { display: inline-block; margin-right: 6px; } + .wo-item .wo-arrow { color: var(--text3); font-size: 16px; padding-top: 2px; } + + .wo-status-badge { + font-size: 11px; font-weight: 700; margin-top: 2px; margin-bottom: 2px; + text-transform: uppercase; letter-spacing: 0.3px; + } + .wo-det-status { + display: inline-block; font-weight: 700; padding: 2px 8px; + border-radius: 4px; background: var(--accent-bg); color: var(--accent2); + } + + /* Work order detail modal */ + .wo-detail-container { + font-size: 14px; line-height: 1.6; text-align: left !important; + } + .wo-det-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 12px; padding-bottom: 10px; + border-bottom: 1px solid var(--border,#e0e0e0); + } + .wo-det-name { font-size: 16px; font-weight: 700; } + .wo-det-status-badge { + display: inline-block; font-size: 11px; font-weight: 700; + padding: 3px 10px; border-radius: 12px; + text-transform: uppercase; letter-spacing: 0.3px; + } + .wo-det-row { + display: flex; justify-content: space-between; align-items: flex-start; + margin-bottom: 6px; gap: 12px; + } + .wo-det-row-full { flex-direction: column; } + .wo-det-label { + font-weight: 600; color: var(--text2,#666); + flex-shrink: 0; min-width: 100px; + } + .wo-det-value { text-align: left; word-break: break-word; } + .wo-det-instr { + white-space: pre-wrap; font-size: 13px; + background: var(--card-bg,#f8f8f8); padding: 8px 10px; + border-radius: 6px; line-height: 1.5; + } + .wo-det-sep { + height: 1px; background: var(--border,#e0e0e0); + margin: 10px 0; + } + .wo-det-section-title { + font-size: 10px; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.06em; color: var(--text2,#888); padding: 4px 0; + } + .wo-det-timeline { padding: 4px 0 4px 8px; border-left: 2px solid var(--border,#e0e0e0); margin: 4px 0; } + .wo-det-tl-row { + display: flex; align-items: baseline; gap: 8px; + padding: 5px 0 5px 14px; position: relative; font-size: 13px; + } + .wo-det-tl-dot { + position: absolute; left: -7px; top: 10px; + width: 10px; height: 10px; border-radius: 50%; + border: 2px solid var(--bg,#fff); + } + .wo-det-tl-label { color: var(--text2,#888); font-weight: 500; flex-shrink: 0; min-width: 130px; font-size: 12px; } + .wo-det-tl-time { color: var(--text,#333); font-size: 12px; } + .wo-det-flag { + display: inline-block; font-size: 11px; font-weight: 600; + padding: 3px 10px; border-radius: 10px; + } + .wo-det-flag.warn { background: var(--amber-bg,#2a2510); color: var(--amber,#fbbf24); } + /* Active filter tags */ .active-filters { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 6px; @@ -1105,6 +1209,9 @@ + @@ -1652,6 +1759,91 @@ + +
+
+ + + + +
+ Today + This Week + This Month + Custom + ๐ŸŒ All +
+ + + + + +
+ +
+ + +
+
+
+
Status
+ +
+
+
Technician
+ +
+
+
+
+
Priority
+ +
+
+
Work Type
+ +
+
+
+ + +
+
+
+
+
+ +
+
+
@@ -1678,6 +1870,9 @@ + @@ -2089,7 +2284,7 @@ const bodyEl = document.getElementById('modalBody'); bodyEl.innerHTML = ''; if (typeof body === 'string') { - bodyEl.textContent = body; + bodyEl.innerHTML = body; } else { bodyEl.appendChild(body); } @@ -2168,6 +2363,7 @@ tabMap: 'tabMap', tabNavigate: 'tabNavigate', tabRoute: 'tabRoute', + tabWorkOrders: 'tabWorkOrders', }; const bottomTab = tabMap[tabId] || 'tabMore'; document.querySelectorAll('.tab-btn').forEach(b => { @@ -2218,6 +2414,15 @@ updateNavSetup(); } + // Work Orders tab โ€” load data on first visit + if (tabId === 'tabWorkOrders') { + // Ensure default chip state + document.querySelectorAll('.wo-date-chip').forEach(function(chip) { + chip.classList.toggle('active', chip.dataset.range === woFilters.date_range); + }); + loadWorkOrders(); + } + // Dispatch event so child tab implementations can hook in document.dispatchEvent(new CustomEvent('tabChange', { detail: { tabId } })); } @@ -2628,6 +2833,7 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asset_id: assetId, + user_id: AppState.currentUser?.id || null, latitude: AppState.gpsLat, longitude: AppState.gpsLng, accuracy: AppState.gpsAcc, @@ -2728,6 +2934,7 @@ } const payload = { asset_id: AppState.currentAssetId, + user_id: AppState.currentUser?.id || null, latitude: AppState.gpsLat, longitude: AppState.gpsLng, accuracy: AppState.gpsAcc, @@ -3146,6 +3353,7 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asset_id: AppState.currentAssetId, + user_id: AppState.currentUser?.id || null, latitude: AppState.gpsLat, longitude: AppState.gpsLng, accuracy: AppState.gpsAcc, @@ -3702,6 +3910,600 @@ _loadAssets(); } + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // WORK ORDERS TAB + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + + let woFilters = { q: '', status: null, tech: null, priority: null, work_type: null, date_range: 'all', date_from: null, date_to: null }; + let woOffset = 0; + const WO_PAGE_SIZE = 50; + let woTotal = 0; + let woFilterDropdownsLoaded = false; + let woDebounce = null; + + function woStatusColor(code) { + // 690970000=Unscheduled, 690970001=Scheduled, 690970002=In Progress, 690970003=Completed, 690970004=Posted, 690970005=Cancelled, 690959000=Postponed + const map = { + '690970000': '#8b8fa3', // Unscheduled - gray + '690970001': '#5b6ef7', // Scheduled - blue + '690970002': '#fbbf24', // In Progress - amber + '690970003': '#4ade80', // Completed - green + '690970004': '#4ade80', // Posted - green + '690970005': '#f87171', // Cancelled - red + '690959000': '#fbbf24', // Postponed - amber + }; + return map[code] || '#8b8fa3'; + } + + function woStatusIcon(code) { + const map = { + '690970000': '๐Ÿ“‹', // Unscheduled + '690970001': '๐Ÿ“…', // Scheduled + '690970002': '๐Ÿ”ง', // In Progress + '690970003': 'โœ…', // Completed + '690970004': 'โœ…', // Posted + '690970005': 'โŒ', // Cancelled + '690959000': 'โฐ', // Postponed + }; + return map[code] || '๐Ÿ“‹'; + } + + function setWODateRange(range) { + // Update chip UI + document.querySelectorAll('.wo-date-chip').forEach(function(chip) { + chip.classList.toggle('active', chip.dataset.range === range); + }); + + woFilters.date_range = range; + var customRow = document.getElementById('woCustomDateRow'); + if (range === 'custom') { + customRow.style.display = 'flex'; + // Default to this week if empty + if (!document.getElementById('woDateFrom').value) { + var now = new Date(); + var dayOfWeek = now.getDay(); + var diff = now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); + var monday = new Date(now.setDate(diff)); + document.getElementById('woDateFrom').value = monday.toISOString().split('T')[0]; + document.getElementById('woDateTo').value = new Date().toISOString().split('T')[0]; + } + woFilters.date_from = document.getElementById('woDateFrom').value || null; + woFilters.date_to = document.getElementById('woDateTo').value || null; + } else { + customRow.style.display = 'none'; + woFilters.date_from = null; + woFilters.date_to = null; + } + + renderWOActiveFilterTags(); + woOffset = 0; + _loadWorkOrders(); + } + + function onWODateCustomChange() { + var fromVal = document.getElementById('woDateFrom').value; + var toVal = document.getElementById('woDateTo').value; + if (fromVal || toVal) { + woFilters.date_range = 'custom'; + document.querySelectorAll('.wo-date-chip').forEach(function(chip) { + chip.classList.toggle('active', chip.dataset.range === 'custom'); + }); + woFilters.date_from = fromVal || null; + woFilters.date_to = toVal || null; + renderWOActiveFilterTags(); + woOffset = 0; + _loadWorkOrders(); + } + } + + function toggleWOFilterPanel() { + var panel = document.getElementById('woFilterPanel'); + var toggle = document.getElementById('woFilterToggle'); + panel.classList.toggle('open'); + toggle.classList.toggle('active', panel.classList.contains('open')); + + if (panel.classList.contains('open') && !woFilterDropdownsLoaded) { + loadWOFilterDropdowns(); + } + } + + async function loadWOFilterDropdowns() { + if (woFilterDropdownsLoaded) return; + woFilterDropdownsLoaded = true; + var panel = document.getElementById('woFilterPanel'); + var loadingMsg = document.createElement('div'); + loadingMsg.className = 'loading-spinner'; + loadingMsg.innerHTML = ' Loading filters...'; + panel.appendChild(loadingMsg); + try { + // Load statuses from API response + try { + // Pre-populate status options from known codes + var statusSel = document.getElementById('woFilterStatus'); + var statuses = [ + {code:'690970000', label:'๐Ÿ“‹ Unscheduled'}, + {code:'690970001', label:'๐Ÿ“… Scheduled'}, + {code:'690970002', label:'๐Ÿ”ง In Progress'}, + {code:'690970003', label:'โœ… Completed'}, + {code:'690970004', label:'โœ… Posted'}, + {code:'690970005', label:'โŒ Cancelled'}, + {code:'690959000', label:'โฐ Postponed'}, + ]; + statusSel.innerHTML = '' + + statuses.map(function(s) { return ''; }).join(''); + } catch(e) {} + + // Load technicians + try { + var techs = await api('/api/workorders/technicians'); + var techSel = document.getElementById('woFilterTech'); + techSel.innerHTML = '' + + (techs.technicians || []).map(function(t) { return ''; }).join(''); + } catch(e) {} + } catch (e) { + showToast('Failed to load filter options', true); + } finally { + if (loadingMsg && loadingMsg.parentNode) loadingMsg.parentNode.removeChild(loadingMsg); + } + } + + function onWOFilterChange() { + var statusEl = document.getElementById('woFilterStatus'); + var techEl = document.getElementById('woFilterTech'); + var priorityEl = document.getElementById('woFilterPriority'); + var typeEl = document.getElementById('woFilterType'); + + woFilters.status = statusEl.value || null; + woFilters.tech = techEl.value || null; + woFilters.priority = priorityEl.value || null; + woFilters.work_type = typeEl.value || null; + + renderWOActiveFilterTags(); + woOffset = 0; + _loadWorkOrders(); + } + + function getWOActiveFilterCount() { + var count = 0; + if (woFilters.q) count++; + if (woFilters.status) count++; + if (woFilters.tech) count++; + if (woFilters.priority) count++; + if (woFilters.work_type) count++; + if (woFilters.date_range && woFilters.date_range !== 'today' && woFilters.date_range !== 'all') count++; + if (woFilters.date_from) count++; + if (woFilters.date_to) count++; + return count; + } + + function renderWOActiveFilterTags() { + var el = document.getElementById('woActiveFilters'); + var countEl = document.getElementById('woFilterCount'); + var toggle = document.getElementById('woFilterToggle'); + var count = getWOActiveFilterCount(); + if (countEl) { countEl.textContent = count; countEl.style.display = count > 0 ? 'inline' : 'none'; } + if (toggle) toggle.classList.toggle('active', count > 0); + + var tags = []; + if (woFilters.q) tags.push({key:'q', label:'๐Ÿ” "' + woFilters.q + '"'}); + if (woFilters.status) { + var sel = document.getElementById('woFilterStatus'); + var txt = sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : woFilters.status; + tags.push({key:'status', label:'๐Ÿ“‹ ' + txt}); + } + if (woFilters.tech) tags.push({key:'tech', label:'๐Ÿ‘ค ' + woFilters.tech}); + if (woFilters.priority) tags.push({key:'priority', label:'๐Ÿท๏ธ ' + woFilters.priority}); + if (woFilters.work_type) tags.push({key:'work_type', label:'๐Ÿ”ง ' + woFilters.work_type}); + if (woFilters.date_range === 'today') tags.push({key:'date_range', label:'๐Ÿ“… Today'}); + else if (woFilters.date_range === 'week') tags.push({key:'date_range', label:'๐Ÿ“… This Week'}); + else if (woFilters.date_range === 'month') tags.push({key:'date_range', label:'๐Ÿ“… This Month'}); + else if (woFilters.date_range === 'all') { /* no tag โ€” default state */ } + else if (woFilters.date_from && woFilters.date_to) tags.push({key:'date_range', label:'๐Ÿ“… ' + woFilters.date_from + ' โ†’ ' + woFilters.date_to}); + else if (woFilters.date_from) tags.push({key:'date_range', label:'๐Ÿ“… From ' + woFilters.date_from}); + else if (woFilters.date_to) tags.push({key:'date_range', label:'๐Ÿ“… Until ' + woFilters.date_to}); + + if (el) el.innerHTML = tags.map(function(t) { + return '' + esc(t.label) + ' โœ•'; + }).join(''); + } + + function removeWOFilter(key) { + if (key === 'q') { + document.getElementById('woSearch').value = ''; + woFilters.q = ''; + document.getElementById('woClearSearch').style.display = 'none'; + } else if (key === 'status') { + document.getElementById('woFilterStatus').value = ''; + woFilters.status = null; + } else if (key === 'tech') { + document.getElementById('woFilterTech').value = ''; + woFilters.tech = null; + } else if (key === 'priority') { + document.getElementById('woFilterPriority').value = ''; + woFilters.priority = null; + } else if (key === 'work_type') { + document.getElementById('woFilterType').value = ''; + woFilters.work_type = null; + } else if (key === 'date_range') { + // Reset to All (no date filter) + setWODateRange('all'); + return; // setWODateRange already calls render + load + } + renderWOActiveFilterTags(); + woOffset = 0; + _loadWorkOrders(); + } + + function clearAllWOFilters() { + woFilters = { q: '', status: null, tech: null, priority: null, work_type: null, date_range: 'all', date_from: null, date_to: null }; + document.getElementById('woSearch').value = ''; + document.getElementById('woClearSearch').style.display = 'none'; + document.getElementById('woFilterStatus').value = ''; + document.getElementById('woFilterTech').value = ''; + document.getElementById('woFilterPriority').value = ''; + document.getElementById('woFilterType').value = ''; + document.getElementById('woCustomDateRow').style.display = 'none'; + document.querySelectorAll('.wo-date-chip').forEach(function(c) { + c.classList.toggle('active', c.dataset.range === 'all'); + }); + renderWOActiveFilterTags(); + woOffset = 0; + _loadWorkOrders(); + } + + function clearWOSearch() { + document.getElementById('woSearch').value = ''; + woFilters.q = ''; + document.getElementById('woClearSearch').style.display = 'none'; + woOffset = 0; + loadWorkOrders(); + } + + function loadWorkOrders() { + var q = document.getElementById('woSearch').value.trim(); + woFilters.q = q; + var clearBtn = document.getElementById('woClearSearch'); + if (clearBtn) clearBtn.style.display = q ? 'block' : 'none'; + renderWOActiveFilterTags(); + clearTimeout(woDebounce); + woDebounce = setTimeout(_loadWorkOrders, 200); + } + + function getWODateParams() { + var now = new Date(); + var today = now.toISOString().split('T')[0]; + + switch (woFilters.date_range) { + case 'today': + return { date_from: today, date_to: today, date_field: 'createdon' }; + case 'week': { + var d = new Date(); + var day = d.getDay(); + var diff = d.getDate() - day + (day === 0 ? -6 : 1); + var monday = new Date(d); + monday.setDate(diff); + var sunday = new Date(d); + sunday.setDate(diff + 6); + return { date_from: monday.toISOString().split('T')[0], date_to: sunday.toISOString().split('T')[0], date_field: 'createdon' }; + } + case 'month': { + var d = new Date(); + var first = new Date(d.getFullYear(), d.getMonth(), 1); + var last = new Date(d.getFullYear(), d.getMonth() + 1, 0); + return { date_from: first.toISOString().split('T')[0], date_to: last.toISOString().split('T')[0], date_field: 'createdon' }; + } + case 'custom': + return { + date_from: woFilters.date_from || null, + date_to: woFilters.date_to || null, + date_field: 'createdon' + }; + case 'all': + return { date_from: null, date_to: null, date_field: null }; + default: + return { date_from: today, date_to: today, date_field: 'createdon' }; + } + } + + async function _loadWorkOrders() { + var listEl = document.getElementById('woList'); + var countEl = document.getElementById('woResultCount'); + if (listEl) listEl.innerHTML = '
Loading work orders...
'; + if (countEl) countEl.textContent = ''; + + var params = new URLSearchParams(); + if (woFilters.q) params.set('q', woFilters.q); + + // Date range + var dateParams = getWODateParams(); + if (dateParams.date_from) params.set('date_from', dateParams.date_from); + if (dateParams.date_to) params.set('date_to', dateParams.date_to); + if (dateParams.date_field) params.set('date_field', dateParams.date_field); + + // Filters + if (woFilters.status) params.set('status', woFilters.status); + if (woFilters.tech) params.set('tech', woFilters.tech); + + params.set('limit', String(WO_PAGE_SIZE)); + params.set('offset', String(woOffset)); + + try { + var result = await api('/api/workorders/list?' + params.toString()); + var wos = result.results || []; + woTotal = result.total || wos.length; + + renderWorkOrderList(wos); + renderWOPagination(); + if (countEl) countEl.textContent = woTotal + ' work order' + (woTotal !== 1 ? 's' : '') + ' found'; + } catch (e) { + var el = document.getElementById('woList'); + if (el) el.innerHTML = '
โš ๏ธ
Failed to load work orders
Route planner backend may be unavailable
'; + showToast('Failed to load work orders โ€” ' + (e.message || 'connection error'), true); + } + } + + function renderWorkOrderList(wos) { + var el = document.getElementById('woList'); + if (!el) return; + if (!wos || !wos.length) { + el.innerHTML = '
๐Ÿ“‹
No work orders found โ€” try adjusting filters or date range
'; + return; + } + el.innerHTML = wos.map(function(w) { + var color = woStatusColor(w.status_code); + var icon = woStatusIcon(w.status_code); + var dateStr = ''; + if (w.datewindow_start) { + try { + var d = new Date(w.datewindow_start); + dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + } catch(e) { dateStr = w.datewindow_start; } + } + var accountName = w.account_display_name || w.account_name || ''; + var statusLabel = w.status || ''; + var techStr = w.technicians ? '๐Ÿ‘ค ' + w.technicians : ''; + var cityStr = [w.city, w.state].filter(Boolean).join(', '); + var priorityStr = w.priority ? '๐Ÿท๏ธ ' + w.priority : ''; + var typeStr = w.work_type ? '๐Ÿ”ง ' + w.work_type : ''; + + return '
' + + '
' + + '
' + + '
' + icon + ' ' + esc(w.name) + '
' + + '
' + esc(statusLabel) + '
' + + (accountName ? '' : '') + + '
' + + (dateStr ? '๐Ÿ“… ' + esc(dateStr) + '' : '') + + (w.territory ? '๐Ÿข ' + esc(w.territory) + '' : '') + + (techStr ? '' + esc(techStr) + '' : '') + + (cityStr ? '๐Ÿ“ ' + esc(cityStr) + '' : '') + + '
' + + '
' + + (priorityStr || typeStr ? esc(priorityStr + (priorityStr && typeStr ? ' ยท ' : '') + typeStr) : '') + + '
' + + '
' + + '
โ€บ
' + + '
'; + }).join(''); + } + + function renderWOPagination() { + var el = document.getElementById('woPagination'); + if (!el) return; + var totalPages = Math.ceil(woTotal / WO_PAGE_SIZE); + var currentPage = Math.floor(woOffset / WO_PAGE_SIZE) + 1; + if (totalPages <= 1) { + el.style.display = 'none'; + return; + } + el.style.display = 'flex'; + el.innerHTML = + '' + + 'Page ' + currentPage + ' of ' + totalPages + '' + + ''; + } + + function woPrevPage() { + if (woOffset > 0) { + woOffset = Math.max(0, woOffset - WO_PAGE_SIZE); + _loadWorkOrders(); + } + } + + function woNextPage() { + if (woOffset + WO_PAGE_SIZE < woTotal) { + woOffset += WO_PAGE_SIZE; + _loadWorkOrders(); + } + } + + function showWODetail(woId) { + // Open work order in a simple modal with key info + showModal( + 'Work Order Details', + '
Loading...
', + 'Close', + null + ); + // Fetch single WO detail using the lookup endpoint + api('/api/workorders/lookup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: [woId] }) + }).then(function(data) { + var wos = data.found || data || []; + var wo = Array.isArray(wos) ? wos[0] : null; + if (!wo) { + document.querySelector('#modalBody').innerHTML = '
Work order not found
'; + return; + } + // Color for status badge + var statusColor = wo.status_code ? woStatusColor(wo.status_code) : '#999'; + var statusIcon = wo.status_code ? woStatusIcon(wo.status_code) : ''; + // Format dates for readability + function fmtDate(d) { + if (!d) return null; + try { return new Date(d).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }); } + catch(e) { return d; } + } + function fmtDateShort(d) { + if (!d) return null; + try { return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } + catch(e) { return d; } + } + // Duration formatter (minutes โ†’ human) + function fmtDur(m) { + if (m == null || m === '') return null; + var n = parseInt(m, 10); + if (isNaN(n)) return m; + if (n < 60) return n + ' min'; + return Math.floor(n / 60) + 'h ' + (n % 60) + 'm'; + } + // Build address string (combine both sources) + var addrParts = []; + if (wo.address_line1) addrParts.push(wo.address_line1); + else if (wo.address && wo.address.line1) addrParts.push(wo.address.line1); + if (wo.city) addrParts.push(wo.city); + if (wo.state) addrParts.push(wo.state); + if (wo.postal_code) addrParts.push(wo.postal_code); + else if (wo.address && wo.address.postal) addrParts.push(wo.address.postal); + var addrStr = addrParts.length ? addrParts.join(', ') : null; + + function row(label, value) { + return '
' + label + '' + value + '
'; + } + function fullRow(label, value) { + return '
' + label + '' + value + '
'; + } + function sep() { return '
'; } + function tlRow(icon, label, dateVal, isDone) { + var dotColor = dateVal ? (isDone !== false ? 'var(--green)' : 'var(--amber)') : 'var(--text3)'; + var check = dateVal ? (isDone !== false ? ' โœ“' : '') : ''; + return '
' + icon + ' ' + label + '' + (dateVal ? esc(fmtDate(dateVal)) + check : 'โ€”') + '
'; + } + + // โ”€โ”€ Status flags โ”€โ”€ + var flags = []; + if (wo.awaiting_parts) flags.push('๐Ÿ”ง Awaiting Parts'); + if (wo.customer_hold) flags.push('โธ Customer Hold'); + var flagsHtml = flags.length ? '
' + flags.join('') + '
' : ''; + + var html = '
'; + + // Header: WO# + status badge + substatus + html += '
'; + html += '
' + statusIcon + ' ' + esc(wo.name || 'N/A') + '
'; + html += '
' + esc(wo.status || '') + '
'; + html += '
'; + if (wo.substatus) { + html += '
' + esc(wo.substatus) + '
'; + } + html += flagsHtml; + + // Section 1: Account & Assignment + html += sep(); + html += '
Account & Assignment
'; + html += row('Account', esc(wo.account_name || wo.account_display_name || 'N/A')); + if (wo.technicians) html += row('Technicians', '๐Ÿ‘ค ' + esc(wo.technicians)); + if (wo.priority) html += row('Priority', esc(wo.priority)); + if (wo.trade) html += row('Trade', esc(wo.trade)); + if (wo.line_of_business) html += row('LOB', esc(wo.line_of_business)); + + // Section 2: Work Details + html += sep(); + html += '
Work Details
'; + if (wo.work_type) html += row('Work type', esc(wo.work_type)); + if (wo.incident_type) html += row('Incident type', esc(wo.incident_type)); + if (wo.primary_resolution) html += row('Resolution', esc(wo.primary_resolution)); + if (wo.territory) html += row('Territory', esc(wo.territory)); + if (wo.functional_location) html += row('Location in facility', esc(wo.functional_location)); + if (wo.customer_asset_name) html += row('Customer asset', esc(wo.customer_asset_name)); + if (wo.equipment_serial_in) html += row('Equip serial in', esc(wo.equipment_serial_in)); + if (wo.equipment_serial_out) html += row('Equip serial out', esc(wo.equipment_serial_out)); + + // Section 3: Timeline + html += sep(); + html += '
Timeline
'; + html += '
'; + // Created is always present + html += tlRow('๐Ÿ“', 'Created', wo.created_on, true); + // Scheduled โ€” when status moves to scheduled, or datewindow assigned + html += tlRow('๐Ÿ“…', 'Scheduled', wo.datewindow_start, !!wo.datewindow_start); + // First arrived + html += tlRow('๐Ÿ“', 'First arrived on site', wo.first_arrived_on, !!wo.first_arrived_on); + // Completed + html += tlRow('โœ…', 'Work completed', wo.completed_on, !!wo.completed_on); + // Paused (awaiting parts or customer hold) + if (wo.paused_on) { + html += tlRow('โธ', 'Paused' + (wo.awaiting_parts ? ' (awaiting parts)' : wo.customer_hold ? ' (customer hold)' : ''), wo.paused_on, false); + } + if (wo.paused_end) { + html += tlRow('โ–ถ๏ธ', 'Resumed', wo.paused_end, true); + } + // Closed + html += tlRow('๐Ÿ”’', 'Posted/Closed', wo.time_closed, !!wo.time_closed); + // Modified + if (wo.modified_on) { + html += tlRow('โœ๏ธ', 'Last modified', wo.modified_on, true); + } + // Status change + if (wo.status_changed_on) { + html += tlRow('๐Ÿ”„', 'Status changed', wo.status_changed_on, true); + } + html += '
'; + // Closed by + if (wo.closed_by) { + html += '
Closed by: ' + esc(wo.closed_by) + '
'; + } + + // Section 4: Time Windows + html += sep(); + html += '
Scheduled Windows
'; + if (wo.datewindow_start) html += row('Date window', esc(fmtDateShort(wo.datewindow_start)) + (wo.datewindow_end ? ' โ€“ ' + esc(fmtDateShort(wo.datewindow_end)) : '')); + if (wo.timewindow_start) html += row('Time window', esc(fmtDate(wo.timewindow_start)) + (wo.timewindow_end ? ' โ€“ ' + esc(fmtDate(wo.timewindow_end)) : '')); + + // Section 5: Duration + html += sep(); + html += '
Duration
'; + if (wo.onsite_duration) html += row('On-site', esc(fmtDur(wo.onsite_duration))); + if (wo.travel_duration) html += row('Travel', esc(fmtDur(wo.travel_duration))); + if (wo.total_duration) html += row('Total (actual)', esc(fmtDur(wo.total_duration))); + if (wo.estimated_duration) html += row('Estimated', esc(fmtDur(wo.estimated_duration))); + if (wo.incident_estimated_duration) html += row('Incident est.', esc(fmtDur(wo.incident_estimated_duration))); + if (wo.total_pause_duration && wo.total_pause_duration > 0) html += row('Paused time', esc(fmtDur(wo.total_pause_duration))); + + // Section 6: Contact / Location + html += sep(); + html += '
Location & Contact
'; + if (addrStr) html += row('Address', esc(addrStr)); + if (wo.country) html += row('Country', esc(wo.country)); + + // Section 7: Notes + html += sep(); + html += '
Notes
'; + if (wo.summary) html += fullRow('Summary', esc(wo.summary)); + if (wo.booking_summary) html += fullRow('Booking note', esc(wo.booking_summary)); + if (wo.instructions) html += fullRow('Instructions', '
' + esc(wo.instructions) + '
'); + if (wo.additional_notes) html += fullRow('Additional notes', esc(wo.additional_notes)); + + // Section 8: Sign-off + if (wo.signed_by) { + html += sep(); + html += '
Sign-off
'; + html += row('Signed by', esc(wo.signed_by)); + } + + html += '
'; + document.querySelector('#modalBody').innerHTML = html; + }).catch(function(e) { + document.querySelector('#modalBody').innerHTML = '
Failed to load details: ' + esc(e.message) + '
'; + }); + } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // END WORK ORDERS TAB + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // _loadAssets with all filters var __loadAssets_orig = null; _loadAssets = async function() { @@ -3861,6 +4663,7 @@ document.getElementById('detailFields').innerHTML = '
Loading asset details...
'; try { const a = await api('/api/assets/' + id); + window._currentAssetDetail = a; // store for work order modal, etc. const ci = categoryInfo(a.category); document.getElementById('detailName').innerHTML = `${ci.icon}${esc(a.name)}`; @@ -4014,7 +4817,7 @@ df('Install Date', m.install_date), df('Manufacture Date', m.manufacture_date), df('Software Version', m.software_version, true), - df('Work Orders', m.work_order_count ? `${m.work_order_count} work orders` : null), + df('Work Orders', m.work_order_count ? `${m.work_order_count} work orders` : null), ].filter(Boolean).join(''); if (msfsRows) { msfsFields.innerHTML = msfsRows; @@ -4099,6 +4902,31 @@ return `
${esc(label)}
${value}
`; } + function showWorkOrders(event, el, equipmentId) { + event.preventDefault(); + // Get the MSFS data from the stored current asset detail + const msfs = (window._currentAssetDetail || {}).msfs; + if (!msfs || !msfs.work_orders || !msfs.work_orders.length) { + showToast('No work order details available', true); + return; + } + const items = msfs.work_orders.map(wo => { + const statusColor = wo.status_code === 690970003 ? '#4ade80' : + wo.status_code === 690970001 ? '#fbbf24' : + wo.status_code === 690970002 ? '#22d3ee' : '#5b5f73'; + return `
+
+ ${esc(wo.number)} + ${esc(wo.status)} +
+ ${wo.type ? `
${esc(wo.type)}
` : ''} + ${wo.created_on ? `
๐Ÿ“… ${esc(wo.created_on.slice(0,10))}
` : ''} + ${wo.notes ? `
${esc(wo.notes)}
` : ''} +
`; + }).join(''); + showModal(`Work Orders โ€” ${msfs.work_order_count} total`, items, 'Close', 'btn-outline'); + } + // โ”€โ”€ GPS Map Editor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let _gpsState = { map: null, marker: null, unlocked: false, asset: null }; @@ -4663,6 +5491,7 @@ if (!AppState.currentAssetId) return; const payload = { asset_id: AppState.currentAssetId, + user_id: AppState.currentUser?.id || null, latitude: AppState.gpsLat, longitude: AppState.gpsLng, accuracy: AppState.gpsAcc, @@ -4693,7 +5522,7 @@ } el.innerHTML = checkins.map(c => `
-
๐Ÿ• ${formatDate(c.created_at)}
+
๐Ÿ• ${formatDate(c.created_at)}${c.username ? ' ยท ๐Ÿ‘ค ' + esc(c.username) : ''}
${c.latitude ? '
๐Ÿ“ ' + Number(c.latitude).toFixed(5) + ', ' + Number(c.longitude).toFixed(5) + ' ยฑ' + Math.round(c.accuracy||0) + 'm
' : ''} ${c.notes ? '
๐Ÿ“ ' + esc(c.notes) + '
' : ''}