feat: Sprint A Wahab review batch — categories, property hierarchy, priority grouping
Items 1-5, 9, 11 from denya-wahab-feedback-s2: - Category.show_in_form (alert-only flag): Gas Leak hidden from issue picker but kept urgent for SLA/alert and reporting; emergency quick path on the new-issue form creates urgent tickets via include_hidden categories. - Seed: Aluminum/Glass, Carpentry, Mould & Damp (Medium default) maintenance categories; Lost Property renamed Missing Item (+ sub). - One alembic migration: add show_in_form (backfill True, Gas Leak False) + data rename Lost Property -> Missing Item. - Property -> Building -> Apartment cascade with searchable apartment combobox on the new-issue form and ticket list filters; /api/tickets/units gains building filter + /units/grouped variant; /api/tickets gains additive building/unit_id filters. apartment_mapping.json committed (deterministic). - Group-by-priority toggle on /tickets (four sections + unknown bucket, age-sortable, composes with filters, URL deep links), FM dashboard active tickets, and CS dashboard priority card click-through. - Tests: 13 new (category visibility, seed idempotency/sync, unit grouping, ticket building/unit filters).
This commit is contained in:
@@ -355,6 +355,32 @@
|
||||
if (diffHrs < 1) return '< 1h';
|
||||
if (diffHrs < 24) return `${diffHrs}h`;
|
||||
return `${diffDays}d`;
|
||||
},
|
||||
|
||||
groupByPriority(tickets, ageDir = 'desc') {
|
||||
// Group tickets into Urgent/High/Medium/Low + an other bucket,
|
||||
// each sorted by age (desc = newest first, asc = oldest first).
|
||||
const order = ['urgent', 'high', 'medium', 'low'];
|
||||
const groups = { urgent: [], high: [], medium: [], low: [], other: [] };
|
||||
const age = (t) => new Date(t.created_at).getTime() || 0;
|
||||
(tickets || []).forEach(t => {
|
||||
const p = String(t.priority || '').toLowerCase();
|
||||
groups[order.includes(p) ? p : 'other'].push(t);
|
||||
});
|
||||
Object.keys(groups).forEach(k => {
|
||||
groups[k].sort((a, b) => (ageDir === 'asc' ? age(a) - age(b) : age(b) - age(a)));
|
||||
});
|
||||
return groups;
|
||||
},
|
||||
|
||||
priorityGroupMeta() {
|
||||
return [
|
||||
{ key: 'urgent', label: '🔴 Urgent', cls: 'border-red-200 bg-red-50' },
|
||||
{ key: 'high', label: '🟠 High', cls: 'border-orange-200 bg-orange-50' },
|
||||
{ key: 'medium', label: '🟡 Medium', cls: 'border-yellow-200 bg-yellow-50' },
|
||||
{ key: 'low', label: '🟢 Low', cls: 'border-green-200 bg-green-50' },
|
||||
{ key: 'other', label: '⚪ No priority / Unknown', cls: 'border-gray-200 bg-gray-50' },
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,8 +249,9 @@
|
||||
this.charts.byProperty = { east: propData.east, west: propData.west, max: Math.max(propData.east, propData.west, 1) };
|
||||
|
||||
// By category — use categories endpoint
|
||||
// include_hidden=true so alert-only categories (Gas Leak) stay labeled in reporting
|
||||
try {
|
||||
const cats = await app().apiGet('/api/tickets/categories/flat');
|
||||
const cats = await app().apiGet('/api/tickets/categories/flat?include_hidden=true');
|
||||
const catCounts = {};
|
||||
all.forEach(t => {
|
||||
if (t.category_id) {
|
||||
|
||||
@@ -70,23 +70,24 @@
|
||||
<!-- Open by Priority -->
|
||||
<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">Open by Priority</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-2">
|
||||
<a href="/tickets?group=priority&priority=urgent" class="flex items-center justify-between rounded-lg px-2 py-1.5 hover:bg-red-50 transition">
|
||||
<span class="text-red-600 font-medium">🔴 Urgent</span>
|
||||
<span class="text-2xl font-bold" x-text="kpi.byPriority?.urgent || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
</a>
|
||||
<a href="/tickets?group=priority&priority=high" class="flex items-center justify-between rounded-lg px-2 py-1.5 hover:bg-orange-50 transition">
|
||||
<span class="text-orange-600 font-medium">🟠 High</span>
|
||||
<span class="text-2xl font-bold" x-text="kpi.byPriority?.high || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
</a>
|
||||
<a href="/tickets?group=priority&priority=medium" class="flex items-center justify-between rounded-lg px-2 py-1.5 hover:bg-yellow-50 transition">
|
||||
<span class="text-yellow-600 font-medium">🟡 Medium</span>
|
||||
<span class="text-2xl font-bold" x-text="kpi.byPriority?.medium || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
</a>
|
||||
<a href="/tickets?group=priority&priority=low" class="flex items-center justify-between rounded-lg px-2 py-1.5 hover:bg-green-50 transition">
|
||||
<span class="text-green-600 font-medium">🟢 Low</span>
|
||||
<span class="text-2xl font-bold" x-text="kpi.byPriority?.low || 0"></span>
|
||||
</div>
|
||||
</a>
|
||||
<p class="text-[11px] text-gray-400 pt-1">Click a row to open the priority-grouped queue.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -127,11 +127,18 @@
|
||||
|
||||
<!-- Active Tickets 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 items-center justify-between">
|
||||
<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">Active Tickets</h3>
|
||||
<a href="/tickets" class="text-sm text-denya-600 hover:text-denya-800">View All →</a>
|
||||
<div class="flex items-center space-x-3">
|
||||
<label class="flex items-center space-x-2 text-sm font-medium text-gray-700 cursor-pointer select-none">
|
||||
<input type="checkbox" x-model="groupByPriority" class="rounded border-gray-300 text-denya-600 focus:ring-denya-500">
|
||||
<span>Group by priority</span>
|
||||
</label>
|
||||
<button x-show="groupByPriority" @click="ageDir = ageDir === 'asc' ? 'desc' : 'asc'" class="px-2 py-1 text-xs border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-600" x-text="ageDir === 'desc' ? 'Newest first ↓' : 'Oldest first ↑'"></button>
|
||||
<a href="/tickets" class="text-sm text-denya-600 hover:text-denya-800">View All →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<div class="overflow-x-auto" x-show="!groupByPriority">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
@@ -160,6 +167,32 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="space-y-3 p-3" x-show="groupByPriority">
|
||||
<template x-for="meta in priorityGroupMeta()" :key="meta.key">
|
||||
<div x-show="(groupedActiveTickets[meta.key] || []).length" class="rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-2.5 flex items-center justify-between border-b border-gray-100" :class="meta.cls">
|
||||
<span class="text-sm font-semibold text-gray-800" x-text="`${meta.label} (${groupedActiveTickets[meta.key].length})`"></span>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<template x-for="ticket in groupedActiveTickets[meta.key]" :key="ticket.id">
|
||||
<tr class="hover:bg-gray-50 transition cursor-pointer" @click="window.location.href='/tickets/'+ticket.id">
|
||||
<td class="px-5 py-3 font-medium text-denya-600" x-text="ticket.ticket_number"></td>
|
||||
<td class="px-5 py-3"><span class="px-2 py-1 rounded-full text-xs font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span></td>
|
||||
<td class="px-5 py-3"><span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></td>
|
||||
<td class="px-5 py-3 text-gray-600" x-text="ticket.assigned_technician_name || 'Unassigned'"></td>
|
||||
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
|
||||
<td class="px-5 py-3 text-xs font-medium" :class="timeSince(ticket.created_at).includes('d') && parseInt(timeSince(ticket.created_at)) > 3 ? 'text-red-600' : 'text-gray-500'" x-text="timeSince(ticket.created_at)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="!activeTickets.length" class="px-5 py-12 text-center text-gray-400">No active tickets</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,6 +204,12 @@
|
||||
aging: {},
|
||||
activeTickets: [],
|
||||
emergencyCount: 0,
|
||||
groupByPriority: false,
|
||||
ageDir: 'desc',
|
||||
|
||||
get groupedActiveTickets() {
|
||||
return app().groupByPriority(this.activeTickets, this.ageDir);
|
||||
},
|
||||
|
||||
async init() {
|
||||
await this.loadData();
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-8 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 font-medium mb-1">Search</label>
|
||||
<input type="text" x-model="filters.search" @input.debounce="loadTickets()" placeholder="Ticket # or description..." class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
|
||||
@@ -52,12 +52,39 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 font-medium mb-1">Property</label>
|
||||
<select x-model="filters.property" @change="loadTickets()" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none">
|
||||
<select x-model="filters.property" @change="onPropertyChange()" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none">
|
||||
<option value="">All Properties</option>
|
||||
<option value="East">Pavilion East</option>
|
||||
<option value="West">Pavilion West</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 font-medium mb-1">Building</label>
|
||||
<select x-model="filters.building" @change="onBuildingChange()" :disabled="!filters.property" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none disabled:bg-gray-50 disabled:text-gray-400">
|
||||
<option value="">All Buildings</option>
|
||||
<template x-for="b in buildings" :key="b">
|
||||
<option :value="b" x-text="b"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<label class="block text-xs text-gray-500 font-medium mb-1">Apartment</label>
|
||||
<input type="text" x-model="searchApartment" @focus="apartmentOpen = true" @input="apartmentOpen = true" @keydown.escape="apartmentOpen = false"
|
||||
:disabled="!filters.building" @blur="closeApartment()"
|
||||
:placeholder="filterUnit ? filterUnit.apartment_code : (filters.building ? 'Search apartment...' : 'Select building')"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none disabled:bg-gray-50 disabled:text-gray-400">
|
||||
<div x-show="apartmentOpen && filteredApartments.length" class="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-40 overflow-y-auto">
|
||||
<template x-for="unit in filteredApartments" :key="unit.id">
|
||||
<button type="button" @mousedown.prevent="selectFilterUnit(unit)" class="block w-full text-left px-3 py-1.5 hover:bg-denya-50 text-xs">
|
||||
<span class="font-medium text-gray-800" x-text="unit.apartment_code"></span>
|
||||
<span class="text-gray-400" x-text="` · Floor ${unit.floor || '—'}`"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div x-show="apartmentOpen && !filteredApartments.length && filters.building" class="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg px-3 py-2 text-xs text-gray-400">
|
||||
No apartments match
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 font-medium mb-1">From</label>
|
||||
<input type="date" x-model="filters.dateFrom" @change="loadTickets()" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none">
|
||||
@@ -67,20 +94,27 @@
|
||||
<input type="date" x-model="filters.dateTo" @change="loadTickets()" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<label class="flex items-center space-x-2 text-sm font-medium text-gray-700 cursor-pointer select-none">
|
||||
<input type="checkbox" x-model="groupByPriority" @change="syncUrl()" class="rounded border-gray-300 text-denya-600 focus:ring-denya-500">
|
||||
<span>Group by priority</span>
|
||||
</label>
|
||||
<div x-show="groupByPriority" class="flex items-center space-x-2 text-xs">
|
||||
<button @click="ageDir = ageDir === 'asc' ? 'desc' : 'asc'" class="px-2 py-1 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-600" x-text="ageDir === 'desc' ? 'Newest first ↓' : 'Oldest first ↑'"></button>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">Page <span x-text="page"></span> of <span x-text="totalPages"></span></span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="page > 1 && (page--, loadTickets())" :disabled="page <= 1" class="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">Prev</button>
|
||||
<button @click="page < totalPages && (page++, loadTickets())" :disabled="page >= totalPages" class="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">Next</button>
|
||||
<button @click="filters = {search:'',status:'',priority:'',property:'',dateFrom:'',dateTo:''}; page=1; loadTickets()" class="px-3 py-1 text-sm text-gray-500 border rounded hover:bg-gray-50">Clear Filters</button>
|
||||
<button @click="clearFilters()" class="px-3 py-1 text-sm text-gray-500 border rounded hover:bg-gray-50">Clear Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tickets Table -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<!-- Tickets Table (flat) -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200" x-show="!groupByPriority">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||
@@ -125,6 +159,53 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tickets grouped by priority -->
|
||||
<div class="space-y-4" x-show="groupByPriority">
|
||||
<template x-for="meta in priorityGroupMeta()" :key="meta.key">
|
||||
<div x-show="(groupedTickets[meta.key] || []).length" class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="px-5 py-3 flex items-center justify-between border-b border-gray-100" :class="meta.cls">
|
||||
<span class="text-sm font-semibold text-gray-800" x-text="`${meta.label} (${groupedTickets[meta.key].length})`"></span>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<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">Ticket</th>
|
||||
<th class="px-5 py-3 text-left">Status</th>
|
||||
<th class="px-5 py-3 text-left">Priority</th>
|
||||
<th class="px-5 py-3 text-left">Description</th>
|
||||
<th class="px-5 py-3 text-left">Assigned To</th>
|
||||
<th class="px-5 py-3 text-left">Created</th>
|
||||
<th class="px-5 py-3 text-left">SLA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<template x-for="ticket in groupedTickets[meta.key]" :key="ticket.id">
|
||||
<tr class="hover:bg-gray-50 transition cursor-pointer" @click="window.location.href='/tickets/'+ticket.id">
|
||||
<td class="px-5 py-3 font-medium text-denya-600" x-text="ticket.ticket_number"></td>
|
||||
<td class="px-5 py-3"><span class="px-2 py-1 rounded-full text-xs font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span></td>
|
||||
<td class="px-5 py-3"><span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></td>
|
||||
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
|
||||
<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-text="formatDateShort(ticket.sla_deadline)"></span>
|
||||
</span>
|
||||
<span x-show="!ticket.sla_deadline" class="text-xs text-gray-300">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="!groupedTotal && !loading" class="bg-white rounded-xl shadow-sm border border-gray-200 px-5 py-16 text-center text-gray-400">
|
||||
No tickets match your filters
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -137,25 +218,130 @@
|
||||
totalPages: 1,
|
||||
sortField: 'created_at',
|
||||
sortDir: 'desc',
|
||||
groupByPriority: false,
|
||||
ageDir: 'desc',
|
||||
unitGroups: {},
|
||||
buildings: [],
|
||||
searchApartment: '',
|
||||
apartmentOpen: false,
|
||||
filterUnit: null,
|
||||
filters: {
|
||||
search: '',
|
||||
status: '',
|
||||
priority: '',
|
||||
property: '',
|
||||
building: '',
|
||||
unitId: '',
|
||||
dateFrom: '',
|
||||
dateTo: ''
|
||||
},
|
||||
|
||||
async init() {
|
||||
this.applyUrlParams();
|
||||
await this.loadUnits();
|
||||
await this.loadTickets();
|
||||
},
|
||||
|
||||
applyUrlParams() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('group') === 'priority') this.groupByPriority = true;
|
||||
if (params.get('priority')) this.filters.priority = params.get('priority');
|
||||
if (params.get('property')) this.filters.property = params.get('property');
|
||||
if (params.get('building')) this.filters.building = params.get('building');
|
||||
if (params.get('unit_id')) this.filters.unitId = params.get('unit_id');
|
||||
},
|
||||
|
||||
syncUrl() {
|
||||
const params = new URLSearchParams();
|
||||
if (this.groupByPriority) params.set('group', 'priority');
|
||||
if (this.filters.priority) params.set('priority', this.filters.priority);
|
||||
if (this.filters.property) params.set('property', this.filters.property);
|
||||
if (this.filters.building) params.set('building', this.filters.building);
|
||||
if (this.filters.unitId) params.set('unit_id', this.filters.unitId);
|
||||
const qs = params.toString();
|
||||
const url = qs ? `/tickets?${qs}` : '/tickets';
|
||||
window.history.replaceState({}, '', url);
|
||||
},
|
||||
|
||||
async loadUnits() {
|
||||
try {
|
||||
const data = await app().apiGet('/api/tickets/units/grouped');
|
||||
this.unitGroups = data || {};
|
||||
if (this.filters.property) this.buildings = Object.keys(this.unitGroups[this.filters.property] || {});
|
||||
if (this.filters.unitId) {
|
||||
// Restore displayed apartment for a unit_id deep link
|
||||
Object.values(this.unitGroups).forEach(prop => Object.values(prop).forEach(units => {
|
||||
const u = (units || []).find(x => x.id == this.filters.unitId);
|
||||
if (u) this.filterUnit = u;
|
||||
}));
|
||||
}
|
||||
} catch (e) { console.error('Units load error', e); }
|
||||
},
|
||||
|
||||
onPropertyChange() {
|
||||
this.filters.building = '';
|
||||
this.filters.unitId = '';
|
||||
this.filterUnit = null;
|
||||
this.searchApartment = '';
|
||||
this.buildings = this.filters.property ? Object.keys(this.unitGroups[this.filters.property] || {}) : [];
|
||||
this.loadTickets();
|
||||
this.syncUrl();
|
||||
},
|
||||
|
||||
onBuildingChange() {
|
||||
this.filters.unitId = '';
|
||||
this.filterUnit = null;
|
||||
this.searchApartment = '';
|
||||
this.loadTickets();
|
||||
this.syncUrl();
|
||||
},
|
||||
|
||||
get buildingUnits() {
|
||||
if (!this.filters.property || !this.filters.building) return [];
|
||||
return this.unitGroups[this.filters.property]?.[this.filters.building] || [];
|
||||
},
|
||||
|
||||
get filteredApartments() {
|
||||
const q = (this.searchApartment || '').toLowerCase().trim();
|
||||
let units = this.buildingUnits;
|
||||
if (q) {
|
||||
units = units.filter(u =>
|
||||
(u.apartment_code || '').toLowerCase().includes(q) ||
|
||||
String(u.floor || '').includes(q)
|
||||
);
|
||||
}
|
||||
return units;
|
||||
},
|
||||
|
||||
closeApartment() {
|
||||
setTimeout(() => { this.apartmentOpen = false; }, 150);
|
||||
},
|
||||
|
||||
selectFilterUnit(unit) {
|
||||
this.filterUnit = unit;
|
||||
this.filters.unitId = unit.id;
|
||||
this.searchApartment = unit.apartment_code;
|
||||
this.apartmentOpen = false;
|
||||
this.loadTickets();
|
||||
this.syncUrl();
|
||||
},
|
||||
|
||||
get groupedTickets() {
|
||||
return app().groupByPriority(this.tickets, this.ageDir);
|
||||
},
|
||||
|
||||
get groupedTotal() {
|
||||
return Object.values(this.groupedTickets).reduce((n, g) => n + g.length, 0);
|
||||
},
|
||||
|
||||
async loadTickets() {
|
||||
try {
|
||||
let url = `/api/tickets?page=${this.page}&page_size=${this.pageSize}`;
|
||||
if (this.filters.status) url += `&status=${encodeURIComponent(this.filters.status)}`;
|
||||
if (this.filters.priority) url += `&priority=${encodeURIComponent(this.filters.priority)}`;
|
||||
if (this.filters.property) url += `&property=${encodeURIComponent(this.filters.property)}`;
|
||||
if (this.filters.building) url += `&building=${encodeURIComponent(this.filters.building)}`;
|
||||
if (this.filters.unitId) url += `&unit_id=${encodeURIComponent(this.filters.unitId)}`;
|
||||
if (this.filters.dateFrom) url += `&date_from=${encodeURIComponent(this.filters.dateFrom)}`;
|
||||
if (this.filters.dateTo) url += `&date_to=${encodeURIComponent(this.filters.dateTo)}`;
|
||||
|
||||
@@ -204,6 +390,16 @@
|
||||
if (valA > valB) return this.sortDir === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
this.filters = { search: '', status: '', priority: '', property: '', building: '', unitId: '', dateFrom: '', dateTo: '' };
|
||||
this.filterUnit = null;
|
||||
this.searchApartment = '';
|
||||
this.buildings = [];
|
||||
this.page = 1;
|
||||
this.loadTickets();
|
||||
this.syncUrl();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+162
-50
@@ -6,6 +6,25 @@
|
||||
<p class="text-gray-500 mt-1">Report a maintenance or customer service issue</p>
|
||||
</div>
|
||||
|
||||
<!-- Report Mode: Standard / Emergency quick path -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-3xl mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<button type="button" @click="setMode('standard')" class="flex items-center justify-center space-x-2 px-4 py-3 rounded-xl border-2 text-sm font-medium transition"
|
||||
:class="mode === 'standard' ? 'border-denya-500 bg-denya-50 text-denya-700' : 'border-gray-200 text-gray-600 hover:bg-gray-50'">
|
||||
<span>Standard Issue</span>
|
||||
</button>
|
||||
<button type="button" @click="setMode('emergency')" class="flex items-center justify-center space-x-2 px-4 py-3 rounded-xl border-2 text-sm font-medium transition"
|
||||
:class="mode === 'emergency' ? 'border-red-500 bg-red-50 text-red-700' : 'border-gray-200 text-gray-600 hover:bg-gray-50'">
|
||||
<span>🚨 Emergency</span>
|
||||
</button>
|
||||
</div>
|
||||
<p x-show="mode === 'emergency'" class="mt-3 text-xs text-red-600 leading-relaxed">
|
||||
Emergency reports (gas leak, fire, flood, electrical hazard) are created with 🔴 Urgent priority and urgent SLA targets apply.
|
||||
The Gas Leak category is available here only — it stays hidden from the standard issue list.
|
||||
</p>
|
||||
<p x-show="mode === 'standard'" class="mt-3 text-xs text-gray-400">Standard maintenance or customer service issues.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-3xl">
|
||||
<form @submit.prevent="submitTicket" class="space-y-5">
|
||||
<!-- Row: Customer Name + Phone -->
|
||||
@@ -20,36 +39,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row: Property + Apartment -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Property <span class="text-red-500">*</span></label>
|
||||
<select x-model="form.property" @change="loadUnits()" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
|
||||
<option value="">Select Property</option>
|
||||
<option value="East">Pavilion East</option>
|
||||
<option value="West">Pavilion West</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Apartment <span class="text-red-500">*</span></label>
|
||||
<select x-model="form.apartment_code" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none" :disabled="!form.property">
|
||||
<option value="">Select Apartment</option>
|
||||
<template x-for="unit in units" :key="unit.id">
|
||||
<option :value="unit.apartment_code" x-text="unit.apartment_code"></option>
|
||||
</template>
|
||||
</select>
|
||||
<!-- Row: Property → Building → Apartment (3-level cascade + searchable combobox) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Location <span class="text-red-500">*</span></label>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<select x-model="form.property" @change="onPropertyChange()" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
|
||||
<option value="">Property</option>
|
||||
<option value="East">Pavilion East</option>
|
||||
<option value="West">Pavilion West</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<select x-model="form.building" @change="onBuildingChange()" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none" :disabled="!form.property">
|
||||
<option value="">Building</option>
|
||||
<template x-for="b in buildings" :key="b">
|
||||
<option :value="b" x-text="b"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input type="text" x-model="searchApartment" @focus="apartmentOpen = true" @input="apartmentOpen = true" @keydown.escape="apartmentOpen = false"
|
||||
:disabled="!form.building" @blur="closeApartment()"
|
||||
:placeholder="form.apartment_code ? form.apartment_code : (form.building ? 'Search apartment (e.g. 011E, 505, 10W)...' : 'Select building first')"
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none disabled:bg-gray-50 disabled:text-gray-400">
|
||||
<div x-show="apartmentOpen && filteredApartments.length" class="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||
<template x-for="unit in filteredApartments" :key="unit.id">
|
||||
<button type="button" @mousedown.prevent="selectApartment(unit)" class="block w-full text-left px-3 py-2 hover:bg-denya-50 text-sm">
|
||||
<span class="font-medium text-gray-800" x-text="unit.apartment_code"></span>
|
||||
<span class="text-xs text-gray-400" x-text="` · Floor ${unit.floor || '—'} · ${unit.building || ''}`"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div x-show="apartmentOpen && !filteredApartments.length && form.building" class="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg px-3 py-2 text-xs text-gray-400">
|
||||
No apartments match “<span x-text="searchApartment"></span>”
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-400">Property → Building → Apartment. Search matches apartment code, floor, or building.</p>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Category <span class="text-red-500">*</span></label>
|
||||
<select x-model="form.category_main" @change="loadSubCategories()" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
|
||||
<select x-model="form.category_main" @change="onCategoryChange()" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
|
||||
<option value="">Select Category</option>
|
||||
<template x-for="cat in categories" :key="cat.id">
|
||||
<option :value="cat.id" x-text="cat.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<div x-show="selectedCategoryHint" class="mt-2 text-xs text-gray-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2" x-text="selectedCategoryHint"></div>
|
||||
</div>
|
||||
<div x-show="subCategories.length">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Sub-Category</label>
|
||||
@@ -62,7 +101,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Priority -->
|
||||
<div>
|
||||
<div x-show="mode === 'emergency'">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Priority</label>
|
||||
<div class="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700 font-medium">
|
||||
🔴 Urgent — fixed for emergency reports (15 min response / 4 h resolution SLA)
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="mode === 'standard'">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Priority</label>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:bg-gray-50" :class="form.priority === 'urgent' ? 'border-red-500 bg-red-50' : 'border-gray-200'">
|
||||
@@ -136,7 +181,7 @@
|
||||
<div class="flex items-center space-x-3 pt-2">
|
||||
<button type="submit" :disabled="submitting" class="px-6 py-2.5 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2">
|
||||
<svg x-show="submitting" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
<span x-text="submitting ? 'Creating...' : 'Create Issue'"></span>
|
||||
<span x-text="submitting ? 'Creating...' : (mode === 'emergency' ? 'Create Emergency Issue' : 'Create Issue')"></span>
|
||||
</button>
|
||||
<a href="/tickets" class="px-6 py-2.5 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition font-medium">Cancel</a>
|
||||
</div>
|
||||
@@ -147,11 +192,14 @@
|
||||
<script>
|
||||
function createTicket() {
|
||||
return {
|
||||
mode: 'standard',
|
||||
form: {
|
||||
customer_name: '',
|
||||
phone: '',
|
||||
property: '',
|
||||
building: '',
|
||||
apartment_code: '',
|
||||
unit: null,
|
||||
category_main: '',
|
||||
category_id: null,
|
||||
priority: '',
|
||||
@@ -161,7 +209,11 @@
|
||||
},
|
||||
categories: [],
|
||||
subCategories: [],
|
||||
units: [],
|
||||
unitGroups: {},
|
||||
buildings: [],
|
||||
searchApartment: '',
|
||||
apartmentOpen: false,
|
||||
selectedCategoryHint: '',
|
||||
photoFiles: [],
|
||||
photoPreviews: [],
|
||||
submitting: false,
|
||||
@@ -169,39 +221,102 @@
|
||||
|
||||
async init() {
|
||||
await this.loadCategories();
|
||||
await this.loadUnits();
|
||||
},
|
||||
|
||||
// ── Report mode ──────────────────────────────────────────
|
||||
setMode(mode) {
|
||||
this.mode = mode;
|
||||
// Reset category selection; emergency path fixes priority
|
||||
this.form.category_main = '';
|
||||
this.form.category_id = null;
|
||||
this.subCategories = [];
|
||||
this.selectedCategoryHint = '';
|
||||
if (mode === 'emergency') this.form.priority = 'urgent';
|
||||
this.loadCategories();
|
||||
},
|
||||
|
||||
async loadCategories() {
|
||||
try {
|
||||
const data = await app().apiGet('/api/tickets/categories');
|
||||
const url = this.mode === 'emergency'
|
||||
? '/api/tickets/categories?type=emergency&include_hidden=true'
|
||||
: '/api/tickets/categories';
|
||||
const data = await app().apiGet(url);
|
||||
// Top-level categories only
|
||||
this.categories = data?.filter(c => !c.parent_id) || [];
|
||||
} catch (e) { console.error('Categories load error', e); }
|
||||
},
|
||||
|
||||
async loadSubCategories() {
|
||||
this.form.category_id = null;
|
||||
this.subCategories = [];
|
||||
if (!this.form.category_main) return;
|
||||
async loadUnits() {
|
||||
try {
|
||||
const data = await app().apiGet('/api/tickets/categories');
|
||||
const parent = data?.find(c => c.id == this.form.category_main);
|
||||
if (parent?.children) {
|
||||
this.subCategories = parent.children;
|
||||
}
|
||||
} catch (e) { console.error('Subcategories load error', e); }
|
||||
const data = await app().apiGet('/api/tickets/units/grouped');
|
||||
this.unitGroups = data || {};
|
||||
} catch (e) { console.error('Units load error', e); }
|
||||
},
|
||||
|
||||
async loadUnits() {
|
||||
// ── Location cascade ─────────────────────────────────────
|
||||
onPropertyChange() {
|
||||
this.form.building = '';
|
||||
this.form.apartment_code = '';
|
||||
this.units = [];
|
||||
if (!this.form.property) return;
|
||||
try {
|
||||
const data = await app().apiGet(`/api/tickets/units?property=${encodeURIComponent(this.form.property)}`);
|
||||
this.units = data || [];
|
||||
} catch (e) {
|
||||
console.error('Units load error', e);
|
||||
this.form.unit = null;
|
||||
this.searchApartment = '';
|
||||
const prop = this.form.property;
|
||||
this.buildings = prop ? Object.keys(this.unitGroups[prop] || {}) : [];
|
||||
},
|
||||
|
||||
onBuildingChange() {
|
||||
this.form.apartment_code = '';
|
||||
this.form.unit = null;
|
||||
this.searchApartment = '';
|
||||
},
|
||||
|
||||
get buildingUnits() {
|
||||
if (!this.form.property || !this.form.building) return [];
|
||||
return this.unitGroups[this.form.property]?.[this.form.building] || [];
|
||||
},
|
||||
|
||||
get filteredApartments() {
|
||||
const q = (this.searchApartment || '').toLowerCase().trim();
|
||||
let units = this.buildingUnits;
|
||||
if (q) {
|
||||
units = units.filter(u =>
|
||||
(u.apartment_code || '').toLowerCase().includes(q) ||
|
||||
(u.building || '').toLowerCase().includes(q) ||
|
||||
String(u.floor || '').includes(q)
|
||||
);
|
||||
}
|
||||
return units;
|
||||
},
|
||||
|
||||
closeApartment() {
|
||||
setTimeout(() => { this.apartmentOpen = false; }, 150);
|
||||
},
|
||||
|
||||
selectApartment(unit) {
|
||||
this.form.unit = unit;
|
||||
this.form.apartment_code = unit.apartment_code;
|
||||
this.searchApartment = unit.apartment_code;
|
||||
this.apartmentOpen = false;
|
||||
},
|
||||
|
||||
// ── Category helpers ─────────────────────────────────────
|
||||
onCategoryChange() {
|
||||
this.form.category_id = null;
|
||||
this.subCategories = [];
|
||||
this.selectedCategoryHint = '';
|
||||
if (!this.form.category_main) return;
|
||||
const parent = this.categories.find(c => c.id == this.form.category_main);
|
||||
if (parent?.children) {
|
||||
this.subCategories = parent.children;
|
||||
}
|
||||
const hints = {
|
||||
'Carpentry': 'Triage tip: use Carpentry for structural timber, door and frame work; Furniture for movable pieces (beds, chairs, tables, wardrobes); Security for lock, latch and door-closing functionality.',
|
||||
'Mould & Damp': 'Damp / mould remediation. Priority defaults to 🟡 Medium unless you change it.',
|
||||
'Aluminum/Glass': 'Windows, doors, sliding/fixed panels, glass and mirror replacement.'
|
||||
};
|
||||
if (parent?.name && hints[parent.name]) this.selectedCategoryHint = hints[parent.name];
|
||||
// Mould & Damp defaults to Medium priority
|
||||
if (parent?.name === 'Mould & Damp' && !this.form.priority) this.form.priority = 'medium';
|
||||
},
|
||||
|
||||
handlePhotos(e) {
|
||||
@@ -226,32 +341,29 @@
|
||||
async submitTicket() {
|
||||
this.error = '';
|
||||
this.submitting = true;
|
||||
|
||||
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!this.form.property || !this.form.apartment_code || !this.form.description) {
|
||||
this.error = 'Please fill in Property, Apartment, and Description.';
|
||||
if (!this.form.property || !this.form.building || !this.form.unit || !this.form.description) {
|
||||
this.error = 'Please fill in Property, Building, Apartment, and Description.';
|
||||
this.submitting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve unit_id from selected apartment_code
|
||||
const selectedUnit = this.units.find(u => u.apartment_code === this.form.apartment_code);
|
||||
|
||||
// Create the ticket
|
||||
const payload = {
|
||||
description: this.form.description,
|
||||
priority: this.form.priority || null,
|
||||
priority: this.mode === 'emergency' ? 'urgent' : (this.form.priority || null),
|
||||
reporter: this.form.reporter || this.form.customer_name || app().user.full_name,
|
||||
reported_via: this.form.reported_via || 'walk-in',
|
||||
category_id: this.form.category_id ? parseInt(this.form.category_id) : (this.form.category_main ? parseInt(this.form.category_main) : null),
|
||||
unit_id: selectedUnit ? selectedUnit.id : null,
|
||||
unit_id: this.form.unit.id,
|
||||
customer_name: this.form.customer_name || null,
|
||||
phone: this.form.phone || null,
|
||||
};
|
||||
|
||||
const ticket = await app().apiPost('/api/tickets', payload);
|
||||
|
||||
|
||||
// Upload photos if any
|
||||
if (this.photoFiles.length > 0 && ticket?.id) {
|
||||
const formData = new FormData();
|
||||
|
||||
Reference in New Issue
Block a user