- Remove duplicate import - Fix → in create ticket - Fix → in ticket detail - Add note field to TicketUpdate schema and handle note-only updates in backend - Update frontend submitNote() to use PATCH endpoint
280 lines
16 KiB
HTML
280 lines
16 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>
|
|
|
|
<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 || 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),
|
|
};
|
|
|
|
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 %}
|