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:
@@ -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")
|
||||
|
||||
@@ -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 #<id>``);
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
+12
-1
@@ -89,6 +89,7 @@
|
||||
<template x-if="isFM">
|
||||
<div class="hidden md:flex space-x-1 ml-6">
|
||||
<a href="/dashboard/fm" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/fm' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
|
||||
<a href="/dashboard/tech-performance" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/tech-performance' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Tech Performance</a>
|
||||
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">All Issues</a>
|
||||
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-denya-800/50 transition-colors">Create Issue</a>
|
||||
</div>
|
||||
@@ -96,6 +97,7 @@
|
||||
<template x-if="isExecutive">
|
||||
<div class="hidden md:flex space-x-1 ml-6">
|
||||
<a href="/dashboard/ceo" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
|
||||
<a href="/dashboard/tech-performance" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/tech-performance' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Tech Performance</a>
|
||||
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Issues</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -113,16 +115,25 @@
|
||||
|
||||
<!-- Mobile Nav -->
|
||||
<div class="md:hidden border-b bg-[#0d2b18] border-denya-800" x-show="isLoggedIn" x-cloak>
|
||||
<template x-if="isCS || isFM">
|
||||
<template x-if="isCS">
|
||||
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
|
||||
<a href="/dashboard/cs" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/cs' ? 'bg-denya-800 text-gold' : 'text-gray-300'">Dashboard</a>
|
||||
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300'">Issues</a>
|
||||
<a href="/tickets/new" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">New Issue</a>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="isFM">
|
||||
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
|
||||
<a href="/dashboard/fm" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/fm' ? 'bg-denya-800 text-gold' : 'text-gray-300'">Dashboard</a>
|
||||
<a href="/dashboard/tech-performance" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/tech-performance' ? 'bg-denya-800 text-gold' : 'text-gray-300'">Tech Performance</a>
|
||||
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300'">Issues</a>
|
||||
<a href="/tickets/new" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">New Issue</a>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="isExecutive">
|
||||
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
|
||||
<a href="/dashboard/ceo" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-800 text-gold' : 'text-gray-300'">CEO Dashboard</a>
|
||||
<a href="/dashboard/tech-performance" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/tech-performance' ? 'bg-denya-800 text-gold' : 'text-gray-300'">Tech Performance</a>
|
||||
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">Issues</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="ceoDashboard()" x-init="init()">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Executive Dashboard</h1>
|
||||
<p class="text-gray-500 mt-1">Read-only strategic overview</p>
|
||||
<div class="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Executive Dashboard</h1>
|
||||
<p class="text-gray-500 mt-1">Read-only strategic overview</p>
|
||||
</div>
|
||||
<a href="/dashboard/tech-performance" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700">Technician Performance →</a>
|
||||
</div>
|
||||
|
||||
<!-- Executive KPI Cards -->
|
||||
|
||||
@@ -80,7 +80,10 @@
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Tech Workload -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Technician Workload</h3>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Technician Workload</h3>
|
||||
<a href="/dashboard/tech-performance" class="text-xs text-denya-600 hover:text-denya-800">Performance →</a>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<template x-for="tech in techWorkload" :key="tech.id">
|
||||
<div class="flex items-center justify-between p-2 hover:bg-gray-50 rounded">
|
||||
@@ -251,11 +254,13 @@
|
||||
this.kpi.westJobs = active.filter(t => t.unit_id && unitMap[t.unit_id] === 'West').length;
|
||||
} catch (e) { console.error('Property stats error', e); }
|
||||
|
||||
// Tech workload (simulated from assigned_to counts)
|
||||
// Tech workload — real technician names (Ticket.assigned_technician_name),
|
||||
// never an id placeholder; keyed on user id so two people sharing a
|
||||
// display name stay separate rows.
|
||||
const techMap = {};
|
||||
active.forEach(t => {
|
||||
if (t.assigned_to) {
|
||||
if (!techMap[t.assigned_to]) techMap[t.assigned_to] = { id: t.assigned_to, name: `Tech #${t.assigned_to}`, activeJobs: 0 };
|
||||
if (!techMap[t.assigned_to]) techMap[t.assigned_to] = { id: t.assigned_to, name: t.assigned_technician_name || 'Unassigned', activeJobs: 0 };
|
||||
techMap[t.assigned_to].activeJobs++;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="techPerformance()" x-init="init()">
|
||||
<div class="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Technician Performance</h1>
|
||||
<p class="text-gray-500 mt-1">Tasks completed per technician, by name — FM & CEO view</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<a x-show="isFM" href="/dashboard/fm" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700">FM Dashboard</a>
|
||||
<a x-show="isExecutive" href="/dashboard/ceo" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700">CEO Dashboard</a>
|
||||
<button @click="loadData()" class="px-3 py-2 text-sm bg-denya-700 text-white rounded-lg hover:bg-denya-800">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI Cards -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-8">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Technicians</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="totals.technicians || 0"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Assigned</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="totals.total_assigned || 0"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Completed</p>
|
||||
<p class="text-3xl font-bold text-green-600 mt-1" x-text="totals.completed || 0"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Completion Rate</p>
|
||||
<p class="text-3xl font-bold mt-1" :class="rateClass(totals.completion_rate)" x-text="formatRate(totals.completion_rate)"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Avg Resolution</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="formatHours(totals.avg_resolution_hours)"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Open Tasks</p>
|
||||
<p class="text-3xl font-bold text-orange-600 mt-1" x-text="totals.open_tickets || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status strip -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-sky-700">In Progress</span>
|
||||
<span class="text-2xl font-bold text-sky-700" x-text="totals.in_progress || 0"></span>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-red-700">Escalated</span>
|
||||
<span class="text-2xl font-bold text-red-700" x-text="totals.escalated || 0"></span>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-600">Pending / Unstarted</span>
|
||||
<span class="text-2xl font-bold text-gray-700" x-text="totals.pending || 0"></span>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-500">Cancelled</span>
|
||||
<span class="text-2xl font-bold text-gray-500" x-text="totals.cancelled || 0"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div x-show="error" class="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
|
||||
<span class="font-semibold">Could not load technician performance.</span>
|
||||
<span x-text="error"></span>
|
||||
</div>
|
||||
|
||||
<!-- Per-technician table -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<div class="px-5 py-4 border-b border-gray-200 flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">By Technician</h3>
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<label for="tech-sort" class="text-gray-500">Sort by</label>
|
||||
<select id="tech-sort" x-model="sortKey" class="border border-gray-300 rounded-lg px-2 py-1.5 text-sm text-gray-700 bg-white">
|
||||
<option value="completed">Completed</option>
|
||||
<option value="total_assigned">Workload (assigned)</option>
|
||||
<option value="completion_rate">Completion rate</option>
|
||||
<option value="avg_resolution_hours">Avg resolution</option>
|
||||
<option value="name">Name</option>
|
||||
</select>
|
||||
<button @click="dir = dir === 'desc' ? 'asc' : 'desc'" class="px-2 py-1.5 text-xs border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-600" x-text="dir === 'desc' ? 'Desc ↓' : 'Asc ↑'"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto" x-show="technicians.length">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th class="px-5 py-3 text-left">Technician</th>
|
||||
<th class="px-4 py-3 text-right">Assigned</th>
|
||||
<th class="px-4 py-3 text-right">Completed</th>
|
||||
<th class="px-4 py-3 text-right">In Progress</th>
|
||||
<th class="px-4 py-3 text-right">Escalated</th>
|
||||
<th class="px-4 py-3 text-right">Pending</th>
|
||||
<th class="px-4 py-3 text-right">Cancelled</th>
|
||||
<th class="px-5 py-3 text-left min-w-[160px]">Completion Rate</th>
|
||||
<th class="px-4 py-3 text-right">Avg Resolution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<template x-for="tech in sortedTechnicians" :key="tech.technician_id">
|
||||
<tr class="hover:bg-gray-50 transition" :title="breakdownTitle(tech)">
|
||||
<td class="px-5 py-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-denya-100 rounded-full flex items-center justify-center text-sm font-medium text-denya-700" x-text="initial(tech.name)"></div>
|
||||
<span class="font-medium text-gray-800" x-text="tech.name"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right font-medium text-gray-700" x-text="tech.total_assigned"></td>
|
||||
<td class="px-4 py-3 text-right font-semibold text-green-700" x-text="tech.completed"></td>
|
||||
<td class="px-4 py-3 text-right text-sky-700" x-text="tech.in_progress"></td>
|
||||
<td class="px-4 py-3 text-right" :class="tech.escalated ? 'text-red-600 font-medium' : 'text-gray-400'" x-text="tech.escalated"></td>
|
||||
<td class="px-4 py-3 text-right text-gray-600" x-text="tech.pending"></td>
|
||||
<td class="px-4 py-3 text-right" :class="tech.cancelled ? 'text-gray-500' : 'text-gray-300'" x-text="tech.cancelled"></td>
|
||||
<td class="px-5 py-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex-1 bg-gray-100 rounded-full h-2.5 overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all" :class="barClass(tech.completion_rate)" :style="'width: ' + Math.min(tech.completion_rate, 100) + '%'"></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-gray-600 w-12 text-right" x-text="formatRate(tech.completion_rate)"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-gray-700">
|
||||
<span x-text="formatHours(tech.avg_resolution_hours)"></span>
|
||||
<span x-show="tech.resolved_without_timestamps > 0" class="block text-[11px] text-gray-400"
|
||||
x-text="tech.resolved_without_timestamps + ' task' + (tech.resolved_without_timestamps === 1 ? '' : 's') + ' without timestamps'"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div x-show="!technicians.length && !error" class="px-5 py-16 text-center">
|
||||
<p class="text-gray-500 font-medium">No technician assignments yet</p>
|
||||
<p class="text-gray-400 text-sm mt-1">Once tickets are assigned to a technician they will appear here with their completion rate and resolution time.</p>
|
||||
</div>
|
||||
|
||||
<div x-show="technicians.length" class="px-5 py-3 border-t border-gray-100 text-xs text-gray-400 flex flex-wrap items-center justify-between gap-2">
|
||||
<span>Completion rate = completed (Completed + Closed) ÷ assigned. Avg resolution spans created → closed where the close timestamp exists.</span>
|
||||
<span><span x-text="totals.unassigned_tickets || 0"></span> ticket(s) have no technician assigned and are excluded from the rows above.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function techPerformance() {
|
||||
return {
|
||||
technicians: [],
|
||||
totals: {},
|
||||
sortKey: 'completed',
|
||||
dir: 'desc',
|
||||
error: '',
|
||||
|
||||
get isFM() { return app().isFM },
|
||||
get isExecutive() { return app().isExecutive },
|
||||
|
||||
get sortedTechnicians() {
|
||||
const list = [...this.technicians];
|
||||
const key = this.sortKey;
|
||||
const flip = this.dir === 'asc' ? 1 : -1;
|
||||
list.sort((a, b) => {
|
||||
if (key === 'name') {
|
||||
return flip * a.name.localeCompare(b.name);
|
||||
}
|
||||
let av = a[key];
|
||||
let bv = b[key];
|
||||
// Missing resolution times sort last in either direction.
|
||||
if (key === 'avg_resolution_hours') {
|
||||
if (av === null || av === undefined) return 1;
|
||||
if (bv === null || bv === undefined) return -1;
|
||||
}
|
||||
av = av || 0;
|
||||
bv = bv || 0;
|
||||
if (av === bv) return a.name.localeCompare(b.name);
|
||||
return flip * (av - bv);
|
||||
});
|
||||
return list;
|
||||
},
|
||||
|
||||
async init() {
|
||||
await this.loadData();
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
this.error = '';
|
||||
try {
|
||||
const data = await app().apiGet('/api/tickets/tech-performance');
|
||||
this.technicians = data?.technicians || [];
|
||||
this.totals = data?.totals || {};
|
||||
} catch (e) {
|
||||
this.error = e.message || 'Unknown error';
|
||||
}
|
||||
},
|
||||
|
||||
formatRate(rate) {
|
||||
if (rate === null || rate === undefined) return '—';
|
||||
return `${rate}%`;
|
||||
},
|
||||
|
||||
formatHours(hours) {
|
||||
if (hours === null || hours === undefined) return '—';
|
||||
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
||||
return `${hours}h`;
|
||||
},
|
||||
|
||||
initial(name) {
|
||||
return (name || '?').charAt(0).toUpperCase();
|
||||
},
|
||||
|
||||
rateClass(rate) {
|
||||
if (!rate) return 'text-gray-400';
|
||||
if (rate >= 70) return 'text-green-600';
|
||||
if (rate >= 40) return 'text-yellow-600';
|
||||
return 'text-red-600';
|
||||
},
|
||||
|
||||
barClass(rate) {
|
||||
if (rate >= 70) return 'bg-green-500';
|
||||
if (rate >= 40) return 'bg-yellow-500';
|
||||
return 'bg-red-500';
|
||||
},
|
||||
|
||||
breakdownTitle(tech) {
|
||||
const parts = Object.entries(tech.status_breakdown || {}).map(([s, n]) => `${s}: ${n}`);
|
||||
return parts.length ? parts.join(' · ') : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user