feat: Wahab demo prep — Cancelled status, phone persistence, admin delete, users picker, detail-page fixes

- Add Cancelled as terminal status reachable from all active states; exclude
  from SLA breach reporting and dashboard active counts; add to status pickers
- Persist customer phone on ticket create/update (was silently dropped);
  add tickets.phone migration + legacy self-heal guard
- Add admin-only DELETE /api/tickets/{id} (removes timeline/photos/escalations)
- Add GET /api/auth/users for the assign-technician dropdown (was hardcoded)
- TicketOut now returns nested unit/category so the detail page stops showing
  '—' for Unit/Property/Category
- Ticket numbering uses max+1 so deletions never re-issue a number
- New-issue form: require Category and (standard mode) Priority client-side
- Tests: 11 new cases covering cancellation, SLA exemption, phone, delete,
  users endpoint, numbering
This commit is contained in:
root
2026-08-02 14:06:30 +00:00
parent ffed595dbd
commit 1dd88a141e
17 changed files with 391 additions and 48 deletions
+10
View File
@@ -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' "
+2 -1
View File
@@ -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,
+16
View File
@@ -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,
+20 -1
View File
@@ -12,7 +12,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
@@ -250,6 +250,25 @@ 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]
(UPLOADS_DIR / name).unlink(missing_ok=True)
# ── Status Transitions (convenience endpoints) ───────────────────────
@router.post("/{ticket_id}/status", response_model=TicketOut)
async def change_ticket_status(
+3
View File
@@ -95,6 +95,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 +114,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
+5 -2
View File
@@ -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,
+46 -17
View File
@@ -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
@@ -19,20 +19,22 @@ from app.services.sla import compute_sla_deadline
# 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"],
"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"],
"On-Field Verification": ["Wahab Review", "Completed", "Closed"],
"Wahab Review": ["Closed", "On-Field Verification"],
"Closed": ["Reopened"],
"Reopened": ["Triage", "Logged"],
# 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"),
@@ -296,7 +309,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 +323,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
+3 -1
View File
@@ -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';
},
+6 -6
View File
@@ -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`;
@@ -288,7 +288,7 @@
},
calcAvgResolution(all) {
const closed = all.filter(t => ['Closed', 'Completed'].includes(t.status) && t.created_at && t.updated_at);
const closed = all.filter(t => ['Closed', 'Completed', 'Cancelled'].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);
+4 -4
View File
@@ -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);
+1 -1
View File
@@ -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
+13 -11
View File
@@ -121,6 +121,10 @@
<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" x-show="ticket.phone">
<dt class="text-sm text-gray-500">Phone</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.phone"></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>
@@ -214,6 +218,7 @@
<option value="Wahab Review">Wahab Review</option>
<option value="Closed">Closed</option>
<option value="Reopened">Reopened</option>
<option value="Cancelled">Cancelled</option>
</select>
</div>
<div>
@@ -328,17 +333,14 @@
},
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' },
];
try {
const users = await app().apiGet('/api/auth/users');
// Assign dropdown should only offer Tech-role staff
this.technicians = (users || []).filter(u => u.role === 'Tech');
} catch (e) {
console.error('Technicians load error', e);
this.technicians = [];
}
},
async submitStatusUpdate() {
+3 -2
View File
@@ -38,6 +38,7 @@
<option value="Wahab Review">Wahab Review</option>
<option value="Closed">Closed</option>
<option value="Reopened">Reopened</option>
<option value="Cancelled">Cancelled</option>
</select>
</div>
<div>
@@ -142,7 +143,7 @@
<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-show="ticket.sla_deadline" class="text-xs" :class="new Date(ticket.sla_deadline) < new Date() && !['Closed','Completed','Cancelled'].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>
@@ -190,7 +191,7 @@
<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-show="ticket.sla_deadline" class="text-xs" :class="new Date(ticket.sla_deadline) < new Date() && !['Closed','Completed','Cancelled'].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>
+10
View File
@@ -363,6 +363,16 @@
this.submitting = false;
return;
}
if (!this.form.category_main && !this.form.category_id) {
this.error = 'Please select a Category — every issue needs one for correct routing and SLA.';
this.submitting = false;
return;
}
if (this.mode === 'standard' && !this.form.priority) {
this.error = 'Please select a Priority — without one the ticket gets no SLA deadline.';
this.submitting = false;
return;
}
// Create the ticket
const payload = {