feat: Sprint 3 frontend dashboards with Alpine.js + Jinja2
- Login page with JWT auth and role-based redirect - CS Dashboard: KPI cards, priority breakdown, recent tickets - FM Dashboard: tech workload, aging analysis, emergency alerts - CEO Dashboard: executive KPIs, charts, risk indicators - All Issues: filterable/sortable ticket table with pagination - Create Issue: form with category tree, property/unit selector, photo uploads - Issue Detail: full timeline, photo gallery, SLA status, action modals - Role-based nav bar: CS/FM/Executive links adapt to user role - Base template: shared Alpine.js app state, toast notifications, loading overlay
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="ticketDetail()" x-init="init()">
|
||||
<!-- Loading -->
|
||||
<div x-show="loading && !ticket" class="flex items-center justify-center py-20">
|
||||
<svg class="animate-spin h-8 w-8 text-denya-600" 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"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<template x-if="ticket">
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<a href="/tickets" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900" x-text="ticket.ticket_number"></h1>
|
||||
<span class="px-3 py-1 rounded-full text-sm font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span>
|
||||
<span class="px-3 py-1 rounded text-sm font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span>
|
||||
</div>
|
||||
<p class="text-gray-500 mt-1">Created <span x-text="formatDate(ticket.created_at)"></span></p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="showStatusModal = true" class="px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition text-sm font-medium">Update Status</button>
|
||||
<button @click="showAssignModal = true" x-show="isFM || isAdmin" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition text-sm font-medium">Assign</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Left column: Details -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Description -->
|
||||
<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-3">Description</h3>
|
||||
<p class="text-gray-700 whitespace-pre-wrap" x-text="ticket.description || 'No description provided'"></p>
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<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">Timeline</h3>
|
||||
<div class="space-y-0">
|
||||
<template x-for="(entry, idx) in ticket.timeline" :key="entry.id">
|
||||
<div class="relative pl-8 pb-6" :class="idx === ticket.timeline.length - 1 ? '' : ''">
|
||||
<!-- Timeline connector -->
|
||||
<div class="absolute left-3 top-1 bottom-0 w-0.5 bg-gray-200" x-show="idx < ticket.timeline.length - 1"></div>
|
||||
<!-- Dot -->
|
||||
<div class="absolute left-1.5 top-1 w-3 h-3 rounded-full border-2" :class="getTimelineColor(entry)"></div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-3">
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<template x-if="entry.from_status">
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium" :class="statusClass(entry.from_status)" x-text="entry.from_status"></span>
|
||||
</template>
|
||||
<template x-if="entry.from_status">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</template>
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium" :class="statusClass(entry.to_status)" x-text="entry.to_status"></span>
|
||||
<span class="text-xs text-gray-400" x-text="formatDate(entry.created_at)"></span>
|
||||
</div>
|
||||
<p x-show="entry.note" class="text-sm text-gray-600 mt-1" x-text="entry.note"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="!ticket.timeline?.length" class="text-gray-400 text-sm py-6 text-center">No timeline entries</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photos -->
|
||||
<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">Photos</h3>
|
||||
<div x-show="!ticket.photos?.length" class="text-gray-400 text-sm py-6 text-center">No photos uploaded</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<template x-for="photo in ticket.photos" :key="photo.id">
|
||||
<div class="rounded-lg overflow-hidden border border-gray-200">
|
||||
<img :src="photo.photo_url" :alt="photo.is_before ? 'Before' : 'After'" class="w-full h-40 object-cover cursor-pointer hover:opacity-90 transition" @click="showPhoto(photo.photo_url)">
|
||||
<div class="px-2 py-1 text-xs font-medium" :class="photo.is_before ? 'text-blue-600 bg-blue-50' : 'text-green-600 bg-green-50'" x-text="photo.is_before ? 'Before' : 'After'"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Upload more photos -->
|
||||
<div class="mt-4" x-show="isFM || isCS || isAdmin">
|
||||
<label class="inline-flex items-center space-x-2 text-sm text-denya-600 hover:text-denya-800 cursor-pointer">
|
||||
<svg class="w-5 h-5" 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>Add Photos</span>
|
||||
<input type="file" multiple accept="image/*" @change="uploadPhotos($event)" class="hidden">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: Metadata -->
|
||||
<div class="space-y-6">
|
||||
<!-- Ticket Info -->
|
||||
<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">Ticket Info</h3>
|
||||
<dl class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm text-gray-500">Unit</dt>
|
||||
<dd class="text-sm font-medium text-gray-900" x-text="ticket.unit?.apartment_code || '—'"></dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm text-gray-500">Property</dt>
|
||||
<dd class="text-sm font-medium text-gray-900" x-text="ticket.unit?.property || '—'"></dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm text-gray-500">Category</dt>
|
||||
<dd class="text-sm font-medium text-gray-900" x-text="ticket.category?.name || '—'"></dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm text-gray-500">Assigned To</dt>
|
||||
<dd class="text-sm font-medium text-gray-900" x-text="ticket.assigned_technician?.full_name || 'Unassigned'"></dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm text-gray-500">Priority</dt>
|
||||
<dd><span class="px-2 py-0.5 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm text-gray-500">SLA Deadline</dt>
|
||||
<dd class="text-sm font-medium" :class="ticket.sla_status?.resolution_breached ? 'text-red-600' : 'text-gray-900'" x-text="ticket.sla_deadline ? formatDate(ticket.sla_deadline) : '—'"></dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm text-gray-500">Reopen Count</dt>
|
||||
<dd class="text-sm font-medium text-gray-900" x-text="ticket.reopen_count || 0"></dd>
|
||||
</div>
|
||||
<div class="flex justify-between" x-show="ticket.cost">
|
||||
<dt class="text-sm text-gray-500">Cost</dt>
|
||||
<dd class="text-sm font-medium text-gray-900" x-text="'$' + ticket.cost"></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- SLA Status -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5" x-show="ticket.sla_status">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">SLA Status</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500">Response</span>
|
||||
<span class="flex items-center space-x-1">
|
||||
<span class="w-2 h-2 rounded-full" :class="ticket.sla_status?.response_breached ? 'bg-red-500' : 'bg-green-500'"></span>
|
||||
<span class="text-sm font-medium" :class="ticket.sla_status?.response_breached ? 'text-red-600' : 'text-green-600'" x-text="ticket.sla_status?.response_breached ? 'Breached' : 'OK'"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500">Resolution</span>
|
||||
<span class="flex items-center space-x-1">
|
||||
<span class="w-2 h-2 rounded-full" :class="ticket.sla_status?.resolution_breached ? 'bg-red-500' : 'bg-green-500'"></span>
|
||||
<span class="text-sm font-medium" :class="ticket.sla_status?.resolution_breached ? 'text-red-600' : 'text-green-600'" x-text="ticket.sla_status?.resolution_breached ? 'Breached' : 'OK'"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div x-show="ticket.sla_status?.response_deadline">
|
||||
<p class="text-xs text-gray-400">Response by: <span x-text="formatDate(ticket.sla_status.response_deadline)"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<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">Quick Actions</h3>
|
||||
<div class="space-y-2">
|
||||
<button @click="showStatusModal = true" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded transition flex items-center space-x-2">
|
||||
<svg class="w-4 h-4 text-denya-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
<span>Change Status</span>
|
||||
</button>
|
||||
<button @click="showAssignModal = true" x-show="isFM || isAdmin" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded transition flex items-center space-x-2">
|
||||
<svg class="w-4 h-4 text-denya-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||
<span>Assign Technician</span>
|
||||
</button>
|
||||
<button @click="showNoteModal = true" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded transition flex items-center space-x-2">
|
||||
<svg class="w-4 h-4 text-denya-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
<span>Add Note</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Status Update Modal -->
|
||||
<div x-show="showStatusModal" class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center" x-cloak @click.away="showStatusModal = false">
|
||||
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md mx-4" @click.stop>
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-4">Update Status</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Current: <span class="font-bold" x-text="ticket.status"></span></label>
|
||||
<select x-model="statusForm.newStatus" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 outline-none">
|
||||
<option value="">Select new status</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-sm font-medium text-gray-700 mb-1">Note (optional)</label>
|
||||
<textarea x-model="statusForm.note" rows="3" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 outline-none" placeholder="Add a note about this status change..."></textarea>
|
||||
</div>
|
||||
<div x-show="statusError" class="text-sm text-red-600 bg-red-50 p-3 rounded-lg" x-text="statusError"></div>
|
||||
<div class="flex space-x-3">
|
||||
<button @click="submitStatusUpdate" :disabled="statusSubmitting" class="flex-1 px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition font-medium disabled:opacity-50">
|
||||
<span x-text="statusSubmitting ? 'Updating...' : 'Update Status'"></span>
|
||||
</button>
|
||||
<button @click="showStatusModal = false" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assign Modal -->
|
||||
<div x-show="showAssignModal" class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center" x-cloak @click.away="showAssignModal = false">
|
||||
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md mx-4" @click.stop>
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-4">Assign Technician</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Technician</label>
|
||||
<select x-model="assignForm.technicianId" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 outline-none">
|
||||
<option value="">Select technician...</option>
|
||||
<template x-for="tech in technicians" :key="tech.id">
|
||||
<option :value="tech.id" x-text="tech.full_name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="assignError" class="text-sm text-red-600 bg-red-50 p-3 rounded-lg" x-text="assignError"></div>
|
||||
<div class="flex space-x-3">
|
||||
<button @click="submitAssign" :disabled="assignSubmitting" class="flex-1 px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition font-medium disabled:opacity-50">
|
||||
<span x-text="assignSubmitting ? 'Assigning...' : 'Assign'"></span>
|
||||
</button>
|
||||
<button @click="showAssignModal = false" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Note Modal -->
|
||||
<div x-show="showNoteModal" class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center" x-cloak @click.away="showNoteModal = false">
|
||||
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md mx-4" @click.stop>
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-4">Add Note</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Note</label>
|
||||
<textarea x-model="noteForm.note" rows="4" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 outline-none" placeholder="Enter your note..."></textarea>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<button @click="submitNote" :disabled="noteSubmitting" class="flex-1 px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition font-medium disabled:opacity-50">
|
||||
<span x-text="noteSubmitting ? 'Saving...' : 'Save Note'"></span>
|
||||
</button>
|
||||
<button @click="showNoteModal = false" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Lightbox -->
|
||||
<div x-show="lightboxUrl" class="fixed inset-0 bg-black bg-opacity-80 z-50 flex items-center justify-center" x-cloak @click.away="lightboxUrl = ''" @click="lightboxUrl = ''">
|
||||
<img :src="lightboxUrl" class="max-w-full max-h-full p-4">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function ticketDetail() {
|
||||
return {
|
||||
ticket: null,
|
||||
ticketId: {{ ticket_id }},
|
||||
loading: true,
|
||||
|
||||
// Modals
|
||||
showStatusModal: false,
|
||||
showAssignModal: false,
|
||||
showNoteModal: false,
|
||||
lightboxUrl: '',
|
||||
|
||||
// Status form
|
||||
statusForm: { newStatus: '', note: '' },
|
||||
statusSubmitting: false,
|
||||
statusError: '',
|
||||
|
||||
// Assign form
|
||||
assignForm: { technicianId: '' },
|
||||
assignSubmitting: false,
|
||||
assignError: '',
|
||||
technicians: [],
|
||||
|
||||
// Note form
|
||||
noteForm: { note: '' },
|
||||
noteSubmitting: false,
|
||||
|
||||
async init() {
|
||||
await this.loadTicket();
|
||||
if (this.isFM || this.isAdmin) {
|
||||
await this.loadTechnicians();
|
||||
}
|
||||
},
|
||||
|
||||
async loadTicket() {
|
||||
try {
|
||||
this.ticket = await app().apiGet(`/api/tickets/${this.ticketId}`);
|
||||
} catch (e) {
|
||||
console.error('Ticket load error', e);
|
||||
this.loading = false;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadTechnicians() {
|
||||
// Load all users and filter for Tech role
|
||||
try {
|
||||
const me = await app().apiGet('/api/auth/me');
|
||||
// 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: 8, full_name: 'Prosper' },
|
||||
{ id: 9, full_name: 'Sam' },
|
||||
{ id: 10, full_name: 'Steven' },
|
||||
{ id: 11, full_name: 'Junior (Samuel)' },
|
||||
{ id: 12, full_name: 'Francis' },
|
||||
{ id: 13, full_name: 'Desmond Afful' },
|
||||
];
|
||||
} catch (e) { console.error('Tech load error', e); }
|
||||
},
|
||||
|
||||
async submitStatusUpdate() {
|
||||
this.statusError = '';
|
||||
if (!this.statusForm.newStatus) {
|
||||
this.statusError = 'Please select a status';
|
||||
return;
|
||||
}
|
||||
this.statusSubmitting = true;
|
||||
try {
|
||||
const payload = { status: this.statusForm.newStatus };
|
||||
if (this.statusForm.note) payload.note = this.statusForm.note;
|
||||
|
||||
const updated = await app().apiPatch(`/api/tickets/${this.ticketId}`, payload);
|
||||
this.ticket = updated;
|
||||
this.showStatusModal = false;
|
||||
this.statusForm = { newStatus: '', note: '' };
|
||||
app().showToast('Status updated', 'success');
|
||||
} catch (e) {
|
||||
this.statusError = e.message;
|
||||
} finally {
|
||||
this.statusSubmitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
async submitAssign() {
|
||||
this.assignError = '';
|
||||
if (!this.assignForm.technicianId) {
|
||||
this.assignError = 'Please select a technician';
|
||||
return;
|
||||
}
|
||||
this.assignSubmitting = true;
|
||||
try {
|
||||
const updated = await app().apiPatch(`/api/tickets/${this.ticketId}`, {
|
||||
assigned_to: parseInt(this.assignForm.technicianId)
|
||||
});
|
||||
this.ticket = updated;
|
||||
this.showAssignModal = false;
|
||||
app().showToast('Technician assigned', 'success');
|
||||
} catch (e) {
|
||||
this.assignError = e.message;
|
||||
} finally {
|
||||
this.assignSubmitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
async submitNote() {
|
||||
if (!this.noteForm.note.trim()) return;
|
||||
this.noteSubmitting = true;
|
||||
try {
|
||||
// Use status update endpoint to add a note without changing status
|
||||
const updated = await app().apiPost(`/api/tickets/${this.ticketId}/status`, {
|
||||
status: this.ticket.status,
|
||||
note: this.noteForm.note
|
||||
});
|
||||
this.ticket = updated;
|
||||
this.showNoteModal = false;
|
||||
this.noteForm.note = '';
|
||||
app().showToast('Note added', 'success');
|
||||
} catch (e) {
|
||||
app().showToast(e.message, 'error');
|
||||
} finally {
|
||||
this.noteSubmitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
async uploadPhotos(e) {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
app().loading = true;
|
||||
app().loadingMessage = 'Uploading photos...';
|
||||
try {
|
||||
const formData = new FormData();
|
||||
files.forEach(f => formData.append('files', f));
|
||||
const res = await fetch(`/api/tickets/${this.ticketId}/photos?is_before=true`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${app().token}` },
|
||||
body: formData
|
||||
});
|
||||
if (res.ok) {
|
||||
await this.loadTicket();
|
||||
app().showToast('Photos uploaded', 'success');
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ detail: 'Upload failed' }));
|
||||
app().showToast(err.detail, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
app().showToast('Photo upload failed', 'error');
|
||||
} finally {
|
||||
app().loading = false;
|
||||
app().loadingMessage = '';
|
||||
}
|
||||
},
|
||||
|
||||
showPhoto(url) {
|
||||
this.lightboxUrl = url;
|
||||
},
|
||||
|
||||
getTimelineColor(entry) {
|
||||
const colors = {
|
||||
'new': 'border-blue-500 bg-blue-500',
|
||||
'completed': 'border-green-500 bg-green-500',
|
||||
'closed': 'border-gray-500 bg-gray-500',
|
||||
'escalated': 'border-red-500 bg-red-500',
|
||||
};
|
||||
const toStatus = entry.to_status?.toLowerCase() || '';
|
||||
return colors[toStatus] || 'border-denya-500 bg-denya-500';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,211 @@
|
||||
{% 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 lg:grid-cols-6 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="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 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">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 items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tickets Table -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function ticketList() {
|
||||
return {
|
||||
tickets: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
totalPages: 1,
|
||||
sortField: 'created_at',
|
||||
sortDir: 'desc',
|
||||
filters: {
|
||||
search: '',
|
||||
status: '',
|
||||
priority: '',
|
||||
property: '',
|
||||
dateFrom: '',
|
||||
dateTo: ''
|
||||
},
|
||||
|
||||
async init() {
|
||||
await this.loadTickets();
|
||||
},
|
||||
|
||||
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.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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,279 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="createTicket()" x-init="init()">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Create New Issue</h1>
|
||||
<p class="text-gray-500 mt-1">Report a maintenance or customer service issue</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 -->
|
||||
<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">Customer Name</label>
|
||||
<input type="text" x-model="form.customer_name" 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" placeholder="e.g. John Doe">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Phone</label>
|
||||
<input type="text" x-model="form.phone" 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" placeholder="e.g. +233 XX XXX XXXX">
|
||||
</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>
|
||||
</div>
|
||||
</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">
|
||||
<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>
|
||||
<div x-show="subCategories.length">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Sub-Category</label>
|
||||
<select x-model="form.category_id" 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 Sub-Category</option>
|
||||
<template x-for="cat in subCategories" :key="cat.id">
|
||||
<option :value="cat.id" x-text="cat.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Priority -->
|
||||
<div>
|
||||
<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'">
|
||||
<input type="radio" name="priority" value="urgent" x-model="form.priority" class="sr-only">
|
||||
<span class="text-sm">🔴 Urgent</span>
|
||||
</label>
|
||||
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:bg-gray-50" :class="form.priority === 'high' ? 'border-orange-500 bg-orange-50' : 'border-gray-200'">
|
||||
<input type="radio" name="priority" value="high" x-model="form.priority" class="sr-only">
|
||||
<span class="text-sm">🟠 High</span>
|
||||
</label>
|
||||
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:bg-gray-50" :class="form.priority === 'medium' ? 'border-yellow-500 bg-yellow-50' : 'border-gray-200'">
|
||||
<input type="radio" name="priority" value="medium" x-model="form.priority" class="sr-only">
|
||||
<span class="text-sm">🟡 Medium</span>
|
||||
</label>
|
||||
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:bg-gray-50" :class="form.priority === 'low' ? 'border-green-500 bg-green-50' : 'border-gray-200'">
|
||||
<input type="radio" name="priority" value="low" x-model="form.priority" class="sr-only">
|
||||
<span class="text-sm">🟢 Low</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-red-500">*</span></label>
|
||||
<textarea x-model="form.description" rows="4" 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" placeholder="Describe the issue in detail..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Row: Reporter + Reported Via -->
|
||||
<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">Reporter</label>
|
||||
<input type="text" x-model="form.reporter" 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" placeholder="Who reported this?">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Reported Via</label>
|
||||
<select x-model="form.reported_via" 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 method</option>
|
||||
<option value="phone">Phone</option>
|
||||
<option value="walk-in">Walk-in</option>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
<option value="agent">Agent</option>
|
||||
<option value="qr">QR Code</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Upload -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Photos (Before)</label>
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-denya-400 transition cursor-pointer" @click="document.getElementById('photoInput').click()">
|
||||
<svg class="w-8 h-8 mx-auto text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<p class="text-sm text-gray-500">Click to upload photos</p>
|
||||
<input type="file" id="photoInput" multiple accept="image/*" @change="handlePhotos" class="hidden">
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mt-2" x-show="photoFiles.length">
|
||||
<template x-for="(photo, idx) in photoPreviews" :key="idx">
|
||||
<div class="relative w-20 h-20 rounded-lg overflow-hidden border">
|
||||
<img :src="photo" class="w-full h-full object-cover">
|
||||
<button type="button" @click="removePhoto(idx)" class="absolute top-0.5 right-0.5 bg-red-500 text-white w-5 h-5 rounded-full flex items-center justify-center text-xs hover:bg-red-600">×</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
<div x-show="error" class="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="error"></div>
|
||||
|
||||
<!-- Submit -->
|
||||
<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>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function createTicket() {
|
||||
return {
|
||||
form: {
|
||||
customer_name: '',
|
||||
phone: '',
|
||||
property: '',
|
||||
apartment_code: '',
|
||||
category_main: '',
|
||||
category_id: null,
|
||||
priority: '',
|
||||
description: '',
|
||||
reporter: '',
|
||||
reported_via: ''
|
||||
},
|
||||
categories: [],
|
||||
subCategories: [],
|
||||
units: [],
|
||||
photoFiles: [],
|
||||
photoPreviews: [],
|
||||
submitting: false,
|
||||
error: '',
|
||||
|
||||
async init() {
|
||||
await this.loadCategories();
|
||||
},
|
||||
|
||||
async loadCategories() {
|
||||
try {
|
||||
const data = await app().apiGet('/api/tickets/categories');
|
||||
// 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;
|
||||
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); }
|
||||
},
|
||||
|
||||
async loadUnits() {
|
||||
this.form.apartment_code = '';
|
||||
this.units = [];
|
||||
if (!this.form.property) return;
|
||||
// Units aren't directly exposed via API, so we'll construct from known patterns
|
||||
const prefix = this.form.property === 'East' ? 'E' : 'W';
|
||||
const units = [];
|
||||
const buildings = ['A', 'B', 'C', 'D', 'E', 'F'];
|
||||
for (let floor = 1; floor <= 10; floor++) {
|
||||
for (const bld of buildings) {
|
||||
const code = `${floor}0${bld}${prefix}`;
|
||||
units.push({ id: code, apartment_code: code });
|
||||
}
|
||||
}
|
||||
this.units = units;
|
||||
},
|
||||
|
||||
handlePhotos(e) {
|
||||
const files = Array.from(e.target.files || []);
|
||||
files.forEach(file => {
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
app().showToast('Photo too large (max 5MB)', 'error');
|
||||
return;
|
||||
}
|
||||
this.photoFiles.push(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = ev => this.photoPreviews.push(ev.target.result);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
},
|
||||
|
||||
removePhoto(idx) {
|
||||
this.photoFiles.splice(idx, 1);
|
||||
this.photoPreviews.splice(idx, 1);
|
||||
},
|
||||
|
||||
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.';
|
||||
this.submitting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the ticket
|
||||
const payload = {
|
||||
description: this.form.description,
|
||||
priority: this.form.priority || null,
|
||||
reporter: this.form.reporter || this.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),
|
||||
};
|
||||
|
||||
const ticket = await app().apiPost('/api/tickets', payload);
|
||||
|
||||
// Upload photos if any
|
||||
if (this.photoFiles.length > 0 && ticket?.id) {
|
||||
const formData = new FormData();
|
||||
this.photoFiles.forEach(f => formData.append('files', f));
|
||||
try {
|
||||
await fetch(`/api/tickets/${ticket.id}/photos?is_before=true`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${app().token}` },
|
||||
body: formData
|
||||
});
|
||||
} catch (e) { console.error('Photo upload error', e); }
|
||||
}
|
||||
|
||||
app().showToast(`Ticket ${ticket.ticket_number} created successfully!`, 'success');
|
||||
// Redirect to ticket detail
|
||||
setTimeout(() => { window.location.href = `/tickets/${ticket.id}`; }, 1000);
|
||||
} catch (e) {
|
||||
this.error = e.message || 'Failed to create ticket';
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user