Files
denya-onecare/app/templates/tickets/new.html
T
root 1dd88a141e feat: Wahab demo prep — Cancelled status, phone persistence, admin delete, users picker, detail-page fixes
- Add Cancelled as terminal status reachable from all active states; exclude
  from SLA breach reporting and dashboard active counts; add to status pickers
- Persist customer phone on ticket create/update (was silently dropped);
  add tickets.phone migration + legacy self-heal guard
- Add admin-only DELETE /api/tickets/{id} (removes timeline/photos/escalations)
- Add GET /api/auth/users for the assign-technician dropdown (was hardcoded)
- TicketOut now returns nested unit/category so the detail page stops showing
  '—' for Unit/Property/Category
- Ticket numbering uses max+1 so deletions never re-issue a number
- New-issue form: require Category and (standard mode) Priority client-side
- Tests: 11 new cases covering cancellation, SLA exemption, phone, delete,
  users endpoint, numbering
2026-08-02 14:06:30 +00:00

417 lines
24 KiB
HTML

{% 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>
<!-- 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 -->
<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 → 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="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>
<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 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'">
<input type="radio" name="priority" value="urgent" x-model="form.priority" @change="priorityAuto = false" 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" @change="priorityAuto = false" 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" @change="priorityAuto = false" 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" @change="priorityAuto = false" 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>
<!-- QR Code excluded per Sprint 3 scope -->
</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">&times;</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...' : (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>
</form>
</div>
</div>
<script>
function createTicket() {
return {
mode: 'standard',
form: {
customer_name: '',
phone: '',
property: '',
building: '',
apartment_code: '',
unit: null,
category_main: '',
category_id: null,
priority: '',
description: '',
reporter: '',
reported_via: ''
},
priorityAuto: false,
categories: [],
subCategories: [],
unitGroups: {},
buildings: [],
searchApartment: '',
apartmentOpen: false,
selectedCategoryHint: '',
photoFiles: [],
photoPreviews: [],
submitting: false,
error: '',
async init() {
await this.loadCategories();
await this.loadUnits();
},
// ── Report mode ──────────────────────────────────────────
setMode(mode) {
this.mode = mode;
// Reset category selection; priority follows mode
this.form.category_main = '';
this.form.category_id = null;
this.subCategories = [];
this.selectedCategoryHint = '';
if (mode === 'emergency') {
this.form.priority = 'urgent';
} else {
this.form.priority = '';
}
this.priorityAuto = false;
this.loadCategories();
},
async loadCategories() {
try {
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 loadUnits() {
try {
const data = await app().apiGet('/api/tickets/units/grouped');
this.unitGroups = data || {};
} catch (e) { console.error('Units load error', e); }
},
// ── Location cascade ─────────────────────────────────────
onPropertyChange() {
this.form.building = '';
this.form.apartment_code = '';
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; clear the auto-default
// when switching away so a non-Mould ticket doesn't keep it silently
if (parent?.name !== 'Mould & Damp' && this.priorityAuto) {
this.form.priority = '';
this.priorityAuto = false;
}
if (parent?.name === 'Mould & Damp' && !this.form.priority) {
this.form.priority = 'medium';
this.priorityAuto = true;
}
},
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.building || !this.form.unit || !this.form.description) {
this.error = 'Please fill in Property, Building, Apartment, and Description.';
this.submitting = false;
return;
}
if (!this.form.category_main && !this.form.category_id) {
this.error = 'Please select a Category — every issue needs one for correct routing and SLA.';
this.submitting = false;
return;
}
if (this.mode === 'standard' && !this.form.priority) {
this.error = 'Please select a Priority — without one the ticket gets no SLA deadline.';
this.submitting = false;
return;
}
// Create the ticket
const payload = {
description: this.form.description,
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: 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();
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 %}