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: