Author SHA1 Message Date
root f84021bc14 Merge remote-tracking branch 'origin/fm/denya-onecare-s3' 2026-07-25 00:19:59 +00:00
root cf55576d10 feat: Denya Developers brand theming
- Brand colors: primary #0d2b18 (deep forest green), accent #c8a96e (gold), bg #faf8f5 (cream), text #1a1a1a
- Nav bg #0d2b18 with gold logo mark and white text
- Denya Developers + OneCare branding in nav header
- Gold accent borders, hover states, and active tab highlights
- Toast/success use brand green shades
- Body background cream (#faf8f5)
2026-07-25 00:19:59 +00:00
jerome 4afdc36765 feat: Replace mock WhatsApp with real Meta Graph API webhook
- POST /api/whatsapp/webhook handles Meta verification (hub.challenge)
- Inbound text messages create tickets via create_ticket()
- Auto-reply confirmation sent back via Meta Graph API
- WhatsApp messages logged with ticket linkage in whatsapp_log
- Added WHATSAPP_PHONE_NUMBER_ID, WHATSAPP_ACCESS_TOKEN, WHATSAPP_VERIFY_TOKEN config
- Kept /mock-log debug endpoint for backward compatibility
- Graceful degradation: logs message even if ticket/reply fails
2026-07-24 20:07:30 -04:00
root c9f5ac4380 feat: Denya Developers brand theming
- Brand colors: primary #0d2b18 (deep forest green), accent #c8a96e (gold), bg #faf8f5 (cream), text #1a1a1a
- Nav bg #0d2b18 with gold logo mark and white text
- Denya Developers + OneCare branding in nav header
- Gold accent borders, hover states, and active tab highlights
- Toast/success use brand green shades
- Body background cream (#faf8f5)
2026-07-23 19:46:08 +00:00
abiba-bot 3920cf14a1 Sprint 3: Alpine.js Dashboards 2026-07-23 19:36:35 +00:00
root 01e28bbcc7 fix: map customer_name to reporter in create_ticket service
Per captain decision: customer_name maps to reporter field (fallback),
phone field skipped for MVP (no DB column).
2026-07-23 19:26:04 +00:00
root ef2297ea87 no-mistakes(review): Fixed aging bucket boundaries and removed wasteful API call 2026-07-23 19:21:31 +00:00
root a9c17d0703 fix: address remaining review findings
- Fix detail.html to use assigned_technician_name (not .assigned_technician?.full_name)
- Remove double error toast in submitNote() catch block
- Replace wasteful API calls in CEO dashboard with units+data approach
- Remove QR Code option from reported_via dropdown (excluded per scope)
2026-07-23 19:18:17 +00:00
root 5560496653 no-mistakes(review): Fix F01/F02/F05: technician name, duplicate timeline, FM dashboard property counts 2026-07-23 19:13:10 +00:00
root 7fab1ede51 fix: address ask-user findings from review
- Move unit loading to use real /api/tickets/units endpoint
- Match unit code format to backend seed (0101E style)
- Send unit_id in create ticket payload
- Fix tech IDs to match seed order (9-15 for Prosper-Afful)
- Add customer_name and phone fields to TicketCreate schema
- Map form customer_name/phone into API payload
2026-07-23 19:08:49 +00:00
root 3dc383e62d fix: auto-fix findings from no-mistakes review
- Remove duplicate  import
- Fix  →  in create ticket
- Fix  →  in ticket detail
- Add note field to TicketUpdate schema and handle note-only updates in backend
- Update frontend submitNote() to use PATCH endpoint
2026-07-23 19:06:33 +00:00
root 9aa7971a28 feat: Sprint 3 frontend dashboards with Alpine.js + Jinja2
- Login page with JWT auth and role-based redirect
- CS Dashboard: KPI cards, priority breakdown, recent tickets
- FM Dashboard: tech workload, aging analysis, emergency alerts
- CEO Dashboard: executive KPIs, charts, risk indicators
- All Issues: filterable/sortable ticket table with pagination
- Create Issue: form with category tree, property/unit selector, photo uploads
- Issue Detail: full timeline, photo gallery, SLA status, action modals
- Role-based nav bar: CS/FM/Executive links adapt to user role
- Base template: shared Alpine.js app state, toast notifications, loading overlay
2026-07-23 19:01:26 +00:00
abiba-bot bc23338bc1 Sprint 2: Ticket Engine, SLA Engine, Photo Uploads, Category System 2026-07-23 18:55:00 +00:00
19 changed files with 2484 additions and 38 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 |
| GET | `/api/whatsapp/mock-log` | No | Recent mock submissions |
### Pages (Sprint 3) — Jinja2 templates at `app/templates/`
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/login` | No | Login page |
| GET | `/dashboard/cs` | Client | CS dashboard |
| GET | `/dashboard/fm` | Client | FM dashboard |
| GET | `/dashboard/ceo` | Client | CEO dashboard |
| GET | `/tickets` | Client | All Issues filterable table |
| GET | `/tickets/new` | Client | Create Issue form |
| GET | `/tickets/{id}` | Client | Issue detail with timeline |
Frontend: Alpine.js (CDN) + Tailwind CSS (CDN). Auth state in localStorage. Role-based nav routing in `base.html`.
### Tickets (Sprint 2)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
+6
View File
@@ -31,6 +31,12 @@ class Settings(BaseSettings):
# ── CORS ─────────────────────────────────────────────────────────
CORS_ORIGINS: str = "*"
# ── WhatsApp ─────────────────────────────────────────────────────
WHATSAPP_PHONE_NUMBER_ID: str = ""
WHATSAPP_ACCESS_TOKEN: str = ""
WHATSAPP_VERIFY_TOKEN: str = ""
META_GRAPH_BASE: str = "https://graph.facebook.com/v18.0"
# ── Paths ────────────────────────────────────────────────────────
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
+2 -1
View File
@@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles
from app.core.config import settings
from app.core.database import Base, async_session_factory, engine
from app.routers import auth, health, tickets, whatsapp
from app.routers import auth, health, pages, tickets, whatsapp
from app.services.seed import seed_categories, seed_units, seed_users
logger = logging.getLogger(__name__)
@@ -61,3 +61,4 @@ app.include_router(health.router)
app.include_router(auth.router)
app.include_router(whatsapp.router)
app.include_router(tickets.router)
app.include_router(pages.router)
+4
View File
@@ -70,6 +70,10 @@ class Ticket(Base):
photos = relationship("TicketPhoto", 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:
return f"<Ticket {self.ticket_number} ({self.status})>"
+6 -4
View File
@@ -1,4 +1,4 @@
"""WhatsApp log model for mock endpoint."""
"""WhatsApp log model for inbound webhook messages."""
from __future__ import annotations
@@ -14,10 +14,12 @@ class WhatsAppLog(Base):
__tablename__ = "whatsapp_log"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
command: Mapped[str] = mapped_column(Text, nullable=False)
from_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
from_number: Mapped[str] = mapped_column(String(50), nullable=False)
message_text: Mapped[str] = mapped_column(Text, nullable=False)
wa_message_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
ticket_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
ticket_number: Mapped[str | None] = mapped_column(String(30), nullable=True)
received_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
def __repr__(self) -> str:
return f"<WhatsAppLog {self.id}: {self.command[:50]}>"
return f"<WhatsAppLog {self.id}: from={self.from_number} ticket={self.ticket_number}>"
+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.models.category import Category
from app.models.ticket import Ticket, TicketPhoto
from app.models.unit import Unit
from app.models.user import User
from app.schemas.ticket import (
CategoryOut,
@@ -27,6 +28,7 @@ from app.schemas.ticket import (
TicketOut,
TicketPhotoOut,
TicketUpdate,
UnitOut,
)
from app.services import ticket as ticket_service
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)
# ── 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 ──────────────────────────────────────────────────────
async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -> list[CategoryTreeOut]:
"""Build a nested category tree."""
+139 -18
View File
@@ -1,44 +1,165 @@
"""Mock WhatsApp endpoint for testing command parsing."""
"""WhatsApp webhook handler — Meta Graph API integration."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends
import httpx
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.models.whatsapp_log import WhatsAppLog
from app.schemas.whatsapp import MockWhatsAppLogEntry, MockWhatsAppRequest, MockWhatsAppResponse
from app.schemas.whatsapp import (
MetaWebhookRequest,
MockWhatsAppLogEntry,
WebhookVerificationResponse,
WhatsAppReplyResponse,
)
from app.services.ticket import create_ticket
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"])
REPLY_TEMPLATE = (
"Thank you for contacting Denya OneCare. "
"Your ticket number is {ticket_number}. "
"We will get back to you soon."
)
@router.post("/mock", response_model=MockWhatsAppResponse)
async def mock_whatsapp(
body: MockWhatsAppRequest,
# ── Meta Graph API helpers ──────────────────────────────────────────
async def send_whatsapp_reply(
to_phone: str,
text: str,
) -> WhatsAppReplyResponse:
"""Send a text message via Meta Graph API."""
url = f"{settings.META_GRAPH_BASE}/{settings.WHATSAPP_PHONE_NUMBER_ID}/messages"
headers = {
"Authorization": f"Bearer {settings.WHATSAPP_ACCESS_TOKEN}",
"Content-Type": "application/x-www-form-urlencoded",
}
data = {
"messaging_product": "whatsapp",
"to": to_phone,
"type": "text",
"text": {"body": text},
}
# Encode nested dict as JSON string for form data (Meta requirement)
data["text"] = '{"body":' + f'"{text}"' + "}"
try:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, data=data, timeout=15.0)
response.raise_for_status()
return WhatsAppReplyResponse(success=True, message="Reply sent")
except httpx.HTTPError as exc:
logger.error("Failed to send WhatsApp reply: %s", exc)
return WhatsAppReplyResponse(success=False, message=str(exc))
# ── Webhook endpoint ────────────────────────────────────────────────
@router.post("/webhook")
async def whatsapp_webhook(
body: MetaWebhookRequest,
db: Annotated[AsyncSession, Depends(get_db)],
) -> MockWhatsAppResponse:
"""Accept a mock WhatsApp command and log it."""
log = WhatsAppLog(
command=body.command,
from_number=body.from_number,
ticket_id=body.ticket_id,
received_at=datetime.now(timezone.utc),
)
db.add(log)
await db.flush()
return MockWhatsAppResponse()
hub_verify_token: str | None = Query(None, alias="hub.verify_token"),
mode: str | None = Query(None),
hub_challenge: str | None = Query(None),
) -> dict:
"""Handle incoming WhatsApp webhook from Meta."""
# ── Verification GET request (Meta sends this on webhook setup) ──
if mode and hub_challenge:
if hub_verify_token != settings.WHATSAPP_VERIFY_TOKEN:
return {"error": "Verify token mismatch"}
return WebhookVerificationResponse(challenge=hub_challenge).model_dump()
# ── Process inbound messages ────────────────────────────────────
if not body.entry:
return {"status": "no entry"}
for entry in body.entry:
if not entry.changes:
continue
for change in entry.changes:
if not change.message or not change.message.text:
logger.info("Non-text message received, skipping")
continue
message_text = change.message.text.text
sender = change.message.from_field
wa_msg_id = change.message.id or change.id
# ── Create ticket ──────────────────────────────────────
try:
ticket = await create_ticket(
db,
data={
"description": message_text,
"reporter": sender,
"reported_via": "WhatsApp",
},
)
except Exception as exc:
logger.error("Failed to create ticket: %s", exc)
# Still log the message even if ticket creation fails
log = WhatsAppLog(
from_number=sender,
message_text=message_text,
wa_message_id=wa_msg_id,
ticket_id=None,
ticket_number=None,
received_at=datetime.now(timezone.utc),
)
db.add(log)
await db.flush()
return {"status": "ticket creation failed, message logged"}
# ── Store WhatsApp log ─────────────────────────────────
log = WhatsAppLog(
from_number=sender,
message_text=message_text,
wa_message_id=wa_msg_id,
ticket_id=ticket.id,
ticket_number=ticket.ticket_number,
received_at=datetime.now(timezone.utc),
)
db.add(log)
await db.flush()
# ── Send auto-reply ────────────────────────────────────
reply_text = REPLY_TEMPLATE.format(ticket_number=ticket.ticket_number)
reply_result = await send_whatsapp_reply(sender, reply_text)
if not reply_result.success:
logger.warning(
"Auto-reply failed for ticket %s: %s",
ticket.ticket_number,
reply_result.message,
)
return {
"status": "processed",
"ticket_id": ticket.id,
"ticket_number": ticket.ticket_number,
"reply_sent": reply_result.success,
}
return {"status": "no messages"}
# ── Legacy debug endpoint ──────────────────────────────────────────
@router.get("/mock-log", response_model=list[MockWhatsAppLogEntry])
async def mock_whatsapp_log(
db: Annotated[AsyncSession, Depends(get_db)],
limit: int = 50,
) -> list[MockWhatsAppLogEntry]:
"""Return recent mock WhatsApp submissions."""
"""Return recent WhatsApp webhook submissions for debugging."""
result = await db.execute(
select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit)
)
+15
View File
@@ -8,6 +8,17 @@ from decimal import Decimal
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 ─────────────────────────────────────────────────────────
class CategoryOut(BaseModel):
id: int
@@ -32,6 +43,8 @@ class TicketCreate(BaseModel):
reported_via: str | None = None # whatsapp, phone, walk-in, qr, agent
description: str | None = None
assigned_to: int | None = None
customer_name: str | None = None
phone: str | None = None
class TicketUpdate(BaseModel):
@@ -46,6 +59,7 @@ class TicketUpdate(BaseModel):
eta: datetime | None = None
cost: Decimal | None = None
parts_used: str | None = None
note: str | None = None
class TicketTimelineOut(BaseModel):
@@ -78,6 +92,7 @@ class TicketBrief(BaseModel):
unit_id: int | None = None
category_id: int | None = None
assigned_to: int | None = None
assigned_technician_name: str | None = None
reporter: str | None = None
description: str | None = None
sla_deadline: datetime | None = None
+61 -12
View File
@@ -1,10 +1,69 @@
"""Pydantic schemas for mock WhatsApp endpoint."""
"""Pydantic schemas for Meta WhatsApp webhook."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, Field
# ── Meta Webhook Verification ────────────────────────────────────────
class WebhookVerificationResponse(BaseModel):
challenge: str
# ── Inbound Message Parsing ─────────────────────────────────────────
class MessageText(BaseModel):
text: str
class Message(BaseModel):
from_field: str = Field(alias="from")
id: str | None = None
text: MessageText | None = None
type: str | None = None
class Contact(BaseModel):
wa_id: str | None = None
profile: dict | None = None
class EntryMessagesItem(BaseModel):
id: str | None = None
message: Message | None = None
contacts: list[Contact] | None = None
timestamp: str | None = None
class Entry(BaseModel):
id: str | None = None
changes: list[EntryMessagesItem] | None = None
metadata: dict | None = None
class MetaWebhookRequest(BaseModel):
object: str | None = None
entry: list[Entry] | None = None
# ── Auto-reply ──────────────────────────────────────────────────────
class WhatsAppReplyResponse(BaseModel):
success: bool
message: str
# ── Legacy mock schemas (kept for /mock-log endpoint) ───────────────
class MockWhatsAppLogEntry(BaseModel):
id: int
from_number: str
message_text: str
wa_message_id: str | None
ticket_id: int | None
ticket_number: str | None
received_at: datetime
model_config = {"from_attributes": True}
class MockWhatsAppRequest(BaseModel):
@@ -16,13 +75,3 @@ class MockWhatsAppRequest(BaseModel):
class MockWhatsAppResponse(BaseModel):
status: str = "received"
message: str = "Command logged successfully"
class MockWhatsAppLogEntry(BaseModel):
id: int
command: str
from_number: str | None
ticket_id: int | None
received_at: datetime
model_config = {"from_attributes": True}
+15 -3
View File
@@ -109,7 +109,7 @@ async def create_ticket(
unit_id=data.get("unit_id"),
category_id=data.get("category_id"),
priority=priority,
reporter=data.get("reporter"),
reporter=data.get("reporter") or data.get("customer_name"),
reported_via=data.get("reported_via"),
description=data.get("description"),
assigned_to=data.get("assigned_to"),
@@ -196,7 +196,7 @@ async def list_tickets(
# Paginate
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)
tickets = list(result.scalars().all())
@@ -214,8 +214,8 @@ async def update_ticket(
# Handle status transitions separately
new_status = data.get("status")
old_status = ticket.status
if new_status is not None:
old_status = ticket.status
if old_status != new_status:
valid_targets = VALID_TRANSITIONS.get(old_status, [])
if new_status not in valid_targets:
@@ -275,6 +275,18 @@ async def update_ticket(
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
for field in ("unit_id", "category_id", "priority", "reporter", "reported_via",
"description", "assigned_to", "eta", "cost", "parts_used"):
+363
View File
@@ -0,0 +1,363 @@
<!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: '#e8f0ea',
100: '#c5d9cb',
200: '#9ebfaa',
300: '#74a589',
400: '#4d8c69',
500: '#2d734d',
600: '#1d5a3a',
700: '#0d2b18',
800: '#0a2012',
900: '#07150c',
},
gold: {
DEFAULT: '#c8a96e',
light: '#e8d5a8',
dark: '#a88a4e',
},
cream: '#faf8f5',
}
}
}
}
</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; }
.brand-gradient { background: linear-gradient(135deg, #0d2b18 0%, #1a3d24 100%); }
</style>
</head>
<body class="bg-cream min-h-screen text-[#1a1a1a]" x-data="app()" x-init="init()">
<!-- Nav Bar -->
<nav class="bg-[#0d2b18] border-b border-denya-800 shadow-lg 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-3">
<!-- Denya Developers Logo Mark -->
<div class="w-9 h-9 bg-gold rounded-lg flex items-center justify-center shadow-sm">
<svg class="w-5 h-5 text-[#0d2b18]" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
</div>
<div class="flex flex-col">
<span class="text-white font-bold text-base leading-tight">Denya Developers</span>
<span class="text-gold text-xs leading-tight font-medium">OneCare</span>
</div>
</a>
<!-- Nav 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 transition-colors" :class="currentPath === '/dashboard/cs' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">All Issues</a>
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-denya-800/50 transition-colors">Create Issue</a>
</div>
</template>
<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 transition-colors" :class="currentPath === '/dashboard/fm' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">All Issues</a>
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-denya-800/50 transition-colors">Create Issue</a>
</div>
</template>
<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 transition-colors" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Issues</a>
</div>
</template>
</div>
<!-- Right side -->
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-300 hidden md:block" x-text="`${user.full_name} (${user.role})`"></span>
<button @click="logout()" class="px-3 py-1.5 text-sm text-gold hover:text-gold-light hover:bg-denya-800/50 rounded-md transition-colors border border-denya-600">
Logout
</button>
</div>
</div>
</div>
</nav>
<!-- Mobile Nav -->
<div class="md:hidden border-b bg-[#0d2b18] border-denya-800" 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-800 text-gold' : 'text-gray-300'">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-800 text-gold' : 'text-gray-300'">Issues</a>
<a href="/tickets/new" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">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-800 text-gold' : 'text-gray-300'">CEO Dashboard</a>
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">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-[#0d2b18]/40 z-50 flex items-center justify-center" x-cloak>
<div class="bg-white rounded-xl p-6 flex items-center space-x-3 shadow-2xl border border-denya-200">
<svg class="animate-spin h-6 w-6 text-denya-700" 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-denya-800 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-denya-600': toast.type === 'success', 'bg-red-600': toast.type === 'error', 'bg-denya-500': toast.type === 'info', 'bg-gold': 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() {
if (!this.isLoggedIn && this.currentPath !== '/login') {
window.location.href = '/login';
return;
}
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'];
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;
const me = await this.apiGet('/api/auth/me');
this.user = me;
localStorage.setItem('user', JSON.stringify(me));
this.isLoggedIn = true;
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 %}