feat(dashboard): technician performance report + real technician names
Wahab customer request (relay #748 v3 next-wave). 3a. Fix 'Tech #N' display: - FM dashboard's "Technician Workload" card was built from `Tech #${t.assigned_to}`; it now reads Ticket.assigned_technician_name (falling back to 'Unassigned', matching /tickets). - Template sweep confirms no other `Tech #` placeholder exists. 3b. Technician performance dashboard: - New bearer-gated GET /api/tickets/tech-performance aggregating existing ticket columns only (no schema change): per-technician total assigned, completed, in progress, escalated, cancelled, pending, open tasks, completion rate, and created_at -> closed_at resolution time with explicit counts for finished tasks lacking a timestamp. Rows key on user id so same-name technicians stay separate; labels are real names. Fleet totals and an unassigned-ticket count are included. - New /dashboard/tech-performance page (FM + CEO nav and links) with sortable table, completion bars and empty states; same-origin vendored assets only. - Bucket map, service, schemas and tests in app/services/ticket.py, app/schemas/ticket.py, tests/test_tech_performance.py. Tests: 111 passed (101 existing + 10 new).
This commit is contained in:
@@ -345,6 +345,169 @@ async def update_ticket(
|
||||
return ticket
|
||||
|
||||
|
||||
# ── Technician performance ───────────────────────────────────────────
|
||||
# Buckets for the technician-performance dashboard (Wahab request). The map is
|
||||
# intentionally not exhaustive: any status missing from it is counted as
|
||||
# "pending" so the named buckets always sum to ``total_assigned`` and no
|
||||
# assigned ticket is silently dropped from the report.
|
||||
_TECH_STATUS_BUCKETS: dict[str, str] = {
|
||||
"Completed": "completed",
|
||||
"Closed": "completed",
|
||||
"Accepted": "in_progress",
|
||||
"Travelling": "in_progress",
|
||||
"On Site": "in_progress",
|
||||
"In Progress": "in_progress",
|
||||
"Waiting Parts": "in_progress",
|
||||
"Escalated": "escalated",
|
||||
"Cancelled": "cancelled",
|
||||
}
|
||||
|
||||
|
||||
def _bucket_for_status(status: str) -> str:
|
||||
"""Map a ticket status onto its technician-performance bucket."""
|
||||
return _TECH_STATUS_BUCKETS.get(status, "pending")
|
||||
|
||||
|
||||
def _empty_tech_entry(technician_id: int, name: str) -> dict[str, Any]:
|
||||
return {
|
||||
"technician_id": technician_id,
|
||||
"name": name,
|
||||
"total_assigned": 0,
|
||||
"completed": 0,
|
||||
"closed": 0,
|
||||
"in_progress": 0,
|
||||
"escalated": 0,
|
||||
"cancelled": 0,
|
||||
"pending": 0,
|
||||
"open_tickets": 0,
|
||||
"completion_rate": 0.0,
|
||||
"avg_resolution_hours": None,
|
||||
"resolved_with_timestamps": 0,
|
||||
"resolved_without_timestamps": 0,
|
||||
"status_breakdown": {},
|
||||
"_hours_sum": 0.0,
|
||||
}
|
||||
|
||||
|
||||
async def get_technician_performance(db: AsyncSession) -> dict[str, Any]:
|
||||
"""Aggregate per-technician workload/outcome stats for the dashboard.
|
||||
|
||||
Derived entirely from existing ``tickets`` columns (``assigned_to``,
|
||||
``status``, ``created_at``, ``closed_at``) joined to ``users.full_name`` —
|
||||
no schema change. Technician identity is keyed on the user id so two people
|
||||
sharing a display name stay separate rows, while the reported label is the
|
||||
real name (never ``"Tech #<id>"``).
|
||||
|
||||
Completion rate is ``completed / total_assigned`` (Completed + Closed count
|
||||
as completed). Resolution time averages ``created_at -> closed_at`` only for
|
||||
rows where ``closed_at`` is set; the number of finished tasks lacking that
|
||||
timestamp is reported separately so an absent average is never mistaken for
|
||||
missing work.
|
||||
"""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
Ticket.assigned_to,
|
||||
User.full_name,
|
||||
Ticket.status,
|
||||
Ticket.created_at,
|
||||
Ticket.closed_at,
|
||||
)
|
||||
.join(User, User.id == Ticket.assigned_to)
|
||||
.where(Ticket.assigned_to.is_not(None))
|
||||
)
|
||||
).all()
|
||||
|
||||
unassigned_result = await db.execute(
|
||||
select(func.count(Ticket.id)).where(Ticket.assigned_to.is_(None))
|
||||
)
|
||||
unassigned_tickets = unassigned_result.scalar() or 0
|
||||
|
||||
by_tech: dict[int, dict[str, Any]] = {}
|
||||
for assigned_to, full_name, status, created_at, closed_at in rows:
|
||||
entry = by_tech.get(assigned_to)
|
||||
if entry is None:
|
||||
entry = _empty_tech_entry(assigned_to, full_name)
|
||||
by_tech[assigned_to] = entry
|
||||
|
||||
entry["total_assigned"] += 1
|
||||
bucket = _bucket_for_status(status)
|
||||
if bucket == "completed":
|
||||
entry["completed"] += 1
|
||||
if status == "Closed":
|
||||
entry["closed"] += 1
|
||||
if closed_at is not None and created_at is not None:
|
||||
hours = (closed_at - created_at).total_seconds() / 3600
|
||||
entry["_hours_sum"] += hours
|
||||
entry["resolved_with_timestamps"] += 1
|
||||
else:
|
||||
entry["resolved_without_timestamps"] += 1
|
||||
else:
|
||||
entry[bucket] += 1
|
||||
|
||||
breakdown = entry["status_breakdown"]
|
||||
breakdown[status] = breakdown.get(status, 0) + 1
|
||||
|
||||
technicians: list[dict[str, Any]] = []
|
||||
total_assigned = total_completed = total_closed = 0
|
||||
total_in_progress = total_escalated = total_cancelled = total_pending = 0
|
||||
total_hours = 0.0
|
||||
total_with_timestamps = total_without_timestamps = 0
|
||||
|
||||
for entry in by_tech.values():
|
||||
assigned = entry["total_assigned"]
|
||||
entry["open_tickets"] = assigned - entry["completed"] - entry["cancelled"]
|
||||
entry["completion_rate"] = round(entry["completed"] / assigned * 100, 1) if assigned else 0.0
|
||||
if entry["resolved_with_timestamps"]:
|
||||
entry["avg_resolution_hours"] = round(
|
||||
entry["_hours_sum"] / entry["resolved_with_timestamps"], 1
|
||||
)
|
||||
entry["status_breakdown"] = dict(
|
||||
sorted(entry["status_breakdown"].items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
)
|
||||
|
||||
total_assigned += assigned
|
||||
total_completed += entry["completed"]
|
||||
total_closed += entry["closed"]
|
||||
total_in_progress += entry["in_progress"]
|
||||
total_escalated += entry["escalated"]
|
||||
total_cancelled += entry["cancelled"]
|
||||
total_pending += entry["pending"]
|
||||
total_hours += entry["_hours_sum"]
|
||||
total_with_timestamps += entry["resolved_with_timestamps"]
|
||||
total_without_timestamps += entry["resolved_without_timestamps"]
|
||||
|
||||
del entry["_hours_sum"]
|
||||
technicians.append(entry)
|
||||
|
||||
# Busiest/most productive first; ties broken by workload then real name.
|
||||
technicians.sort(key=lambda e: (-e["completed"], -e["total_assigned"], e["name"].lower()))
|
||||
|
||||
totals = {
|
||||
"technicians": len(technicians),
|
||||
"total_assigned": total_assigned,
|
||||
"completed": total_completed,
|
||||
"closed": total_closed,
|
||||
"in_progress": total_in_progress,
|
||||
"escalated": total_escalated,
|
||||
"cancelled": total_cancelled,
|
||||
"pending": total_pending,
|
||||
"open_tickets": total_assigned - total_completed - total_cancelled,
|
||||
"completion_rate": round(total_completed / total_assigned * 100, 1) if total_assigned else 0.0,
|
||||
"avg_resolution_hours": round(total_hours / total_with_timestamps, 1) if total_with_timestamps else None,
|
||||
"resolved_with_timestamps": total_with_timestamps,
|
||||
"resolved_without_timestamps": total_without_timestamps,
|
||||
"unassigned_tickets": unassigned_tickets,
|
||||
"total_tickets": total_assigned + unassigned_tickets,
|
||||
}
|
||||
|
||||
return {
|
||||
"generated_at": datetime.now(timezone.utc),
|
||||
"technicians": technicians,
|
||||
"totals": totals,
|
||||
}
|
||||
|
||||
|
||||
async def delete_ticket(db: AsyncSession, ticket_id: int) -> Ticket:
|
||||
"""Delete a ticket and all dependent rows (timeline, photos, escalations).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user