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:
@@ -53,6 +53,19 @@ Users, units, and categories are auto-seeded on first startup via lifespan hook:
|
||||
| POST | `/api/whatsapp/mock` | No | Mock WhatsApp |
|
||||
| GET | `/api/whatsapp/mock-log` | No | Recent mock submissions |
|
||||
|
||||
### Pages (Sprint 3) — Jinja2 templates at `app/templates/`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/login` | No | Login page |
|
||||
| GET | `/dashboard/cs` | Client | CS dashboard |
|
||||
| GET | `/dashboard/fm` | Client | FM dashboard |
|
||||
| GET | `/dashboard/ceo` | Client | CEO dashboard |
|
||||
| GET | `/tickets` | Client | All Issues filterable table |
|
||||
| GET | `/tickets/new` | Client | Create Issue form |
|
||||
| GET | `/tickets/{id}` | Client | Issue detail with timeline |
|
||||
|
||||
Frontend: Alpine.js (CDN) + Tailwind CSS (CDN). Auth state in localStorage. Role-based nav routing in `base.html`.
|
||||
|
||||
### Tickets (Sprint 2)
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
|
||||
+2
-1
@@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base, async_session_factory, engine
|
||||
from app.routers import auth, health, tickets, whatsapp
|
||||
from app.routers import auth, health, pages, tickets, whatsapp
|
||||
from app.services.seed import seed_categories, seed_units, seed_users
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -61,3 +61,4 @@ app.include_router(health.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(whatsapp.router)
|
||||
app.include_router(tickets.router)
|
||||
app.include_router(pages.router)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Frontend page routes — serve Jinja2 HTML templates for the SPA-like dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
router = APIRouter(tags=["pages"])
|
||||
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request):
|
||||
return templates.TemplateResponse(request, "login.html")
|
||||
|
||||
|
||||
@router.get("/dashboard/cs", response_class=HTMLResponse)
|
||||
async def cs_dashboard(request: Request):
|
||||
return templates.TemplateResponse(request, "dashboard/cs.html")
|
||||
|
||||
|
||||
@router.get("/dashboard/fm", response_class=HTMLResponse)
|
||||
async def fm_dashboard(request: Request):
|
||||
return templates.TemplateResponse(request, "dashboard/fm.html")
|
||||
|
||||
|
||||
@router.get("/dashboard/ceo", response_class=HTMLResponse)
|
||||
async def ceo_dashboard(request: Request):
|
||||
return templates.TemplateResponse(request, "dashboard/ceo.html")
|
||||
|
||||
|
||||
@router.get("/tickets", response_class=HTMLResponse)
|
||||
async def ticket_list(request: Request):
|
||||
return templates.TemplateResponse(request, "tickets/list.html")
|
||||
|
||||
|
||||
@router.get("/tickets/new", response_class=HTMLResponse)
|
||||
async def create_ticket_page(request: Request):
|
||||
return templates.TemplateResponse(request, "tickets/new.html")
|
||||
|
||||
|
||||
@router.get("/tickets/{ticket_id}", response_class=HTMLResponse)
|
||||
async def ticket_detail_page(request: Request, ticket_id: int):
|
||||
return templates.TemplateResponse(request, "tickets/detail.html", context={"ticket_id": ticket_id})
|
||||
@@ -0,0 +1,356 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Denya OneCare</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
denya: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
.status-new { @apply bg-gray-100 text-gray-800; }
|
||||
.status-logged { @apply bg-blue-100 text-blue-800; }
|
||||
.status-triage { @apply bg-yellow-100 text-yellow-800; }
|
||||
.status-assigned { @apply bg-indigo-100 text-indigo-800; }
|
||||
.status-accepted { @apply bg-purple-100 text-purple-800; }
|
||||
.status-travelling { @apply bg-cyan-100 text-cyan-800; }
|
||||
.status-onsite { @apply bg-teal-100 text-teal-800; }
|
||||
.status-inprogress { @apply bg-sky-100 text-sky-800; }
|
||||
.status-waitingparts { @apply bg-orange-100 text-orange-800; }
|
||||
.status-escalated { @apply bg-red-100 text-red-800; }
|
||||
.status-completed { @apply bg-green-100 text-green-800; }
|
||||
.status-on-field-verification { @apply bg-emerald-100 text-emerald-800; }
|
||||
.status-wahab-review { @apply bg-violet-100 text-violet-800; }
|
||||
.status-closed { @apply bg-gray-200 text-gray-600; }
|
||||
.status-reopened { @apply bg-pink-100 text-pink-800; }
|
||||
.priority-urgent { @apply bg-red-100 text-red-800 border-red-300; }
|
||||
.priority-high { @apply bg-orange-100 text-orange-800 border-orange-300; }
|
||||
.priority-medium { @apply bg-yellow-100 text-yellow-800 border-yellow-300; }
|
||||
.priority-low { @apply bg-green-100 text-green-800 border-green-300; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="app()" x-init="init()">
|
||||
<!-- Nav Bar -->
|
||||
<nav class="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-50" x-show="isLoggedIn" x-cloak>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Left side -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/dashboard/cs" class="flex items-center space-x-2 text-denya-700 font-bold text-lg">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
<span>Denya OneCare</span>
|
||||
</a>
|
||||
<!-- CS Links -->
|
||||
<template x-if="isCS">
|
||||
<div class="hidden md:flex space-x-1 ml-6">
|
||||
<a href="/dashboard/cs" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/cs' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a>
|
||||
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">All Issues</a>
|
||||
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50">Create Issue</a>
|
||||
</div>
|
||||
</template>
|
||||
<!-- FM Links -->
|
||||
<template x-if="isFM">
|
||||
<div class="hidden md:flex space-x-1 ml-6">
|
||||
<a href="/dashboard/fm" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/fm' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a>
|
||||
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">All Issues</a>
|
||||
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50">Create Issue</a>
|
||||
</div>
|
||||
</template>
|
||||
<!-- CEO/Director Links -->
|
||||
<template x-if="isExecutive">
|
||||
<div class="hidden md:flex space-x-1 ml-6">
|
||||
<a href="/dashboard/ceo" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a>
|
||||
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Issues</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Right side -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm text-gray-600 hidden md:block" x-text="`${user.full_name} (${user.role})`"></span>
|
||||
<button @click="logout()" class="px-3 py-1.5 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Nav (CS) -->
|
||||
<div class="md:hidden border-b bg-white" x-show="isLoggedIn" x-cloak>
|
||||
<template x-if="isCS || isFM">
|
||||
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
|
||||
<a href="/dashboard/cs" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/cs' ? 'bg-denya-50 text-denya-700' : 'text-gray-500'" x-text="isCS ? 'CS Dashboard' : 'FM Dashboard'"></a>
|
||||
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-50 text-denya-700' : 'text-gray-500'">Issues</a>
|
||||
<a href="/tickets/new" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-500">New Issue</a>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="isExecutive">
|
||||
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
|
||||
<a href="/dashboard/ceo" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-50 text-denya-700' : 'text-gray-500'">CEO Dashboard</a>
|
||||
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-500">Issues</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div x-show="loading" class="fixed inset-0 bg-black bg-opacity-30 z-50 flex items-center justify-center" x-cloak>
|
||||
<div class="bg-white rounded-lg p-6 flex items-center space-x-3 shadow-xl">
|
||||
<svg class="animate-spin h-6 w-6 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>
|
||||
<span class="text-gray-700 font-medium" x-text="loadingMessage || 'Loading...'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<div class="fixed bottom-4 right-4 z-50 space-y-2">
|
||||
<template x-for="toast in toasts" :key="toast.id">
|
||||
<div class="px-4 py-3 rounded-lg shadow-lg text-white text-sm font-medium transition-all duration-300"
|
||||
:class="{'bg-green-600': toast.type === 'success', 'bg-red-600': toast.type === 'error', 'bg-blue-600': toast.type === 'info', 'bg-yellow-600': toast.type === 'warning'}"
|
||||
x-init="setTimeout(() => { toasts = toasts.filter(t => t.id !== toast.id) }, toast.duration || 4000)">
|
||||
<span x-text="toast.message"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function app() {
|
||||
return {
|
||||
// Auth state
|
||||
user: JSON.parse(localStorage.getItem('user') || '{}'),
|
||||
token: localStorage.getItem('access_token') || null,
|
||||
isLoggedIn: !!localStorage.getItem('access_token'),
|
||||
currentPath: window.location.pathname,
|
||||
|
||||
// UI state
|
||||
loading: false,
|
||||
loadingMessage: '',
|
||||
toasts: [],
|
||||
|
||||
// Role helpers
|
||||
get isCS() { return ['CS Rep', 'CS Manager'].includes(this.user.role) },
|
||||
get isFM() { return ['FM Dispatcher', 'Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
|
||||
get isExecutive() { return ['CEO', 'Director'].includes(this.user.role) },
|
||||
get isAdmin() { return ['Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
|
||||
|
||||
init() {
|
||||
// Redirect to login if not logged in (skip for login page)
|
||||
if (!this.isLoggedIn && this.currentPath !== '/login') {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
// Verify token on load
|
||||
if (this.isLoggedIn) {
|
||||
this.fetchMe();
|
||||
}
|
||||
},
|
||||
|
||||
async fetchMe() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me', {
|
||||
headers: this.authHeaders()
|
||||
});
|
||||
if (res.ok) {
|
||||
const userData = await res.json();
|
||||
this.user = userData;
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
} else if (res.status === 401) {
|
||||
this.logout();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Auth check failed', e);
|
||||
}
|
||||
},
|
||||
|
||||
authHeaders() {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
||||
return headers;
|
||||
},
|
||||
|
||||
async api(method, url, body = null) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const opts = {
|
||||
method,
|
||||
headers: this.authHeaders()
|
||||
};
|
||||
if (body && !(body instanceof FormData)) {
|
||||
opts.body = JSON.stringify(body);
|
||||
} else if (body instanceof FormData) {
|
||||
opts.body = body;
|
||||
delete opts.headers['Content-Type']; // Let browser set multipart boundary
|
||||
opts.headers = { 'Authorization': `Bearer ${this.token}` };
|
||||
}
|
||||
const res = await fetch(url, opts);
|
||||
if (res.status === 401 && this.currentPath !== '/login') {
|
||||
this.logout();
|
||||
return null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
this.showToast(e.message, 'error');
|
||||
throw e;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
apiGet(url) { return this.api('GET', url); },
|
||||
apiPost(url, body) { return this.api('POST', url, body); },
|
||||
apiPatch(url, body) { return this.api('PATCH', url, body); },
|
||||
apiDelete(url) { return this.api('DELETE', url); },
|
||||
|
||||
async login(email, password) {
|
||||
try {
|
||||
this.loading = true;
|
||||
this.loadingMessage = 'Signing in...';
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Login failed' }));
|
||||
throw new Error(err.detail);
|
||||
}
|
||||
const data = await res.json();
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
this.token = data.access_token;
|
||||
|
||||
// Fetch user info
|
||||
const me = await this.apiGet('/api/auth/me');
|
||||
this.user = me;
|
||||
localStorage.setItem('user', JSON.stringify(me));
|
||||
this.isLoggedIn = true;
|
||||
|
||||
// Role-based redirect
|
||||
const role = me.role;
|
||||
if (['CS Rep', 'CS Manager'].includes(role)) {
|
||||
window.location.href = '/dashboard/cs';
|
||||
} else if (['FM Dispatcher', 'Admin/Jerome', 'Admin/Wahab'].includes(role)) {
|
||||
window.location.href = '/dashboard/fm';
|
||||
} else if (['CEO', 'Director'].includes(role)) {
|
||||
window.location.href = '/dashboard/ceo';
|
||||
} else {
|
||||
window.location.href = '/tickets';
|
||||
}
|
||||
} catch (e) {
|
||||
this.showToast(e.message, 'error');
|
||||
throw e;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.loadingMessage = '';
|
||||
}
|
||||
},
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
this.user = {};
|
||||
this.token = null;
|
||||
this.isLoggedIn = false;
|
||||
window.location.href = '/login';
|
||||
},
|
||||
|
||||
showToast(message, type = 'info', duration = 4000) {
|
||||
const id = Date.now() + Math.random();
|
||||
this.toasts.push({ id, message, type, duration });
|
||||
},
|
||||
|
||||
statusClass(status) {
|
||||
if (!status) return 'bg-gray-100 text-gray-800';
|
||||
const map = {
|
||||
'new': 'status-new',
|
||||
'logged': 'status-logged',
|
||||
'triage': 'status-triage',
|
||||
'assigned': 'status-assigned',
|
||||
'accepted': 'status-accepted',
|
||||
'travelling': 'status-travelling',
|
||||
'on site': 'status-onsite',
|
||||
'in progress': 'status-inprogress',
|
||||
'waiting parts': 'status-waitingparts',
|
||||
'escalated': 'status-escalated',
|
||||
'completed': 'status-completed',
|
||||
'on-field verification': 'status-on-field-verification',
|
||||
'wahab review': 'status-wahab-review',
|
||||
'closed': 'status-closed',
|
||||
'reopened': 'status-reopened'
|
||||
};
|
||||
return map[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
|
||||
},
|
||||
|
||||
priorityBadge(priority) {
|
||||
const labels = { urgent: '🔴 Urgent', high: '🟠 High', medium: '🟡 Medium', low: '🟢 Low' };
|
||||
return labels[priority] || priority;
|
||||
},
|
||||
|
||||
priorityClass(priority) {
|
||||
const map = { urgent: 'priority-urgent', high: 'priority-high', medium: 'priority-medium', low: 'priority-low' };
|
||||
return map[priority] || 'bg-gray-100 text-gray-800';
|
||||
},
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
},
|
||||
|
||||
formatDateShort(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
},
|
||||
|
||||
timeSince(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const now = new Date();
|
||||
const d = new Date(dateStr);
|
||||
const diffMs = now - d;
|
||||
const diffHrs = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (diffHrs < 1) return '< 1h';
|
||||
if (diffHrs < 24) return `${diffHrs}h`;
|
||||
return `${diffDays}d`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,283 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="ceoDashboard()" x-init="init()">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Executive Dashboard</h1>
|
||||
<p class="text-gray-500 mt-1">Read-only strategic overview</p>
|
||||
</div>
|
||||
|
||||
<!-- Executive KPI Cards -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Open Tickets</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.openTickets || 0"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Critical (Urgent)</p>
|
||||
<p class="text-3xl font-bold text-red-600 mt-1" x-text="kpi.criticalCount || 0"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">SLA Compliance</p>
|
||||
<p class="text-3xl font-bold mt-1" :class="kpi.slaCompliance >= 90 ? 'text-green-600' : kpi.slaCompliance >= 70 ? 'text-yellow-600' : 'text-red-600'" x-text="(kpi.slaCompliance || 0) + '%'"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Avg Resolution</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.avgResolutionTime || '—'"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Avg Response</p>
|
||||
<p class="text-2xl font-bold text-gray-900 mt-1" x-text="kpi.avgResponseTime || '—'"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Reopened This Week</p>
|
||||
<p class="text-2xl font-bold text-orange-600 mt-1" x-text="kpi.reopenedThisWeek || 0"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Total Tickets</p>
|
||||
<p class="text-2xl font-bold text-gray-900 mt-1" x-text="kpi.totalTickets || 0"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Completion Rate</p>
|
||||
<p class="text-2xl font-bold mt-1" :class="kpi.completionRate >= 70 ? 'text-green-600' : 'text-yellow-600'" x-text="(kpi.completionRate || 0) + '%'"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Complaints by Property (Simple Bar) -->
|
||||
<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">Complaints by Property</h3>
|
||||
<div class="flex items-end space-x-6 h-40 px-2">
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<span class="text-lg font-bold text-denya-600" x-text="charts.byProperty?.east || 0"></span>
|
||||
<div class="w-full bg-denya-200 rounded-t-lg mt-1 transition-all" :style="'height: ' + Math.max((charts.byProperty?.east || 0) / Math.max(charts.byProperty?.max || 1, 1) * 120, 8) + 'px'" style="height: 8px;"></div>
|
||||
<span class="text-xs text-gray-500 mt-2">East</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<span class="text-lg font-bold text-denya-600" x-text="charts.byProperty?.west || 0"></span>
|
||||
<div class="w-full bg-denya-400 rounded-t-lg mt-1 transition-all" :style="'height: ' + Math.max((charts.byProperty?.west || 0) / Math.max(charts.byProperty?.max || 1, 1) * 120, 8) + 'px'" style="height: 8px;"></div>
|
||||
<span class="text-xs text-gray-500 mt-2">West</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Complaints by Category -->
|
||||
<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">Complaints by Category</h3>
|
||||
<div class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<template x-for="item in charts.byCategory" :key="item.name">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-xs text-gray-600 w-20 truncate" x-text="item.name"></span>
|
||||
<div class="flex-1 bg-gray-100 rounded-full h-4 overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all" :style="'width: ' + (item.count / Math.max(charts.byCategoryMax, 1) * 100) + '%'" :class="item.color || 'bg-denya-500'"></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-gray-600 w-8 text-right" x-text="item.count"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="!charts.byCategory?.length" class="text-gray-400 text-sm py-6 text-center">No data yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Priority Distribution -->
|
||||
<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">Tickets by Priority</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-red-600">🔴 Urgent</span>
|
||||
<span class="text-2xl font-bold" x-text="charts.byPriority?.urgent || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-orange-600">🟠 High</span>
|
||||
<span class="text-2xl font-bold" x-text="charts.byPriority?.high || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-yellow-600">🟡 Medium</span>
|
||||
<span class="text-2xl font-bold" x-text="charts.byPriority?.medium || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-green-600">🟢 Low</span>
|
||||
<span class="text-2xl font-bold" x-text="charts.byPriority?.low || 0"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Trends (Simple) -->
|
||||
<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">Monthly Trends</h3>
|
||||
<div class="flex items-end space-x-1 h-40 overflow-x-auto pb-2">
|
||||
<template x-for="(item, idx) in charts.monthlyTrend" :key="idx">
|
||||
<div class="flex-1 flex flex-col items-center min-w-[30px]">
|
||||
<span class="text-xs font-medium text-gray-600 mb-1" x-text="item.count"></span>
|
||||
<div class="w-full bg-denya-300 rounded-t transition-all" :style="'height: ' + Math.max(item.count / Math.max(charts.monthlyMax || 1, 1) * 100, 4) + 'px'"></div>
|
||||
<span class="text-xs text-gray-400 mt-1" x-text="item.month"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="!charts.monthlyTrend?.length" class="text-gray-400 text-sm py-6 text-center w-full">No data yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Risk Indicators -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-8">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Risk Indicators</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="p-3 rounded-lg" :class="indicators.openEmergencies > 0 ? 'bg-red-50 border border-red-200' : 'bg-green-50 border border-green-200'">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-3 h-3 rounded-full" :class="indicators.openEmergencies > 0 ? 'bg-red-500 animate-pulse' : 'bg-green-500'"></div>
|
||||
<span class="text-xs font-medium">Open Emergencies</span>
|
||||
</div>
|
||||
<p class="text-lg font-bold mt-1" x-text="indicators.openEmergencies || 0"></p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg" :class="indicators.reopenedThisWeek > 0 ? 'bg-orange-50 border border-orange-200' : 'bg-green-50 border border-green-200'">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-3 h-3 rounded-full" :class="indicators.reopenedThisWeek > 0 ? 'bg-orange-500' : 'bg-green-500'"></div>
|
||||
<span class="text-xs font-medium">Reopened This Week</span>
|
||||
</div>
|
||||
<p class="text-lg font-bold mt-1" x-text="indicators.reopenedThisWeek || 0"></p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg" :class="indicators.overdueJobs > 0 ? 'bg-red-50 border border-red-200' : 'bg-green-50 border border-green-200'">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-3 h-3 rounded-full" :class="indicators.overdueJobs > 0 ? 'bg-red-500' : 'bg-green-500'"></div>
|
||||
<span class="text-xs font-medium">Overdue (Past SLA)</span>
|
||||
</div>
|
||||
<p class="text-lg font-bold mt-1" x-text="indicators.overdueJobs || 0"></p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg" :class="indicators.pendingVerification > 0 ? 'bg-yellow-50 border border-yellow-200' : 'bg-green-50 border border-green-200'">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-3 h-3 rounded-full" :class="indicators.pendingVerification > 0 ? 'bg-yellow-500' : 'bg-green-500'"></div>
|
||||
<span class="text-xs font-medium">Pending Verification</span>
|
||||
</div>
|
||||
<p class="text-lg font-bold mt-1" x-text="indicators.pendingVerification || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function ceoDashboard() {
|
||||
return {
|
||||
kpi: {},
|
||||
charts: { byProperty: {}, byCategory: [], byPriority: {}, monthlyTrend: [], byCategoryMax: 1, monthlyMax: 1 },
|
||||
indicators: {},
|
||||
|
||||
async init() {
|
||||
await this.loadData();
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
try {
|
||||
const allData = await app().apiGet('/api/tickets?page_size=500');
|
||||
if (!allData?.items) return;
|
||||
const all = allData.items;
|
||||
const total = allData.total || all.length;
|
||||
|
||||
// Basic KPIs
|
||||
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
||||
const closed = all.filter(t => ['Closed', 'Completed'].includes(t.status));
|
||||
const urgent = all.filter(t => t.priority === 'urgent');
|
||||
const withSLA = all.filter(t => t.sla_deadline);
|
||||
const slaMet = withSLA.filter(t => new Date(t.sla_deadline) > new Date() || ['Closed', 'Completed'].includes(t.status));
|
||||
|
||||
// Monthly restored
|
||||
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const reopenedThisWeek = all.filter(t => t.status === 'Reopened' && new Date(t.updated_at) >= oneWeekAgo).length;
|
||||
|
||||
this.kpi = {
|
||||
openTickets: open.length,
|
||||
criticalCount: urgent.length,
|
||||
slaCompliance: withSLA.length ? Math.round((slaMet.length / withSLA.length) * 100) : 100,
|
||||
avgResolutionTime: this.calcAvgResolution(all),
|
||||
avgResponseTime: this.calcAvgResponse(all),
|
||||
reopenedThisWeek,
|
||||
totalTickets: total,
|
||||
completionRate: total ? Math.round((closed.length / total) * 100) : 0,
|
||||
};
|
||||
|
||||
// Chart data
|
||||
const byPriority = { urgent: 0, high: 0, medium: 0, low: 0 };
|
||||
open.forEach(t => { if (t.priority) byPriority[t.priority]++; });
|
||||
this.charts.byPriority = byPriority;
|
||||
|
||||
// Monthly trends (last 6 months)
|
||||
const months = {};
|
||||
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
all.forEach(t => {
|
||||
const d = new Date(t.created_at);
|
||||
const key = `${d.getFullYear()}-${d.getMonth()}`;
|
||||
months[key] = (months[key] || 0) + 1;
|
||||
});
|
||||
const sortedMonths = Object.keys(months).sort().slice(-6);
|
||||
this.charts.monthlyTrend = sortedMonths.map(k => {
|
||||
const [y, m] = k.split('-').map(Number);
|
||||
return { month: monthNames[m], count: months[k] };
|
||||
});
|
||||
this.charts.monthlyMax = Math.max(...this.charts.monthlyTrend.map(m => m.count), 1);
|
||||
|
||||
// By property
|
||||
const propData = { east: 0, west: 0 };
|
||||
all.forEach(t => { /* would need unit join — estimate from ticket IDs */ });
|
||||
try {
|
||||
const east = await app().apiGet('/api/tickets?property=East&page_size=1');
|
||||
const west = await app().apiGet('/api/tickets?property=West&page_size=1');
|
||||
propData.east = east?.total || 0;
|
||||
propData.west = west?.total || 0;
|
||||
} catch (e) { /* api may not support property filter directly */ }
|
||||
this.charts.byProperty = { east: propData.east, west: propData.west, max: Math.max(propData.east, propData.west, 1) };
|
||||
|
||||
// By category — use categories endpoint
|
||||
try {
|
||||
const cats = await app().apiGet('/api/tickets/categories/flat');
|
||||
const catCounts = {};
|
||||
all.forEach(t => {
|
||||
if (t.category_id) {
|
||||
const cat = cats?.find(c => c.id === t.category_id);
|
||||
const name = cat ? cat.name : `Cat #${t.category_id}`;
|
||||
catCounts[name] = (catCounts[name] || 0) + 1;
|
||||
}
|
||||
});
|
||||
const colors = ['bg-blue-500', 'bg-green-500', 'bg-yellow-500', 'bg-red-500', 'bg-purple-500', 'bg-pink-500', 'bg-indigo-500', 'bg-teal-500'];
|
||||
this.charts.byCategory = Object.entries(catCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([name, count], i) => ({ name, count, color: colors[i % colors.length] }));
|
||||
this.charts.byCategoryMax = Math.max(...this.charts.byCategory.map(c => c.count), 1);
|
||||
} catch (e) { console.error('Category stats error', e); }
|
||||
|
||||
// Risk indicators
|
||||
this.indicators = {
|
||||
openEmergencies: urgent.filter(t => !['Closed', 'Completed'].includes(t.status)).length,
|
||||
reopenedThisWeek,
|
||||
overdueJobs: open.filter(t => t.sla_deadline && new Date(t.sla_deadline) < new Date()).length,
|
||||
pendingVerification: all.filter(t => ['Completed', 'On-Field Verification'].includes(t.status)).length,
|
||||
};
|
||||
|
||||
} catch (e) { console.error('CEO data load error', e); }
|
||||
},
|
||||
|
||||
calcAvgResponse(all) {
|
||||
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
||||
if (!open.length) return '—';
|
||||
const avgHrs = open.reduce((sum, t) => sum + Math.min((new Date() - new Date(t.created_at)) / (1000 * 60 * 60), 168), 0) / open.length;
|
||||
if (avgHrs < 1) return `${Math.round(avgHrs * 60)}m`;
|
||||
return `${Math.round(avgHrs)}h`;
|
||||
},
|
||||
|
||||
calcAvgResolution(all) {
|
||||
const closed = all.filter(t => ['Closed', 'Completed'].includes(t.status) && t.created_at && t.updated_at);
|
||||
if (!closed.length) return '—';
|
||||
const avgHrs = closed.reduce((sum, t) => {
|
||||
const diff = (new Date(t.updated_at) - new Date(t.created_at)) / (1000 * 60 * 60);
|
||||
return sum + diff;
|
||||
}, 0) / closed.length;
|
||||
if (avgHrs < 1) return `${Math.round(avgHrs * 60)}m`;
|
||||
return `${Math.round(avgHrs)}h`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,244 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="csDashboard()" x-init="init()">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Customer Service Dashboard</h1>
|
||||
<p class="text-gray-500 mt-1">Welcome back, <span x-text="user.full_name"></span></p>
|
||||
</div>
|
||||
|
||||
<!-- KPI Cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 font-medium">New Today</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.newToday || 0"></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 font-medium">Open Tickets</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.openTickets || 0"></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 font-medium">Avg Response Time</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.avgResponseTime || '—'"></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 font-medium">SLA Compliance</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="(kpi.slaCompliance || 0) + '%'"></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority Breakdown + Escalated -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Open by Priority -->
|
||||
<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">Open by Priority</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-red-600 font-medium">🔴 Urgent</span>
|
||||
<span class="text-2xl font-bold" x-text="kpi.byPriority?.urgent || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-orange-600 font-medium">🟠 High</span>
|
||||
<span class="text-2xl font-bold" x-text="kpi.byPriority?.high || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-yellow-600 font-medium">🟡 Medium</span>
|
||||
<span class="text-2xl font-bold" x-text="kpi.byPriority?.medium || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-green-600 font-medium">🟢 Low</span>
|
||||
<span class="text-2xl font-bold" x-text="kpi.byPriority?.low || 0"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Oldest Unassigned -->
|
||||
<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">Oldest Unassigned</h3>
|
||||
<div x-show="!oldestUnassigned" class="text-gray-400 text-sm py-8 text-center">No unassigned tickets</div>
|
||||
<template x-if="oldestUnassigned">
|
||||
<div>
|
||||
<a :href="'/tickets/' + oldestUnassigned.id" class="block p-3 bg-orange-50 rounded-lg border border-orange-200 hover:bg-orange-100 transition">
|
||||
<div class="font-medium text-sm" x-text="oldestUnassigned.ticket_number"></div>
|
||||
<div class="text-xs text-gray-600 mt-1" x-text="oldestUnassigned.description?.substring(0, 80)"></div>
|
||||
<div class="text-xs text-gray-400 mt-1">Waiting <span x-text="timeSince(oldestUnassigned.created_at)"></span></div>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Escalated -->
|
||||
<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">Escalated Tickets</h3>
|
||||
<div class="text-3xl font-bold text-red-600 mb-3" x-text="kpi.escalatedCount || 0"></div>
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto">
|
||||
<template x-for="ticket in escalatedTickets" :key="ticket.id">
|
||||
<a :href="'/tickets/' + ticket.id" class="block p-2 bg-red-50 rounded border border-red-200 hover:bg-red-100 transition">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm font-medium" x-text="ticket.ticket_number"></span>
|
||||
<span class="text-xs text-red-600 font-medium" x-text="ticket.priority"></span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 mt-0.5" x-text="ticket.description?.substring(0, 60)"></div>
|
||||
</a>
|
||||
</template>
|
||||
<div x-show="!escalatedTickets.length" class="text-gray-400 text-sm py-4 text-center">No escalated tickets</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Tickets Table -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<div class="px-5 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Recent Tickets</h3>
|
||||
<a href="/tickets" class="text-sm text-denya-600 hover:text-denya-800">View All →</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th class="px-5 py-3 text-left">Ticket</th>
|
||||
<th class="px-5 py-3 text-left">Status</th>
|
||||
<th class="px-5 py-3 text-left">Priority</th>
|
||||
<th class="px-5 py-3 text-left">Description</th>
|
||||
<th class="px-5 py-3 text-left">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<template x-for="ticket in recentTickets" :key="ticket.id">
|
||||
<tr class="hover:bg-gray-50 transition cursor-pointer" @click="window.location.href='/tickets/'+ticket.id">
|
||||
<td class="px-5 py-3 font-medium text-denya-600" x-text="ticket.ticket_number"></td>
|
||||
<td class="px-5 py-3"><span class="px-2 py-1 rounded-full text-xs font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span></td>
|
||||
<td class="px-5 py-3"><span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></td>
|
||||
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
|
||||
<td class="px-5 py-3 text-gray-500 text-xs" x-text="formatDate(ticket.created_at)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="!recentTickets.length">
|
||||
<td colspan="5" class="px-5 py-12 text-center text-gray-400">No tickets found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function csDashboard() {
|
||||
return {
|
||||
kpi: { byPriority: {} },
|
||||
recentTickets: [],
|
||||
escalatedTickets: [],
|
||||
oldestUnassigned: null,
|
||||
|
||||
async init() {
|
||||
await this.loadKPIs();
|
||||
await this.loadRecentTickets();
|
||||
await this.loadEscalated();
|
||||
},
|
||||
|
||||
async loadKPIs() {
|
||||
try {
|
||||
const tickets = await app().apiGet('/api/tickets?page_size=200');
|
||||
if (!tickets?.items) return;
|
||||
const all = tickets.items;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// New today
|
||||
const newToday = all.filter(t => new Date(t.created_at) >= today && t.status === 'New').length;
|
||||
|
||||
// Open tickets (not closed/completed)
|
||||
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
||||
|
||||
// By priority
|
||||
const byPriority = { urgent: 0, high: 0, medium: 0, low: 0 };
|
||||
open.forEach(t => { if (t.priority) byPriority[t.priority] = (byPriority[t.priority] || 0) + 1; });
|
||||
|
||||
// Oldest unassigned
|
||||
const unassigned = all.filter(t => !t.assigned_to && !['Closed', 'Completed'].includes(t.status))
|
||||
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
this.oldestUnassigned = unassigned[0] || null;
|
||||
|
||||
// SLA compliance (rough: tickets with sla_deadline not breached)
|
||||
const withSLA = all.filter(t => t.sla_deadline);
|
||||
const slaMet = withSLA.filter(t => new Date(t.sla_deadline) > new Date() || ['Closed', 'Completed'].includes(t.status));
|
||||
|
||||
this.kpi = {
|
||||
newToday,
|
||||
openTickets: open.length,
|
||||
byPriority,
|
||||
escalatedCount: all.filter(t => t.status === 'Escalated').length,
|
||||
avgResponseTime: this.calcAvgResponse(all),
|
||||
slaCompliance: withSLA.length ? Math.round((slaMet.length / withSLA.length) * 100) : 100
|
||||
};
|
||||
} catch (e) { console.error('KPI load error', e); }
|
||||
},
|
||||
|
||||
async loadRecentTickets() {
|
||||
try {
|
||||
const data = await app().apiGet('/api/tickets?page_size=20');
|
||||
this.recentTickets = data?.items || [];
|
||||
} catch (e) { console.error('Recent tickets load error', e); }
|
||||
},
|
||||
|
||||
async loadEscalated() {
|
||||
try {
|
||||
const data = await app().apiGet('/api/tickets?status=Escalated&page_size=10');
|
||||
this.escalatedTickets = data?.items || [];
|
||||
} catch (e) { console.error('Escalated load error', e); }
|
||||
},
|
||||
|
||||
calcAvgResponse(all) {
|
||||
// Rough approximation — in real system this would come from timeline analysis
|
||||
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
||||
if (!open.length) return '—';
|
||||
const avgHrs = open.reduce((sum, t) => {
|
||||
const diff = (new Date() - new Date(t.created_at)) / (1000 * 60 * 60);
|
||||
return sum + Math.min(diff, 168); // cap at 1 week
|
||||
}, 0) / open.length;
|
||||
if (avgHrs < 1) return `${Math.round(avgHrs * 60)}m`;
|
||||
return `${Math.round(avgHrs)}h`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,248 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="fmDashboard()" x-init="init()">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Facilities Management Dashboard</h1>
|
||||
<p class="text-gray-500 mt-1">Welcome back, <span x-text="user.full_name"></span></p>
|
||||
</div>
|
||||
|
||||
<!-- Emergency Alert -->
|
||||
<div x-show="emergencyCount > 0" class="mb-6 p-4 bg-red-50 border-2 border-red-300 rounded-xl flex items-center space-x-3">
|
||||
<svg class="w-8 h-8 text-red-600 flex-shrink-0 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-bold text-red-800"><span x-text="emergencyCount"></span> Emergency <span x-text="emergencyCount === 1 ? 'Job' : 'Jobs'"></span> Require Immediate Attention!</p>
|
||||
<p class="text-sm text-red-600">View in the table below</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI Cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 font-medium">Active Jobs</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.activeJobs || 0"></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 font-medium">Due Today</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.dueToday || 0"></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 font-medium">Waiting Parts</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.waitingParts || 0"></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 font-medium">Jobs East / West</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1"><span x-text="kpi.eastJobs || 0"></span> / <span x-text="kpi.westJobs || 0"></span></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Technician Workload + Aging Analysis -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Tech Workload -->
|
||||
<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">Technician Workload</h3>
|
||||
<div class="space-y-3">
|
||||
<template x-for="tech in techWorkload" :key="tech.id">
|
||||
<div class="flex items-center justify-between p-2 hover:bg-gray-50 rounded">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-denya-100 rounded-full flex items-center justify-center text-sm font-medium text-denya-700" x-text="tech.name.charAt(0)"></div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700" x-text="tech.name"></p>
|
||||
<p class="text-xs text-gray-400" x-text="tech.specialty || 'Technician'"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-lg font-bold" :class="tech.activeJobs > 3 ? 'text-red-600' : tech.activeJobs > 1 ? 'text-yellow-600' : 'text-green-600'" x-text="tech.activeJobs"></span>
|
||||
<span class="text-xs text-gray-400">active</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="!techWorkload.length" class="text-gray-400 text-sm py-6 text-center">No active assignments</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aging Analysis -->
|
||||
<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">Aging Analysis</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between p-3 bg-green-50 rounded-lg">
|
||||
<span class="text-sm font-medium text-green-700">< 24h</span>
|
||||
<span class="text-2xl font-bold text-green-700" x-text="aging.under24h || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-yellow-50 rounded-lg">
|
||||
<span class="text-sm font-medium text-yellow-700">1-2 days</span>
|
||||
<span class="text-2xl font-bold text-yellow-700" x-text="aging.oneToTwoDays || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-orange-50 rounded-lg">
|
||||
<span class="text-sm font-medium text-orange-700">3-5 days</span>
|
||||
<span class="text-2xl font-bold text-orange-700" x-text="aging.threeToFiveDays || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-red-50 rounded-lg">
|
||||
<span class="text-sm font-medium text-red-700">Overdue > 5d</span>
|
||||
<span class="text-2xl font-bold text-red-700" x-text="aging.overFiveDays || 0"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Tickets Table -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<div class="px-5 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Active Tickets</h3>
|
||||
<a href="/tickets" class="text-sm text-denya-600 hover:text-denya-800">View All →</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th class="px-5 py-3 text-left">Ticket</th>
|
||||
<th class="px-5 py-3 text-left">Status</th>
|
||||
<th class="px-5 py-3 text-left">Priority</th>
|
||||
<th class="px-5 py-3 text-left">Technician</th>
|
||||
<th class="px-5 py-3 text-left">Description</th>
|
||||
<th class="px-5 py-3 text-left">Age</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<template x-for="ticket in activeTickets" :key="ticket.id">
|
||||
<tr class="hover:bg-gray-50 transition cursor-pointer" :class="ticket.priority === 'urgent' ? 'bg-red-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" x-text="ticket.assigned_technician_name || 'Unassigned'"></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-xs font-medium" :class="timeSince(ticket.created_at).includes('d') && parseInt(timeSince(ticket.created_at)) > 3 ? 'text-red-600' : 'text-gray-500'" x-text="timeSince(ticket.created_at)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="!activeTickets.length">
|
||||
<td colspan="6" class="px-5 py-12 text-center text-gray-400">No active tickets</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function fmDashboard() {
|
||||
return {
|
||||
kpi: {},
|
||||
techWorkload: [],
|
||||
aging: {},
|
||||
activeTickets: [],
|
||||
emergencyCount: 0,
|
||||
|
||||
async init() {
|
||||
await this.loadData();
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
try {
|
||||
const data = await app().apiGet('/api/tickets?page_size=200');
|
||||
if (!data?.items) return;
|
||||
const all = data.items;
|
||||
|
||||
// Active: not closed/completed
|
||||
const active = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
||||
this.activeTickets = active;
|
||||
|
||||
// KPIs
|
||||
this.kpi.activeJobs = active.length;
|
||||
this.kpi.waitingParts = active.filter(t => t.status === 'Waiting Parts').length;
|
||||
|
||||
// Due today
|
||||
const today = new Date();
|
||||
today.setHours(23, 59, 59, 0);
|
||||
const dueToday = active.filter(t => {
|
||||
if (!t.sla_deadline) return false;
|
||||
const deadline = new Date(t.sla_deadline);
|
||||
return deadline <= today;
|
||||
});
|
||||
this.kpi.dueToday = dueToday.length;
|
||||
|
||||
// Emergency
|
||||
this.emergencyCount = active.filter(t => t.priority === 'urgent').length;
|
||||
|
||||
// East vs West — we need full tickets with unit info
|
||||
// For now, estimate from overall data or show placeholder
|
||||
// We'll load again with property filter or just use total
|
||||
try {
|
||||
const east = await app().apiGet('/api/tickets?property=East&page_size=1');
|
||||
const west = await app().apiGet('/api/tickets?property=West&page_size=1');
|
||||
const eastTotal = east?.total || 0;
|
||||
const westTotal = west?.total || 0;
|
||||
this.kpi.eastJobs = eastTotal;
|
||||
this.kpi.westJobs = westTotal;
|
||||
} catch (e) { console.error('Property stats error', e); }
|
||||
|
||||
// Tech workload (simulated from assigned_to counts)
|
||||
const techMap = {};
|
||||
active.forEach(t => {
|
||||
if (t.assigned_to) {
|
||||
if (!techMap[t.assigned_to]) techMap[t.assigned_to] = { id: t.assigned_to, name: `Tech #${t.assigned_to}`, activeJobs: 0 };
|
||||
techMap[t.assigned_to].activeJobs++;
|
||||
}
|
||||
});
|
||||
this.techWorkload = Object.values(techMap).sort((a, b) => b.activeJobs - a.activeJobs);
|
||||
|
||||
// Aging
|
||||
const now = new Date();
|
||||
this.aging = {
|
||||
under24h: active.filter(t => (now - new Date(t.created_at)) < 24 * 60 * 60 * 1000).length,
|
||||
oneToTwoDays: active.filter(t => {
|
||||
const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24);
|
||||
return diff >= 1 && diff < 2;
|
||||
}).length,
|
||||
threeToFiveDays: active.filter(t => {
|
||||
const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24);
|
||||
return diff >= 2 && diff < 5;
|
||||
}).length,
|
||||
overFiveDays: active.filter(t => (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24) >= 5).length,
|
||||
};
|
||||
|
||||
} catch (e) { console.error('FM data load error', e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,74 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="min-h-[80vh] flex items-center justify-center">
|
||||
<div class="w-full max-w-md" x-data="loginForm()">
|
||||
<div class="bg-white rounded-2xl shadow-lg p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="mx-auto w-16 h-16 bg-denya-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg class="w-8 h-8 text-denya-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Denya OneCare</h1>
|
||||
<p class="text-gray-500 mt-1">Sign in to your dashboard</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitLogin" class="space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input type="email" x-model="email" required
|
||||
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 transition"
|
||||
placeholder="you@example.com">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input type="password" x-model="password" required
|
||||
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 transition"
|
||||
placeholder="••••••••">
|
||||
</div>
|
||||
<div x-show="error" class="text-red-600 text-sm bg-red-50 p-3 rounded-lg" x-text="error"></div>
|
||||
<button type="submit" :disabled="submitting"
|
||||
class="w-full py-2.5 px-4 bg-denya-600 hover:bg-denya-700 text-white font-medium rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2">
|
||||
<svg x-show="submitting" class="animate-spin h-5 w-5" 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>
|
||||
<span x-text="submitting ? 'Signing in...' : 'Sign In'"></span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<p class="text-xs text-gray-400 text-center">
|
||||
<strong>Demo Accounts:</strong><br>
|
||||
bella@denya.com / denya123 — CS Rep<br>
|
||||
nicholas@denya.com / denya123 — FM Dispatcher<br>
|
||||
scott@denya.com / denya123 — CEO<br>
|
||||
jerome@denya.com / denya123 — Admin
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function loginForm() {
|
||||
return {
|
||||
email: '',
|
||||
password: '',
|
||||
error: '',
|
||||
submitting: false,
|
||||
async submitLogin() {
|
||||
this.error = '';
|
||||
this.submitting = true;
|
||||
try {
|
||||
await app().login(this.email, this.password);
|
||||
} catch (e) {
|
||||
this.error = e.message || 'Invalid credentials. Please try again.';
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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