diff --git a/AGENTS.md b/AGENTS.md index 96fa43d..a3cdd37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,7 @@ Users, units, and categories are auto-seeded on first startup via lifespan hook: | POST | `/api/auth/refresh` | Token | Refresh tokens | | GET | `/api/auth/me` | Bearer | Current user | | GET | `/api/auth/admin-only` | Admin/Jerome, Admin/Wahab | RBAC demo endpoint | +| GET | `/api/auth/users` | Bearer | List users (id, name, role) for the assign-technician picker | | POST | `/api/whatsapp/mock` | No | Mock WhatsApp | | GET | `/api/whatsapp/mock-log` | No | Recent mock submissions | @@ -73,8 +74,10 @@ Frontend: Alpine.js (CDN) + Tailwind CSS (CDN). Auth state in localStorage. Role |--------|------|------|-------------| | POST | `/api/tickets` | Bearer | Create ticket (auto-number PAV-YYYY-NNNNN) | | GET | `/api/tickets` | No | List tickets (filter: status, priority, property, building, unit_id, 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) | +| GET | `/api/tickets/{id}` | No | Get ticket detail with timeline, photos, SLA status, nested unit/category, phone | +| GET | `/api/tickets/{id}/transitions` | No | Valid next statuses for the ticket's current status (drives the detail-page status picker) | +| PATCH | `/api/tickets/{id}` | Bearer | Update ticket (validates status transitions; assigning a technician auto-advances New/Logged/Triage to Assigned) | +| DELETE | `/api/tickets/{id}` | Admin/Jerome, Admin/Wahab | Delete ticket + children (timeline/photos/escalations); test/scratch cleanup only | | 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) | @@ -91,8 +94,11 @@ Frontend: Alpine.js (CDN) + Tailwind CSS (CDN). Auth state in localStorage. Role ## 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 +### Status Lifecycle (16 statuses) +New → Logged → Triage → Assigned → Accepted → Travelling → On Site → In Progress → Waiting Parts → Escalated → Completed → On-Field Verification → Wahab Review → Closed → Reopened → Cancelled + +Cancelled is terminal (no outgoing transitions) and reachable from any active +state; it is excluded from SLA breach reporting and dashboard "active" counts. Valid transitions defined in `app/services/ticket.py::VALID_TRANSITIONS`. Invalid transitions return 400. diff --git a/PRD.md b/PRD.md index 132ffa0..fff1fe0 100644 --- a/PRD.md +++ b/PRD.md @@ -120,7 +120,7 @@ Close Ticket Archive → Executive Reporting ``` -### Detailed Ticket Lifecycle (15 Statuses) +### Detailed Ticket Lifecycle (16 Statuses) | # | Status | Description | Who Sets | |---|--------|-------------|----------| @@ -139,6 +139,7 @@ Archive → Executive Reporting | 13 | **Wahab Review** | Final QA oversight and closure approval | Wahab | | 14 | **Closed** | Ticket officially closed | Wahab / Nicholas | | 15 | **Reopened** | Issue resurfaces within 7 days | Agent / System | +| 16 | **Cancelled** | Ticket withdrawn (test/scratch cleanup, guest no-show); terminal — reachable from any active state, excluded from SLA breach reporting and dashboard active counts | Any staff (via dashboard) | ### Step-by-Step Detail @@ -154,9 +155,10 @@ Archive → Executive Reporting 9. **Wahab Review** — Wahab does final QA oversight in dashboard → status → Wahab Review → approves or sends back 10. **Auto Follow-up** (48 hours later — internal only) — System flags ticket for Wahab/Nicholas: `Issue PAV-001 (505E WC) — resolved 48h ago. Any follow-up needed?` → If issue resurfaces → auto-reopens, escalates to Ama -### Reopening & Reassignment +### Reopening, Reassignment & Cancellation - **Reopening:** If a verified-closed issue resurfaces within 7 days, the gatekeeper can Reopen it. A reopened issue auto-escalates to Ama. +- **Cancellation:** Any ticket can be cancelled from an active state (terminal; cannot resume work). Cancelled tickets are excluded from SLA breach reporting and dashboard active counts. - **Reassignment:** A technician who cannot complete a job can request: `#reassign PAV-001 [technician name]` → Nicholas receives notification and approves/reassigns in dashboard. --- diff --git a/alembic/versions/c4e8f1a2d3b4_tickets_phone_and_cancelled_status.py b/alembic/versions/c4e8f1a2d3b4_tickets_phone_and_cancelled_status.py new file mode 100644 index 0000000..a9ee91d --- /dev/null +++ b/alembic/versions/c4e8f1a2d3b4_tickets_phone_and_cancelled_status.py @@ -0,0 +1,40 @@ +"""tickets.phone column + Cancelled status support + +Revision ID: c4e8f1a2d3b4 +Revises: b2f4a6c8e0d2 +Create Date: 2026-08-02 00:00:00.000000 + +Demo-prep batch (Wahab review): one schema change. + +* Add ``tickets.phone`` (nullable) so the customer phone collected on the + new-issue form is actually persisted instead of silently dropped. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c4e8f1a2d3b4' +down_revision: Union[str, Sequence[str], None] = 'b2f4a6c8e0d2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add tickets.phone (nullable).""" + op.add_column( + 'tickets', + sa.Column( + 'phone', + sa.String(length=50), + nullable=True, + comment='Customer contact phone (captured on the new-issue form)', + ), + ) + + +def downgrade() -> None: + """Drop tickets.phone.""" + op.drop_column('tickets', 'phone') diff --git a/app/core/security.py b/app/core/security.py index c93eff6..6e0657d 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone -from functools import wraps from typing import Annotated, Any, Callable import bcrypt diff --git a/app/main.py b/app/main.py index 81094dd..e10f27e 100644 --- a/app/main.py +++ b/app/main.py @@ -28,6 +28,16 @@ async def ensure_legacy_schema(conn) -> None: text("ALTER TABLE categories ADD COLUMN show_in_form BOOLEAN NOT NULL DEFAULT 1") ) logger.info("Added missing categories.show_in_form column (legacy database)") + + result = await conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='tickets'")) + if result.scalar(): + result = await conn.execute(text("PRAGMA table_info(tickets)")) + ticket_columns = {row[1] for row in result} + if "phone" not in ticket_columns: + await conn.execute( + text("ALTER TABLE tickets ADD COLUMN phone VARCHAR(50)") + ) + logger.info("Added missing tickets.phone column (legacy database)") result = await conn.execute( text( "UPDATE categories SET name = 'Missing Item' " diff --git a/app/models/ticket.py b/app/models/ticket.py index aad9282..919bdc5 100644 --- a/app/models/ticket.py +++ b/app/models/ticket.py @@ -29,7 +29,7 @@ class Ticket(Base): comment=( "New, Logged, Triage, Assigned, Accepted, Travelling, On Site, " "In Progress, Waiting Parts, Escalated, Completed, " - "On-Field Verification, Wahab Review, Closed, Reopened" + "On-Field Verification, Wahab Review, Closed, Reopened, Cancelled" ), ) unit_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("units.id"), nullable=True) @@ -40,6 +40,7 @@ class Ticket(Base): comment="urgent, high, medium, low", ) reporter: Mapped[str | None] = mapped_column(String(255), nullable=True) + phone: Mapped[str | None] = mapped_column(String(50), nullable=True) reported_via: Mapped[str | None] = mapped_column( String(20), nullable=True, diff --git a/app/routers/auth.py b/app/routers/auth.py index 27af060..3cf7b2f 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Depends +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db @@ -22,6 +23,21 @@ from app.services import auth as auth_service router = APIRouter(prefix="/api/auth", tags=["auth"]) +@router.get("/users", response_model=list[UserOut]) +async def list_users( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> list[User]: + """List users (id, name, role) for assignment pickers. + + Previously the frontend hard-coded technician ids/names in detail.html; + this endpoint makes the assign dropdown data-driven so a seed change never + silently breaks technician assignment. + """ + result = await db.execute(select(User).order_by(User.full_name)) + return list(result.scalars().all()) + + @router.post("/register", response_model=UserOut, status_code=201) async def register( body: RegisterRequest, diff --git a/app/routers/tickets.py b/app/routers/tickets.py index ac23cce..c6405aa 100644 --- a/app/routers/tickets.py +++ b/app/routers/tickets.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import uuid from datetime import datetime from pathlib import Path @@ -12,7 +13,7 @@ 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.core.security import get_current_user, require_roles from app.models.category import Category from app.models.ticket import Ticket, TicketPhoto from app.models.unit import Unit @@ -32,6 +33,8 @@ from app.schemas.ticket import ( from app.services import ticket as ticket_service from app.services.sla import get_sla_status +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api/tickets", tags=["tickets"]) # Ensure uploads directory exists @@ -224,6 +227,19 @@ async def list_tickets( return TicketListResponse(items=items, total=total, page=page, page_size=page_size) +@router.get("/{ticket_id}/transitions") +async def get_ticket_transitions( + ticket_id: int, + db: Annotated[AsyncSession, Depends(get_db)], +) -> dict: + """Return the valid next statuses for a ticket's current status.""" + ticket = await ticket_service.get_ticket(db, ticket_id) + return { + "current_status": ticket.status, + "transitions": ticket_service.VALID_TRANSITIONS.get(ticket.status, []), + } + + @router.get("/{ticket_id}", response_model=TicketOut) async def get_ticket( ticket_id: int, @@ -250,6 +266,32 @@ async def update_ticket( return ticket +@router.delete("/{ticket_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_ticket( + ticket_id: int, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_roles("Admin/Jerome", "Admin/Wahab"))], +) -> None: + """Delete a ticket and its children (timeline, photos, escalations). + + Admin-only: intended for removing test/scratch tickets from the demo + database, never for routine workflow use. + """ + ticket = await ticket_service.delete_ticket(db, ticket_id) + # Remove orphaned photo files from disk after the DB rows are gone. + for photo in ticket.photos: + if photo.photo_url: + name = photo.photo_url.rsplit("/", 1)[-1] + try: + (UPLOADS_DIR / name).unlink(missing_ok=True) + except OSError: + logger.warning( + "Could not remove orphaned photo file %s for ticket %s", + name, + ticket_id, + ) + + # ── Status Transitions (convenience endpoints) ─────────────────────── @router.post("/{ticket_id}/status", response_model=TicketOut) async def change_ticket_status( diff --git a/app/schemas/auth.py b/app/schemas/auth.py index cf2b704..3febf54 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel class RegisterRequest(BaseModel): diff --git a/app/schemas/ticket.py b/app/schemas/ticket.py index 044612e..e3d9ae0 100644 --- a/app/schemas/ticket.py +++ b/app/schemas/ticket.py @@ -54,6 +54,7 @@ class TicketUpdate(BaseModel): category_id: int | None = None priority: str | None = None reporter: str | None = None + phone: str | None = None reported_via: str | None = None description: str | None = None assigned_to: int | None = None @@ -95,6 +96,7 @@ class TicketBrief(BaseModel): assigned_to: int | None = None assigned_technician_name: str | None = None reporter: str | None = None + phone: str | None = None description: str | None = None sla_deadline: datetime | None = None reopen_count: int = 0 @@ -113,6 +115,8 @@ class TicketOut(TicketBrief): customer_rating: int | None = None timeline: list[TicketTimelineOut] = [] photos: list[TicketPhotoOut] = [] + unit: UnitOut | None = None + category: CategoryOut | None = None sla_status: dict | None = None diff --git a/app/services/auth.py b/app/services/auth.py index de505a7..9bed864 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -3,11 +3,9 @@ from __future__ import annotations from fastapi import HTTPException, status -from jose import JWTError, jwt from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.core.config import settings from app.core.security import ( create_access_token, create_refresh_token, diff --git a/app/services/sla.py b/app/services/sla.py index 9995446..d62e429 100644 --- a/app/services/sla.py +++ b/app/services/sla.py @@ -55,9 +55,12 @@ def should_escalate_on_response(ticket: Ticket) -> bool: return datetime.now(timezone.utc) > deadline +TERMINAL_STATUSES = {"Closed", "Completed", "Cancelled"} + + 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"): + if ticket.sla_deadline is None or ticket.status in TERMINAL_STATUSES: return False deadline = ticket.sla_deadline if deadline.tzinfo is None: @@ -88,7 +91,7 @@ async def get_sla_status(ticket: Ticket) -> dict: 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 + resolution_breached = now > rd if ticket.status not in TERMINAL_STATUSES else False return { "priority": ticket.priority, diff --git a/app/services/ticket.py b/app/services/ticket.py index 8dae348..a8cf8da 100644 --- a/app/services/ticket.py +++ b/app/services/ticket.py @@ -6,11 +6,11 @@ from datetime import datetime, timezone from typing import Any from fastapi import HTTPException, status -from sqlalchemy import func, select +from sqlalchemy import delete as sa_delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.models.ticket import Escalation, Ticket, TicketTimeline +from app.models.ticket import Escalation, Ticket, TicketPhoto, TicketTimeline from app.models.unit import Unit from app.models.user import User from app.services.sla import compute_sla_deadline @@ -18,21 +18,23 @@ 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"], + "New": ["Logged", "Cancelled"], + "Logged": ["Triage", "Closed", "Cancelled"], + "Triage": ["Assigned", "Escalated", "Cancelled"], + "Assigned": ["Accepted", "Triage", "Cancelled"], + "Accepted": ["Travelling", "Triage", "Cancelled"], + "Travelling": ["On Site", "Triage", "Cancelled"], + "On Site": ["In Progress", "Triage", "Cancelled"], + "In Progress": ["Waiting Parts", "Escalated", "Completed", "Cancelled"], + "Waiting Parts": ["In Progress", "Escalated", "Cancelled"], + "Escalated": ["Triage", "In Progress", "Completed", "Closed", "Cancelled"], + "Completed": ["On-Field Verification", "In Progress", "Cancelled"], + "On-Field Verification": ["Wahab Review", "Completed", "Closed", "Cancelled"], + "Wahab Review": ["Closed", "On-Field Verification", "Cancelled"], "Closed": ["Reopened"], - "Reopened": ["Triage", "Logged"], + "Reopened": ["Triage", "Logged", "Cancelled"], + # Terminal: cancelled tickets cannot resume work. + "Cancelled": [], } REOPEN_WINDOW_DAYS = 7 @@ -41,15 +43,25 @@ SLA_ACK_USER = "Ama" # ── Helpers ────────────────────────────────────────────────────────── async def _generate_ticket_number(db: AsyncSession) -> str: - """Generate the next ticket number in PAV-YYYY-NNNNN format.""" + """Generate the next ticket number in PAV-YYYY-NNNNN format. + + Uses the highest existing suffix + 1 (not a row count) so that deleting + tickets never re-issues an already-used number. + """ 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}%")) + select(func.max(Ticket.ticket_number)).where(Ticket.ticket_number.like(f"{prefix}%")) ) - count = result.scalar() or 0 - return f"{prefix}{count + 1:05d}" + max_number = result.scalar() + if max_number: + try: + next_seq = int(max_number.rsplit("-", 1)[1]) + 1 + except (ValueError, IndexError): + next_seq = 1 + else: + next_seq = 1 + return f"{prefix}{next_seq:05d}" async def _log_status_change( @@ -110,6 +122,7 @@ async def create_ticket( category_id=data.get("category_id"), priority=priority, reporter=data.get("reporter") or data.get("customer_name"), + phone=data.get("phone"), reported_via=data.get("reported_via"), description=data.get("description"), assigned_to=data.get("assigned_to"), @@ -223,6 +236,7 @@ async def update_ticket( # Handle status transitions separately new_status = data.get("status") old_status = ticket.status + status_changed = new_status is not None and old_status != new_status if new_status is not None: if old_status != new_status: valid_targets = VALID_TRANSITIONS.get(old_status, []) @@ -283,9 +297,25 @@ async def update_ticket( ticket.status = new_status + # Assigning a technician advances pre-Assigned tickets to Assigned with a + # timeline entry; tickets already past Assigned keep their current status. + advanced_to_assigned = False + if "assigned_to" in data and data["assigned_to"] != ticket.assigned_to: + if ticket.status in {"New", "Logged", "Triage"}: + await _log_status_change( + db, + ticket.id, + from_status=ticket.status, + to_status="Assigned", + note=data.get("note") if not status_changed else None, + user_id=user.id if user else None, + ) + ticket.status = "Assigned" + advanced_to_assigned = True + # 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): + if note_only and not status_changed and not advanced_to_assigned: await _log_status_change( db, ticket.id, @@ -296,7 +326,7 @@ async def update_ticket( ) # Update other fields - for field in ("unit_id", "category_id", "priority", "reporter", "reported_via", + for field in ("unit_id", "category_id", "priority", "reporter", "phone", "reported_via", "description", "assigned_to", "eta", "cost", "parts_used"): if field in data: setattr(ticket, field, data[field]) @@ -310,3 +340,19 @@ async def update_ticket( return ticket +async def delete_ticket(db: AsyncSession, ticket_id: int) -> Ticket: + """Delete a ticket and all dependent rows (timeline, photos, escalations). + + Returns the deleted ticket so the caller can remove orphaned photo files. + """ + ticket = await _get_ticket_or_404(db, ticket_id) + + # Delete escalations, timeline, and photo rows first (FK children). + await db.execute(sa_delete(Escalation).where(Escalation.ticket_id == ticket_id)) + await db.execute(sa_delete(TicketTimeline).where(TicketTimeline.ticket_id == ticket_id)) + await db.execute(sa_delete(TicketPhoto).where(TicketPhoto.ticket_id == ticket_id)) + await db.delete(ticket) + await db.flush() + return ticket + + diff --git a/app/templates/base.html b/app/templates/base.html index d12c5bf..1d4a2a3 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -51,6 +51,7 @@ .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; } + .status-cancelled { @apply bg-gray-300 text-gray-700 line-through; } .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; } @@ -318,7 +319,8 @@ 'on-field verification': 'status-on-field-verification', 'wahab review': 'status-wahab-review', 'closed': 'status-closed', - 'reopened': 'status-reopened' + 'reopened': 'status-reopened', + 'cancelled': 'status-cancelled' }; return map[status.toLowerCase()] || 'bg-gray-100 text-gray-800'; }, diff --git a/app/templates/dashboard/ceo.html b/app/templates/dashboard/ceo.html index 4c1232e..97b7a57 100644 --- a/app/templates/dashboard/ceo.html +++ b/app/templates/dashboard/ceo.html @@ -189,11 +189,11 @@ if (!all.length) return; // Basic KPIs - const open = all.filter(t => !['Closed', 'Completed'].includes(t.status)); - const closed = all.filter(t => ['Closed', 'Completed'].includes(t.status)); + const open = all.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status)); + const closed = all.filter(t => ['Closed', 'Completed', 'Cancelled'].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)); + const slaMet = withSLA.filter(t => new Date(t.sla_deadline) > new Date() || ['Closed', 'Completed', 'Cancelled'].includes(t.status)); // Monthly restored const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); @@ -270,7 +270,7 @@ // Risk indicators this.indicators = { - openEmergencies: urgent.filter(t => !['Closed', 'Completed'].includes(t.status)).length, + openEmergencies: urgent.filter(t => !['Closed', 'Completed', 'Cancelled'].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, @@ -280,7 +280,7 @@ }, calcAvgResponse(all) { - const open = all.filter(t => !['Closed', 'Completed'].includes(t.status)); + const open = all.filter(t => !['Closed', 'Completed', 'Cancelled'].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`; diff --git a/app/templates/dashboard/cs.html b/app/templates/dashboard/cs.html index af8efd5..4704e1e 100644 --- a/app/templates/dashboard/cs.html +++ b/app/templates/dashboard/cs.html @@ -188,20 +188,20 @@ 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)); + const open = all.filter(t => !['Closed', 'Completed', 'Cancelled'].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)) + const unassigned = all.filter(t => !t.assigned_to && !['Closed', 'Completed', 'Cancelled'].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)); + const slaMet = withSLA.filter(t => new Date(t.sla_deadline) > new Date() || ['Closed', 'Completed', 'Cancelled'].includes(t.status)); this.kpi = { newToday, @@ -230,7 +230,7 @@ calcAvgResponse(all) { // Rough approximation — in real system this would come from timeline analysis - const open = all.filter(t => !['Closed', 'Completed'].includes(t.status)); + const open = all.filter(t => !['Closed', 'Completed', 'Cancelled'].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); diff --git a/app/templates/dashboard/fm.html b/app/templates/dashboard/fm.html index 273e4ce..d478a54 100644 --- a/app/templates/dashboard/fm.html +++ b/app/templates/dashboard/fm.html @@ -222,7 +222,7 @@ const all = data.items; // Active: not closed/completed - const active = all.filter(t => !['Closed', 'Completed'].includes(t.status)); + const active = all.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status)); this.activeTickets = active; // KPIs diff --git a/app/templates/tickets/detail.html b/app/templates/tickets/detail.html index f3ddbcb..ffdce9d 100644 --- a/app/templates/tickets/detail.html +++ b/app/templates/tickets/detail.html @@ -121,6 +121,10 @@