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 @@
Route
+
+ Work Orders
+
Logout
@@ -1652,6 +1759,91 @@
+
+
+
+
+
+
+
+
+
+
+
+ Today
+ This Week
+ This Month
+ Custom
+ 🌐 All
+
+
+
+
+ From:
+
+ To:
+
+
+
+
+
+
+ Filters 0
+
+
+
+
+
+
+
+
Status
+
+ All statuses
+
+
+
+
Technician
+
+ All technicians
+
+
+
+
+
+
Priority
+
+ All priorities
+ 🔴 Urgent
+ 🟠 High
+ 🟡 Medium
+ 🟢 Low
+
+
+
+
Work Type
+
+ All types
+ 🔧 Install
+ 🔧 Repair
+ 📋 PM
+ 🚨 Emergency
+
+
+
+
+ Clear all filters
+ Done
+
+
+
+
+
+
+
+
+
@@ -1678,6 +1870,9 @@
Nav
+
+ 🔧 Orders
+
Route
@@ -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 = '
All statuses ' +
+ statuses.map(function(s) { return '
' + s.label + ' '; }).join('');
+ } catch(e) {}
+
+ // Load technicians
+ try {
+ var techs = await api('/api/workorders/technicians');
+ var techSel = document.getElementById('woFilterTech');
+ techSel.innerHTML = '
All technicians ' +
+ (techs.technicians || []).map(function(t) { return '
' + esc(t) + ' '; }).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 ? '
' + esc(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 =
+ '
← Prev ' +
+ '
Page ' + currentPage + ' of ' + totalPages + ' ' +
+ '
= totalPages ? 'disabled' : '') + '>Next → ';
+ }
+
+ 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 += '';
+ 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 `
`;
}
+ 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) + '
' : ''}