feat: Wahab demo prep — Cancelled status, phone persistence, admin delete, users picker, detail-page fixes

- Add Cancelled as terminal status reachable from all active states; exclude
  from SLA breach reporting and dashboard active counts; add to status pickers
- Persist customer phone on ticket create/update (was silently dropped);
  add tickets.phone migration + legacy self-heal guard
- Add admin-only DELETE /api/tickets/{id} (removes timeline/photos/escalations)
- Add GET /api/auth/users for the assign-technician dropdown (was hardcoded)
- TicketOut now returns nested unit/category so the detail page stops showing
  '—' for Unit/Property/Category
- Ticket numbering uses max+1 so deletions never re-issue a number
- New-issue form: require Category and (standard mode) Priority client-side
- Tests: 11 new cases covering cancellation, SLA exemption, phone, delete,
  users endpoint, numbering
This commit is contained in:
root
2026-08-02 14:06:30 +00:00
parent ffed595dbd
commit 1dd88a141e
17 changed files with 391 additions and 48 deletions
+6 -6
View File
@@ -189,11 +189,11 @@
if (!all.length) return;
// Basic KPIs
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
const closed = all.filter(t => ['Closed', 'Completed'].includes(t.status));
const open = all.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status));
const closed = all.filter(t => ['Closed', 'Completed', 'Cancelled'].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));
const slaMet = withSLA.filter(t => new Date(t.sla_deadline) > new Date() || ['Closed', 'Completed', 'Cancelled'].includes(t.status));
// Monthly restored
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
@@ -270,7 +270,7 @@
// Risk indicators
this.indicators = {
openEmergencies: urgent.filter(t => !['Closed', 'Completed'].includes(t.status)).length,
openEmergencies: urgent.filter(t => !['Closed', 'Completed', 'Cancelled'].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,
@@ -280,7 +280,7 @@
},
calcAvgResponse(all) {
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
const open = all.filter(t => !['Closed', 'Completed', 'Cancelled'].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`;
@@ -288,7 +288,7 @@
},
calcAvgResolution(all) {
const closed = all.filter(t => ['Closed', 'Completed'].includes(t.status) && t.created_at && t.updated_at);
const closed = all.filter(t => ['Closed', 'Completed', 'Cancelled'].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);
+4 -4
View File
@@ -188,20 +188,20 @@
const newToday = all.filter(t => new Date(t.created_at) >= today && t.status === 'New').length;
// Open tickets (not closed/completed)
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
const open = all.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status));
// By priority
const byPriority = { urgent: 0, high: 0, medium: 0, low: 0 };
open.forEach(t => { if (t.priority) byPriority[t.priority] = (byPriority[t.priority] || 0) + 1; });
// Oldest unassigned
const unassigned = all.filter(t => !t.assigned_to && !['Closed', 'Completed'].includes(t.status))
const unassigned = all.filter(t => !t.assigned_to && !['Closed', 'Completed', 'Cancelled'].includes(t.status))
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
this.oldestUnassigned = unassigned[0] || null;
// SLA compliance (rough: tickets with sla_deadline not breached)
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));
const slaMet = withSLA.filter(t => new Date(t.sla_deadline) > new Date() || ['Closed', 'Completed', 'Cancelled'].includes(t.status));
this.kpi = {
newToday,
@@ -230,7 +230,7 @@
calcAvgResponse(all) {
// Rough approximation — in real system this would come from timeline analysis
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
const open = all.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status));
if (!open.length) return '—';
const avgHrs = open.reduce((sum, t) => {
const diff = (new Date() - new Date(t.created_at)) / (1000 * 60 * 60);
+1 -1
View File
@@ -222,7 +222,7 @@
const all = data.items;
// Active: not closed/completed
const active = all.filter(t => !['Closed', 'Completed'].includes(t.status));
const active = all.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status));
this.activeTickets = active;
// KPIs