From 9aa7971a28fdafd15f929b2e08b5257f0d001c1f Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Jul 2026 19:01:26 +0000 Subject: [PATCH 1/7] feat: Sprint 3 frontend dashboards with Alpine.js + Jinja2 - Login page with JWT auth and role-based redirect - CS Dashboard: KPI cards, priority breakdown, recent tickets - FM Dashboard: tech workload, aging analysis, emergency alerts - CEO Dashboard: executive KPIs, charts, risk indicators - All Issues: filterable/sortable ticket table with pagination - Create Issue: form with category tree, property/unit selector, photo uploads - Issue Detail: full timeline, photo gallery, SLA status, action modals - Role-based nav bar: CS/FM/Executive links adapt to user role - Base template: shared Alpine.js app state, toast notifications, loading overlay --- AGENTS.md | 13 + app/main.py | 3 +- app/routers/pages.py | 50 ++++ app/templates/base.html | 356 +++++++++++++++++++++++ app/templates/dashboard/ceo.html | 283 ++++++++++++++++++ app/templates/dashboard/cs.html | 244 ++++++++++++++++ app/templates/dashboard/fm.html | 248 ++++++++++++++++ app/templates/login.html | 74 +++++ app/templates/tickets/detail.html | 456 ++++++++++++++++++++++++++++++ app/templates/tickets/list.html | 211 ++++++++++++++ app/templates/tickets/new.html | 279 ++++++++++++++++++ 11 files changed, 2216 insertions(+), 1 deletion(-) create mode 100644 app/routers/pages.py create mode 100644 app/templates/base.html create mode 100644 app/templates/dashboard/ceo.html create mode 100644 app/templates/dashboard/cs.html create mode 100644 app/templates/dashboard/fm.html create mode 100644 app/templates/login.html create mode 100644 app/templates/tickets/detail.html create mode 100644 app/templates/tickets/list.html create mode 100644 app/templates/tickets/new.html 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/routers/pages.py b/app/routers/pages.py new file mode 100644 index 0000000..1123105 --- /dev/null +++ b/app/routers/pages.py @@ -0,0 +1,50 @@ +"""Frontend page routes — serve Jinja2 HTML templates for the SPA-like dashboard.""" + +from __future__ import annotations + +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +router = APIRouter(tags=["pages"]) + +templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates")) + + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request): + return templates.TemplateResponse(request, "login.html") + + +@router.get("/dashboard/cs", response_class=HTMLResponse) +async def cs_dashboard(request: Request): + return templates.TemplateResponse(request, "dashboard/cs.html") + + +@router.get("/dashboard/fm", response_class=HTMLResponse) +async def fm_dashboard(request: Request): + return templates.TemplateResponse(request, "dashboard/fm.html") + + +@router.get("/dashboard/ceo", response_class=HTMLResponse) +async def ceo_dashboard(request: Request): + return templates.TemplateResponse(request, "dashboard/ceo.html") + + +@router.get("/tickets", response_class=HTMLResponse) +async def ticket_list(request: Request): + return templates.TemplateResponse(request, "tickets/list.html") + + +@router.get("/tickets/new", response_class=HTMLResponse) +async def create_ticket_page(request: Request): + return templates.TemplateResponse(request, "tickets/new.html") + + +@router.get("/tickets/{ticket_id}", response_class=HTMLResponse) +async def ticket_detail_page(request: Request, ticket_id: int): + return templates.TemplateResponse(request, "tickets/detail.html", context={"ticket_id": ticket_id}) 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..4315a64 --- /dev/null +++ b/app/templates/dashboard/ceo.html @@ -0,0 +1,283 @@ +{% 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..94ab73c --- /dev/null +++ b/app/templates/dashboard/fm.html @@ -0,0 +1,248 @@ +{% 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..977b9ae --- /dev/null +++ b/app/templates/tickets/detail.html @@ -0,0 +1,456 @@ +{% 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..f9aea87 --- /dev/null +++ b/app/templates/tickets/new.html @@ -0,0 +1,279 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Create New Issue

+

Report a maintenance or customer service issue

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

Click to upload photos

+ +
+
+ +
+
+ + +
+ + +
+ + Cancel +
+
+
+
+ + +{% endblock %} From 3dc383e62d2a14fe61ad565ab9e78a0101d9a1d1 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Jul 2026 19:06:33 +0000 Subject: [PATCH 2/7] fix: auto-fix findings from no-mistakes review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove duplicate import - Fix → in create ticket - Fix → in ticket detail - Add note field to TicketUpdate schema and handle note-only updates in backend - Update frontend submitNote() to use PATCH endpoint --- app/routers/pages.py | 2 -- app/schemas/ticket.py | 1 + app/services/ticket.py | 12 ++++++++++++ app/templates/tickets/detail.html | 7 +++---- app/templates/tickets/new.html | 2 +- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app/routers/pages.py b/app/routers/pages.py index 1123105..ef1da73 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -2,8 +2,6 @@ from __future__ import annotations -from __future__ import annotations - from pathlib import Path from fastapi import APIRouter, Request diff --git a/app/schemas/ticket.py b/app/schemas/ticket.py index e8652b8..a3ff4d7 100644 --- a/app/schemas/ticket.py +++ b/app/schemas/ticket.py @@ -46,6 +46,7 @@ class TicketUpdate(BaseModel): eta: datetime | None = None cost: Decimal | None = None parts_used: str | None = None + note: str | None = None class TicketTimelineOut(BaseModel): diff --git a/app/services/ticket.py b/app/services/ticket.py index 9efcb7f..c22f061 100644 --- a/app/services/ticket.py +++ b/app/services/ticket.py @@ -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 (new_status is None or new_status == ticket.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/tickets/detail.html b/app/templates/tickets/detail.html index 977b9ae..17ceb36 100644 --- a/app/templates/tickets/detail.html +++ b/app/templates/tickets/detail.html @@ -311,7 +311,7 @@ async init() { await this.loadTicket(); - if (this.isFM || this.isAdmin) { + if (app().isFM || app().isAdmin) { await this.loadTechnicians(); } }, @@ -392,9 +392,8 @@ if (!this.noteForm.note.trim()) return; this.noteSubmitting = true; try { - // Use status update endpoint to add a note without changing status - const updated = await app().apiPost(`/api/tickets/${this.ticketId}/status`, { - status: this.ticket.status, + // 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; diff --git a/app/templates/tickets/new.html b/app/templates/tickets/new.html index f9aea87..c7b117b 100644 --- a/app/templates/tickets/new.html +++ b/app/templates/tickets/new.html @@ -244,7 +244,7 @@ const payload = { description: this.form.description, priority: this.form.priority || null, - reporter: this.form.reporter || this.user.full_name, + reporter: this.form.reporter || 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), }; From 7fab1ede515e21497699623a3bd4b38fcd5ac997 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Jul 2026 19:08:49 +0000 Subject: [PATCH 3/7] fix: address ask-user findings from review - Move unit loading to use real /api/tickets/units endpoint - Match unit code format to backend seed (0101E style) - Send unit_id in create ticket payload - Fix tech IDs to match seed order (9-15 for Prosper-Afful) - Add customer_name and phone fields to TicketCreate schema - Map form customer_name/phone into API payload --- app/routers/tickets.py | 16 ++++++++++++++++ app/schemas/ticket.py | 13 +++++++++++++ app/templates/tickets/detail.html | 13 +++++++------ app/templates/tickets/new.html | 23 ++++++++++++----------- 4 files changed, 48 insertions(+), 17 deletions(-) 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 a3ff4d7..e958899 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): diff --git a/app/templates/tickets/detail.html b/app/templates/tickets/detail.html index 17ceb36..c933a3a 100644 --- a/app/templates/tickets/detail.html +++ b/app/templates/tickets/detail.html @@ -334,12 +334,13 @@ // Users list not exposed via API directly, so use a known list // In a real system we'd have GET /api/users this.technicians = [ - { id: 8, full_name: 'Prosper' }, - { id: 9, full_name: 'Sam' }, - { id: 10, full_name: 'Steven' }, - { id: 11, full_name: 'Junior (Samuel)' }, - { id: 12, full_name: 'Francis' }, - { id: 13, full_name: 'Desmond Afful' }, + { 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' }, ]; } catch (e) { console.error('Tech load error', e); } }, diff --git a/app/templates/tickets/new.html b/app/templates/tickets/new.html index c7b117b..ad060ca 100644 --- a/app/templates/tickets/new.html +++ b/app/templates/tickets/new.html @@ -196,17 +196,12 @@ this.form.apartment_code = ''; this.units = []; if (!this.form.property) return; - // Units aren't directly exposed via API, so we'll construct from known patterns - const prefix = this.form.property === 'East' ? 'E' : 'W'; - const units = []; - const buildings = ['A', 'B', 'C', 'D', 'E', 'F']; - for (let floor = 1; floor <= 10; floor++) { - for (const bld of buildings) { - const code = `${floor}0${bld}${prefix}`; - units.push({ id: code, apartment_code: code }); - } + 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); } - this.units = units; }, handlePhotos(e) { @@ -240,13 +235,19 @@ 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 || app().user.full_name, + 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); From 55604966533c48fde306a5ed3132af10b0050fa5 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Jul 2026 19:13:10 +0000 Subject: [PATCH 4/7] no-mistakes(review): Fix F01/F02/F05: technician name, duplicate timeline, FM dashboard property counts --- app/models/ticket.py | 4 ++++ app/schemas/ticket.py | 1 + app/services/ticket.py | 6 +++--- app/templates/dashboard/fm.html | 15 ++++++--------- 4 files changed, 14 insertions(+), 12 deletions(-) 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/schemas/ticket.py b/app/schemas/ticket.py index e958899..e4bdfdf 100644 --- a/app/schemas/ticket.py +++ b/app/schemas/ticket.py @@ -92,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 c22f061..5bb51d9 100644 --- a/app/services/ticket.py +++ b/app/services/ticket.py @@ -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: @@ -277,7 +277,7 @@ async def update_ticket( # Handle standalone note (no status change) note_only = data.get("note") - if note_only and (new_status is None or new_status == ticket.status): + if note_only and not (new_status is not None and old_status != new_status): await _log_status_change( db, ticket.id, diff --git a/app/templates/dashboard/fm.html b/app/templates/dashboard/fm.html index 94ab73c..894b26d 100644 --- a/app/templates/dashboard/fm.html +++ b/app/templates/dashboard/fm.html @@ -203,16 +203,13 @@ // Emergency this.emergencyCount = active.filter(t => t.priority === 'urgent').length; - // East vs West — we need full tickets with unit info - // For now, estimate from overall data or show placeholder - // We'll load again with property filter or just use total + // East vs West — compute from loaded tickets using unit map try { - const east = await app().apiGet('/api/tickets?property=East&page_size=1'); - const west = await app().apiGet('/api/tickets?property=West&page_size=1'); - const eastTotal = east?.total || 0; - const westTotal = west?.total || 0; - this.kpi.eastJobs = eastTotal; - this.kpi.westJobs = westTotal; + 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) From a9c17d0703e4bdd390c6ecc7fb71ad11e7708bc0 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Jul 2026 19:18:17 +0000 Subject: [PATCH 5/7] fix: address remaining review findings - Fix detail.html to use assigned_technician_name (not .assigned_technician?.full_name) - Remove double error toast in submitNote() catch block - Replace wasteful API calls in CEO dashboard with units+data approach - Remove QR Code option from reported_via dropdown (excluded per scope) --- app/templates/dashboard/ceo.html | 21 ++++++++++++++------- app/templates/tickets/detail.html | 4 ++-- app/templates/tickets/new.html | 2 +- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/app/templates/dashboard/ceo.html b/app/templates/dashboard/ceo.html index 4315a64..cc06113 100644 --- a/app/templates/dashboard/ceo.html +++ b/app/templates/dashboard/ceo.html @@ -218,15 +218,22 @@ }); this.charts.monthlyMax = Math.max(...this.charts.monthlyTrend.map(m => m.count), 1); - // By property + // By property — load units once and compute from ticket data const propData = { east: 0, west: 0 }; - all.forEach(t => { /* would need unit join — estimate from ticket IDs */ }); try { - const east = await app().apiGet('/api/tickets?property=East&page_size=1'); - const west = await app().apiGet('/api/tickets?property=West&page_size=1'); - propData.east = east?.total || 0; - propData.west = west?.total || 0; - } catch (e) { /* api may not support property filter directly */ } + 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 diff --git a/app/templates/tickets/detail.html b/app/templates/tickets/detail.html index c933a3a..b722ec5 100644 --- a/app/templates/tickets/detail.html +++ b/app/templates/tickets/detail.html @@ -115,7 +115,7 @@
Assigned To
-
+
Reporter
@@ -402,7 +402,7 @@ this.noteForm.note = ''; app().showToast('Note added', 'success'); } catch (e) { - app().showToast(e.message, 'error'); + // No need to show error here — api() base method already shows it } finally { this.noteSubmitting = false; } diff --git a/app/templates/tickets/new.html b/app/templates/tickets/new.html index ad060ca..dec3dee 100644 --- a/app/templates/tickets/new.html +++ b/app/templates/tickets/new.html @@ -104,7 +104,7 @@ - +
From ef2297ea875561922f260b10a7b7f679cc528c5d Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Jul 2026 19:21:31 +0000 Subject: [PATCH 6/7] no-mistakes(review): Fixed aging bucket boundaries and removed wasteful API call --- app/templates/dashboard/fm.html | 4 ++-- app/templates/tickets/detail.html | 26 +++++++++++--------------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/app/templates/dashboard/fm.html b/app/templates/dashboard/fm.html index 894b26d..5e5e89c 100644 --- a/app/templates/dashboard/fm.html +++ b/app/templates/dashboard/fm.html @@ -228,11 +228,11 @@ under24h: active.filter(t => (now - new Date(t.created_at)) < 24 * 60 * 60 * 1000).length, oneToTwoDays: active.filter(t => { const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24); - return diff >= 1 && diff < 2; + return diff >= 1 && diff < 3; }).length, threeToFiveDays: active.filter(t => { const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24); - return diff >= 2 && diff < 5; + return diff >= 3 && diff < 5; }).length, overFiveDays: active.filter(t => (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24) >= 5).length, }; diff --git a/app/templates/tickets/detail.html b/app/templates/tickets/detail.html index b722ec5..f3ddbcb 100644 --- a/app/templates/tickets/detail.html +++ b/app/templates/tickets/detail.html @@ -328,21 +328,17 @@ }, async loadTechnicians() { - // Load all users and filter for Tech role - try { - const me = await app().apiGet('/api/auth/me'); - // Users list not exposed via API directly, so use a known list - // In a real system we'd have GET /api/users - this.technicians = [ - { id: 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' }, - ]; - } catch (e) { console.error('Tech load error', e); } + // 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() { From 01e28bbcc7e73eb0c943186096a17be9d4bbbac8 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Jul 2026 19:26:04 +0000 Subject: [PATCH 7/7] fix: map customer_name to reporter in create_ticket service Per captain decision: customer_name maps to reporter field (fallback), phone field skipped for MVP (no DB column). --- app/services/ticket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/ticket.py b/app/services/ticket.py index 5bb51d9..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"),