Fix check-in user tracking

- 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
This commit is contained in:
2026-05-31 23:54:13 -04:00
parent 07539b683e
commit e325981b17
2 changed files with 1136 additions and 11 deletions
+304 -8
View File
@@ -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:
+832 -3
View File
@@ -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 @@
<button class="dn-item" data-tab="tabRoute" onclick="navFromDrawer('tabRoute')">
<span class="dn-icon"><svg class="gi" aria-hidden="true"><use href="#gi-map"/></svg></span> Route
</button>
<button class="dn-item" data-tab="tabWorkOrders" onclick="navFromDrawer('tabWorkOrders')">
<span class="dn-icon"><svg class="gi" aria-hidden="true"><use href="#gi-wrench"/></svg></span> Work Orders
</button>
<button class="dn-item" id="logoutBtn" onclick="doLogout()" style="display:none;">
<span class="dn-icon"><svg class="gi" aria-hidden="true"><use href="#gi-exit"/></svg></span> Logout
</button>
@@ -1652,6 +1759,91 @@
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
TAB: WORK ORDERS
═══════════════════════════════════════════════════════════════════════ -->
<div id="tabWorkOrders" class="tab-panel">
<div id="woListView">
<!-- Search bar -->
<div class="search-bar">
<input id="woSearch" type="text" class="input-field" placeholder="Search WO#, account, city..." oninput="loadWorkOrders()">
<button class="clear-btn" id="woClearSearch" onclick="clearWOSearch()" title="Clear search" style="display:none;"><svg class="gi" aria-hidden="true"><use href="#gi-cross"/></svg></button>
</div>
<!-- Date range chips -->
<div style="display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap;">
<span class="wo-date-chip" data-range="today" onclick="setWODateRange('today')">Today</span>
<span class="wo-date-chip" data-range="week" onclick="setWODateRange('week')">This Week</span>
<span class="wo-date-chip" data-range="month" onclick="setWODateRange('month')">This Month</span>
<span class="wo-date-chip" data-range="custom" onclick="setWODateRange('custom')">Custom</span>
<span class="wo-date-chip wo-all-chip" data-range="all" onclick="setWODateRange('all')">🌐 All</span>
</div>
<!-- Custom date picker (hidden by default) -->
<div id="woCustomDateRow" style="display:none;gap:8px;margin-bottom:8px;align-items:center;flex-wrap:wrap;">
<label style="font-size:12px;color:var(--text2);">From:</label>
<input id="woDateFrom" type="date" class="input-field" style="flex:1;min-width:120px;" onchange="onWODateCustomChange()">
<label style="font-size:12px;color:var(--text2);">To:</label>
<input id="woDateTo" type="date" class="input-field" style="flex:1;min-width:120px;" onchange="onWODateCustomChange()">
</div>
<!-- Filter toggle -->
<div style="display:flex;gap:6px;align-items:flex-start;margin-bottom:8px;flex-wrap:wrap;">
<button class="filter-toggle" id="woFilterToggle" onclick="toggleWOFilterPanel()" title="Toggle filter panel">
<svg class="gi" aria-hidden="true"><use href="#gi-target"/></svg> Filters <span id="woFilterCount" class="ft-count" style="display:none;">0</span>
</button>
</div>
<!-- Filter panel (collapsible) -->
<div class="filter-panel" id="woFilterPanel">
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Status</div>
<select id="woFilterStatus" class="input-field" onchange="onWOFilterChange()">
<option value="">All statuses</option>
</select>
</div>
<div style="flex:1">
<div class="fp-label">Technician</div>
<select id="woFilterTech" class="input-field" onchange="onWOFilterChange()">
<option value="">All technicians</option>
</select>
</div>
</div>
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Priority</div>
<select id="woFilterPriority" class="input-field" onchange="onWOFilterChange()">
<option value="">All priorities</option>
<option value="Urgent">🔴 Urgent</option>
<option value="High">🟠 High</option>
<option value="Medium">🟡 Medium</option>
<option value="Low">🟢 Low</option>
</select>
</div>
<div style="flex:1">
<div class="fp-label">Work Type</div>
<select id="woFilterType" class="input-field" onchange="onWOFilterChange()">
<option value="">All types</option>
<option value="Install">🔧 Install</option>
<option value="Repair">🔧 Repair</option>
<option value="PM">📋 PM</option>
<option value="Emergency">🚨 Emergency</option>
</select>
</div>
</div>
<div class="fp-actions">
<button class="btn btn-outline btn-xs" onclick="clearAllWOFilters()" style="flex:1;"><svg class="gi" aria-hidden="true"><use href="#gi-cross"/></svg> Clear all filters</button>
<button class="btn btn-outline btn-xs" onclick="toggleWOFilterPanel()" style="flex:1;"><svg class="gi" aria-hidden="true"><use href="#gi-check"/></svg> Done</button>
</div>
</div>
<div class="active-filters" id="woActiveFilters"></div>
<div class="result-count" id="woResultCount"></div>
<div id="woList"></div>
<div id="woPagination" class="pagination-bar" style="display:none;margin-top:8px;"></div>
</div>
</div>
<!-- Asset Detail Modal -->
<div id="rpAssetOverlay" class="asset-overlay" onclick="rpCloseAssetModal()"></div>
<div id="rpAssetModal" class="asset-modal">
@@ -1678,6 +1870,9 @@
<button class="tab-btn" data-tab="tabNavigate" onclick="switchTab('tabNavigate')" title="Navigate to an asset">
<span class="tab-icon"><svg class="gi" aria-hidden="true"><use href="#gi-compass"/></svg></span> Nav
</button>
<button class="tab-btn" data-tab="tabWorkOrders" onclick="switchTab('tabWorkOrders')" title="View work orders">
<span class="tab-icon">🔧</span> Orders
</button>
<button class="tab-btn" data-tab="tabRoute" onclick="switchTab('tabRoute')" title="Plan a route">
<span class="tab-icon"><svg class="gi" aria-hidden="true"><use href="#gi-map"/></svg></span> Route
</button>
@@ -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 = '<span class="spinner"></span> 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 = '<option value="">All statuses</option>' +
statuses.map(function(s) { return '<option value="' + s.code + '">' + s.label + '</option>'; }).join('');
} catch(e) {}
// Load technicians
try {
var techs = await api('/api/workorders/technicians');
var techSel = document.getElementById('woFilterTech');
techSel.innerHTML = '<option value="">All technicians</option>' +
(techs.technicians || []).map(function(t) { return '<option value="' + esc(t) + '">' + esc(t) + '</option>'; }).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 '<span class="filter-tag">' + esc(t.label) + ' <span class="ft-remove" onclick="removeWOFilter(\'' + t.key + '\')"></span></span>';
}).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 = '<div class="loading-spinner"><span class="spinner"></span> Loading work orders...</div>';
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 = '<div class="empty-state"><div class="es-icon">⚠️</div><strong>Failed to load work orders</strong><br>Route planner backend may be unavailable</div>';
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 = '<div class="empty-state"><div class="es-icon">📋</div>No work orders found — try adjusting filters or date range</div>';
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 '<div class="wo-item" onclick="showWODetail(\'' + esc(w.id) + '\')">' +
'<div class="wo-status-dot" style="background:' + color + '"></div>' +
'<div class="wo-info">' +
'<div class="wo-name">' + icon + ' ' + esc(w.name) + '</div>' +
'<div class="wo-status-badge" style="color:' + color + '">' + esc(statusLabel) + '</div>' +
(accountName ? '<div class="wo-account">' + esc(accountName) + '</div>' : '') +
'<div class="wo-meta">' +
(dateStr ? '<span>📅 ' + esc(dateStr) + '</span>' : '') +
(w.territory ? '<span>🏢 ' + esc(w.territory) + '</span>' : '') +
(techStr ? '<span>' + esc(techStr) + '</span>' : '') +
(cityStr ? '<span>📍 ' + esc(cityStr) + '</span>' : '') +
'</div>' +
'<div class="wo-meta" style="color:var(--text2);">' +
(priorityStr || typeStr ? esc(priorityStr + (priorityStr && typeStr ? ' · ' : '') + typeStr) : '') +
'</div>' +
'</div>' +
'<div class="wo-arrow"></div>' +
'</div>';
}).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 =
'<button class="btn btn-outline btn-xs" onclick="woPrevPage()" ' + (currentPage <= 1 ? 'disabled' : '') + '>← Prev</button>' +
'<span style="flex:1;text-align:center;font-size:12px;color:var(--text2);">Page ' + currentPage + ' of ' + totalPages + '</span>' +
'<button class="btn btn-outline btn-xs" onclick="woNextPage()" ' + (currentPage >= totalPages ? 'disabled' : '') + '>Next →</button>';
}
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',
'<div class="loading-spinner"><span class="spinner"></span> Loading...</div>',
'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 = '<div class="empty-state">Work order not found</div>';
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 '<div class="wo-det-row"><span class="wo-det-label">' + label + '</span><span class="wo-det-value">' + value + '</span></div>';
}
function fullRow(label, value) {
return '<div class="wo-det-row wo-det-row-full"><span class="wo-det-label">' + label + '</span><span class="wo-det-value">' + value + '</span></div>';
}
function sep() { return '<div class="wo-det-sep"></div>'; }
function tlRow(icon, label, dateVal, isDone) {
var dotColor = dateVal ? (isDone !== false ? 'var(--green)' : 'var(--amber)') : 'var(--text3)';
var check = dateVal ? (isDone !== false ? ' ✓' : '') : '';
return '<div class="wo-det-tl-row"><span class="wo-det-tl-dot" style="background:' + dotColor + '"></span><span class="wo-det-tl-label">' + icon + ' ' + label + '</span><span class="wo-det-tl-time">' + (dateVal ? esc(fmtDate(dateVal)) + check : '<span class="text-muted"></span>') + '</span></div>';
}
// ── Status flags ──
var flags = [];
if (wo.awaiting_parts) flags.push('<span class="wo-det-flag warn">🔧 Awaiting Parts</span>');
if (wo.customer_hold) flags.push('<span class="wo-det-flag warn">⏸ Customer Hold</span>');
var flagsHtml = flags.length ? '<div style="display:flex;gap:6px;flex-wrap:wrap;padding:6px 0;">' + flags.join('') + '</div>' : '';
var html = '<div class="wo-detail-container">';
// Header: WO# + status badge + substatus
html += '<div class="wo-det-header">';
html += '<div class="wo-det-name">' + statusIcon + ' ' + esc(wo.name || 'N/A') + '</div>';
html += '<div class="wo-det-status-badge" style="background:' + statusColor + '22;color:' + statusColor + ';border:1px solid ' + statusColor + '44;">' + esc(wo.status || '') + '</div>';
html += '</div>';
if (wo.substatus) {
html += '<div style="text-align:center;font-size:12px;color:var(--text2);margin-bottom:8px;">' + esc(wo.substatus) + '</div>';
}
html += flagsHtml;
// Section 1: Account & Assignment
html += sep();
html += '<div class="wo-det-section-title">Account &amp; Assignment</div>';
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 += '<div class="wo-det-section-title">Work Details</div>';
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 += '<div class="wo-det-section-title">Timeline</div>';
html += '<div class="wo-det-timeline">';
// 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 += '</div>';
// Closed by
if (wo.closed_by) {
html += '<div style="text-align:right;font-size:11px;color:var(--text2);padding:4px 0;">Closed by: ' + esc(wo.closed_by) + '</div>';
}
// Section 4: Time Windows
html += sep();
html += '<div class="wo-det-section-title">Scheduled Windows</div>';
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 += '<div class="wo-det-section-title">Duration</div>';
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 += '<div class="wo-det-section-title">Location &amp; Contact</div>';
if (addrStr) html += row('Address', esc(addrStr));
if (wo.country) html += row('Country', esc(wo.country));
// Section 7: Notes
html += sep();
html += '<div class="wo-det-section-title">Notes</div>';
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', '<div class="wo-det-instr">' + esc(wo.instructions) + '</div>');
if (wo.additional_notes) html += fullRow('Additional notes', esc(wo.additional_notes));
// Section 8: Sign-off
if (wo.signed_by) {
html += sep();
html += '<div class="wo-det-section-title">Sign-off</div>';
html += row('Signed by', esc(wo.signed_by));
}
html += '</div>';
document.querySelector('#modalBody').innerHTML = html;
}).catch(function(e) {
document.querySelector('#modalBody').innerHTML = '<div class="empty-state">Failed to load details: ' + esc(e.message) + '</div>';
});
}
// ═══════════════════════════════════════════════════════════════════════
// END WORK ORDERS TAB
// ═══════════════════════════════════════════════════════════════════════
// _loadAssets with all filters
var __loadAssets_orig = null;
_loadAssets = async function() {
@@ -3861,6 +4663,7 @@
document.getElementById('detailFields').innerHTML = '<div class="loading-spinner"><span class="spinner"></span> Loading asset details...</div>';
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 = `<span style="margin-right:8px">${ci.icon}</span>${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 ? `<a href="#" onclick="showWorkOrders(event, this, '${esc(m.equipment_id || '')}')" style="color:var(--accent2);text-decoration:underline;">${m.work_order_count} work orders</a>` : null),
].filter(Boolean).join('');
if (msfsRows) {
msfsFields.innerHTML = msfsRows;
@@ -4099,6 +4902,31 @@
return `<div class="detail-field"><div class="df-label">${esc(label)}</div><div class="df-value"${cls}>${value}</div></div>`;
}
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 `<div style="padding:8px 0;border-bottom:1px solid var(--border);">
<div style="display:flex;justify-content:space-between;align-items:center;">
<strong style="font-size:13px;">${esc(wo.number)}</strong>
<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:${statusColor}22;color:${statusColor};">${esc(wo.status)}</span>
</div>
${wo.type ? `<div style="font-size:12px;color:var(--text2);margin-top:2px;">${esc(wo.type)}</div>` : ''}
${wo.created_on ? `<div style="font-size:11px;color:var(--text3);margin-top:2px;">📅 ${esc(wo.created_on.slice(0,10))}</div>` : ''}
${wo.notes ? `<div style="font-size:11px;color:var(--text3);margin-top:4px;line-height:1.4;max-height:60px;overflow-y:auto;">${esc(wo.notes)}</div>` : ''}
</div>`;
}).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 => `
<div class="checkin-item">
<div class="ci-time">🕐 ${formatDate(c.created_at)}</div>
<div class="ci-time">🕐 ${formatDate(c.created_at)}${c.username ? ' · 👤 ' + esc(c.username) : ''}</div>
${c.latitude ? '<div class="ci-coords">📍 ' + Number(c.latitude).toFixed(5) + ', ' + Number(c.longitude).toFixed(5) + ' ±' + Math.round(c.accuracy||0) + 'm</div>' : ''}
${c.notes ? '<div class="ci-notes">📝 ' + esc(c.notes) + '</div>' : ''}
</div>