- Fix detail.html to use assigned_technician_name (not .assigned_technician?.full_name) - Remove double error toast in submitNote() catch block - Replace wasteful API calls in CEO dashboard with units+data approach - Remove QR Code option from reported_via dropdown (excluded per scope)
457 lines
28 KiB
HTML
457 lines
28 KiB
HTML
{% 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_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 (app().isFM || app().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: 9, full_name: 'Prosper' },
|
|
{ id: 10, full_name: 'Sam' },
|
|
{ id: 11, full_name: 'Steven' },
|
|
{ id: 12, full_name: 'Junior (Samuel)' },
|
|
{ id: 13, full_name: 'Francis' },
|
|
{ id: 14, full_name: 'Desmond Afful' },
|
|
{ id: 15, full_name: 'Afful' },
|
|
];
|
|
} 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 PATCH endpoint which now supports note-only updates
|
|
const updated = await app().apiPatch(`/api/tickets/${this.ticketId}`, {
|
|
note: this.noteForm.note
|
|
});
|
|
this.ticket = updated;
|
|
this.showNoteModal = false;
|
|
this.noteForm.note = '';
|
|
app().showToast('Note added', 'success');
|
|
} catch (e) {
|
|
// No need to show error here — api() base method already shows it
|
|
} 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 %}
|