- Fix detail.html to use assigned_technician_name (not .assigned_technician?.full_name) - Remove double error toast in submitNote() catch block - Replace wasteful API calls in CEO dashboard with units+data approach - Remove QR Code option from reported_via dropdown (excluded per scope)
291 lines
18 KiB
HTML
291 lines
18 KiB
HTML
{% 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>
|
|
|
|
<!-- Executive KPI Cards -->
|
|
<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">
|
|
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Open Tickets</p>
|
|
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.openTickets || 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">Critical (Urgent)</p>
|
|
<p class="text-3xl font-bold text-red-600 mt-1" x-text="kpi.criticalCount || 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">SLA Compliance</p>
|
|
<p class="text-3xl font-bold mt-1" :class="kpi.slaCompliance >= 90 ? 'text-green-600' : kpi.slaCompliance >= 70 ? 'text-yellow-600' : 'text-red-600'" x-text="(kpi.slaCompliance || 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">Avg Resolution</p>
|
|
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.avgResolutionTime || '—'"></p>
|
|
</div>
|
|
</div>
|
|
|
|
<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">
|
|
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Avg Response</p>
|
|
<p class="text-2xl font-bold text-gray-900 mt-1" x-text="kpi.avgResponseTime || '—'"></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">Reopened This Week</p>
|
|
<p class="text-2xl font-bold text-orange-600 mt-1" x-text="kpi.reopenedThisWeek || 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">Total Tickets</p>
|
|
<p class="text-2xl font-bold text-gray-900 mt-1" x-text="kpi.totalTickets || 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-2xl font-bold mt-1" :class="kpi.completionRate >= 70 ? 'text-green-600' : 'text-yellow-600'" x-text="(kpi.completionRate || 0) + '%'"></p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts Row -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
|
<!-- Complaints by Property (Simple Bar) -->
|
|
<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">Complaints by Property</h3>
|
|
<div class="flex items-end space-x-6 h-40 px-2">
|
|
<div class="flex-1 flex flex-col items-center">
|
|
<span class="text-lg font-bold text-denya-600" x-text="charts.byProperty?.east || 0"></span>
|
|
<div class="w-full bg-denya-200 rounded-t-lg mt-1 transition-all" :style="'height: ' + Math.max((charts.byProperty?.east || 0) / Math.max(charts.byProperty?.max || 1, 1) * 120, 8) + 'px'" style="height: 8px;"></div>
|
|
<span class="text-xs text-gray-500 mt-2">East</span>
|
|
</div>
|
|
<div class="flex-1 flex flex-col items-center">
|
|
<span class="text-lg font-bold text-denya-600" x-text="charts.byProperty?.west || 0"></span>
|
|
<div class="w-full bg-denya-400 rounded-t-lg mt-1 transition-all" :style="'height: ' + Math.max((charts.byProperty?.west || 0) / Math.max(charts.byProperty?.max || 1, 1) * 120, 8) + 'px'" style="height: 8px;"></div>
|
|
<span class="text-xs text-gray-500 mt-2">West</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Complaints by Category -->
|
|
<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">Complaints by Category</h3>
|
|
<div class="space-y-2 max-h-64 overflow-y-auto">
|
|
<template x-for="item in charts.byCategory" :key="item.name">
|
|
<div class="flex items-center space-x-2">
|
|
<span class="text-xs text-gray-600 w-20 truncate" x-text="item.name"></span>
|
|
<div class="flex-1 bg-gray-100 rounded-full h-4 overflow-hidden">
|
|
<div class="h-full rounded-full transition-all" :style="'width: ' + (item.count / Math.max(charts.byCategoryMax, 1) * 100) + '%'" :class="item.color || 'bg-denya-500'"></div>
|
|
</div>
|
|
<span class="text-xs font-medium text-gray-600 w-8 text-right" x-text="item.count"></span>
|
|
</div>
|
|
</template>
|
|
<div x-show="!charts.byCategory?.length" class="text-gray-400 text-sm py-6 text-center">No data yet</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
|
<!-- Priority Distribution -->
|
|
<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">Tickets by Priority</h3>
|
|
<div class="space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-sm font-medium text-red-600">🔴 Urgent</span>
|
|
<span class="text-2xl font-bold" x-text="charts.byPriority?.urgent || 0"></span>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-sm font-medium text-orange-600">🟠 High</span>
|
|
<span class="text-2xl font-bold" x-text="charts.byPriority?.high || 0"></span>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-sm font-medium text-yellow-600">🟡 Medium</span>
|
|
<span class="text-2xl font-bold" x-text="charts.byPriority?.medium || 0"></span>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-sm font-medium text-green-600">🟢 Low</span>
|
|
<span class="text-2xl font-bold" x-text="charts.byPriority?.low || 0"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Monthly Trends (Simple) -->
|
|
<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">Monthly Trends</h3>
|
|
<div class="flex items-end space-x-1 h-40 overflow-x-auto pb-2">
|
|
<template x-for="(item, idx) in charts.monthlyTrend" :key="idx">
|
|
<div class="flex-1 flex flex-col items-center min-w-[30px]">
|
|
<span class="text-xs font-medium text-gray-600 mb-1" x-text="item.count"></span>
|
|
<div class="w-full bg-denya-300 rounded-t transition-all" :style="'height: ' + Math.max(item.count / Math.max(charts.monthlyMax || 1, 1) * 100, 4) + 'px'"></div>
|
|
<span class="text-xs text-gray-400 mt-1" x-text="item.month"></span>
|
|
</div>
|
|
</template>
|
|
<div x-show="!charts.monthlyTrend?.length" class="text-gray-400 text-sm py-6 text-center w-full">No data yet</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Risk Indicators -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-8">
|
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Risk Indicators</h3>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div class="p-3 rounded-lg" :class="indicators.openEmergencies > 0 ? 'bg-red-50 border border-red-200' : 'bg-green-50 border border-green-200'">
|
|
<div class="flex items-center space-x-2">
|
|
<div class="w-3 h-3 rounded-full" :class="indicators.openEmergencies > 0 ? 'bg-red-500 animate-pulse' : 'bg-green-500'"></div>
|
|
<span class="text-xs font-medium">Open Emergencies</span>
|
|
</div>
|
|
<p class="text-lg font-bold mt-1" x-text="indicators.openEmergencies || 0"></p>
|
|
</div>
|
|
<div class="p-3 rounded-lg" :class="indicators.reopenedThisWeek > 0 ? 'bg-orange-50 border border-orange-200' : 'bg-green-50 border border-green-200'">
|
|
<div class="flex items-center space-x-2">
|
|
<div class="w-3 h-3 rounded-full" :class="indicators.reopenedThisWeek > 0 ? 'bg-orange-500' : 'bg-green-500'"></div>
|
|
<span class="text-xs font-medium">Reopened This Week</span>
|
|
</div>
|
|
<p class="text-lg font-bold mt-1" x-text="indicators.reopenedThisWeek || 0"></p>
|
|
</div>
|
|
<div class="p-3 rounded-lg" :class="indicators.overdueJobs > 0 ? 'bg-red-50 border border-red-200' : 'bg-green-50 border border-green-200'">
|
|
<div class="flex items-center space-x-2">
|
|
<div class="w-3 h-3 rounded-full" :class="indicators.overdueJobs > 0 ? 'bg-red-500' : 'bg-green-500'"></div>
|
|
<span class="text-xs font-medium">Overdue (Past SLA)</span>
|
|
</div>
|
|
<p class="text-lg font-bold mt-1" x-text="indicators.overdueJobs || 0"></p>
|
|
</div>
|
|
<div class="p-3 rounded-lg" :class="indicators.pendingVerification > 0 ? 'bg-yellow-50 border border-yellow-200' : 'bg-green-50 border border-green-200'">
|
|
<div class="flex items-center space-x-2">
|
|
<div class="w-3 h-3 rounded-full" :class="indicators.pendingVerification > 0 ? 'bg-yellow-500' : 'bg-green-500'"></div>
|
|
<span class="text-xs font-medium">Pending Verification</span>
|
|
</div>
|
|
<p class="text-lg font-bold mt-1" x-text="indicators.pendingVerification || 0"></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function ceoDashboard() {
|
|
return {
|
|
kpi: {},
|
|
charts: { byProperty: {}, byCategory: [], byPriority: {}, monthlyTrend: [], byCategoryMax: 1, monthlyMax: 1 },
|
|
indicators: {},
|
|
|
|
async init() {
|
|
await this.loadData();
|
|
},
|
|
|
|
async loadData() {
|
|
try {
|
|
const allData = await app().apiGet('/api/tickets?page_size=500');
|
|
if (!allData?.items) return;
|
|
const all = allData.items;
|
|
const total = allData.total || all.length;
|
|
|
|
// Basic KPIs
|
|
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
|
const closed = all.filter(t => ['Closed', 'Completed'].includes(t.status));
|
|
const urgent = all.filter(t => t.priority === 'urgent');
|
|
const withSLA = all.filter(t => t.sla_deadline);
|
|
const slaMet = withSLA.filter(t => new Date(t.sla_deadline) > new Date() || ['Closed', 'Completed'].includes(t.status));
|
|
|
|
// Monthly restored
|
|
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
const reopenedThisWeek = all.filter(t => t.status === 'Reopened' && new Date(t.updated_at) >= oneWeekAgo).length;
|
|
|
|
this.kpi = {
|
|
openTickets: open.length,
|
|
criticalCount: urgent.length,
|
|
slaCompliance: withSLA.length ? Math.round((slaMet.length / withSLA.length) * 100) : 100,
|
|
avgResolutionTime: this.calcAvgResolution(all),
|
|
avgResponseTime: this.calcAvgResponse(all),
|
|
reopenedThisWeek,
|
|
totalTickets: total,
|
|
completionRate: total ? Math.round((closed.length / total) * 100) : 0,
|
|
};
|
|
|
|
// Chart data
|
|
const byPriority = { urgent: 0, high: 0, medium: 0, low: 0 };
|
|
open.forEach(t => { if (t.priority) byPriority[t.priority]++; });
|
|
this.charts.byPriority = byPriority;
|
|
|
|
// Monthly trends (last 6 months)
|
|
const months = {};
|
|
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
all.forEach(t => {
|
|
const d = new Date(t.created_at);
|
|
const key = `${d.getFullYear()}-${d.getMonth()}`;
|
|
months[key] = (months[key] || 0) + 1;
|
|
});
|
|
const sortedMonths = Object.keys(months).sort().slice(-6);
|
|
this.charts.monthlyTrend = sortedMonths.map(k => {
|
|
const [y, m] = k.split('-').map(Number);
|
|
return { month: monthNames[m], count: months[k] };
|
|
});
|
|
this.charts.monthlyMax = Math.max(...this.charts.monthlyTrend.map(m => m.count), 1);
|
|
|
|
// By property — load units once and compute from ticket data
|
|
const propData = { east: 0, west: 0 };
|
|
try {
|
|
const units = await app().apiGet('/api/tickets/units');
|
|
if (units && units.length > 0) {
|
|
const unitPropertyMap = {};
|
|
units.forEach(u => { unitPropertyMap[u.id] = u.property; });
|
|
all.forEach(t => {
|
|
if (t.unit_id && unitPropertyMap[t.unit_id]) {
|
|
const p = unitPropertyMap[t.unit_id].toLowerCase();
|
|
if (p === 'east') propData.east++;
|
|
else if (p === 'west') propData.west++;
|
|
}
|
|
});
|
|
}
|
|
} catch (e) { console.error('Property stats error', e); }
|
|
this.charts.byProperty = { east: propData.east, west: propData.west, max: Math.max(propData.east, propData.west, 1) };
|
|
|
|
// By category — use categories endpoint
|
|
try {
|
|
const cats = await app().apiGet('/api/tickets/categories/flat');
|
|
const catCounts = {};
|
|
all.forEach(t => {
|
|
if (t.category_id) {
|
|
const cat = cats?.find(c => c.id === t.category_id);
|
|
const name = cat ? cat.name : `Cat #${t.category_id}`;
|
|
catCounts[name] = (catCounts[name] || 0) + 1;
|
|
}
|
|
});
|
|
const colors = ['bg-blue-500', 'bg-green-500', 'bg-yellow-500', 'bg-red-500', 'bg-purple-500', 'bg-pink-500', 'bg-indigo-500', 'bg-teal-500'];
|
|
this.charts.byCategory = Object.entries(catCounts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 10)
|
|
.map(([name, count], i) => ({ name, count, color: colors[i % colors.length] }));
|
|
this.charts.byCategoryMax = Math.max(...this.charts.byCategory.map(c => c.count), 1);
|
|
} catch (e) { console.error('Category stats error', e); }
|
|
|
|
// Risk indicators
|
|
this.indicators = {
|
|
openEmergencies: urgent.filter(t => !['Closed', 'Completed'].includes(t.status)).length,
|
|
reopenedThisWeek,
|
|
overdueJobs: open.filter(t => t.sla_deadline && new Date(t.sla_deadline) < new Date()).length,
|
|
pendingVerification: all.filter(t => ['Completed', 'On-Field Verification'].includes(t.status)).length,
|
|
};
|
|
|
|
} catch (e) { console.error('CEO data load error', e); }
|
|
},
|
|
|
|
calcAvgResponse(all) {
|
|
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
|
if (!open.length) return '—';
|
|
const avgHrs = open.reduce((sum, t) => sum + Math.min((new Date() - new Date(t.created_at)) / (1000 * 60 * 60), 168), 0) / open.length;
|
|
if (avgHrs < 1) return `${Math.round(avgHrs * 60)}m`;
|
|
return `${Math.round(avgHrs)}h`;
|
|
},
|
|
|
|
calcAvgResolution(all) {
|
|
const closed = all.filter(t => ['Closed', 'Completed'].includes(t.status) && t.created_at && t.updated_at);
|
|
if (!closed.length) return '—';
|
|
const avgHrs = closed.reduce((sum, t) => {
|
|
const diff = (new Date(t.updated_at) - new Date(t.created_at)) / (1000 * 60 * 60);
|
|
return sum + diff;
|
|
}, 0) / closed.length;
|
|
if (avgHrs < 1) return `${Math.round(avgHrs * 60)}m`;
|
|
return `${Math.round(avgHrs)}h`;
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
{% endblock %}
|