Files
denya-onecare/app/templates/dashboard/tech-performance.html
T

240 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 &amp; 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 / In review</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;
// two missing values fall through to the name tiebreak.
if (key === 'avg_resolution_hours') {
const aMissing = av === null || av === undefined;
const bMissing = bv === null || bv === undefined;
if (aMissing || bMissing) {
if (aMissing && bMissing) return a.name.localeCompare(b.name);
return aMissing ? 1 : -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 %}