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).
408 lines
24 KiB
HTML
408 lines
24 KiB
HTML
{% extends "base.html" %}
|
|
{% block content %}
|
|
<div x-data="ticketList()" x-init="init()">
|
|
<div class="mb-6 flex items-center justify-between">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-gray-900">All Issues</h1>
|
|
<p class="text-gray-500 mt-1" x-text="`${total} total tickets`"></p>
|
|
</div>
|
|
<a href="/tickets/new" class="px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition text-sm font-medium flex items-center space-x-1">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
|
<span>New Issue</span>
|
|
</a>
|
|
</div>
|
|
|
|
<!-- 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 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">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-gray-500 font-medium mb-1">Status</label>
|
|
<select x-model="filters.status" @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">
|
|
<option value="">All Statuses</option>
|
|
<option value="New">New</option>
|
|
<option value="Logged">Logged</option>
|
|
<option value="Triage">Triage</option>
|
|
<option value="Assigned">Assigned</option>
|
|
<option value="Accepted">Accepted</option>
|
|
<option value="Travelling">Travelling</option>
|
|
<option value="On Site">On Site</option>
|
|
<option value="In Progress">In Progress</option>
|
|
<option value="Waiting Parts">Waiting Parts</option>
|
|
<option value="Escalated">Escalated</option>
|
|
<option value="Completed">Completed</option>
|
|
<option value="On-Field Verification">On-Field Verification</option>
|
|
<option value="Wahab Review">Wahab Review</option>
|
|
<option value="Closed">Closed</option>
|
|
<option value="Reopened">Reopened</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-gray-500 font-medium mb-1">Priority</label>
|
|
<select x-model="filters.priority" @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">
|
|
<option value="">All Priorities</option>
|
|
<option value="urgent">🔴 Urgent</option>
|
|
<option value="high">🟠 High</option>
|
|
<option value="medium">🟡 Medium</option>
|
|
<option value="low">🟢 Low</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-gray-500 font-medium mb-1">Property</label>
|
|
<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">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-gray-500 font-medium mb-1">To</label>
|
|
<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 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="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 (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">
|
|
<tr>
|
|
<th class="px-5 py-3 text-left cursor-pointer hover:text-gray-900" @click="sortBy('ticket_number')">
|
|
Ticket <span x-show="sortField === 'ticket_number'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
|
</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 cursor-pointer hover:text-gray-900" @click="sortBy('created_at')">
|
|
Created <span x-show="sortField === 'created_at'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
|
</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 tickets" :key="ticket.id">
|
|
<tr class="hover:bg-gray-50 transition cursor-pointer" :class="ticket.priority === 'urgent' ? 'bg-red-50/50' : ''" @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>
|
|
<tr x-show="!tickets.length && !loading">
|
|
<td colspan="7" class="px-5 py-16 text-center text-gray-400">
|
|
<svg class="w-12 h-12 mx-auto text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
|
|
No tickets match your filters
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</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>
|
|
function ticketList() {
|
|
return {
|
|
tickets: [],
|
|
total: 0,
|
|
page: 1,
|
|
pageSize: 50,
|
|
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)}`;
|
|
|
|
const data = await app().apiGet(url);
|
|
if (data) {
|
|
this.tickets = data.items || [];
|
|
this.total = data.total || 0;
|
|
this.totalPages = Math.ceil(this.total / this.pageSize) || 1;
|
|
|
|
// Client-side search filter for ticket number or description
|
|
if (this.filters.search) {
|
|
const q = this.filters.search.toLowerCase();
|
|
this.tickets = this.tickets.filter(t =>
|
|
(t.ticket_number && t.ticket_number.toLowerCase().includes(q)) ||
|
|
(t.description && t.description.toLowerCase().includes(q))
|
|
);
|
|
}
|
|
|
|
// Sort
|
|
this.applySort();
|
|
}
|
|
} catch (e) { console.error('Tickets load error', e); }
|
|
},
|
|
|
|
sortBy(field) {
|
|
if (this.sortField === field) {
|
|
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
this.sortField = field;
|
|
this.sortDir = 'desc';
|
|
}
|
|
this.applySort();
|
|
},
|
|
|
|
applySort() {
|
|
this.tickets.sort((a, b) => {
|
|
let valA = a[this.sortField] || '';
|
|
let valB = b[this.sortField] || '';
|
|
if (this.sortField === 'created_at') {
|
|
valA = new Date(valA).getTime();
|
|
valB = new Date(valB).getTime();
|
|
}
|
|
if (typeof valA === 'string') valA = valA.toLowerCase();
|
|
if (typeof valB === 'string') valB = valB.toLowerCase();
|
|
if (valA < valB) return this.sortDir === 'asc' ? -1 : 1;
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
{% endblock %}
|