6 Commits
Author SHA1 Message Date
root 01e28bbcc7 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).
2026-07-23 19:26:04 +00:00
root ef2297ea87 no-mistakes(review): Fixed aging bucket boundaries and removed wasteful API call 2026-07-23 19:21:31 +00:00
root a9c17d0703 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)
2026-07-23 19:18:17 +00:00
root 5560496653 no-mistakes(review): Fix F01/F02/F05: technician name, duplicate timeline, FM dashboard property counts 2026-07-23 19:13:10 +00:00
root 7fab1ede51 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
2026-07-23 19:08:49 +00:00
root 3dc383e62d fix: auto-fix findings from no-mistakes review
- 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
2026-07-23 19:06:33 +00:00
9 changed files with 101 additions and 55 deletions
+4
View File
@@ -70,6 +70,10 @@ class Ticket(Base):
photos = relationship("TicketPhoto", back_populates="ticket") photos = relationship("TicketPhoto", back_populates="ticket")
escalations = relationship("Escalation", 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: def __repr__(self) -> str:
return f"<Ticket {self.ticket_number} ({self.status})>" return f"<Ticket {self.ticket_number} ({self.status})>"
-2
View File
@@ -2,8 +2,6 @@
from __future__ import annotations from __future__ import annotations
from __future__ import annotations
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
+16
View File
@@ -16,6 +16,7 @@ from app.core.database import get_db
from app.core.security import get_current_user from app.core.security import get_current_user
from app.models.category import Category from app.models.category import Category
from app.models.ticket import Ticket, TicketPhoto from app.models.ticket import Ticket, TicketPhoto
from app.models.unit import Unit
from app.models.user import User from app.models.user import User
from app.schemas.ticket import ( from app.schemas.ticket import (
CategoryOut, CategoryOut,
@@ -27,6 +28,7 @@ from app.schemas.ticket import (
TicketOut, TicketOut,
TicketPhotoOut, TicketPhotoOut,
TicketUpdate, TicketUpdate,
UnitOut,
) )
from app.services import ticket as ticket_service from app.services import ticket as ticket_service
from app.services.sla import get_sla_status 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) 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 ────────────────────────────────────────────────────── # ── Categories ──────────────────────────────────────────────────────
async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -> list[CategoryTreeOut]: async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -> list[CategoryTreeOut]:
"""Build a nested category tree.""" """Build a nested category tree."""
+15
View File
@@ -8,6 +8,17 @@ from decimal import Decimal
from pydantic import BaseModel, Field 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 ───────────────────────────────────────────────────────── # ── Category ─────────────────────────────────────────────────────────
class CategoryOut(BaseModel): class CategoryOut(BaseModel):
id: int id: int
@@ -32,6 +43,8 @@ class TicketCreate(BaseModel):
reported_via: str | None = None # whatsapp, phone, walk-in, qr, agent reported_via: str | None = None # whatsapp, phone, walk-in, qr, agent
description: str | None = None description: str | None = None
assigned_to: int | None = None assigned_to: int | None = None
customer_name: str | None = None
phone: str | None = None
class TicketUpdate(BaseModel): class TicketUpdate(BaseModel):
@@ -46,6 +59,7 @@ class TicketUpdate(BaseModel):
eta: datetime | None = None eta: datetime | None = None
cost: Decimal | None = None cost: Decimal | None = None
parts_used: str | None = None parts_used: str | None = None
note: str | None = None
class TicketTimelineOut(BaseModel): class TicketTimelineOut(BaseModel):
@@ -78,6 +92,7 @@ class TicketBrief(BaseModel):
unit_id: int | None = None unit_id: int | None = None
category_id: int | None = None category_id: int | None = None
assigned_to: int | None = None assigned_to: int | None = None
assigned_technician_name: str | None = None
reporter: str | None = None reporter: str | None = None
description: str | None = None description: str | None = None
sla_deadline: datetime | None = None sla_deadline: datetime | None = None
+15 -3
View File
@@ -109,7 +109,7 @@ async def create_ticket(
unit_id=data.get("unit_id"), unit_id=data.get("unit_id"),
category_id=data.get("category_id"), category_id=data.get("category_id"),
priority=priority, priority=priority,
reporter=data.get("reporter"), reporter=data.get("reporter") or data.get("customer_name"),
reported_via=data.get("reported_via"), reported_via=data.get("reported_via"),
description=data.get("description"), description=data.get("description"),
assigned_to=data.get("assigned_to"), assigned_to=data.get("assigned_to"),
@@ -196,7 +196,7 @@ async def list_tickets(
# Paginate # Paginate
offset = (page - 1) * page_size 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) result = await db.execute(query)
tickets = list(result.scalars().all()) tickets = list(result.scalars().all())
@@ -214,8 +214,8 @@ async def update_ticket(
# Handle status transitions separately # Handle status transitions separately
new_status = data.get("status") new_status = data.get("status")
if new_status is not None:
old_status = ticket.status old_status = ticket.status
if new_status is not None:
if old_status != new_status: if old_status != new_status:
valid_targets = VALID_TRANSITIONS.get(old_status, []) valid_targets = VALID_TRANSITIONS.get(old_status, [])
if new_status not in valid_targets: if new_status not in valid_targets:
@@ -275,6 +275,18 @@ async def update_ticket(
ticket.status = new_status 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 # Update other fields
for field in ("unit_id", "category_id", "priority", "reporter", "reported_via", for field in ("unit_id", "category_id", "priority", "reporter", "reported_via",
"description", "assigned_to", "eta", "cost", "parts_used"): "description", "assigned_to", "eta", "cost", "parts_used"):
+14 -7
View File
@@ -218,15 +218,22 @@
}); });
this.charts.monthlyMax = Math.max(...this.charts.monthlyTrend.map(m => m.count), 1); 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 }; const propData = { east: 0, west: 0 };
all.forEach(t => { /* would need unit join — estimate from ticket IDs */ });
try { try {
const east = await app().apiGet('/api/tickets?property=East&page_size=1'); const units = await app().apiGet('/api/tickets/units');
const west = await app().apiGet('/api/tickets?property=West&page_size=1'); if (units && units.length > 0) {
propData.east = east?.total || 0; const unitPropertyMap = {};
propData.west = west?.total || 0; units.forEach(u => { unitPropertyMap[u.id] = u.property; });
} catch (e) { /* api may not support property filter directly */ } 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) }; this.charts.byProperty = { east: propData.east, west: propData.west, max: Math.max(propData.east, propData.west, 1) };
// By category — use categories endpoint // By category — use categories endpoint
+8 -11
View File
@@ -203,16 +203,13 @@
// Emergency // Emergency
this.emergencyCount = active.filter(t => t.priority === 'urgent').length; this.emergencyCount = active.filter(t => t.priority === 'urgent').length;
// East vs West — we need full tickets with unit info // East vs West — compute from loaded tickets using unit map
// For now, estimate from overall data or show placeholder
// We'll load again with property filter or just use total
try { try {
const east = await app().apiGet('/api/tickets?property=East&page_size=1'); const units = await app().apiGet('/api/tickets/units');
const west = await app().apiGet('/api/tickets?property=West&page_size=1'); const unitMap = {};
const eastTotal = east?.total || 0; if (units) units.forEach(u => { unitMap[u.id] = u.property; });
const westTotal = west?.total || 0; this.kpi.eastJobs = active.filter(t => t.unit_id && unitMap[t.unit_id] === 'East').length;
this.kpi.eastJobs = eastTotal; this.kpi.westJobs = active.filter(t => t.unit_id && unitMap[t.unit_id] === 'West').length;
this.kpi.westJobs = westTotal;
} catch (e) { console.error('Property stats error', e); } } catch (e) { console.error('Property stats error', e); }
// Tech workload (simulated from assigned_to counts) // Tech workload (simulated from assigned_to counts)
@@ -231,11 +228,11 @@
under24h: active.filter(t => (now - new Date(t.created_at)) < 24 * 60 * 60 * 1000).length, under24h: active.filter(t => (now - new Date(t.created_at)) < 24 * 60 * 60 * 1000).length,
oneToTwoDays: active.filter(t => { oneToTwoDays: active.filter(t => {
const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24); const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24);
return diff >= 1 && diff < 2; return diff >= 1 && diff < 3;
}).length, }).length,
threeToFiveDays: active.filter(t => { threeToFiveDays: active.filter(t => {
const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24); const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24);
return diff >= 2 && diff < 5; return diff >= 3 && diff < 5;
}).length, }).length,
overFiveDays: active.filter(t => (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24) >= 5).length, overFiveDays: active.filter(t => (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24) >= 5).length,
}; };
+12 -16
View File
@@ -115,7 +115,7 @@
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<dt class="text-sm text-gray-500">Assigned To</dt> <dt class="text-sm text-gray-500">Assigned To</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.assigned_technician?.full_name || 'Unassigned'"></dd> <dd class="text-sm font-medium text-gray-900" x-text="ticket.assigned_technician_name || 'Unassigned'"></dd>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<dt class="text-sm text-gray-500">Reporter</dt> <dt class="text-sm text-gray-500">Reporter</dt>
@@ -311,7 +311,7 @@
async init() { async init() {
await this.loadTicket(); await this.loadTicket();
if (this.isFM || this.isAdmin) { if (app().isFM || app().isAdmin) {
await this.loadTechnicians(); await this.loadTechnicians();
} }
}, },
@@ -328,20 +328,17 @@
}, },
async loadTechnicians() { 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 // Users list not exposed via API directly, so use a known list
// In a real system we'd have GET /api/users // In a real system we'd have GET /api/users
this.technicians = [ this.technicians = [
{ id: 8, full_name: 'Prosper' }, { id: 9, full_name: 'Prosper' },
{ id: 9, full_name: 'Sam' }, { id: 10, full_name: 'Sam' },
{ id: 10, full_name: 'Steven' }, { id: 11, full_name: 'Steven' },
{ id: 11, full_name: 'Junior (Samuel)' }, { id: 12, full_name: 'Junior (Samuel)' },
{ id: 12, full_name: 'Francis' }, { id: 13, full_name: 'Francis' },
{ id: 13, full_name: 'Desmond Afful' }, { id: 14, full_name: 'Desmond Afful' },
{ id: 15, full_name: 'Afful' },
]; ];
} catch (e) { console.error('Tech load error', e); }
}, },
async submitStatusUpdate() { async submitStatusUpdate() {
@@ -392,9 +389,8 @@
if (!this.noteForm.note.trim()) return; if (!this.noteForm.note.trim()) return;
this.noteSubmitting = true; this.noteSubmitting = true;
try { try {
// Use status update endpoint to add a note without changing status // Use PATCH endpoint which now supports note-only updates
const updated = await app().apiPost(`/api/tickets/${this.ticketId}/status`, { const updated = await app().apiPatch(`/api/tickets/${this.ticketId}`, {
status: this.ticket.status,
note: this.noteForm.note note: this.noteForm.note
}); });
this.ticket = updated; this.ticket = updated;
@@ -402,7 +398,7 @@
this.noteForm.note = ''; this.noteForm.note = '';
app().showToast('Note added', 'success'); app().showToast('Note added', 'success');
} catch (e) { } catch (e) {
app().showToast(e.message, 'error'); // No need to show error here — api() base method already shows it
} finally { } finally {
this.noteSubmitting = false; this.noteSubmitting = false;
} }
+13 -12
View File
@@ -104,7 +104,7 @@
<option value="walk-in">Walk-in</option> <option value="walk-in">Walk-in</option>
<option value="whatsapp">WhatsApp</option> <option value="whatsapp">WhatsApp</option>
<option value="agent">Agent</option> <option value="agent">Agent</option>
<option value="qr">QR Code</option> <!-- QR Code excluded per Sprint 3 scope -->
</select> </select>
</div> </div>
</div> </div>
@@ -196,17 +196,12 @@
this.form.apartment_code = ''; this.form.apartment_code = '';
this.units = []; this.units = [];
if (!this.form.property) return; if (!this.form.property) return;
// Units aren't directly exposed via API, so we'll construct from known patterns try {
const prefix = this.form.property === 'East' ? 'E' : 'W'; const data = await app().apiGet(`/api/tickets/units?property=${encodeURIComponent(this.form.property)}`);
const units = []; this.units = data || [];
const buildings = ['A', 'B', 'C', 'D', 'E', 'F']; } catch (e) {
for (let floor = 1; floor <= 10; floor++) { console.error('Units load error', e);
for (const bld of buildings) {
const code = `${floor}0${bld}${prefix}`;
units.push({ id: code, apartment_code: code });
} }
}
this.units = units;
}, },
handlePhotos(e) { handlePhotos(e) {
@@ -240,13 +235,19 @@
return; return;
} }
// Resolve unit_id from selected apartment_code
const selectedUnit = this.units.find(u => u.apartment_code === this.form.apartment_code);
// Create the ticket // Create the ticket
const payload = { const payload = {
description: this.form.description, description: this.form.description,
priority: this.form.priority || null, priority: this.form.priority || null,
reporter: this.form.reporter || this.user.full_name, reporter: this.form.reporter || this.form.customer_name || app().user.full_name,
reported_via: this.form.reported_via || 'walk-in', 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), 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); const ticket = await app().apiPost('/api/tickets', payload);