diff --git a/AGENTS.md b/AGENTS.md index 270f375..c94df25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,7 @@ number. Covered by `tests/test_whatsapp_demo_number.py`. | GET | `/dashboard/cs` | Client | CS dashboard | | GET | `/dashboard/fm` | Client | FM dashboard | | GET | `/dashboard/ceo` | Client | CEO dashboard | +| GET | `/dashboard/tech-performance` | Client | Technician performance report (FM + CEO nav) | | GET | `/tickets` | Client | All Issues filterable table | | GET | `/tickets/new` | Client | Create Issue form | | GET | `/tickets/{id}` | Client | Issue detail with timeline | @@ -96,6 +97,20 @@ number. Covered by `tests/test_whatsapp_demo_number.py`. Frontend: Alpine.js + Tailwind CSS vendored same-origin (no CDN) — see "Frontend assets" below. Auth state in localStorage. Role-based nav routing in `base.html`. +### Technician performance (Wahab request) +`GET /api/tickets/tech-performance` (Bearer) aggregates per-technician workload/outcomes by +**real name** using only existing ticket columns (`assigned_to`/`status`/`created_at`/`closed_at` +→ `users.full_name`) — no schema change. Aggregation lives in +`app/services/ticket.py::get_technician_performance`; bucket map `_TECH_STATUS_BUCKETS` defines +`completed` (Completed/Closed), `in_progress`, `escalated`, `cancelled`, and `pending` (remainder), +so the buckets always sum to `total_assigned`. `completion_rate = completed/total_assigned`; +`avg_resolution_hours` averages `created_at → closed_at` only where `closed_at` is set, with +`resolved_without_timestamps` counting finished tasks that lack one. Rows key on user id (same-name +techs stay separate), sorted completed desc → workload → name. UI: +`app/templates/dashboard/tech-performance.html`, linked from FM + CEO nav and the FM +"Technician Workload" card — that card reads `Ticket.assigned_technician_name`, never an id +placeholder. Regression: `tests/test_tech_performance.py`. + ### Frontend assets (vendored, LAN-safe) - Alpine.js 3.17.2 + Tailwind Play 3.4.17 are committed under `app/static/vendor/` and served at `/static/vendor/…` (mounted in `app/main.py`, versioned diff --git a/app/routers/pages.py b/app/routers/pages.py index ef1da73..bba4d0b 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -33,6 +33,14 @@ async def ceo_dashboard(request: Request): return templates.TemplateResponse(request, "dashboard/ceo.html") +@router.get("/dashboard/tech-performance", response_class=HTMLResponse) +async def tech_performance_dashboard(request: Request): + """Technician performance report (FM + CEO). Data comes from + ``GET /api/tickets/tech-performance``; the page itself follows the same + client-side auth pattern as the other dashboards.""" + return templates.TemplateResponse(request, "dashboard/tech-performance.html") + + @router.get("/tickets", response_class=HTMLResponse) async def ticket_list(request: Request): return templates.TemplateResponse(request, "tickets/list.html") diff --git a/app/routers/tickets.py b/app/routers/tickets.py index 9a60eee..4d9f818 100644 --- a/app/routers/tickets.py +++ b/app/routers/tickets.py @@ -23,6 +23,7 @@ from app.schemas.ticket import ( CategoryOut, CategoryTreeOut, SLAStatusOut, + TechnicianPerformanceReportOut, TicketBrief, TicketCreate, TicketListResponse, @@ -248,6 +249,22 @@ async def list_tickets( return TicketListResponse(items=items, total=total, page=page, page_size=effective_page_size) +@router.get("/tech-performance", response_model=TechnicianPerformanceReportOut) +async def technician_performance( + db: Annotated[AsyncSession, Depends(get_db)], + _current_user: Annotated[User, Depends(get_current_user)], +) -> dict: + """Per-technician performance aggregate for the FM/CEO dashboards. + + Technician names come from ``users.full_name`` (never ``Tech #``); + counts and resolution times are derived from existing ticket columns, so no + schema change is involved. Registered before ``/{ticket_id}`` so the literal + path is not swallowed by the int-typed ticket-id route. Requires a bearer + token, matching every other endpoint that powers a logged-in dashboard. + """ + return await ticket_service.get_technician_performance(db) + + @router.get("/{ticket_id}/transitions") async def get_ticket_transitions( ticket_id: int, diff --git a/app/schemas/ticket.py b/app/schemas/ticket.py index 86172ba..5435b7b 100644 --- a/app/schemas/ticket.py +++ b/app/schemas/ticket.py @@ -129,6 +129,60 @@ class TicketListResponse(BaseModel): page_size: int +# ── Technician performance ─────────────────────────────────────────── +class TechnicianPerformanceOut(BaseModel): + """Per-technician workload and outcome aggregate (by real name). + + ``open_tickets`` is ``total_assigned - completed - cancelled``; + ``pending`` is the remainder bucket (statuses such as New/Logged/Triage/ + Assigned plus verification stages) and keeps the named buckets summing to + ``total_assigned``. ``avg_resolution_hours`` averages ``created_at -> + closed_at`` and is ``null`` when no finished task carries a timestamp — + ``resolved_without_timestamps`` then says how many those are. + """ + technician_id: int + name: str + total_assigned: int + completed: int + closed: int + in_progress: int + escalated: int + cancelled: int + pending: int + open_tickets: int + completion_rate: float + avg_resolution_hours: float | None = None + resolved_with_timestamps: int + resolved_without_timestamps: int + status_breakdown: dict[str, int] = {} + + +class TechnicianPerformanceTotalsOut(BaseModel): + """Fleet-wide roll-up of :class:`TechnicianPerformanceOut`.""" + technicians: int + total_tickets: int + total_assigned: int + completed: int + closed: int + in_progress: int + escalated: int + cancelled: int + pending: int + open_tickets: int + completion_rate: float + avg_resolution_hours: float | None = None + resolved_with_timestamps: int + resolved_without_timestamps: int + unassigned_tickets: int + + +class TechnicianPerformanceReportOut(BaseModel): + """Response for ``GET /api/tickets/tech-performance``.""" + generated_at: datetime + technicians: list[TechnicianPerformanceOut] + totals: TechnicianPerformanceTotalsOut + + # ── SLA ────────────────────────────────────────────────────────────── class SLAStatusOut(BaseModel): priority: str | None = None diff --git a/app/services/ticket.py b/app/services/ticket.py index 00fd6d1..b49332a 100644 --- a/app/services/ticket.py +++ b/app/services/ticket.py @@ -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 #"``). + + 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). diff --git a/app/templates/base.html b/app/templates/base.html index 7feac9c..092864a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -89,6 +89,7 @@