diff --git a/AGENTS.md b/AGENTS.md index 8fb0dcc..61805f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,19 @@ Users, units, and categories are auto-seeded on first startup via lifespan hook: | POST | `/api/whatsapp/mock` | No | Mock WhatsApp | | GET | `/api/whatsapp/mock-log` | No | Recent mock submissions | +### Pages (Sprint 3) — Jinja2 templates at `app/templates/` +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/login` | No | Login page | +| GET | `/dashboard/cs` | Client | CS dashboard | +| GET | `/dashboard/fm` | Client | FM dashboard | +| GET | `/dashboard/ceo` | Client | CEO dashboard | +| GET | `/tickets` | Client | All Issues filterable table | +| GET | `/tickets/new` | Client | Create Issue form | +| GET | `/tickets/{id}` | Client | Issue detail with timeline | + +Frontend: Alpine.js (CDN) + Tailwind CSS (CDN). Auth state in localStorage. Role-based nav routing in `base.html`. + ### Tickets (Sprint 2) | Method | Path | Auth | Description | |--------|------|------|-------------| diff --git a/app/main.py b/app/main.py index 1d82e9c..9947821 100644 --- a/app/main.py +++ b/app/main.py @@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles from app.core.config import settings from app.core.database import Base, async_session_factory, engine -from app.routers import auth, health, tickets, whatsapp +from app.routers import auth, health, pages, tickets, whatsapp from app.services.seed import seed_categories, seed_units, seed_users logger = logging.getLogger(__name__) @@ -61,3 +61,4 @@ app.include_router(health.router) app.include_router(auth.router) app.include_router(whatsapp.router) app.include_router(tickets.router) +app.include_router(pages.router) diff --git a/app/models/ticket.py b/app/models/ticket.py index c270dcb..aad9282 100644 --- a/app/models/ticket.py +++ b/app/models/ticket.py @@ -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"" diff --git a/app/routers/pages.py b/app/routers/pages.py new file mode 100644 index 0000000..ef1da73 --- /dev/null +++ b/app/routers/pages.py @@ -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}) diff --git a/app/routers/tickets.py b/app/routers/tickets.py index c6f21e1..d681940 100644 --- a/app/routers/tickets.py +++ b/app/routers/tickets.py @@ -16,6 +16,7 @@ from app.core.database import get_db from app.core.security import get_current_user from app.models.category import Category from app.models.ticket import Ticket, TicketPhoto +from app.models.unit import Unit from app.models.user import User from app.schemas.ticket import ( CategoryOut, @@ -27,6 +28,7 @@ from app.schemas.ticket import ( TicketOut, TicketPhotoOut, TicketUpdate, + UnitOut, ) from app.services import ticket as ticket_service from app.services.sla import get_sla_status @@ -38,6 +40,20 @@ UPLOADS_DIR = settings.BASE_DIR / "uploads" UPLOADS_DIR.mkdir(parents=True, exist_ok=True) +# ── Units ────────────────────────────────────────────────────────── +@router.get("/units", response_model=list[UnitOut]) +async def list_units( + db: Annotated[AsyncSession, Depends(get_db)], + property_filter: str | None = Query(None, alias="property"), +) -> list[Unit]: + """List all units, optionally filtered by property (East/West).""" + query = select(Unit).order_by(Unit.apartment_code) + if property_filter: + query = query.where(Unit.property == property_filter) + result = await db.execute(query) + return list(result.scalars().all()) + + # ── Categories ────────────────────────────────────────────────────── async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -> list[CategoryTreeOut]: """Build a nested category tree.""" diff --git a/app/schemas/ticket.py b/app/schemas/ticket.py index e8652b8..e4bdfdf 100644 --- a/app/schemas/ticket.py +++ b/app/schemas/ticket.py @@ -8,6 +8,17 @@ from decimal import Decimal from pydantic import BaseModel, Field +# ── Unit ───────────────────────────────────────────────────────────── +class UnitOut(BaseModel): + id: int + property: str + apartment_code: str + building: str | None = None + floor: int | None = None + + model_config = {"from_attributes": True} + + # ── Category ───────────────────────────────────────────────────────── class CategoryOut(BaseModel): id: int @@ -32,6 +43,8 @@ class TicketCreate(BaseModel): reported_via: str | None = None # whatsapp, phone, walk-in, qr, agent description: str | None = None assigned_to: int | None = None + customer_name: str | None = None + phone: str | None = None class TicketUpdate(BaseModel): @@ -46,6 +59,7 @@ class TicketUpdate(BaseModel): eta: datetime | None = None cost: Decimal | None = None parts_used: str | None = None + note: str | None = None class TicketTimelineOut(BaseModel): @@ -78,6 +92,7 @@ class TicketBrief(BaseModel): unit_id: int | None = None category_id: int | None = None assigned_to: int | None = None + assigned_technician_name: str | None = None reporter: str | None = None description: str | None = None sla_deadline: datetime | None = None diff --git a/app/services/ticket.py b/app/services/ticket.py index 9efcb7f..83383d6 100644 --- a/app/services/ticket.py +++ b/app/services/ticket.py @@ -109,7 +109,7 @@ async def create_ticket( unit_id=data.get("unit_id"), category_id=data.get("category_id"), priority=priority, - reporter=data.get("reporter"), + reporter=data.get("reporter") or data.get("customer_name"), reported_via=data.get("reported_via"), description=data.get("description"), assigned_to=data.get("assigned_to"), @@ -196,7 +196,7 @@ async def list_tickets( # Paginate offset = (page - 1) * page_size - query = query.order_by(Ticket.created_at.desc()).offset(offset).limit(page_size) + query = query.order_by(Ticket.created_at.desc()).offset(offset).limit(page_size).options(selectinload(Ticket.assigned_technician)) result = await db.execute(query) tickets = list(result.scalars().all()) @@ -214,8 +214,8 @@ async def update_ticket( # Handle status transitions separately new_status = data.get("status") + old_status = ticket.status if new_status is not None: - old_status = ticket.status if old_status != new_status: valid_targets = VALID_TRANSITIONS.get(old_status, []) if new_status not in valid_targets: @@ -275,6 +275,18 @@ async def update_ticket( ticket.status = new_status + # Handle standalone note (no status change) + note_only = data.get("note") + if note_only and not (new_status is not None and old_status != new_status): + await _log_status_change( + db, + ticket.id, + from_status=ticket.status, + to_status=ticket.status, + note=note_only, + user_id=user.id if user else None, + ) + # Update other fields for field in ("unit_id", "category_id", "priority", "reporter", "reported_via", "description", "assigned_to", "eta", "cost", "parts_used"): diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..c25cfab --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,356 @@ + + + + + + Denya OneCare + + + + + + + + + + +
+ + +
+ + +
+ {% block content %}{% endblock %} +
+ + +
+
+ + + + + +
+
+ + +
+ +
+ + + + diff --git a/app/templates/dashboard/ceo.html b/app/templates/dashboard/ceo.html new file mode 100644 index 0000000..cc06113 --- /dev/null +++ b/app/templates/dashboard/ceo.html @@ -0,0 +1,290 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Executive Dashboard

+

Read-only strategic overview

+
+ + +
+
+

Open Tickets

+

+
+
+

Critical (Urgent)

+

+
+
+

SLA Compliance

+

+
+
+

Avg Resolution

+

+
+
+ +
+
+

Avg Response

+

+
+
+

Reopened This Week

+

+
+
+

Total Tickets

+

+
+
+

Completion Rate

+

+
+
+ + +
+ +
+

Complaints by Property

+
+
+ +
+ East +
+
+ +
+ West +
+
+
+ + +
+

Complaints by Category

+
+ +
No data yet
+
+
+
+ +
+ +
+

Tickets by Priority

+
+
+ 🔴 Urgent + +
+
+ 🟠 High + +
+
+ 🟡 Medium + +
+
+ 🟢 Low + +
+
+
+ + +
+

Monthly Trends

+
+ +
No data yet
+
+
+
+ + +
+

Risk Indicators

+
+
+
+
+ Open Emergencies +
+

+
+
+
+
+ Reopened This Week +
+

+
+
+
+
+ Overdue (Past SLA) +
+

+
+
+
+
+ Pending Verification +
+

+
+
+
+
+ + +{% endblock %} diff --git a/app/templates/dashboard/cs.html b/app/templates/dashboard/cs.html new file mode 100644 index 0000000..b5064b6 --- /dev/null +++ b/app/templates/dashboard/cs.html @@ -0,0 +1,244 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Customer Service Dashboard

+

Welcome back,

+
+ + +
+
+
+
+

New Today

+

+
+
+ + + +
+
+
+ +
+
+
+

Open Tickets

+

+
+
+ + + +
+
+
+ +
+
+
+

Avg Response Time

+

+
+
+ + + +
+
+
+ +
+
+
+

SLA Compliance

+

+
+
+ + + +
+
+
+
+ + +
+ +
+

Open by Priority

+
+
+ 🔴 Urgent + +
+
+ 🟠 High + +
+
+ 🟡 Medium + +
+
+ 🟢 Low + +
+
+
+ + +
+

Oldest Unassigned

+
No unassigned tickets
+ +
+ + +
+

Escalated Tickets

+
+
+ +
No escalated tickets
+
+
+
+ + +
+
+

Recent Tickets

+ View All → +
+
+ + + + + + + + + + + + + + + + +
TicketStatusPriorityDescriptionCreated
No tickets found
+
+
+
+ + +{% endblock %} diff --git a/app/templates/dashboard/fm.html b/app/templates/dashboard/fm.html new file mode 100644 index 0000000..5e5e89c --- /dev/null +++ b/app/templates/dashboard/fm.html @@ -0,0 +1,245 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Facilities Management Dashboard

+

Welcome back,

+
+ + +
+ + + +
+

Emergency Require Immediate Attention!

+

View in the table below

+
+
+ + +
+
+
+
+

Active Jobs

+

+
+
+ + + +
+
+
+ +
+
+
+

Due Today

+

+
+
+ + + +
+
+
+ +
+
+
+

Waiting Parts

+

+
+
+ + + +
+
+
+ +
+
+
+

Jobs East / West

+

/

+
+
+ + + +
+
+
+
+ + +
+ +
+

Technician Workload

+
+ +
No active assignments
+
+
+ + +
+

Aging Analysis

+
+
+ < 24h + +
+
+ 1-2 days + +
+
+ 3-5 days + +
+
+ Overdue > 5d + +
+
+
+
+ + +
+
+

Active Tickets

+ View All → +
+
+ + + + + + + + + + + + + + + + + +
TicketStatusPriorityTechnicianDescriptionAge
No active tickets
+
+
+
+ + +{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..9bed0d5 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+
+
+ + + +
+

Denya OneCare

+

Sign in to your dashboard

+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+

+ Demo Accounts:
+ bella@denya.com / denya123 — CS Rep
+ nicholas@denya.com / denya123 — FM Dispatcher
+ scott@denya.com / denya123 — CEO
+ jerome@denya.com / denya123 — Admin +

+
+
+
+
+ + +{% endblock %} diff --git a/app/templates/tickets/detail.html b/app/templates/tickets/detail.html new file mode 100644 index 0000000..f3ddbcb --- /dev/null +++ b/app/templates/tickets/detail.html @@ -0,0 +1,452 @@ +{% extends "base.html" %} +{% block content %} +
+ +
+ + + + +
+ + + + +
+
+

Update Status

+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+

Assign Technician

+
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+

Add Note

+
+
+ + +
+
+ + +
+
+
+
+ + +
+ +
+
+ + +{% endblock %} diff --git a/app/templates/tickets/list.html b/app/templates/tickets/list.html new file mode 100644 index 0000000..cab1602 --- /dev/null +++ b/app/templates/tickets/list.html @@ -0,0 +1,211 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+

All Issues

+

+
+ + + New Issue + +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ Page of +
+
+ + + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + +
+ Ticket + StatusPriorityDescriptionAssigned To + Created + SLA
+ + No tickets match your filters +
+
+
+
+ + +{% endblock %} diff --git a/app/templates/tickets/new.html b/app/templates/tickets/new.html new file mode 100644 index 0000000..dec3dee --- /dev/null +++ b/app/templates/tickets/new.html @@ -0,0 +1,280 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Create New Issue

+

Report a maintenance or customer service issue

+
+ +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+ +
+ + + + +
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + + +

Click to upload photos

+ +
+
+ +
+
+ + +
+ + +
+ + Cancel +
+
+
+
+ + +{% endblock %}