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).
235 lines
13 KiB
HTML
235 lines
13 KiB
HTML
{% 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 %}
|