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:
@@ -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
@@ -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 & 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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user