Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4afdc36765 | ||
|
|
3920cf14a1 | ||
|
|
01e28bbcc7 | ||
|
|
ef2297ea87 | ||
|
|
a9c17d0703 | ||
|
|
5560496653 | ||
|
|
7fab1ede51 | ||
|
|
3dc383e62d | ||
|
|
9aa7971a28 | ||
|
|
bc23338bc1 | ||
|
|
344d555529 | ||
|
|
1120081760 | ||
|
|
57b18a27e3 | ||
|
|
a27b1856cc | ||
|
|
2de3c8514c | ||
|
|
31ae78ec07 | ||
|
|
65622de7a6 | ||
|
|
06bcdb0392 |
@@ -4,6 +4,8 @@ __pycache__/
|
||||
*.sqlite3
|
||||
.env
|
||||
.venv/
|
||||
uploads/
|
||||
test_*.py
|
||||
venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
|
||||
@@ -19,9 +19,10 @@ app/
|
||||
├── core/ # config, database, security (JWT + bcrypt + RBAC)
|
||||
├── models/ # SQLAlchemy ORM models
|
||||
├── schemas/ # Pydantic request/response schemas
|
||||
├── services/ # Business logic (auth, seed)
|
||||
├── services/ # Business logic (auth, seed, ticket, sla)
|
||||
└── routers/ # FastAPI route handlers
|
||||
alembic/ # Database migrations
|
||||
uploads/ # Photo uploads (created at runtime)
|
||||
```
|
||||
|
||||
## Commands
|
||||
@@ -31,14 +32,16 @@ alembic/ # Database migrations
|
||||
|
||||
## Seed data
|
||||
|
||||
Users and units are auto-seeded on first startup via lifespan hook:
|
||||
Users, units, and categories are auto-seeded on first startup via lifespan hook:
|
||||
- 17 users covering all roles (Admin/Jerome, Admin/Wahab, CS Rep, CS Manager, FM Dispatcher, Tech, CEO, Director)
|
||||
- 120 apartment units (East/West, 10 floors × 6 apts per wing)
|
||||
- Default password for all seed users: `denya123`
|
||||
- Units load from `apartment_mapping.json` if present, else built-in fallback
|
||||
- Categories: 20 top-level (10 Maintenance, 6 CS, 4 Emergency) with sub-categories, seeded from `app/services/seed.py::SEED_CATEGORIES_DATA`
|
||||
|
||||
## Key API endpoints
|
||||
|
||||
### Auth & Health
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/health` | No | Health check |
|
||||
@@ -50,6 +53,33 @@ Users and units 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 |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/tickets` | Bearer | Create ticket (auto-number PAV-YYYY-NNNNN) |
|
||||
| GET | `/api/tickets` | No | List tickets (filter: status, priority, property, category_id, assigned_to, date_from, date_to) |
|
||||
| GET | `/api/tickets/{id}` | No | Get ticket detail with timeline, photos, SLA status |
|
||||
| PATCH | `/api/tickets/{id}` | Bearer | Update ticket (validates status transitions) |
|
||||
| POST | `/api/tickets/{id}/status` | Bearer | Change status with note |
|
||||
| GET | `/api/tickets/{id}/sla` | No | Check SLA breach status |
|
||||
| POST | `/api/tickets/{id}/photos` | Bearer | Upload photos (multipart, is_before param) |
|
||||
| GET | `/api/tickets/{id}/photos` | No | List photos |
|
||||
| GET | `/api/tickets/categories` | No | Category tree (optional `?type=` filter) |
|
||||
| GET | `/api/tickets/categories/flat` | No | Flat category list |
|
||||
|
||||
## Auth
|
||||
|
||||
- JWT access (30min) + refresh (7d) tokens
|
||||
@@ -57,6 +87,28 @@ Users and units are auto-seeded on first startup via lifespan hook:
|
||||
- Use `require_roles("Admin/Jerome", "Admin/Wahab")` dependency for RBAC
|
||||
- `sub` claim holds string user ID
|
||||
|
||||
## Ticket System (Sprint 2)
|
||||
|
||||
### Status Lifecycle (15 statuses)
|
||||
New → Logged → Triage → Assigned → Accepted → Travelling → On Site → In Progress → Waiting Parts → Escalated → Completed → On-Field Verification → Wahab Review → Closed → Reopened
|
||||
|
||||
Valid transitions defined in `app/services/ticket.py::VALID_TRANSITIONS`. Invalid transitions return 400.
|
||||
|
||||
### SLA Engine
|
||||
Defined in `app/services/sla.py`. Priority-based targets:
|
||||
- Urgent: respond 15min, resolve 4h
|
||||
- High: respond 30min, resolve 24h
|
||||
- Medium: respond 4h, resolve 72h (3d)
|
||||
- Low: respond 24h, resolve 168h (7d)
|
||||
|
||||
`sla_deadline` auto-calculated on ticket creation. SLA status check at `GET /api/tickets/{id}/sla`.
|
||||
|
||||
### Ticket Number Format
|
||||
`PAV-YYYY-NNNNN` — sequential per year (e.g., PAV-2026-00001).
|
||||
|
||||
### Photo Uploads
|
||||
Stored under `uploads/` with UUID filenames. Static-files mounted at `/uploads/`. Multipart POST with `is_before` query param.
|
||||
|
||||
## Database
|
||||
|
||||
SQLite via aiosqlite with async SQLAlchemy 2.0. Alembic for migrations.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+13
-4
@@ -4,16 +4,16 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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, whatsapp
|
||||
from app.services.seed import seed_units, seed_users
|
||||
from app.routers import auth, health, pages, tickets, whatsapp
|
||||
from app.services.seed import seed_categories, seed_units, seed_users
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,6 +29,8 @@ async def lifespan(app: FastAPI):
|
||||
await session.commit()
|
||||
await seed_units(session, json_path=str(settings.BASE_DIR / "apartment_mapping.json"))
|
||||
await session.commit()
|
||||
await seed_categories(session)
|
||||
await session.commit()
|
||||
yield
|
||||
await engine.dispose()
|
||||
logger.info("Denya OneCare stopped.")
|
||||
@@ -49,7 +51,14 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ── Static files (uploads) ───────────────────────────────────────────
|
||||
uploads_dir = Path(settings.BASE_DIR / "uploads")
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads")
|
||||
|
||||
# ── Routers ──────────────────────────────────────────────────────────
|
||||
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)
|
||||
|
||||
@@ -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})>"
|
||||
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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})
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Ticket CRUD endpoints, photo uploads, SLA checks, and category listing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, status
|
||||
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.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,
|
||||
CategoryTreeOut,
|
||||
SLAStatusOut,
|
||||
TicketBrief,
|
||||
TicketCreate,
|
||||
TicketListResponse,
|
||||
TicketOut,
|
||||
TicketPhotoOut,
|
||||
TicketUpdate,
|
||||
UnitOut,
|
||||
)
|
||||
from app.services import ticket as ticket_service
|
||||
from app.services.sla import get_sla_status
|
||||
|
||||
router = APIRouter(prefix="/api/tickets", tags=["tickets"])
|
||||
|
||||
# Ensure uploads directory exists
|
||||
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."""
|
||||
result = await db.execute(
|
||||
select(Category)
|
||||
.where(Category.parent_id == parent_id)
|
||||
.order_by(Category.name)
|
||||
)
|
||||
categories = result.scalars().all()
|
||||
tree = []
|
||||
for cat in categories:
|
||||
children = await _build_category_tree(db, cat.id)
|
||||
tree.append(CategoryTreeOut(
|
||||
id=cat.id,
|
||||
type=cat.type,
|
||||
name=cat.name,
|
||||
parent_id=cat.parent_id,
|
||||
sla_urgency=cat.sla_urgency,
|
||||
children=children,
|
||||
))
|
||||
return tree
|
||||
|
||||
|
||||
@router.get("/categories", response_model=list[CategoryTreeOut])
|
||||
async def list_categories(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
type_filter: str | None = Query(None, alias="type"),
|
||||
) -> list[CategoryTreeOut]:
|
||||
"""Return the full category tree, optionally filtered by type (maintenance, cs, emergency)."""
|
||||
if type_filter:
|
||||
# Return flat list of top-level categories of the given type
|
||||
result = await db.execute(
|
||||
select(Category)
|
||||
.where(Category.parent_id.is_(None), Category.type == type_filter)
|
||||
.order_by(Category.name)
|
||||
)
|
||||
parents = result.scalars().all()
|
||||
tree = []
|
||||
for parent in parents:
|
||||
children_result = await db.execute(
|
||||
select(Category)
|
||||
.where(Category.parent_id == parent.id)
|
||||
.order_by(Category.name)
|
||||
)
|
||||
children = children_result.scalars().all()
|
||||
tree.append(CategoryTreeOut(
|
||||
id=parent.id,
|
||||
type=parent.type,
|
||||
name=parent.name,
|
||||
parent_id=parent.parent_id,
|
||||
sla_urgency=parent.sla_urgency,
|
||||
children=[CategoryOut.model_validate(c) for c in children],
|
||||
))
|
||||
return tree
|
||||
return await _build_category_tree(db)
|
||||
|
||||
|
||||
@router.get("/categories/flat", response_model=list[CategoryOut])
|
||||
async def list_categories_flat(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
type_filter: str | None = Query(None, alias="type"),
|
||||
) -> list[CategoryOut]:
|
||||
"""Return a flat list of all categories (no nesting), optionally filtered by type."""
|
||||
query = select(Category).order_by(Category.type, Category.name)
|
||||
if type_filter:
|
||||
query = query.where(Category.type == type_filter)
|
||||
result = await db.execute(query)
|
||||
categories = result.scalars().all()
|
||||
return [CategoryOut.model_validate(c) for c in categories]
|
||||
|
||||
|
||||
# ── Ticket CRUD ──────────────────────────────────────────────────────
|
||||
@router.post("", response_model=TicketOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_ticket(
|
||||
body: TicketCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> Ticket:
|
||||
"""Create a new ticket. Auto-generates ticket number PAV-YYYY-NNNNN."""
|
||||
ticket = await ticket_service.create_ticket(db, body.model_dump(exclude_none=True), user=current_user)
|
||||
# Reload with relationships
|
||||
ticket = await ticket_service.get_ticket(db, ticket.id)
|
||||
sla = await get_sla_status(ticket)
|
||||
ticket.sla_status = sla # type: ignore[attr-defined]
|
||||
return ticket
|
||||
|
||||
|
||||
@router.get("", response_model=TicketListResponse)
|
||||
async def list_tickets(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
status: str | None = Query(None),
|
||||
priority: str | None = Query(None),
|
||||
property: str | None = Query(None),
|
||||
category_id: int | None = Query(None),
|
||||
assigned_to: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
) -> TicketListResponse:
|
||||
"""List tickets with optional filtering and pagination."""
|
||||
tickets, total = await ticket_service.list_tickets(
|
||||
db,
|
||||
status_filter=status,
|
||||
priority_filter=priority,
|
||||
property_filter=property,
|
||||
category_id=category_id,
|
||||
assigned_to=assigned_to,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
items = [TicketBrief.model_validate(t) for t in tickets]
|
||||
return TicketListResponse(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}", response_model=TicketOut)
|
||||
async def get_ticket(
|
||||
ticket_id: int,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> Ticket:
|
||||
"""Get a single ticket by ID with full details, timeline, photos, and SLA status."""
|
||||
ticket = await ticket_service.get_ticket(db, ticket_id)
|
||||
sla = await get_sla_status(ticket)
|
||||
ticket.sla_status = sla # type: ignore[attr-defined]
|
||||
return ticket
|
||||
|
||||
|
||||
@router.patch("/{ticket_id}", response_model=TicketOut)
|
||||
async def update_ticket(
|
||||
ticket_id: int,
|
||||
body: TicketUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> Ticket:
|
||||
"""Update a ticket. Validates status transitions and logs timeline."""
|
||||
ticket = await ticket_service.update_ticket(db, ticket_id, body.model_dump(exclude_none=True), user=current_user)
|
||||
sla = await get_sla_status(ticket)
|
||||
ticket.sla_status = sla # type: ignore[attr-defined]
|
||||
return ticket
|
||||
|
||||
|
||||
# ── Status Transitions (convenience endpoints) ───────────────────────
|
||||
@router.post("/{ticket_id}/status", response_model=TicketOut)
|
||||
async def change_ticket_status(
|
||||
ticket_id: int,
|
||||
body: dict,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> Ticket:
|
||||
"""Change a ticket's status with optional note.
|
||||
|
||||
Request body:
|
||||
```json
|
||||
{"status": "Logged", "note": "Verified with customer"}
|
||||
```
|
||||
"""
|
||||
status_val = body.get("status")
|
||||
if not status_val:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="status is required")
|
||||
update_data = {"status": status_val}
|
||||
if "note" in body:
|
||||
update_data["note"] = body["note"]
|
||||
|
||||
ticket = await ticket_service.update_ticket(db, ticket_id, update_data, user=current_user)
|
||||
sla = await get_sla_status(ticket)
|
||||
ticket.sla_status = sla # type: ignore[attr-defined]
|
||||
return ticket
|
||||
|
||||
|
||||
# ── SLA ──────────────────────────────────────────────────────────────
|
||||
@router.get("/{ticket_id}/sla", response_model=SLAStatusOut)
|
||||
async def check_sla(
|
||||
ticket_id: int,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> dict:
|
||||
"""Check SLA status for a ticket — response deadline, resolution deadline, breach flags."""
|
||||
ticket = await ticket_service.get_ticket(db, ticket_id)
|
||||
return await get_sla_status(ticket)
|
||||
|
||||
|
||||
# ── Photo Uploads ────────────────────────────────────────────────────
|
||||
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
ALLOWED_MIME_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/photos", response_model=list[TicketPhotoOut], status_code=status.HTTP_201_CREATED)
|
||||
async def upload_photos(
|
||||
ticket_id: int,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
files: list[UploadFile],
|
||||
is_before: bool = True,
|
||||
) -> list[TicketPhoto]:
|
||||
"""Upload photos for a ticket (before/after). Stores files under uploads/."""
|
||||
# Verify ticket exists
|
||||
await ticket_service.get_ticket(db, ticket_id)
|
||||
|
||||
# Validate all files before any write operations
|
||||
for file in files:
|
||||
if file.content_type not in ALLOWED_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported content type '{file.content_type}'. Allowed: image/jpeg, image/png, image/gif, image/webp",
|
||||
)
|
||||
ext = Path(file.filename or "photo.jpg").suffix.lower() if file.filename else ".jpg"
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported file extension '{ext}'. Allowed: .jpg, .jpeg, .png, .gif, .webp",
|
||||
)
|
||||
|
||||
created_photos: list[TicketPhoto] = []
|
||||
file_data: list[tuple[Path, bytes]] = []
|
||||
for file in files:
|
||||
# Generate unique filename
|
||||
ext = Path(file.filename or "photo.jpg").suffix.lower() if file.filename else ".jpg"
|
||||
unique_name = f"{uuid.uuid4().hex}{ext}"
|
||||
file_path = UPLOADS_DIR / unique_name
|
||||
|
||||
content = await file.read()
|
||||
file_data.append((file_path, content))
|
||||
|
||||
# Database record (no file write yet)
|
||||
photo = TicketPhoto(
|
||||
ticket_id=ticket_id,
|
||||
photo_url=f"/uploads/{unique_name}",
|
||||
is_before=is_before,
|
||||
)
|
||||
db.add(photo)
|
||||
created_photos.append(photo)
|
||||
|
||||
await db.flush()
|
||||
for p in created_photos:
|
||||
await db.refresh(p)
|
||||
|
||||
# Write files to disk only after successful DB flush
|
||||
for file_path, content in file_data:
|
||||
file_path.write_bytes(content)
|
||||
|
||||
return created_photos
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/photos", response_model=list[TicketPhotoOut])
|
||||
async def list_photos(
|
||||
ticket_id: int,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> list[TicketPhoto]:
|
||||
"""List all photos for a ticket."""
|
||||
await ticket_service.get_ticket(db, ticket_id)
|
||||
result = await db.execute(
|
||||
select(TicketPhoto)
|
||||
.where(TicketPhoto.ticket_id == ticket_id)
|
||||
.order_by(TicketPhoto.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
+139
-18
@@ -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)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Pydantic schemas for ticket CRUD, timeline, photos, and SLA."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
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
|
||||
type: str
|
||||
name: str
|
||||
parent_id: int | None = None
|
||||
sla_urgency: str | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CategoryTreeOut(CategoryOut):
|
||||
children: list[CategoryTreeOut] = []
|
||||
|
||||
|
||||
# ── Ticket ───────────────────────────────────────────────────────────
|
||||
class TicketCreate(BaseModel):
|
||||
unit_id: int | None = None
|
||||
category_id: int | None = None
|
||||
priority: str | None = None # urgent, high, medium, low
|
||||
reporter: str | None = None
|
||||
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):
|
||||
status: str | None = None
|
||||
unit_id: int | None = None
|
||||
category_id: int | None = None
|
||||
priority: str | None = None
|
||||
reporter: str | None = None
|
||||
reported_via: str | None = None
|
||||
description: str | None = None
|
||||
assigned_to: int | None = None
|
||||
eta: datetime | None = None
|
||||
cost: Decimal | None = None
|
||||
parts_used: str | None = None
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class TicketTimelineOut(BaseModel):
|
||||
id: int
|
||||
ticket_id: int
|
||||
from_status: str | None = None
|
||||
to_status: str | None = None
|
||||
note: str | None = None
|
||||
user_id: int | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TicketPhotoOut(BaseModel):
|
||||
id: int
|
||||
ticket_id: int
|
||||
photo_url: str
|
||||
is_before: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TicketBrief(BaseModel):
|
||||
"""Summary view for list endpoints."""
|
||||
id: int
|
||||
ticket_number: str
|
||||
status: str
|
||||
priority: str | None = None
|
||||
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
|
||||
reopen_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TicketOut(TicketBrief):
|
||||
"""Full ticket detail with relations."""
|
||||
closed_at: datetime | None = None
|
||||
eta: datetime | None = None
|
||||
cost: Decimal | None = None
|
||||
parts_used: str | None = None
|
||||
customer_rating: int | None = None
|
||||
timeline: list[TicketTimelineOut] = []
|
||||
photos: list[TicketPhotoOut] = []
|
||||
sla_status: dict | None = None
|
||||
|
||||
|
||||
class TicketListResponse(BaseModel):
|
||||
items: list[TicketBrief]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
# ── SLA ──────────────────────────────────────────────────────────────
|
||||
class SLAStatusOut(BaseModel):
|
||||
priority: str | None = None
|
||||
response_deadline: str | None = None
|
||||
resolution_deadline: str | None = None
|
||||
response_breached: bool = False
|
||||
resolution_breached: bool = False
|
||||
current_status: str | None = None
|
||||
+61
-12
@@ -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}
|
||||
|
||||
+87
-2
@@ -1,6 +1,6 @@
|
||||
"""Seed script — users and units.
|
||||
"""Seed script — users, units, and categories.
|
||||
|
||||
Called on first startup or via :func:`seed_all`.
|
||||
Called on first startup via lifespan hook.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.security import hash_password
|
||||
from app.models.unit import Unit
|
||||
from app.models.user import User
|
||||
from app.models.category import Category
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -118,3 +119,87 @@ async def seed_units(db: AsyncSession, json_path: str | Path | None = None) -> l
|
||||
else:
|
||||
logger.info("All units already exist")
|
||||
return created
|
||||
|
||||
|
||||
# ── Category taxonomy (PRD §7) ───────────────────────────────────────
|
||||
# Top-level categories with sub-categories
|
||||
SEED_CATEGORIES_DATA: list[dict] = [
|
||||
# ── Maintenance ──────────────────────────────────────────────
|
||||
{"type": "maintenance", "name": "Plumbing", "subs": ["WC not flushing", "Leaky tap", "Pipe burst", "Blocked drain", "Water heater"]},
|
||||
{"type": "maintenance", "name": "Electrical", "subs": ["No power", "Tripped breaker", "Light fitting", "Socket repair", "Voltage issues"]},
|
||||
{"type": "maintenance", "name": "HVAC", "subs": ["AC not cooling", "AC leaking", "Thermostat", "Fan not working"]},
|
||||
{"type": "maintenance", "name": "Furniture", "subs": ["Broken bed", "Broken chair", "Broken table", "Wardrobe issue"]},
|
||||
{"type": "maintenance", "name": "Appliances", "subs": ["Fridge", "Washing machine", "Microwave", "TV", "Kettle"]},
|
||||
{"type": "maintenance", "name": "Cleaning", "subs": ["Deep clean requested", "Post-checkout turnover", "Common area cleaning"]},
|
||||
{"type": "maintenance", "name": "Pest Control", "subs": ["Insects", "Rodents", "Fumigation"]},
|
||||
{"type": "maintenance", "name": "Security", "subs": ["Lock broken", "Door not closing", "Window latch", "CCTV issue"]},
|
||||
{"type": "maintenance", "name": "Internet", "subs": ["WiFi down", "Slow speed", "Router reset"]},
|
||||
{"type": "maintenance", "name": "Structural", "subs": ["Wall crack", "Ceiling leak", "Floor tile", "Paint touch-up"]},
|
||||
# ── Customer Service ─────────────────────────────────────────
|
||||
{"type": "cs", "name": "Check-in", "subs": ["Early check-in", "Key handover", "Welcome instructions"]},
|
||||
{"type": "cs", "name": "Check-out", "subs": ["Late checkout", "Key return", "Inspection"]},
|
||||
{"type": "cs", "name": "Housekeeping", "subs": ["Mid-stay cleaning", "Linen change", "Restocking"]},
|
||||
{"type": "cs", "name": "Lost Property", "subs": ["Guest left items behind"]},
|
||||
{"type": "cs", "name": "Billing", "subs": ["Invoice question", "Payment issue", "Deposit query"]},
|
||||
{"type": "cs", "name": "Staff Behaviour", "subs": ["Staff conduct feedback"]},
|
||||
# ── Emergency ────────────────────────────────────────────────
|
||||
{"type": "emergency", "name": "Fire", "subs": ["Smoke detected", "Fire alarm", "Sprinkler issue"], "sla_urgency": "urgent"},
|
||||
{"type": "emergency", "name": "Flood", "subs": ["Major water leak", "Burst pipe", "Overflowing"], "sla_urgency": "urgent"},
|
||||
{"type": "emergency", "name": "Gas Leak", "subs": ["Gas smell", "Suspected leak"], "sla_urgency": "urgent"},
|
||||
{"type": "emergency", "name": "Electrical Hazard", "subs": ["Sparking", "Exposed wires", "Power outage multiple units"], "sla_urgency": "urgent"},
|
||||
]
|
||||
|
||||
|
||||
async def seed_categories(db: AsyncSession) -> list[Category]:
|
||||
"""Seed the categories table with the full PRD §7 taxonomy."""
|
||||
created: list[Category] = []
|
||||
|
||||
for group in SEED_CATEGORIES_DATA:
|
||||
# Check if parent category exists
|
||||
result = await db.execute(
|
||||
select(Category).where(
|
||||
Category.type == group["type"],
|
||||
Category.name == group["name"],
|
||||
Category.parent_id.is_(None),
|
||||
)
|
||||
)
|
||||
parent = result.scalar_one_or_none()
|
||||
if parent is None:
|
||||
parent = Category(
|
||||
type=group["type"],
|
||||
name=group["name"],
|
||||
parent_id=None,
|
||||
sla_urgency=group.get("sla_urgency"),
|
||||
)
|
||||
db.add(parent)
|
||||
await db.flush()
|
||||
created.append(parent)
|
||||
|
||||
# Seed sub-categories
|
||||
for sub_name in group["subs"]:
|
||||
result = await db.execute(
|
||||
select(Category).where(
|
||||
Category.type == group["type"],
|
||||
Category.name == sub_name,
|
||||
Category.parent_id == parent.id,
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing is None:
|
||||
sub = Category(
|
||||
type=group["type"],
|
||||
name=sub_name,
|
||||
parent_id=parent.id,
|
||||
sla_urgency=None,
|
||||
)
|
||||
db.add(sub)
|
||||
created.append(sub)
|
||||
|
||||
if created:
|
||||
await db.flush()
|
||||
for c in created:
|
||||
await db.refresh(c)
|
||||
logger.info("Seeded %d categories", len(created))
|
||||
else:
|
||||
logger.info("All seed categories already exist")
|
||||
return created
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""SLA engine — priority-based deadlines, escalation triggers, and status checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.models.ticket import Ticket
|
||||
|
||||
# ── Priority SLA Targets (from PRD §11) ──────────────────────────────
|
||||
# Each entry: (response_window_minutes, resolution_window_hours)
|
||||
SLA_TARGETS: dict[str, tuple[int, int]] = {
|
||||
"urgent": (15, 4), # respond 10-15min, resolve 2-4h → use upper bound
|
||||
"high": (30, 24), # respond 30min, resolve 8-24h
|
||||
"medium": (240, 72), # respond 4h (240min), resolve 2-3d (72h)
|
||||
"low": (1440, 168), # respond 1d (1440min), resolve 5-7d (168h)
|
||||
}
|
||||
|
||||
|
||||
def compute_sla_deadline(priority: str, created_at: datetime | None = None) -> datetime:
|
||||
"""Return the resolution deadline for a given priority.
|
||||
|
||||
Uses the upper-bound resolution target.
|
||||
"""
|
||||
now = created_at or datetime.now(timezone.utc)
|
||||
_response_min, resolution_hours = SLA_TARGETS.get(priority, (240, 72))
|
||||
return now + timedelta(hours=resolution_hours)
|
||||
|
||||
|
||||
def get_sla_response_window(priority: str) -> timedelta:
|
||||
"""Return the response-window timedelta for a given priority."""
|
||||
minutes, _hours = SLA_TARGETS.get(priority, (240, 72))
|
||||
return timedelta(minutes=minutes)
|
||||
|
||||
|
||||
def get_sla_resolution_window(priority: str) -> timedelta:
|
||||
"""Return the resolution-window timedelta for a given priority."""
|
||||
_minutes, hours = SLA_TARGETS.get(priority, (240, 72))
|
||||
return timedelta(hours=hours)
|
||||
|
||||
|
||||
# ── Escalation detection ─────────────────────────────────────────────
|
||||
def should_escalate_on_response(ticket: Ticket) -> bool:
|
||||
"""Return True if the ticket's response SLA has been breached.
|
||||
|
||||
A ticket is in breach if its status is still in the pre-acknowledgement
|
||||
states (New, Logged, Triage, Assigned) beyond the response window.
|
||||
"""
|
||||
if not ticket.priority or ticket.created_at is None:
|
||||
return False
|
||||
pre_ack_states = {"New", "Logged", "Triage", "Assigned"}
|
||||
if ticket.status not in pre_ack_states:
|
||||
return False
|
||||
response_window = get_sla_response_window(ticket.priority)
|
||||
deadline = ticket.created_at.replace(tzinfo=timezone.utc) + response_window
|
||||
return datetime.now(timezone.utc) > deadline
|
||||
|
||||
|
||||
def is_sla_breached(ticket: Ticket) -> bool:
|
||||
"""Return True if the ticket's resolution SLA deadline has passed."""
|
||||
if ticket.sla_deadline is None or ticket.status in ("Closed", "Completed"):
|
||||
return False
|
||||
deadline = ticket.sla_deadline
|
||||
if deadline.tzinfo is None:
|
||||
deadline = deadline.replace(tzinfo=timezone.utc)
|
||||
return datetime.now(timezone.utc) > deadline
|
||||
|
||||
|
||||
# ── API helper ───────────────────────────────────────────────────────
|
||||
async def get_sla_status(ticket: Ticket) -> dict:
|
||||
"""Return a structured SLA status dict for a ticket."""
|
||||
now = datetime.now(timezone.utc)
|
||||
created = ticket.created_at
|
||||
if created and created.tzinfo is None:
|
||||
created = created.replace(tzinfo=timezone.utc)
|
||||
|
||||
response_deadline = None
|
||||
resolution_deadline = ticket.sla_deadline
|
||||
|
||||
if ticket.priority and created:
|
||||
response_deadline = created + get_sla_response_window(ticket.priority)
|
||||
|
||||
response_breached = False
|
||||
if response_deadline and ticket.status in {"New", "Logged", "Triage", "Assigned"}:
|
||||
response_breached = now > response_deadline
|
||||
|
||||
resolution_breached = False
|
||||
if resolution_deadline:
|
||||
rd = resolution_deadline
|
||||
if rd.tzinfo is None:
|
||||
rd = rd.replace(tzinfo=timezone.utc)
|
||||
resolution_breached = now > rd if ticket.status not in ("Closed", "Completed") else False
|
||||
|
||||
return {
|
||||
"priority": ticket.priority,
|
||||
"response_deadline": response_deadline.isoformat() if response_deadline else None,
|
||||
"resolution_deadline": resolution_deadline.isoformat() if resolution_deadline else None,
|
||||
"response_breached": response_breached,
|
||||
"resolution_breached": resolution_breached,
|
||||
"current_status": ticket.status,
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Ticket business logic — CRUD, status transitions, SLA enforcement."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.ticket import Escalation, Ticket, TicketTimeline
|
||||
from app.models.unit import Unit
|
||||
from app.models.user import User
|
||||
from app.services.sla import compute_sla_deadline
|
||||
|
||||
# ── Status Transition Map ────────────────────────────────────────────
|
||||
# Keys: current status → list of valid next statuses
|
||||
VALID_TRANSITIONS: dict[str, list[str]] = {
|
||||
"New": ["Logged"],
|
||||
"Logged": ["Triage", "Closed"],
|
||||
"Triage": ["Assigned", "Escalated"],
|
||||
"Assigned": ["Accepted", "Triage"],
|
||||
"Accepted": ["Travelling", "Triage"],
|
||||
"Travelling": ["On Site", "Triage"],
|
||||
"On Site": ["In Progress", "Triage"],
|
||||
"In Progress": ["Waiting Parts", "Escalated", "Completed"],
|
||||
"Waiting Parts": ["In Progress", "Escalated"],
|
||||
"Escalated": ["Triage", "In Progress", "Completed", "Closed"],
|
||||
"Completed": ["On-Field Verification", "In Progress"],
|
||||
"On-Field Verification": ["Wahab Review", "Completed", "Closed"],
|
||||
"Wahab Review": ["Closed", "On-Field Verification"],
|
||||
"Closed": ["Reopened"],
|
||||
"Reopened": ["Triage", "Logged"],
|
||||
}
|
||||
|
||||
REOPEN_WINDOW_DAYS = 7
|
||||
SLA_ACK_USER = "Ama"
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
async def _generate_ticket_number(db: AsyncSession) -> str:
|
||||
"""Generate the next ticket number in PAV-YYYY-NNNNN format."""
|
||||
year = datetime.now(timezone.utc).year
|
||||
prefix = f"PAV-{year}-"
|
||||
# Count existing tickets this year
|
||||
result = await db.execute(
|
||||
select(func.count(Ticket.id)).where(Ticket.ticket_number.like(f"{prefix}%"))
|
||||
)
|
||||
count = result.scalar() or 0
|
||||
return f"{prefix}{count + 1:05d}"
|
||||
|
||||
|
||||
async def _log_status_change(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
from_status: str | None,
|
||||
to_status: str | None,
|
||||
note: str | None = None,
|
||||
user_id: int | None = None,
|
||||
) -> TicketTimeline:
|
||||
"""Create a timeline entry for a status change."""
|
||||
entry = TicketTimeline(
|
||||
ticket_id=ticket_id,
|
||||
from_status=from_status,
|
||||
to_status=to_status,
|
||||
note=note,
|
||||
user_id=user_id,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
return entry
|
||||
|
||||
|
||||
async def _get_ticket_or_404(db: AsyncSession, ticket_id: int) -> Ticket:
|
||||
"""Fetch a ticket by ID or raise 404."""
|
||||
result = await db.execute(
|
||||
select(Ticket)
|
||||
.options(
|
||||
selectinload(Ticket.timeline),
|
||||
selectinload(Ticket.photos),
|
||||
selectinload(Ticket.assigned_technician),
|
||||
selectinload(Ticket.unit),
|
||||
selectinload(Ticket.category),
|
||||
)
|
||||
.where(Ticket.id == ticket_id)
|
||||
)
|
||||
ticket = result.scalar_one_or_none()
|
||||
if ticket is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket not found")
|
||||
return ticket
|
||||
|
||||
|
||||
# ── CRUD ─────────────────────────────────────────────────────────────
|
||||
async def create_ticket(
|
||||
db: AsyncSession,
|
||||
data: dict[str, Any],
|
||||
user: User | None = None,
|
||||
) -> Ticket:
|
||||
"""Create a new ticket with auto-numbering and SLA deadline."""
|
||||
ticket_number = await _generate_ticket_number(db)
|
||||
priority = data.get("priority")
|
||||
sla_deadline = compute_sla_deadline(priority) if priority else None
|
||||
|
||||
ticket = Ticket(
|
||||
ticket_number=ticket_number,
|
||||
status="New",
|
||||
unit_id=data.get("unit_id"),
|
||||
category_id=data.get("category_id"),
|
||||
priority=priority,
|
||||
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"),
|
||||
sla_deadline=sla_deadline,
|
||||
)
|
||||
db.add(ticket)
|
||||
await db.flush()
|
||||
await db.refresh(ticket)
|
||||
|
||||
# Log initial creation
|
||||
await _log_status_change(
|
||||
db,
|
||||
ticket.id,
|
||||
from_status=None,
|
||||
to_status="New",
|
||||
note="Ticket created",
|
||||
user_id=user.id if user else None,
|
||||
)
|
||||
|
||||
# Auto-advance New → Logged (creation is also the logging step)
|
||||
ticket.status = "Logged"
|
||||
await _log_status_change(
|
||||
db,
|
||||
ticket.id,
|
||||
from_status="New",
|
||||
to_status="Logged",
|
||||
note="Ticket logged and numbered",
|
||||
user_id=user.id if user else None,
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(ticket)
|
||||
return ticket
|
||||
|
||||
|
||||
async def get_ticket(db: AsyncSession, ticket_id: int) -> Ticket:
|
||||
"""Get a single ticket with full details."""
|
||||
return await _get_ticket_or_404(db, ticket_id)
|
||||
|
||||
|
||||
async def list_tickets(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
status_filter: str | None = None,
|
||||
priority_filter: str | None = None,
|
||||
property_filter: str | None = None,
|
||||
category_id: int | None = None,
|
||||
assigned_to: int | None = None,
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> tuple[list[Ticket], int]:
|
||||
"""List tickets with optional filters. Returns (tickets, total_count)."""
|
||||
query = select(Ticket)
|
||||
count_query = select(func.count(Ticket.id))
|
||||
|
||||
if status_filter:
|
||||
query = query.where(Ticket.status == status_filter)
|
||||
count_query = count_query.where(Ticket.status == status_filter)
|
||||
if priority_filter:
|
||||
query = query.where(Ticket.priority == priority_filter)
|
||||
count_query = count_query.where(Ticket.priority == priority_filter)
|
||||
if property_filter:
|
||||
# Join unit to filter by property
|
||||
query = query.join(Ticket.unit).where(Unit.property == property_filter)
|
||||
count_query = count_query.join(Ticket.unit).where(Unit.property == property_filter)
|
||||
if category_id:
|
||||
query = query.where(Ticket.category_id == category_id)
|
||||
count_query = count_query.where(Ticket.category_id == category_id)
|
||||
if assigned_to:
|
||||
query = query.where(Ticket.assigned_to == assigned_to)
|
||||
count_query = count_query.where(Ticket.assigned_to == assigned_to)
|
||||
if date_from:
|
||||
query = query.where(Ticket.created_at >= date_from)
|
||||
count_query = count_query.where(Ticket.created_at >= date_from)
|
||||
if date_to:
|
||||
query = query.where(Ticket.created_at <= date_to)
|
||||
count_query = count_query.where(Ticket.created_at <= date_to)
|
||||
|
||||
# Count total
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Paginate
|
||||
offset = (page - 1) * 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())
|
||||
return tickets, total
|
||||
|
||||
|
||||
async def update_ticket(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
data: dict[str, Any],
|
||||
user: User | None = None,
|
||||
) -> Ticket:
|
||||
"""Update a ticket. Status changes are validated and logged."""
|
||||
ticket = await _get_ticket_or_404(db, ticket_id)
|
||||
|
||||
# Handle status transitions separately
|
||||
new_status = data.get("status")
|
||||
old_status = ticket.status
|
||||
if new_status is not None:
|
||||
if old_status != new_status:
|
||||
valid_targets = VALID_TRANSITIONS.get(old_status, [])
|
||||
if new_status not in valid_targets:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid status transition: {old_status} → {new_status}. "
|
||||
f"Valid targets: {valid_targets}",
|
||||
)
|
||||
|
||||
# Handle special logic for reopening
|
||||
if new_status == "Reopened":
|
||||
if old_status != "Closed":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Can only reopen a Closed ticket",
|
||||
)
|
||||
if ticket.closed_at:
|
||||
closed_at = ticket.closed_at
|
||||
if closed_at.tzinfo is None:
|
||||
closed_at = closed_at.replace(tzinfo=timezone.utc)
|
||||
days_since_close = (datetime.now(timezone.utc) - closed_at).days
|
||||
if days_since_close > REOPEN_WINDOW_DAYS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot reopen: ticket closed {days_since_close} days ago "
|
||||
f"(max {REOPEN_WINDOW_DAYS} days)",
|
||||
)
|
||||
|
||||
# Auto-escalate to Ama
|
||||
ama_result = await db.execute(
|
||||
select(User).where(User.full_name == SLA_ACK_USER)
|
||||
)
|
||||
ama_user = ama_result.scalar_one_or_none()
|
||||
ticket.reopen_count += 1
|
||||
ticket.closed_at = None
|
||||
if ama_user:
|
||||
escalation = Escalation(
|
||||
ticket_id=ticket.id,
|
||||
escalated_to=ama_user.id,
|
||||
reason=f"Auto-escalation: ticket reopened (reopen #{ticket.reopen_count})",
|
||||
)
|
||||
db.add(escalation)
|
||||
|
||||
# Handle closing
|
||||
if new_status == "Closed":
|
||||
ticket.closed_at = datetime.now(timezone.utc)
|
||||
|
||||
# Log the transition
|
||||
await _log_status_change(
|
||||
db,
|
||||
ticket.id,
|
||||
from_status=old_status,
|
||||
to_status=new_status,
|
||||
note=data.get("note"),
|
||||
user_id=user.id if user else None,
|
||||
)
|
||||
|
||||
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"):
|
||||
if field in data:
|
||||
setattr(ticket, field, data[field])
|
||||
|
||||
# Recompute SLA if priority changed
|
||||
if data.get("priority"):
|
||||
ticket.sla_deadline = compute_sla_deadline(data["priority"])
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(ticket)
|
||||
return ticket
|
||||
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Denya OneCare</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
denya: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
.status-new { @apply bg-gray-100 text-gray-800; }
|
||||
.status-logged { @apply bg-blue-100 text-blue-800; }
|
||||
.status-triage { @apply bg-yellow-100 text-yellow-800; }
|
||||
.status-assigned { @apply bg-indigo-100 text-indigo-800; }
|
||||
.status-accepted { @apply bg-purple-100 text-purple-800; }
|
||||
.status-travelling { @apply bg-cyan-100 text-cyan-800; }
|
||||
.status-onsite { @apply bg-teal-100 text-teal-800; }
|
||||
.status-inprogress { @apply bg-sky-100 text-sky-800; }
|
||||
.status-waitingparts { @apply bg-orange-100 text-orange-800; }
|
||||
.status-escalated { @apply bg-red-100 text-red-800; }
|
||||
.status-completed { @apply bg-green-100 text-green-800; }
|
||||
.status-on-field-verification { @apply bg-emerald-100 text-emerald-800; }
|
||||
.status-wahab-review { @apply bg-violet-100 text-violet-800; }
|
||||
.status-closed { @apply bg-gray-200 text-gray-600; }
|
||||
.status-reopened { @apply bg-pink-100 text-pink-800; }
|
||||
.priority-urgent { @apply bg-red-100 text-red-800 border-red-300; }
|
||||
.priority-high { @apply bg-orange-100 text-orange-800 border-orange-300; }
|
||||
.priority-medium { @apply bg-yellow-100 text-yellow-800 border-yellow-300; }
|
||||
.priority-low { @apply bg-green-100 text-green-800 border-green-300; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="app()" x-init="init()">
|
||||
<!-- Nav Bar -->
|
||||
<nav class="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-50" x-show="isLoggedIn" x-cloak>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Left side -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/dashboard/cs" class="flex items-center space-x-2 text-denya-700 font-bold text-lg">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
<span>Denya OneCare</span>
|
||||
</a>
|
||||
<!-- CS Links -->
|
||||
<template x-if="isCS">
|
||||
<div class="hidden md:flex space-x-1 ml-6">
|
||||
<a href="/dashboard/cs" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/cs' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a>
|
||||
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">All Issues</a>
|
||||
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50">Create Issue</a>
|
||||
</div>
|
||||
</template>
|
||||
<!-- FM Links -->
|
||||
<template x-if="isFM">
|
||||
<div class="hidden md:flex space-x-1 ml-6">
|
||||
<a href="/dashboard/fm" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/fm' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a>
|
||||
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">All Issues</a>
|
||||
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50">Create Issue</a>
|
||||
</div>
|
||||
</template>
|
||||
<!-- CEO/Director Links -->
|
||||
<template x-if="isExecutive">
|
||||
<div class="hidden md:flex space-x-1 ml-6">
|
||||
<a href="/dashboard/ceo" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a>
|
||||
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Issues</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Right side -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm text-gray-600 hidden md:block" x-text="`${user.full_name} (${user.role})`"></span>
|
||||
<button @click="logout()" class="px-3 py-1.5 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Nav (CS) -->
|
||||
<div class="md:hidden border-b bg-white" x-show="isLoggedIn" x-cloak>
|
||||
<template x-if="isCS || isFM">
|
||||
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
|
||||
<a href="/dashboard/cs" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/cs' ? 'bg-denya-50 text-denya-700' : 'text-gray-500'" x-text="isCS ? 'CS Dashboard' : 'FM Dashboard'"></a>
|
||||
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-50 text-denya-700' : 'text-gray-500'">Issues</a>
|
||||
<a href="/tickets/new" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-500">New Issue</a>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="isExecutive">
|
||||
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
|
||||
<a href="/dashboard/ceo" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-50 text-denya-700' : 'text-gray-500'">CEO Dashboard</a>
|
||||
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-500">Issues</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div x-show="loading" class="fixed inset-0 bg-black bg-opacity-30 z-50 flex items-center justify-center" x-cloak>
|
||||
<div class="bg-white rounded-lg p-6 flex items-center space-x-3 shadow-xl">
|
||||
<svg class="animate-spin h-6 w-6 text-denya-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span class="text-gray-700 font-medium" x-text="loadingMessage || 'Loading...'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<div class="fixed bottom-4 right-4 z-50 space-y-2">
|
||||
<template x-for="toast in toasts" :key="toast.id">
|
||||
<div class="px-4 py-3 rounded-lg shadow-lg text-white text-sm font-medium transition-all duration-300"
|
||||
:class="{'bg-green-600': toast.type === 'success', 'bg-red-600': toast.type === 'error', 'bg-blue-600': toast.type === 'info', 'bg-yellow-600': toast.type === 'warning'}"
|
||||
x-init="setTimeout(() => { toasts = toasts.filter(t => t.id !== toast.id) }, toast.duration || 4000)">
|
||||
<span x-text="toast.message"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function app() {
|
||||
return {
|
||||
// Auth state
|
||||
user: JSON.parse(localStorage.getItem('user') || '{}'),
|
||||
token: localStorage.getItem('access_token') || null,
|
||||
isLoggedIn: !!localStorage.getItem('access_token'),
|
||||
currentPath: window.location.pathname,
|
||||
|
||||
// UI state
|
||||
loading: false,
|
||||
loadingMessage: '',
|
||||
toasts: [],
|
||||
|
||||
// Role helpers
|
||||
get isCS() { return ['CS Rep', 'CS Manager'].includes(this.user.role) },
|
||||
get isFM() { return ['FM Dispatcher', 'Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
|
||||
get isExecutive() { return ['CEO', 'Director'].includes(this.user.role) },
|
||||
get isAdmin() { return ['Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
|
||||
|
||||
init() {
|
||||
// Redirect to login if not logged in (skip for login page)
|
||||
if (!this.isLoggedIn && this.currentPath !== '/login') {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
// Verify token on load
|
||||
if (this.isLoggedIn) {
|
||||
this.fetchMe();
|
||||
}
|
||||
},
|
||||
|
||||
async fetchMe() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me', {
|
||||
headers: this.authHeaders()
|
||||
});
|
||||
if (res.ok) {
|
||||
const userData = await res.json();
|
||||
this.user = userData;
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
} else if (res.status === 401) {
|
||||
this.logout();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Auth check failed', e);
|
||||
}
|
||||
},
|
||||
|
||||
authHeaders() {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
||||
return headers;
|
||||
},
|
||||
|
||||
async api(method, url, body = null) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const opts = {
|
||||
method,
|
||||
headers: this.authHeaders()
|
||||
};
|
||||
if (body && !(body instanceof FormData)) {
|
||||
opts.body = JSON.stringify(body);
|
||||
} else if (body instanceof FormData) {
|
||||
opts.body = body;
|
||||
delete opts.headers['Content-Type']; // Let browser set multipart boundary
|
||||
opts.headers = { 'Authorization': `Bearer ${this.token}` };
|
||||
}
|
||||
const res = await fetch(url, opts);
|
||||
if (res.status === 401 && this.currentPath !== '/login') {
|
||||
this.logout();
|
||||
return null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
this.showToast(e.message, 'error');
|
||||
throw e;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
apiGet(url) { return this.api('GET', url); },
|
||||
apiPost(url, body) { return this.api('POST', url, body); },
|
||||
apiPatch(url, body) { return this.api('PATCH', url, body); },
|
||||
apiDelete(url) { return this.api('DELETE', url); },
|
||||
|
||||
async login(email, password) {
|
||||
try {
|
||||
this.loading = true;
|
||||
this.loadingMessage = 'Signing in...';
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Login failed' }));
|
||||
throw new Error(err.detail);
|
||||
}
|
||||
const data = await res.json();
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
this.token = data.access_token;
|
||||
|
||||
// Fetch user info
|
||||
const me = await this.apiGet('/api/auth/me');
|
||||
this.user = me;
|
||||
localStorage.setItem('user', JSON.stringify(me));
|
||||
this.isLoggedIn = true;
|
||||
|
||||
// Role-based redirect
|
||||
const role = me.role;
|
||||
if (['CS Rep', 'CS Manager'].includes(role)) {
|
||||
window.location.href = '/dashboard/cs';
|
||||
} else if (['FM Dispatcher', 'Admin/Jerome', 'Admin/Wahab'].includes(role)) {
|
||||
window.location.href = '/dashboard/fm';
|
||||
} else if (['CEO', 'Director'].includes(role)) {
|
||||
window.location.href = '/dashboard/ceo';
|
||||
} else {
|
||||
window.location.href = '/tickets';
|
||||
}
|
||||
} catch (e) {
|
||||
this.showToast(e.message, 'error');
|
||||
throw e;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.loadingMessage = '';
|
||||
}
|
||||
},
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
this.user = {};
|
||||
this.token = null;
|
||||
this.isLoggedIn = false;
|
||||
window.location.href = '/login';
|
||||
},
|
||||
|
||||
showToast(message, type = 'info', duration = 4000) {
|
||||
const id = Date.now() + Math.random();
|
||||
this.toasts.push({ id, message, type, duration });
|
||||
},
|
||||
|
||||
statusClass(status) {
|
||||
if (!status) return 'bg-gray-100 text-gray-800';
|
||||
const map = {
|
||||
'new': 'status-new',
|
||||
'logged': 'status-logged',
|
||||
'triage': 'status-triage',
|
||||
'assigned': 'status-assigned',
|
||||
'accepted': 'status-accepted',
|
||||
'travelling': 'status-travelling',
|
||||
'on site': 'status-onsite',
|
||||
'in progress': 'status-inprogress',
|
||||
'waiting parts': 'status-waitingparts',
|
||||
'escalated': 'status-escalated',
|
||||
'completed': 'status-completed',
|
||||
'on-field verification': 'status-on-field-verification',
|
||||
'wahab review': 'status-wahab-review',
|
||||
'closed': 'status-closed',
|
||||
'reopened': 'status-reopened'
|
||||
};
|
||||
return map[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
|
||||
},
|
||||
|
||||
priorityBadge(priority) {
|
||||
const labels = { urgent: '🔴 Urgent', high: '🟠 High', medium: '🟡 Medium', low: '🟢 Low' };
|
||||
return labels[priority] || priority;
|
||||
},
|
||||
|
||||
priorityClass(priority) {
|
||||
const map = { urgent: 'priority-urgent', high: 'priority-high', medium: 'priority-medium', low: 'priority-low' };
|
||||
return map[priority] || 'bg-gray-100 text-gray-800';
|
||||
},
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
},
|
||||
|
||||
formatDateShort(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
},
|
||||
|
||||
timeSince(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const now = new Date();
|
||||
const d = new Date(dateStr);
|
||||
const diffMs = now - d;
|
||||
const diffHrs = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (diffHrs < 1) return '< 1h';
|
||||
if (diffHrs < 24) return `${diffHrs}h`;
|
||||
return `${diffDays}d`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,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 %}
|
||||
@@ -0,0 +1,244 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="csDashboard()" x-init="init()">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Customer Service Dashboard</h1>
|
||||
<p class="text-gray-500 mt-1">Welcome back, <span x-text="user.full_name"></span></p>
|
||||
</div>
|
||||
|
||||
<!-- KPI Cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 font-medium">New Today</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.newToday || 0"></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 font-medium">Open Tickets</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.openTickets || 0"></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 font-medium">Avg Response Time</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.avgResponseTime || '—'"></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 font-medium">SLA Compliance</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="(kpi.slaCompliance || 0) + '%'"></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority Breakdown + Escalated -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Open by Priority -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Open by Priority</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-red-600 font-medium">🔴 Urgent</span>
|
||||
<span class="text-2xl font-bold" x-text="kpi.byPriority?.urgent || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-orange-600 font-medium">🟠 High</span>
|
||||
<span class="text-2xl font-bold" x-text="kpi.byPriority?.high || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-yellow-600 font-medium">🟡 Medium</span>
|
||||
<span class="text-2xl font-bold" x-text="kpi.byPriority?.medium || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-green-600 font-medium">🟢 Low</span>
|
||||
<span class="text-2xl font-bold" x-text="kpi.byPriority?.low || 0"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Oldest Unassigned -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Oldest Unassigned</h3>
|
||||
<div x-show="!oldestUnassigned" class="text-gray-400 text-sm py-8 text-center">No unassigned tickets</div>
|
||||
<template x-if="oldestUnassigned">
|
||||
<div>
|
||||
<a :href="'/tickets/' + oldestUnassigned.id" class="block p-3 bg-orange-50 rounded-lg border border-orange-200 hover:bg-orange-100 transition">
|
||||
<div class="font-medium text-sm" x-text="oldestUnassigned.ticket_number"></div>
|
||||
<div class="text-xs text-gray-600 mt-1" x-text="oldestUnassigned.description?.substring(0, 80)"></div>
|
||||
<div class="text-xs text-gray-400 mt-1">Waiting <span x-text="timeSince(oldestUnassigned.created_at)"></span></div>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Escalated -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Escalated Tickets</h3>
|
||||
<div class="text-3xl font-bold text-red-600 mb-3" x-text="kpi.escalatedCount || 0"></div>
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto">
|
||||
<template x-for="ticket in escalatedTickets" :key="ticket.id">
|
||||
<a :href="'/tickets/' + ticket.id" class="block p-2 bg-red-50 rounded border border-red-200 hover:bg-red-100 transition">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm font-medium" x-text="ticket.ticket_number"></span>
|
||||
<span class="text-xs text-red-600 font-medium" x-text="ticket.priority"></span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 mt-0.5" x-text="ticket.description?.substring(0, 60)"></div>
|
||||
</a>
|
||||
</template>
|
||||
<div x-show="!escalatedTickets.length" class="text-gray-400 text-sm py-4 text-center">No escalated tickets</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Tickets Table -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<div class="px-5 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Recent Tickets</h3>
|
||||
<a href="/tickets" class="text-sm text-denya-600 hover:text-denya-800">View All →</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th class="px-5 py-3 text-left">Ticket</th>
|
||||
<th class="px-5 py-3 text-left">Status</th>
|
||||
<th class="px-5 py-3 text-left">Priority</th>
|
||||
<th class="px-5 py-3 text-left">Description</th>
|
||||
<th class="px-5 py-3 text-left">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<template x-for="ticket in recentTickets" :key="ticket.id">
|
||||
<tr class="hover:bg-gray-50 transition cursor-pointer" @click="window.location.href='/tickets/'+ticket.id">
|
||||
<td class="px-5 py-3 font-medium text-denya-600" x-text="ticket.ticket_number"></td>
|
||||
<td class="px-5 py-3"><span class="px-2 py-1 rounded-full text-xs font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span></td>
|
||||
<td class="px-5 py-3"><span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></td>
|
||||
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
|
||||
<td class="px-5 py-3 text-gray-500 text-xs" x-text="formatDate(ticket.created_at)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="!recentTickets.length">
|
||||
<td colspan="5" class="px-5 py-12 text-center text-gray-400">No tickets found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function csDashboard() {
|
||||
return {
|
||||
kpi: { byPriority: {} },
|
||||
recentTickets: [],
|
||||
escalatedTickets: [],
|
||||
oldestUnassigned: null,
|
||||
|
||||
async init() {
|
||||
await this.loadKPIs();
|
||||
await this.loadRecentTickets();
|
||||
await this.loadEscalated();
|
||||
},
|
||||
|
||||
async loadKPIs() {
|
||||
try {
|
||||
const tickets = await app().apiGet('/api/tickets?page_size=200');
|
||||
if (!tickets?.items) return;
|
||||
const all = tickets.items;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// New today
|
||||
const newToday = all.filter(t => new Date(t.created_at) >= today && t.status === 'New').length;
|
||||
|
||||
// Open tickets (not closed/completed)
|
||||
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
||||
|
||||
// By priority
|
||||
const byPriority = { urgent: 0, high: 0, medium: 0, low: 0 };
|
||||
open.forEach(t => { if (t.priority) byPriority[t.priority] = (byPriority[t.priority] || 0) + 1; });
|
||||
|
||||
// Oldest unassigned
|
||||
const unassigned = all.filter(t => !t.assigned_to && !['Closed', 'Completed'].includes(t.status))
|
||||
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
this.oldestUnassigned = unassigned[0] || null;
|
||||
|
||||
// SLA compliance (rough: tickets with sla_deadline not breached)
|
||||
const withSLA = all.filter(t => t.sla_deadline);
|
||||
const slaMet = withSLA.filter(t => new Date(t.sla_deadline) > new Date() || ['Closed', 'Completed'].includes(t.status));
|
||||
|
||||
this.kpi = {
|
||||
newToday,
|
||||
openTickets: open.length,
|
||||
byPriority,
|
||||
escalatedCount: all.filter(t => t.status === 'Escalated').length,
|
||||
avgResponseTime: this.calcAvgResponse(all),
|
||||
slaCompliance: withSLA.length ? Math.round((slaMet.length / withSLA.length) * 100) : 100
|
||||
};
|
||||
} catch (e) { console.error('KPI load error', e); }
|
||||
},
|
||||
|
||||
async loadRecentTickets() {
|
||||
try {
|
||||
const data = await app().apiGet('/api/tickets?page_size=20');
|
||||
this.recentTickets = data?.items || [];
|
||||
} catch (e) { console.error('Recent tickets load error', e); }
|
||||
},
|
||||
|
||||
async loadEscalated() {
|
||||
try {
|
||||
const data = await app().apiGet('/api/tickets?status=Escalated&page_size=10');
|
||||
this.escalatedTickets = data?.items || [];
|
||||
} catch (e) { console.error('Escalated load error', e); }
|
||||
},
|
||||
|
||||
calcAvgResponse(all) {
|
||||
// Rough approximation — in real system this would come from timeline analysis
|
||||
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
||||
if (!open.length) return '—';
|
||||
const avgHrs = open.reduce((sum, t) => {
|
||||
const diff = (new Date() - new Date(t.created_at)) / (1000 * 60 * 60);
|
||||
return sum + Math.min(diff, 168); // cap at 1 week
|
||||
}, 0) / open.length;
|
||||
if (avgHrs < 1) return `${Math.round(avgHrs * 60)}m`;
|
||||
return `${Math.round(avgHrs)}h`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,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">< 24h</span>
|
||||
<span class="text-2xl font-bold text-green-700" x-text="aging.under24h || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-yellow-50 rounded-lg">
|
||||
<span class="text-sm font-medium text-yellow-700">1-2 days</span>
|
||||
<span class="text-2xl font-bold text-yellow-700" x-text="aging.oneToTwoDays || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-orange-50 rounded-lg">
|
||||
<span class="text-sm font-medium text-orange-700">3-5 days</span>
|
||||
<span class="text-2xl font-bold text-orange-700" x-text="aging.threeToFiveDays || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-red-50 rounded-lg">
|
||||
<span class="text-sm font-medium text-red-700">Overdue > 5d</span>
|
||||
<span class="text-2xl font-bold text-red-700" x-text="aging.overFiveDays || 0"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Tickets Table -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<div class="px-5 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Active Tickets</h3>
|
||||
<a href="/tickets" class="text-sm text-denya-600 hover:text-denya-800">View All →</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th class="px-5 py-3 text-left">Ticket</th>
|
||||
<th class="px-5 py-3 text-left">Status</th>
|
||||
<th class="px-5 py-3 text-left">Priority</th>
|
||||
<th class="px-5 py-3 text-left">Technician</th>
|
||||
<th class="px-5 py-3 text-left">Description</th>
|
||||
<th class="px-5 py-3 text-left">Age</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<template x-for="ticket in activeTickets" :key="ticket.id">
|
||||
<tr class="hover:bg-gray-50 transition cursor-pointer" :class="ticket.priority === 'urgent' ? 'bg-red-50' : ''" @click="window.location.href='/tickets/'+ticket.id">
|
||||
<td class="px-5 py-3 font-medium text-denya-600" x-text="ticket.ticket_number"></td>
|
||||
<td class="px-5 py-3"><span class="px-2 py-1 rounded-full text-xs font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span></td>
|
||||
<td class="px-5 py-3"><span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></td>
|
||||
<td class="px-5 py-3 text-gray-600" x-text="ticket.assigned_technician_name || 'Unassigned'"></td>
|
||||
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
|
||||
<td class="px-5 py-3 text-xs font-medium" :class="timeSince(ticket.created_at).includes('d') && parseInt(timeSince(ticket.created_at)) > 3 ? 'text-red-600' : 'text-gray-500'" x-text="timeSince(ticket.created_at)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="!activeTickets.length">
|
||||
<td colspan="6" class="px-5 py-12 text-center text-gray-400">No active tickets</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function fmDashboard() {
|
||||
return {
|
||||
kpi: {},
|
||||
techWorkload: [],
|
||||
aging: {},
|
||||
activeTickets: [],
|
||||
emergencyCount: 0,
|
||||
|
||||
async init() {
|
||||
await this.loadData();
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
try {
|
||||
const data = await app().apiGet('/api/tickets?page_size=200');
|
||||
if (!data?.items) return;
|
||||
const all = data.items;
|
||||
|
||||
// Active: not closed/completed
|
||||
const active = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
||||
this.activeTickets = active;
|
||||
|
||||
// KPIs
|
||||
this.kpi.activeJobs = active.length;
|
||||
this.kpi.waitingParts = active.filter(t => t.status === 'Waiting Parts').length;
|
||||
|
||||
// Due today
|
||||
const today = new Date();
|
||||
today.setHours(23, 59, 59, 0);
|
||||
const dueToday = active.filter(t => {
|
||||
if (!t.sla_deadline) return false;
|
||||
const deadline = new Date(t.sla_deadline);
|
||||
return deadline <= today;
|
||||
});
|
||||
this.kpi.dueToday = dueToday.length;
|
||||
|
||||
// Emergency
|
||||
this.emergencyCount = active.filter(t => t.priority === 'urgent').length;
|
||||
|
||||
// East vs West — 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 %}
|
||||
@@ -0,0 +1,74 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="min-h-[80vh] flex items-center justify-center">
|
||||
<div class="w-full max-w-md" x-data="loginForm()">
|
||||
<div class="bg-white rounded-2xl shadow-lg p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="mx-auto w-16 h-16 bg-denya-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg class="w-8 h-8 text-denya-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Denya OneCare</h1>
|
||||
<p class="text-gray-500 mt-1">Sign in to your dashboard</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitLogin" class="space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input type="email" x-model="email" required
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none transition"
|
||||
placeholder="you@example.com">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input type="password" x-model="password" required
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none transition"
|
||||
placeholder="••••••••">
|
||||
</div>
|
||||
<div x-show="error" class="text-red-600 text-sm bg-red-50 p-3 rounded-lg" x-text="error"></div>
|
||||
<button type="submit" :disabled="submitting"
|
||||
class="w-full py-2.5 px-4 bg-denya-600 hover:bg-denya-700 text-white font-medium rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2">
|
||||
<svg x-show="submitting" class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span x-text="submitting ? 'Signing in...' : 'Sign In'"></span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<p class="text-xs text-gray-400 text-center">
|
||||
<strong>Demo Accounts:</strong><br>
|
||||
bella@denya.com / denya123 — CS Rep<br>
|
||||
nicholas@denya.com / denya123 — FM Dispatcher<br>
|
||||
scott@denya.com / denya123 — CEO<br>
|
||||
jerome@denya.com / denya123 — Admin
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function loginForm() {
|
||||
return {
|
||||
email: '',
|
||||
password: '',
|
||||
error: '',
|
||||
submitting: false,
|
||||
async submitLogin() {
|
||||
this.error = '';
|
||||
this.submitting = true;
|
||||
try {
|
||||
await app().login(this.email, this.password);
|
||||
} catch (e) {
|
||||
this.error = e.message || 'Invalid credentials. Please try again.';
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,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 %}
|
||||
@@ -0,0 +1,211 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="ticketList()" x-init="init()">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">All Issues</h1>
|
||||
<p class="text-gray-500 mt-1" x-text="`${total} total tickets`"></p>
|
||||
</div>
|
||||
<a href="/tickets/new" class="px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition text-sm font-medium flex items-center space-x-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
<span>New Issue</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 font-medium mb-1">Search</label>
|
||||
<input type="text" x-model="filters.search" @input.debounce="loadTickets()" placeholder="Ticket # or description..." class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 font-medium mb-1">Status</label>
|
||||
<select x-model="filters.status" @change="loadTickets()" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="New">New</option>
|
||||
<option value="Logged">Logged</option>
|
||||
<option value="Triage">Triage</option>
|
||||
<option value="Assigned">Assigned</option>
|
||||
<option value="Accepted">Accepted</option>
|
||||
<option value="Travelling">Travelling</option>
|
||||
<option value="On Site">On Site</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="Waiting Parts">Waiting Parts</option>
|
||||
<option value="Escalated">Escalated</option>
|
||||
<option value="Completed">Completed</option>
|
||||
<option value="On-Field Verification">On-Field Verification</option>
|
||||
<option value="Wahab Review">Wahab Review</option>
|
||||
<option value="Closed">Closed</option>
|
||||
<option value="Reopened">Reopened</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 font-medium mb-1">Priority</label>
|
||||
<select x-model="filters.priority" @change="loadTickets()" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none">
|
||||
<option value="">All Priorities</option>
|
||||
<option value="urgent">🔴 Urgent</option>
|
||||
<option value="high">🟠 High</option>
|
||||
<option value="medium">🟡 Medium</option>
|
||||
<option value="low">🟢 Low</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 font-medium mb-1">Property</label>
|
||||
<select x-model="filters.property" @change="loadTickets()" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none">
|
||||
<option value="">All Properties</option>
|
||||
<option value="East">Pavilion East</option>
|
||||
<option value="West">Pavilion West</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 font-medium mb-1">From</label>
|
||||
<input type="date" x-model="filters.dateFrom" @change="loadTickets()" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 font-medium mb-1">To</label>
|
||||
<input type="date" x-model="filters.dateTo" @change="loadTickets()" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-xs text-gray-500">Page <span x-text="page"></span> of <span x-text="totalPages"></span></span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="page > 1 && (page--, loadTickets())" :disabled="page <= 1" class="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">Prev</button>
|
||||
<button @click="page < totalPages && (page++, loadTickets())" :disabled="page >= totalPages" class="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">Next</button>
|
||||
<button @click="filters = {search:'',status:'',priority:'',property:'',dateFrom:'',dateTo:''}; page=1; loadTickets()" class="px-3 py-1 text-sm text-gray-500 border rounded hover:bg-gray-50">Clear Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tickets Table -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th class="px-5 py-3 text-left cursor-pointer hover:text-gray-900" @click="sortBy('ticket_number')">
|
||||
Ticket <span x-show="sortField === 'ticket_number'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
||||
</th>
|
||||
<th class="px-5 py-3 text-left">Status</th>
|
||||
<th class="px-5 py-3 text-left">Priority</th>
|
||||
<th class="px-5 py-3 text-left">Description</th>
|
||||
<th class="px-5 py-3 text-left">Assigned To</th>
|
||||
<th class="px-5 py-3 text-left cursor-pointer hover:text-gray-900" @click="sortBy('created_at')">
|
||||
Created <span x-show="sortField === 'created_at'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
||||
</th>
|
||||
<th class="px-5 py-3 text-left">SLA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<template x-for="ticket in tickets" :key="ticket.id">
|
||||
<tr class="hover:bg-gray-50 transition cursor-pointer" :class="ticket.priority === 'urgent' ? 'bg-red-50/50' : ''" @click="window.location.href='/tickets/'+ticket.id">
|
||||
<td class="px-5 py-3 font-medium text-denya-600" x-text="ticket.ticket_number"></td>
|
||||
<td class="px-5 py-3"><span class="px-2 py-1 rounded-full text-xs font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span></td>
|
||||
<td class="px-5 py-3"><span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></td>
|
||||
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
|
||||
<td class="px-5 py-3 text-gray-500 text-xs" x-text="ticket.assigned_technician_name || '—'"></td>
|
||||
<td class="px-5 py-3 text-gray-500 text-xs" x-text="formatDate(ticket.created_at)"></td>
|
||||
<td class="px-5 py-3">
|
||||
<span x-show="ticket.sla_deadline" class="text-xs" :class="new Date(ticket.sla_deadline) < new Date() && !['Closed','Completed'].includes(ticket.status) ? 'text-red-600 font-medium' : 'text-gray-400'">
|
||||
<span x-text="formatDateShort(ticket.sla_deadline)"></span>
|
||||
</span>
|
||||
<span x-show="!ticket.sla_deadline" class="text-xs text-gray-300">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="!tickets.length && !loading">
|
||||
<td colspan="7" class="px-5 py-16 text-center text-gray-400">
|
||||
<svg class="w-12 h-12 mx-auto text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
|
||||
No tickets match your filters
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function ticketList() {
|
||||
return {
|
||||
tickets: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
totalPages: 1,
|
||||
sortField: 'created_at',
|
||||
sortDir: 'desc',
|
||||
filters: {
|
||||
search: '',
|
||||
status: '',
|
||||
priority: '',
|
||||
property: '',
|
||||
dateFrom: '',
|
||||
dateTo: ''
|
||||
},
|
||||
|
||||
async init() {
|
||||
await this.loadTickets();
|
||||
},
|
||||
|
||||
async loadTickets() {
|
||||
try {
|
||||
let url = `/api/tickets?page=${this.page}&page_size=${this.pageSize}`;
|
||||
if (this.filters.status) url += `&status=${encodeURIComponent(this.filters.status)}`;
|
||||
if (this.filters.priority) url += `&priority=${encodeURIComponent(this.filters.priority)}`;
|
||||
if (this.filters.property) url += `&property=${encodeURIComponent(this.filters.property)}`;
|
||||
if (this.filters.dateFrom) url += `&date_from=${encodeURIComponent(this.filters.dateFrom)}`;
|
||||
if (this.filters.dateTo) url += `&date_to=${encodeURIComponent(this.filters.dateTo)}`;
|
||||
|
||||
const data = await app().apiGet(url);
|
||||
if (data) {
|
||||
this.tickets = data.items || [];
|
||||
this.total = data.total || 0;
|
||||
this.totalPages = Math.ceil(this.total / this.pageSize) || 1;
|
||||
|
||||
// Client-side search filter for ticket number or description
|
||||
if (this.filters.search) {
|
||||
const q = this.filters.search.toLowerCase();
|
||||
this.tickets = this.tickets.filter(t =>
|
||||
(t.ticket_number && t.ticket_number.toLowerCase().includes(q)) ||
|
||||
(t.description && t.description.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
this.applySort();
|
||||
}
|
||||
} catch (e) { console.error('Tickets load error', e); }
|
||||
},
|
||||
|
||||
sortBy(field) {
|
||||
if (this.sortField === field) {
|
||||
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sortField = field;
|
||||
this.sortDir = 'desc';
|
||||
}
|
||||
this.applySort();
|
||||
},
|
||||
|
||||
applySort() {
|
||||
this.tickets.sort((a, b) => {
|
||||
let valA = a[this.sortField] || '';
|
||||
let valB = b[this.sortField] || '';
|
||||
if (this.sortField === 'created_at') {
|
||||
valA = new Date(valA).getTime();
|
||||
valB = new Date(valB).getTime();
|
||||
}
|
||||
if (typeof valA === 'string') valA = valA.toLowerCase();
|
||||
if (typeof valB === 'string') valB = valB.toLowerCase();
|
||||
if (valA < valB) return this.sortDir === 'asc' ? -1 : 1;
|
||||
if (valA > valB) return this.sortDir === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,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">×</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 %}
|
||||
Reference in New Issue
Block a user