Sprint 3: Alpine.js Dashboards

This commit was merged in pull request #3.
This commit is contained in:
2026-07-23 19:36:35 +00:00
15 changed files with 2265 additions and 4 deletions
+13
View File
@@ -53,6 +53,19 @@ Users, units, and categories are auto-seeded on first startup via lifespan hook:
| POST | `/api/whatsapp/mock` | No | Mock WhatsApp | | POST | `/api/whatsapp/mock` | No | Mock WhatsApp |
| GET | `/api/whatsapp/mock-log` | No | Recent mock submissions | | 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) ### Tickets (Sprint 2)
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
|--------|------|------|-------------| |--------|------|------|-------------|
+2 -1
View File
@@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles
from app.core.config import settings from app.core.config import settings
from app.core.database import Base, async_session_factory, engine 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 from app.services.seed import seed_categories, seed_units, seed_users
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -61,3 +61,4 @@ app.include_router(health.router)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(whatsapp.router) app.include_router(whatsapp.router)
app.include_router(tickets.router) app.include_router(tickets.router)
app.include_router(pages.router)
+4
View File
@@ -70,6 +70,10 @@ class Ticket(Base):
photos = relationship("TicketPhoto", back_populates="ticket") photos = relationship("TicketPhoto", back_populates="ticket")
escalations = relationship("Escalation", back_populates="ticket") escalations = relationship("Escalation", back_populates="ticket")
@property
def assigned_technician_name(self) -> str | None:
return self.assigned_technician.full_name if self.assigned_technician else None
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Ticket {self.ticket_number} ({self.status})>" return f"<Ticket {self.ticket_number} ({self.status})>"
+48
View File
@@ -0,0 +1,48 @@
"""Frontend page routes — serve Jinja2 HTML templates for the SPA-like dashboard."""
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})
+16
View File
@@ -16,6 +16,7 @@ from app.core.database import get_db
from app.core.security import get_current_user from app.core.security import get_current_user
from app.models.category import Category from app.models.category import Category
from app.models.ticket import Ticket, TicketPhoto from app.models.ticket import Ticket, TicketPhoto
from app.models.unit import Unit
from app.models.user import User from app.models.user import User
from app.schemas.ticket import ( from app.schemas.ticket import (
CategoryOut, CategoryOut,
@@ -27,6 +28,7 @@ from app.schemas.ticket import (
TicketOut, TicketOut,
TicketPhotoOut, TicketPhotoOut,
TicketUpdate, TicketUpdate,
UnitOut,
) )
from app.services import ticket as ticket_service from app.services import ticket as ticket_service
from app.services.sla import get_sla_status from app.services.sla import get_sla_status
@@ -38,6 +40,20 @@ UPLOADS_DIR = settings.BASE_DIR / "uploads"
UPLOADS_DIR.mkdir(parents=True, exist_ok=True) UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
# ── Units ──────────────────────────────────────────────────────────
@router.get("/units", response_model=list[UnitOut])
async def list_units(
db: Annotated[AsyncSession, Depends(get_db)],
property_filter: str | None = Query(None, alias="property"),
) -> list[Unit]:
"""List all units, optionally filtered by property (East/West)."""
query = select(Unit).order_by(Unit.apartment_code)
if property_filter:
query = query.where(Unit.property == property_filter)
result = await db.execute(query)
return list(result.scalars().all())
# ── Categories ────────────────────────────────────────────────────── # ── Categories ──────────────────────────────────────────────────────
async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -> list[CategoryTreeOut]: async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -> list[CategoryTreeOut]:
"""Build a nested category tree.""" """Build a nested category tree."""
+15
View File
@@ -8,6 +8,17 @@ from decimal import Decimal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# ── Unit ─────────────────────────────────────────────────────────────
class UnitOut(BaseModel):
id: int
property: str
apartment_code: str
building: str | None = None
floor: int | None = None
model_config = {"from_attributes": True}
# ── Category ───────────────────────────────────────────────────────── # ── Category ─────────────────────────────────────────────────────────
class CategoryOut(BaseModel): class CategoryOut(BaseModel):
id: int id: int
@@ -32,6 +43,8 @@ class TicketCreate(BaseModel):
reported_via: str | None = None # whatsapp, phone, walk-in, qr, agent reported_via: str | None = None # whatsapp, phone, walk-in, qr, agent
description: str | None = None description: str | None = None
assigned_to: int | None = None assigned_to: int | None = None
customer_name: str | None = None
phone: str | None = None
class TicketUpdate(BaseModel): class TicketUpdate(BaseModel):
@@ -46,6 +59,7 @@ class TicketUpdate(BaseModel):
eta: datetime | None = None eta: datetime | None = None
cost: Decimal | None = None cost: Decimal | None = None
parts_used: str | None = None parts_used: str | None = None
note: str | None = None
class TicketTimelineOut(BaseModel): class TicketTimelineOut(BaseModel):
@@ -78,6 +92,7 @@ class TicketBrief(BaseModel):
unit_id: int | None = None unit_id: int | None = None
category_id: int | None = None category_id: int | None = None
assigned_to: int | None = None assigned_to: int | None = None
assigned_technician_name: str | None = None
reporter: str | None = None reporter: str | None = None
description: str | None = None description: str | None = None
sla_deadline: datetime | None = None sla_deadline: datetime | None = None
+15 -3
View File
@@ -109,7 +109,7 @@ async def create_ticket(
unit_id=data.get("unit_id"), unit_id=data.get("unit_id"),
category_id=data.get("category_id"), category_id=data.get("category_id"),
priority=priority, priority=priority,
reporter=data.get("reporter"), reporter=data.get("reporter") or data.get("customer_name"),
reported_via=data.get("reported_via"), reported_via=data.get("reported_via"),
description=data.get("description"), description=data.get("description"),
assigned_to=data.get("assigned_to"), assigned_to=data.get("assigned_to"),
@@ -196,7 +196,7 @@ async def list_tickets(
# Paginate # Paginate
offset = (page - 1) * page_size offset = (page - 1) * page_size
query = query.order_by(Ticket.created_at.desc()).offset(offset).limit(page_size) query = query.order_by(Ticket.created_at.desc()).offset(offset).limit(page_size).options(selectinload(Ticket.assigned_technician))
result = await db.execute(query) result = await db.execute(query)
tickets = list(result.scalars().all()) tickets = list(result.scalars().all())
@@ -214,8 +214,8 @@ async def update_ticket(
# Handle status transitions separately # Handle status transitions separately
new_status = data.get("status") new_status = data.get("status")
old_status = ticket.status
if new_status is not None: if new_status is not None:
old_status = ticket.status
if old_status != new_status: if old_status != new_status:
valid_targets = VALID_TRANSITIONS.get(old_status, []) valid_targets = VALID_TRANSITIONS.get(old_status, [])
if new_status not in valid_targets: if new_status not in valid_targets:
@@ -275,6 +275,18 @@ async def update_ticket(
ticket.status = new_status ticket.status = new_status
# Handle standalone note (no status change)
note_only = data.get("note")
if note_only and not (new_status is not None and old_status != new_status):
await _log_status_change(
db,
ticket.id,
from_status=ticket.status,
to_status=ticket.status,
note=note_only,
user_id=user.id if user else None,
)
# Update other fields # Update other fields
for field in ("unit_id", "category_id", "priority", "reporter", "reported_via", for field in ("unit_id", "category_id", "priority", "reporter", "reported_via",
"description", "assigned_to", "eta", "cost", "parts_used"): "description", "assigned_to", "eta", "cost", "parts_used"):
+356
View File
@@ -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>
+290
View File
@@ -0,0 +1,290 @@
{% 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 — load units once and compute from ticket data
const propData = { east: 0, west: 0 };
try {
const units = await app().apiGet('/api/tickets/units');
if (units && units.length > 0) {
const unitPropertyMap = {};
units.forEach(u => { unitPropertyMap[u.id] = u.property; });
all.forEach(t => {
if (t.unit_id && unitPropertyMap[t.unit_id]) {
const p = unitPropertyMap[t.unit_id].toLowerCase();
if (p === 'east') propData.east++;
else if (p === 'west') propData.west++;
}
});
}
} catch (e) { console.error('Property stats error', e); }
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 %}
+244
View File
@@ -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 %}
+245
View File
@@ -0,0 +1,245 @@
{% 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">&lt; 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 &gt; 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 — compute from loaded tickets using unit map
try {
const units = await app().apiGet('/api/tickets/units');
const unitMap = {};
if (units) units.forEach(u => { unitMap[u.id] = u.property; });
this.kpi.eastJobs = active.filter(t => t.unit_id && unitMap[t.unit_id] === 'East').length;
this.kpi.westJobs = active.filter(t => t.unit_id && unitMap[t.unit_id] === 'West').length;
} 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 < 3;
}).length,
threeToFiveDays: active.filter(t => {
const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24);
return diff >= 3 && 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 %}
+74
View File
@@ -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 %}
+452
View File
@@ -0,0 +1,452 @@
{% extends "base.html" %}
{% block content %}
<div x-data="ticketDetail()" x-init="init()">
<!-- Loading -->
<div x-show="loading && !ticket" class="flex items-center justify-center py-20">
<svg class="animate-spin h-8 w-8 text-denya-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
<template x-if="ticket">
<div>
<!-- Header -->
<div class="mb-6">
<div class="flex items-start justify-between">
<div>
<div class="flex items-center space-x-3">
<a href="/tickets" class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
</a>
<h1 class="text-2xl font-bold text-gray-900" x-text="ticket.ticket_number"></h1>
<span class="px-3 py-1 rounded-full text-sm font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span>
<span class="px-3 py-1 rounded text-sm font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span>
</div>
<p class="text-gray-500 mt-1">Created <span x-text="formatDate(ticket.created_at)"></span></p>
</div>
<div class="flex items-center space-x-2">
<button @click="showStatusModal = true" class="px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition text-sm font-medium">Update Status</button>
<button @click="showAssignModal = true" x-show="isFM || isAdmin" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition text-sm font-medium">Assign</button>
</div>
</div>
</div>
<!-- Main grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Left column: Details -->
<div class="lg:col-span-2 space-y-6">
<!-- Description -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Description</h3>
<p class="text-gray-700 whitespace-pre-wrap" x-text="ticket.description || 'No description provided'"></p>
</div>
<!-- Timeline -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Timeline</h3>
<div class="space-y-0">
<template x-for="(entry, idx) in ticket.timeline" :key="entry.id">
<div class="relative pl-8 pb-6" :class="idx === ticket.timeline.length - 1 ? '' : ''">
<!-- Timeline connector -->
<div class="absolute left-3 top-1 bottom-0 w-0.5 bg-gray-200" x-show="idx < ticket.timeline.length - 1"></div>
<!-- Dot -->
<div class="absolute left-1.5 top-1 w-3 h-3 rounded-full border-2" :class="getTimelineColor(entry)"></div>
<div class="bg-gray-50 rounded-lg p-3">
<div class="flex items-center space-x-2 text-sm">
<template x-if="entry.from_status">
<span class="px-2 py-0.5 rounded text-xs font-medium" :class="statusClass(entry.from_status)" x-text="entry.from_status"></span>
</template>
<template x-if="entry.from_status">
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</template>
<span class="px-2 py-0.5 rounded text-xs font-medium" :class="statusClass(entry.to_status)" x-text="entry.to_status"></span>
<span class="text-xs text-gray-400" x-text="formatDate(entry.created_at)"></span>
</div>
<p x-show="entry.note" class="text-sm text-gray-600 mt-1" x-text="entry.note"></p>
</div>
</div>
</template>
<div x-show="!ticket.timeline?.length" class="text-gray-400 text-sm py-6 text-center">No timeline entries</div>
</div>
</div>
<!-- Photos -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Photos</h3>
<div x-show="!ticket.photos?.length" class="text-gray-400 text-sm py-6 text-center">No photos uploaded</div>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
<template x-for="photo in ticket.photos" :key="photo.id">
<div class="rounded-lg overflow-hidden border border-gray-200">
<img :src="photo.photo_url" :alt="photo.is_before ? 'Before' : 'After'" class="w-full h-40 object-cover cursor-pointer hover:opacity-90 transition" @click="showPhoto(photo.photo_url)">
<div class="px-2 py-1 text-xs font-medium" :class="photo.is_before ? 'text-blue-600 bg-blue-50' : 'text-green-600 bg-green-50'" x-text="photo.is_before ? 'Before' : 'After'"></div>
</div>
</template>
</div>
<!-- Upload more photos -->
<div class="mt-4" x-show="isFM || isCS || isAdmin">
<label class="inline-flex items-center space-x-2 text-sm text-denya-600 hover:text-denya-800 cursor-pointer">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
<span>Add Photos</span>
<input type="file" multiple accept="image/*" @change="uploadPhotos($event)" class="hidden">
</label>
</div>
</div>
</div>
<!-- Right column: Metadata -->
<div class="space-y-6">
<!-- Ticket Info -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Ticket Info</h3>
<dl class="space-y-3">
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Unit</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.unit?.apartment_code || '—'"></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Property</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.unit?.property || '—'"></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Category</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.category?.name || '—'"></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Assigned To</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.assigned_technician_name || 'Unassigned'"></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Reporter</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.reporter || '—'"></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Reported Via</dt>
<dd class="text-sm font-medium text-gray-900 capitalize" x-text="ticket.reported_via || '—'"></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Priority</dt>
<dd><span class="px-2 py-0.5 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">SLA Deadline</dt>
<dd class="text-sm font-medium" :class="ticket.sla_status?.resolution_breached ? 'text-red-600' : 'text-gray-900'" x-text="ticket.sla_deadline ? formatDate(ticket.sla_deadline) : '—'"></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Reopen Count</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.reopen_count || 0"></dd>
</div>
<div class="flex justify-between" x-show="ticket.cost">
<dt class="text-sm text-gray-500">Cost</dt>
<dd class="text-sm font-medium text-gray-900" x-text="'$' + ticket.cost"></dd>
</div>
</dl>
</div>
<!-- SLA Status -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5" x-show="ticket.sla_status">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">SLA Status</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500">Response</span>
<span class="flex items-center space-x-1">
<span class="w-2 h-2 rounded-full" :class="ticket.sla_status?.response_breached ? 'bg-red-500' : 'bg-green-500'"></span>
<span class="text-sm font-medium" :class="ticket.sla_status?.response_breached ? 'text-red-600' : 'text-green-600'" x-text="ticket.sla_status?.response_breached ? 'Breached' : 'OK'"></span>
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500">Resolution</span>
<span class="flex items-center space-x-1">
<span class="w-2 h-2 rounded-full" :class="ticket.sla_status?.resolution_breached ? 'bg-red-500' : 'bg-green-500'"></span>
<span class="text-sm font-medium" :class="ticket.sla_status?.resolution_breached ? 'text-red-600' : 'text-green-600'" x-text="ticket.sla_status?.resolution_breached ? 'Breached' : 'OK'"></span>
</span>
</div>
<div x-show="ticket.sla_status?.response_deadline">
<p class="text-xs text-gray-400">Response by: <span x-text="formatDate(ticket.sla_status.response_deadline)"></span></p>
</div>
</div>
</div>
<!-- Actions -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Quick Actions</h3>
<div class="space-y-2">
<button @click="showStatusModal = true" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded transition flex items-center space-x-2">
<svg class="w-4 h-4 text-denya-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
<span>Change Status</span>
</button>
<button @click="showAssignModal = true" x-show="isFM || isAdmin" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded transition flex items-center space-x-2">
<svg class="w-4 h-4 text-denya-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
<span>Assign Technician</span>
</button>
<button @click="showNoteModal = true" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded transition flex items-center space-x-2">
<svg class="w-4 h-4 text-denya-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
<span>Add Note</span>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- Status Update Modal -->
<div x-show="showStatusModal" class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center" x-cloak @click.away="showStatusModal = false">
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md mx-4" @click.stop>
<h3 class="text-lg font-bold text-gray-900 mb-4">Update Status</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Current: <span class="font-bold" x-text="ticket.status"></span></label>
<select x-model="statusForm.newStatus" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 outline-none">
<option value="">Select new status</option>
<option value="Logged">Logged</option>
<option value="Triage">Triage</option>
<option value="Assigned">Assigned</option>
<option value="Accepted">Accepted</option>
<option value="Travelling">Travelling</option>
<option value="On Site">On Site</option>
<option value="In Progress">In Progress</option>
<option value="Waiting Parts">Waiting Parts</option>
<option value="Escalated">Escalated</option>
<option value="Completed">Completed</option>
<option value="On-Field Verification">On-Field Verification</option>
<option value="Wahab Review">Wahab Review</option>
<option value="Closed">Closed</option>
<option value="Reopened">Reopened</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Note (optional)</label>
<textarea x-model="statusForm.note" rows="3" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 outline-none" placeholder="Add a note about this status change..."></textarea>
</div>
<div x-show="statusError" class="text-sm text-red-600 bg-red-50 p-3 rounded-lg" x-text="statusError"></div>
<div class="flex space-x-3">
<button @click="submitStatusUpdate" :disabled="statusSubmitting" class="flex-1 px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition font-medium disabled:opacity-50">
<span x-text="statusSubmitting ? 'Updating...' : 'Update Status'"></span>
</button>
<button @click="showStatusModal = false" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Assign Modal -->
<div x-show="showAssignModal" class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center" x-cloak @click.away="showAssignModal = false">
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md mx-4" @click.stop>
<h3 class="text-lg font-bold text-gray-900 mb-4">Assign Technician</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Technician</label>
<select x-model="assignForm.technicianId" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 outline-none">
<option value="">Select technician...</option>
<template x-for="tech in technicians" :key="tech.id">
<option :value="tech.id" x-text="tech.full_name"></option>
</template>
</select>
</div>
<div x-show="assignError" class="text-sm text-red-600 bg-red-50 p-3 rounded-lg" x-text="assignError"></div>
<div class="flex space-x-3">
<button @click="submitAssign" :disabled="assignSubmitting" class="flex-1 px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition font-medium disabled:opacity-50">
<span x-text="assignSubmitting ? 'Assigning...' : 'Assign'"></span>
</button>
<button @click="showAssignModal = false" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Note Modal -->
<div x-show="showNoteModal" class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center" x-cloak @click.away="showNoteModal = false">
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md mx-4" @click.stop>
<h3 class="text-lg font-bold text-gray-900 mb-4">Add Note</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Note</label>
<textarea x-model="noteForm.note" rows="4" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 outline-none" placeholder="Enter your note..."></textarea>
</div>
<div class="flex space-x-3">
<button @click="submitNote" :disabled="noteSubmitting" class="flex-1 px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition font-medium disabled:opacity-50">
<span x-text="noteSubmitting ? 'Saving...' : 'Save Note'"></span>
</button>
<button @click="showNoteModal = false" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Photo Lightbox -->
<div x-show="lightboxUrl" class="fixed inset-0 bg-black bg-opacity-80 z-50 flex items-center justify-center" x-cloak @click.away="lightboxUrl = ''" @click="lightboxUrl = ''">
<img :src="lightboxUrl" class="max-w-full max-h-full p-4">
</div>
</div>
<script>
function ticketDetail() {
return {
ticket: null,
ticketId: {{ ticket_id }},
loading: true,
// Modals
showStatusModal: false,
showAssignModal: false,
showNoteModal: false,
lightboxUrl: '',
// Status form
statusForm: { newStatus: '', note: '' },
statusSubmitting: false,
statusError: '',
// Assign form
assignForm: { technicianId: '' },
assignSubmitting: false,
assignError: '',
technicians: [],
// Note form
noteForm: { note: '' },
noteSubmitting: false,
async init() {
await this.loadTicket();
if (app().isFM || app().isAdmin) {
await this.loadTechnicians();
}
},
async loadTicket() {
try {
this.ticket = await app().apiGet(`/api/tickets/${this.ticketId}`);
} catch (e) {
console.error('Ticket load error', e);
this.loading = false;
} finally {
this.loading = false;
}
},
async loadTechnicians() {
// Users list not exposed via API directly, so use a known list
// In a real system we'd have GET /api/users
this.technicians = [
{ id: 9, full_name: 'Prosper' },
{ id: 10, full_name: 'Sam' },
{ id: 11, full_name: 'Steven' },
{ id: 12, full_name: 'Junior (Samuel)' },
{ id: 13, full_name: 'Francis' },
{ id: 14, full_name: 'Desmond Afful' },
{ id: 15, full_name: 'Afful' },
];
},
async submitStatusUpdate() {
this.statusError = '';
if (!this.statusForm.newStatus) {
this.statusError = 'Please select a status';
return;
}
this.statusSubmitting = true;
try {
const payload = { status: this.statusForm.newStatus };
if (this.statusForm.note) payload.note = this.statusForm.note;
const updated = await app().apiPatch(`/api/tickets/${this.ticketId}`, payload);
this.ticket = updated;
this.showStatusModal = false;
this.statusForm = { newStatus: '', note: '' };
app().showToast('Status updated', 'success');
} catch (e) {
this.statusError = e.message;
} finally {
this.statusSubmitting = false;
}
},
async submitAssign() {
this.assignError = '';
if (!this.assignForm.technicianId) {
this.assignError = 'Please select a technician';
return;
}
this.assignSubmitting = true;
try {
const updated = await app().apiPatch(`/api/tickets/${this.ticketId}`, {
assigned_to: parseInt(this.assignForm.technicianId)
});
this.ticket = updated;
this.showAssignModal = false;
app().showToast('Technician assigned', 'success');
} catch (e) {
this.assignError = e.message;
} finally {
this.assignSubmitting = false;
}
},
async submitNote() {
if (!this.noteForm.note.trim()) return;
this.noteSubmitting = true;
try {
// Use PATCH endpoint which now supports note-only updates
const updated = await app().apiPatch(`/api/tickets/${this.ticketId}`, {
note: this.noteForm.note
});
this.ticket = updated;
this.showNoteModal = false;
this.noteForm.note = '';
app().showToast('Note added', 'success');
} catch (e) {
// No need to show error here — api() base method already shows it
} finally {
this.noteSubmitting = false;
}
},
async uploadPhotos(e) {
const files = Array.from(e.target.files || []);
if (!files.length) return;
app().loading = true;
app().loadingMessage = 'Uploading photos...';
try {
const formData = new FormData();
files.forEach(f => formData.append('files', f));
const res = await fetch(`/api/tickets/${this.ticketId}/photos?is_before=true`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${app().token}` },
body: formData
});
if (res.ok) {
await this.loadTicket();
app().showToast('Photos uploaded', 'success');
} else {
const err = await res.json().catch(() => ({ detail: 'Upload failed' }));
app().showToast(err.detail, 'error');
}
} catch (e) {
app().showToast('Photo upload failed', 'error');
} finally {
app().loading = false;
app().loadingMessage = '';
}
},
showPhoto(url) {
this.lightboxUrl = url;
},
getTimelineColor(entry) {
const colors = {
'new': 'border-blue-500 bg-blue-500',
'completed': 'border-green-500 bg-green-500',
'closed': 'border-gray-500 bg-gray-500',
'escalated': 'border-red-500 bg-red-500',
};
const toStatus = entry.to_status?.toLowerCase() || '';
return colors[toStatus] || 'border-denya-500 bg-denya-500';
}
}
}
</script>
{% endblock %}
+211
View File
@@ -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 %}
+280
View File
@@ -0,0 +1,280 @@
{% 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>
<!-- QR Code excluded per Sprint 3 scope -->
</select>
</div>
</div>
<!-- Photo Upload -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Photos (Before)</label>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-denya-400 transition cursor-pointer" @click="document.getElementById('photoInput').click()">
<svg class="w-8 h-8 mx-auto text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="text-sm text-gray-500">Click to upload photos</p>
<input type="file" id="photoInput" multiple accept="image/*" @change="handlePhotos" class="hidden">
</div>
<div class="flex flex-wrap gap-2 mt-2" x-show="photoFiles.length">
<template x-for="(photo, idx) in photoPreviews" :key="idx">
<div class="relative w-20 h-20 rounded-lg overflow-hidden border">
<img :src="photo" class="w-full h-full object-cover">
<button type="button" @click="removePhoto(idx)" class="absolute top-0.5 right-0.5 bg-red-500 text-white w-5 h-5 rounded-full flex items-center justify-center text-xs hover:bg-red-600">&times;</button>
</div>
</template>
</div>
</div>
<!-- Error Display -->
<div x-show="error" class="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="error"></div>
<!-- Submit -->
<div class="flex items-center space-x-3 pt-2">
<button type="submit" :disabled="submitting" class="px-6 py-2.5 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2">
<svg x-show="submitting" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
<span x-text="submitting ? 'Creating...' : '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;
try {
const data = await app().apiGet(`/api/tickets/units?property=${encodeURIComponent(this.form.property)}`);
this.units = data || [];
} catch (e) {
console.error('Units load error', e);
}
},
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;
}
// Resolve unit_id from selected apartment_code
const selectedUnit = this.units.find(u => u.apartment_code === this.form.apartment_code);
// Create the ticket
const payload = {
description: this.form.description,
priority: this.form.priority || null,
reporter: this.form.reporter || this.form.customer_name || app().user.full_name,
reported_via: this.form.reported_via || 'walk-in',
category_id: this.form.category_id ? parseInt(this.form.category_id) : (this.form.category_main ? parseInt(this.form.category_main) : null),
unit_id: selectedUnit ? selectedUnit.id : null,
customer_name: this.form.customer_name || null,
phone: this.form.phone || null,
};
const ticket = await app().apiPost('/api/tickets', payload);
// Upload photos if any
if (this.photoFiles.length > 0 && ticket?.id) {
const formData = new FormData();
this.photoFiles.forEach(f => formData.append('files', f));
try {
await fetch(`/api/tickets/${ticket.id}/photos?is_before=true`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${app().token}` },
body: formData
});
} catch (e) { console.error('Photo upload error', e); }
}
app().showToast(`Ticket ${ticket.ticket_number} created successfully!`, 'success');
// Redirect to ticket detail
setTimeout(() => { window.location.href = `/tickets/${ticket.id}`; }, 1000);
} catch (e) {
this.error = e.message || 'Failed to create ticket';
} finally {
this.submitting = false;
}
}
}
}
</script>
{% endblock %}