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:
@@ -51,6 +51,7 @@
|
||||
.status-wahab-review { @apply bg-violet-100 text-violet-800; }
|
||||
.status-closed { @apply bg-gray-200 text-gray-600; }
|
||||
.status-reopened { @apply bg-pink-100 text-pink-800; }
|
||||
.status-cancelled { @apply bg-gray-300 text-gray-700 line-through; }
|
||||
.priority-urgent { @apply bg-red-100 text-red-800 border-red-300; }
|
||||
.priority-high { @apply bg-orange-100 text-orange-800 border-orange-300; }
|
||||
.priority-medium { @apply bg-yellow-100 text-yellow-800 border-yellow-300; }
|
||||
@@ -318,7 +319,8 @@
|
||||
'on-field verification': 'status-on-field-verification',
|
||||
'wahab review': 'status-wahab-review',
|
||||
'closed': 'status-closed',
|
||||
'reopened': 'status-reopened'
|
||||
'reopened': 'status-reopened',
|
||||
'cancelled': 'status-cancelled'
|
||||
};
|
||||
return map[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -121,6 +121,10 @@
|
||||
<dt class="text-sm text-gray-500">Reporter</dt>
|
||||
<dd class="text-sm font-medium text-gray-900" x-text="ticket.reporter || '—'"></dd>
|
||||
</div>
|
||||
<div class="flex justify-between" x-show="ticket.phone">
|
||||
<dt class="text-sm text-gray-500">Phone</dt>
|
||||
<dd class="text-sm font-medium text-gray-900" x-text="ticket.phone"></dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm text-gray-500">Reported Via</dt>
|
||||
<dd class="text-sm font-medium text-gray-900 capitalize" x-text="ticket.reported_via || '—'"></dd>
|
||||
@@ -214,6 +218,7 @@
|
||||
<option value="Wahab Review">Wahab Review</option>
|
||||
<option value="Closed">Closed</option>
|
||||
<option value="Reopened">Reopened</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -328,17 +333,14 @@
|
||||
},
|
||||
|
||||
async loadTechnicians() {
|
||||
// Users list not exposed via API directly, so use a known list
|
||||
// In a real system we'd have GET /api/users
|
||||
this.technicians = [
|
||||
{ id: 9, full_name: 'Prosper' },
|
||||
{ id: 10, full_name: 'Sam' },
|
||||
{ id: 11, full_name: 'Steven' },
|
||||
{ id: 12, full_name: 'Junior (Samuel)' },
|
||||
{ id: 13, full_name: 'Francis' },
|
||||
{ id: 14, full_name: 'Desmond Afful' },
|
||||
{ id: 15, full_name: 'Afful' },
|
||||
];
|
||||
try {
|
||||
const users = await app().apiGet('/api/auth/users');
|
||||
// Assign dropdown should only offer Tech-role staff
|
||||
this.technicians = (users || []).filter(u => u.role === 'Tech');
|
||||
} catch (e) {
|
||||
console.error('Technicians load error', e);
|
||||
this.technicians = [];
|
||||
}
|
||||
},
|
||||
|
||||
async submitStatusUpdate() {
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<option value="Wahab Review">Wahab Review</option>
|
||||
<option value="Closed">Closed</option>
|
||||
<option value="Reopened">Reopened</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -142,7 +143,7 @@
|
||||
<td class="px-5 py-3 text-gray-500 text-xs" x-text="ticket.assigned_technician_name || '—'"></td>
|
||||
<td class="px-5 py-3 text-gray-500 text-xs" x-text="formatDate(ticket.created_at)"></td>
|
||||
<td class="px-5 py-3">
|
||||
<span x-show="ticket.sla_deadline" class="text-xs" :class="new Date(ticket.sla_deadline) < new Date() && !['Closed','Completed'].includes(ticket.status) ? 'text-red-600 font-medium' : 'text-gray-400'">
|
||||
<span x-show="ticket.sla_deadline" class="text-xs" :class="new Date(ticket.sla_deadline) < new Date() && !['Closed','Completed','Cancelled'].includes(ticket.status) ? 'text-red-600 font-medium' : 'text-gray-400'">
|
||||
<span x-text="formatDateShort(ticket.sla_deadline)"></span>
|
||||
</span>
|
||||
<span x-show="!ticket.sla_deadline" class="text-xs text-gray-300">—</span>
|
||||
@@ -190,7 +191,7 @@
|
||||
<td class="px-5 py-3 text-gray-500 text-xs" x-text="ticket.assigned_technician_name || '—'"></td>
|
||||
<td class="px-5 py-3 text-gray-500 text-xs" x-text="formatDate(ticket.created_at)"></td>
|
||||
<td class="px-5 py-3">
|
||||
<span x-show="ticket.sla_deadline" class="text-xs" :class="new Date(ticket.sla_deadline) < new Date() && !['Closed','Completed'].includes(ticket.status) ? 'text-red-600 font-medium' : 'text-gray-400'">
|
||||
<span x-show="ticket.sla_deadline" class="text-xs" :class="new Date(ticket.sla_deadline) < new Date() && !['Closed','Completed','Cancelled'].includes(ticket.status) ? 'text-red-600 font-medium' : 'text-gray-400'">
|
||||
<span x-text="formatDateShort(ticket.sla_deadline)"></span>
|
||||
</span>
|
||||
<span x-show="!ticket.sla_deadline" class="text-xs text-gray-300">—</span>
|
||||
|
||||
@@ -363,6 +363,16 @@
|
||||
this.submitting = false;
|
||||
return;
|
||||
}
|
||||
if (!this.form.category_main && !this.form.category_id) {
|
||||
this.error = 'Please select a Category — every issue needs one for correct routing and SLA.';
|
||||
this.submitting = false;
|
||||
return;
|
||||
}
|
||||
if (this.mode === 'standard' && !this.form.priority) {
|
||||
this.error = 'Please select a Priority — without one the ticket gets no SLA deadline.';
|
||||
this.submitting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the ticket
|
||||
const payload = {
|
||||
|
||||
Reference in New Issue
Block a user