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