15 Commits
Author SHA1 Message Date
root 2e995ee758 no-mistakes(review): Add pytest pythonpath config so tests resolve app imports 2026-07-31 10:18:52 +00:00
root d4ef96f17c no-mistakes(document): docs: add pytest suite to AGENTS.md structure and commands 2026-07-31 10:09:54 +00:00
root d074f347dc no-mistakes(review): Add pytest-asyncio dev dep and deterministic pagination tiebreaker 2026-07-31 09:57:56 +00:00
root 47be148240 fix(dashboard): paginate CEO dashboard ticket fetch to respect API cap
The CEO dashboard requested /api/tickets?page_size=500, but the API caps
page_size at 200 (le=200 in app/routers/tickets.py), so the request
returned 422 and every KPI/chart rendered zeros.

- ceo.html: fetch all tickets by looping pages of page_size=200 until
  total items are collected (with a safety bound), keeping KPIs accurate
  as volume grows past 200.
- tests: add test suite anchoring the pagination contract — page_size=500
  returns 422, page_size=200 returns items/total/page/page_size, and a
  page loop collects every ticket without duplicates.
- pyproject: enable pytest-asyncio auto mode and tests/ discovery.
- .gitignore: un-ignore committed tests/test_*.py.
2026-07-31 09:53:35 +00:00
root f84021bc14 Merge remote-tracking branch 'origin/fm/denya-onecare-s3' 2026-07-25 00:19:59 +00:00
root cf55576d10 feat: Denya Developers brand theming
- Brand colors: primary #0d2b18 (deep forest green), accent #c8a96e (gold), bg #faf8f5 (cream), text #1a1a1a
- Nav bg #0d2b18 with gold logo mark and white text
- Denya Developers + OneCare branding in nav header
- Gold accent borders, hover states, and active tab highlights
- Toast/success use brand green shades
- Body background cream (#faf8f5)
2026-07-25 00:19:59 +00:00
jerome 4afdc36765 feat: Replace mock WhatsApp with real Meta Graph API webhook
- POST /api/whatsapp/webhook handles Meta verification (hub.challenge)
- Inbound text messages create tickets via create_ticket()
- Auto-reply confirmation sent back via Meta Graph API
- WhatsApp messages logged with ticket linkage in whatsapp_log
- Added WHATSAPP_PHONE_NUMBER_ID, WHATSAPP_ACCESS_TOKEN, WHATSAPP_VERIFY_TOKEN config
- Kept /mock-log debug endpoint for backward compatibility
- Graceful degradation: logs message even if ticket/reply fails
2026-07-24 20:07:30 -04:00
root c9f5ac4380 feat: Denya Developers brand theming
- Brand colors: primary #0d2b18 (deep forest green), accent #c8a96e (gold), bg #faf8f5 (cream), text #1a1a1a
- Nav bg #0d2b18 with gold logo mark and white text
- Denya Developers + OneCare branding in nav header
- Gold accent borders, hover states, and active tab highlights
- Toast/success use brand green shades
- Body background cream (#faf8f5)
2026-07-23 19:46:08 +00:00
abiba-bot 3920cf14a1 Sprint 3: Alpine.js Dashboards 2026-07-23 19:36:35 +00:00
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
19 changed files with 515 additions and 141 deletions
+1
View File
@@ -6,6 +6,7 @@ __pycache__/
.venv/ .venv/
uploads/ uploads/
test_*.py test_*.py
!tests/test_*.py
venv/ venv/
*.egg-info/ *.egg-info/
dist/ dist/
+2
View File
@@ -22,6 +22,7 @@ app/
├── services/ # Business logic (auth, seed, ticket, sla) ├── services/ # Business logic (auth, seed, ticket, sla)
└── routers/ # FastAPI route handlers └── routers/ # FastAPI route handlers
alembic/ # Database migrations alembic/ # Database migrations
tests/ # pytest suite; conftest.py swaps DATABASE_URL to a temp SQLite
uploads/ # Photo uploads (created at runtime) uploads/ # Photo uploads (created at runtime)
``` ```
@@ -29,6 +30,7 @@ uploads/ # Photo uploads (created at runtime)
- `alembic upgrade head` — apply migrations - `alembic upgrade head` — apply migrations
- `alembic revision --autogenerate -m "msg"` — new migration - `alembic revision --autogenerate -m "msg"` — new migration
- `pytest` — run the API test suite (tests/; pagination contract anchored in tests/test_tickets_pagination.py)
## Seed data ## Seed data
+6
View File
@@ -31,6 +31,12 @@ class Settings(BaseSettings):
# ── CORS ───────────────────────────────────────────────────────── # ── CORS ─────────────────────────────────────────────────────────
CORS_ORIGINS: str = "*" CORS_ORIGINS: str = "*"
# ── WhatsApp ─────────────────────────────────────────────────────
WHATSAPP_PHONE_NUMBER_ID: str = ""
WHATSAPP_ACCESS_TOKEN: str = ""
WHATSAPP_VERIFY_TOKEN: str = ""
META_GRAPH_BASE: str = "https://graph.facebook.com/v18.0"
# ── Paths ──────────────────────────────────────────────────────── # ── Paths ────────────────────────────────────────────────────────
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
+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})>"
+6 -4
View File
@@ -1,4 +1,4 @@
"""WhatsApp log model for mock endpoint.""" """WhatsApp log model for inbound webhook messages."""
from __future__ import annotations from __future__ import annotations
@@ -14,10 +14,12 @@ class WhatsAppLog(Base):
__tablename__ = "whatsapp_log" __tablename__ = "whatsapp_log"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
command: Mapped[str] = mapped_column(Text, nullable=False) from_number: Mapped[str] = mapped_column(String(50), nullable=False)
from_number: Mapped[str | None] = mapped_column(String(50), nullable=True) message_text: Mapped[str] = mapped_column(Text, nullable=False)
wa_message_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
ticket_id: Mapped[int | None] = mapped_column(Integer, nullable=True) ticket_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
ticket_number: Mapped[str | None] = mapped_column(String(30), nullable=True)
received_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) received_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<WhatsAppLog {self.id}: {self.command[:50]}>" return f"<WhatsAppLog {self.id}: from={self.from_number} ticket={self.ticket_number}>"
-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."""
+139 -18
View File
@@ -1,44 +1,165 @@
"""Mock WhatsApp endpoint for testing command parsing.""" """WhatsApp webhook handler — Meta Graph API integration."""
from __future__ import annotations from __future__ import annotations
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends import httpx
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.models.whatsapp_log import WhatsAppLog from app.models.whatsapp_log import WhatsAppLog
from app.schemas.whatsapp import MockWhatsAppLogEntry, MockWhatsAppRequest, MockWhatsAppResponse from app.schemas.whatsapp import (
MetaWebhookRequest,
MockWhatsAppLogEntry,
WebhookVerificationResponse,
WhatsAppReplyResponse,
)
from app.services.ticket import create_ticket
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"]) router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"])
REPLY_TEMPLATE = (
"Thank you for contacting Denya OneCare. "
"Your ticket number is {ticket_number}. "
"We will get back to you soon."
)
@router.post("/mock", response_model=MockWhatsAppResponse)
async def mock_whatsapp( # ── Meta Graph API helpers ──────────────────────────────────────────
body: MockWhatsAppRequest, async def send_whatsapp_reply(
to_phone: str,
text: str,
) -> WhatsAppReplyResponse:
"""Send a text message via Meta Graph API."""
url = f"{settings.META_GRAPH_BASE}/{settings.WHATSAPP_PHONE_NUMBER_ID}/messages"
headers = {
"Authorization": f"Bearer {settings.WHATSAPP_ACCESS_TOKEN}",
"Content-Type": "application/x-www-form-urlencoded",
}
data = {
"messaging_product": "whatsapp",
"to": to_phone,
"type": "text",
"text": {"body": text},
}
# Encode nested dict as JSON string for form data (Meta requirement)
data["text"] = '{"body":' + f'"{text}"' + "}"
try:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, data=data, timeout=15.0)
response.raise_for_status()
return WhatsAppReplyResponse(success=True, message="Reply sent")
except httpx.HTTPError as exc:
logger.error("Failed to send WhatsApp reply: %s", exc)
return WhatsAppReplyResponse(success=False, message=str(exc))
# ── Webhook endpoint ────────────────────────────────────────────────
@router.post("/webhook")
async def whatsapp_webhook(
body: MetaWebhookRequest,
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
) -> MockWhatsAppResponse: hub_verify_token: str | None = Query(None, alias="hub.verify_token"),
"""Accept a mock WhatsApp command and log it.""" mode: str | None = Query(None),
log = WhatsAppLog( hub_challenge: str | None = Query(None),
command=body.command, ) -> dict:
from_number=body.from_number, """Handle incoming WhatsApp webhook from Meta."""
ticket_id=body.ticket_id,
received_at=datetime.now(timezone.utc), # ── Verification GET request (Meta sends this on webhook setup) ──
) if mode and hub_challenge:
db.add(log) if hub_verify_token != settings.WHATSAPP_VERIFY_TOKEN:
await db.flush() return {"error": "Verify token mismatch"}
return MockWhatsAppResponse() return WebhookVerificationResponse(challenge=hub_challenge).model_dump()
# ── Process inbound messages ────────────────────────────────────
if not body.entry:
return {"status": "no entry"}
for entry in body.entry:
if not entry.changes:
continue
for change in entry.changes:
if not change.message or not change.message.text:
logger.info("Non-text message received, skipping")
continue
message_text = change.message.text.text
sender = change.message.from_field
wa_msg_id = change.message.id or change.id
# ── Create ticket ──────────────────────────────────────
try:
ticket = await create_ticket(
db,
data={
"description": message_text,
"reporter": sender,
"reported_via": "WhatsApp",
},
)
except Exception as exc:
logger.error("Failed to create ticket: %s", exc)
# Still log the message even if ticket creation fails
log = WhatsAppLog(
from_number=sender,
message_text=message_text,
wa_message_id=wa_msg_id,
ticket_id=None,
ticket_number=None,
received_at=datetime.now(timezone.utc),
)
db.add(log)
await db.flush()
return {"status": "ticket creation failed, message logged"}
# ── Store WhatsApp log ─────────────────────────────────
log = WhatsAppLog(
from_number=sender,
message_text=message_text,
wa_message_id=wa_msg_id,
ticket_id=ticket.id,
ticket_number=ticket.ticket_number,
received_at=datetime.now(timezone.utc),
)
db.add(log)
await db.flush()
# ── Send auto-reply ────────────────────────────────────
reply_text = REPLY_TEMPLATE.format(ticket_number=ticket.ticket_number)
reply_result = await send_whatsapp_reply(sender, reply_text)
if not reply_result.success:
logger.warning(
"Auto-reply failed for ticket %s: %s",
ticket.ticket_number,
reply_result.message,
)
return {
"status": "processed",
"ticket_id": ticket.id,
"ticket_number": ticket.ticket_number,
"reply_sent": reply_result.success,
}
return {"status": "no messages"}
# ── Legacy debug endpoint ──────────────────────────────────────────
@router.get("/mock-log", response_model=list[MockWhatsAppLogEntry]) @router.get("/mock-log", response_model=list[MockWhatsAppLogEntry])
async def mock_whatsapp_log( async def mock_whatsapp_log(
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
limit: int = 50, limit: int = 50,
) -> list[MockWhatsAppLogEntry]: ) -> list[MockWhatsAppLogEntry]:
"""Return recent mock WhatsApp submissions.""" """Return recent WhatsApp webhook submissions for debugging."""
result = await db.execute( result = await db.execute(
select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit) select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit)
) )
+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
+61 -12
View File
@@ -1,10 +1,69 @@
"""Pydantic schemas for mock WhatsApp endpoint.""" """Pydantic schemas for Meta WhatsApp webhook."""
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel, Field
# ── Meta Webhook Verification ────────────────────────────────────────
class WebhookVerificationResponse(BaseModel):
challenge: str
# ── Inbound Message Parsing ─────────────────────────────────────────
class MessageText(BaseModel):
text: str
class Message(BaseModel):
from_field: str = Field(alias="from")
id: str | None = None
text: MessageText | None = None
type: str | None = None
class Contact(BaseModel):
wa_id: str | None = None
profile: dict | None = None
class EntryMessagesItem(BaseModel):
id: str | None = None
message: Message | None = None
contacts: list[Contact] | None = None
timestamp: str | None = None
class Entry(BaseModel):
id: str | None = None
changes: list[EntryMessagesItem] | None = None
metadata: dict | None = None
class MetaWebhookRequest(BaseModel):
object: str | None = None
entry: list[Entry] | None = None
# ── Auto-reply ──────────────────────────────────────────────────────
class WhatsAppReplyResponse(BaseModel):
success: bool
message: str
# ── Legacy mock schemas (kept for /mock-log endpoint) ───────────────
class MockWhatsAppLogEntry(BaseModel):
id: int
from_number: str
message_text: str
wa_message_id: str | None
ticket_id: int | None
ticket_number: str | None
received_at: datetime
model_config = {"from_attributes": True}
class MockWhatsAppRequest(BaseModel): class MockWhatsAppRequest(BaseModel):
@@ -16,13 +75,3 @@ class MockWhatsAppRequest(BaseModel):
class MockWhatsAppResponse(BaseModel): class MockWhatsAppResponse(BaseModel):
status: str = "received" status: str = "received"
message: str = "Command logged successfully" message: str = "Command logged successfully"
class MockWhatsAppLogEntry(BaseModel):
id: int
command: str
from_number: str | None
ticket_id: int | None
received_at: datetime
model_config = {"from_attributes": True}
+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(), Ticket.id.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")
old_status = ticket.status
if new_status is not None: if new_status is not None:
old_status = ticket.status
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"):
+55 -48
View File
@@ -12,17 +12,23 @@
extend: { extend: {
colors: { colors: {
denya: { denya: {
50: '#eff6ff', 50: '#e8f0ea',
100: '#dbeafe', 100: '#c5d9cb',
200: '#bfdbfe', 200: '#9ebfaa',
300: '#93c5fd', 300: '#74a589',
400: '#60a5fa', 400: '#4d8c69',
500: '#3b82f6', 500: '#2d734d',
600: '#2563eb', 600: '#1d5a3a',
700: '#1d4ed8', 700: '#0d2b18',
800: '#1e40af', 800: '#0a2012',
900: '#1e3a8a', 900: '#07150c',
} },
gold: {
DEFAULT: '#c8a96e',
light: '#e8d5a8',
dark: '#a88a4e',
},
cream: '#faf8f5',
} }
} }
} }
@@ -49,49 +55,54 @@
.priority-high { @apply bg-orange-100 text-orange-800 border-orange-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; } .priority-medium { @apply bg-yellow-100 text-yellow-800 border-yellow-300; }
.priority-low { @apply bg-green-100 text-green-800 border-green-300; } .priority-low { @apply bg-green-100 text-green-800 border-green-300; }
.brand-gradient { background: linear-gradient(135deg, #0d2b18 0%, #1a3d24 100%); }
</style> </style>
</head> </head>
<body class="bg-gray-50 min-h-screen" x-data="app()" x-init="init()"> <body class="bg-cream min-h-screen text-[#1a1a1a]" x-data="app()" x-init="init()">
<!-- Nav Bar --> <!-- Nav Bar -->
<nav class="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-50" x-show="isLoggedIn" x-cloak> <nav class="bg-[#0d2b18] border-b border-denya-800 shadow-lg sticky top-0 z-50" x-show="isLoggedIn" x-cloak>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16"> <div class="flex items-center justify-between h-16">
<!-- Left side --> <!-- Left side -->
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<a href="/dashboard/cs" class="flex items-center space-x-2 text-denya-700 font-bold text-lg"> <a href="/dashboard/cs" class="flex items-center space-x-3">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <!-- Denya Developers Logo Mark -->
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/> <div class="w-9 h-9 bg-gold rounded-lg flex items-center justify-center shadow-sm">
</svg> <svg class="w-5 h-5 text-[#0d2b18]" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<span>Denya OneCare</span> <path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
</div>
<div class="flex flex-col">
<span class="text-white font-bold text-base leading-tight">Denya Developers</span>
<span class="text-gold text-xs leading-tight font-medium">OneCare</span>
</div>
</a> </a>
<!-- CS Links --> <!-- Nav Links -->
<template x-if="isCS"> <template x-if="isCS">
<div class="hidden md:flex space-x-1 ml-6"> <div class="hidden md:flex space-x-1 ml-6">
<a href="/dashboard/cs" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/cs' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a> <a href="/dashboard/cs" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/cs' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">All Issues</a> <a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">All Issues</a>
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50">Create Issue</a> <a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-denya-800/50 transition-colors">Create Issue</a>
</div> </div>
</template> </template>
<!-- FM Links -->
<template x-if="isFM"> <template x-if="isFM">
<div class="hidden md:flex space-x-1 ml-6"> <div class="hidden md:flex space-x-1 ml-6">
<a href="/dashboard/fm" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/fm' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a> <a href="/dashboard/fm" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/fm' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">All Issues</a> <a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">All Issues</a>
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50">Create Issue</a> <a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-denya-800/50 transition-colors">Create Issue</a>
</div> </div>
</template> </template>
<!-- CEO/Director Links -->
<template x-if="isExecutive"> <template x-if="isExecutive">
<div class="hidden md:flex space-x-1 ml-6"> <div class="hidden md:flex space-x-1 ml-6">
<a href="/dashboard/ceo" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a> <a href="/dashboard/ceo" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Issues</a> <a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Issues</a>
</div> </div>
</template> </template>
</div> </div>
<!-- Right side --> <!-- Right side -->
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<span class="text-sm text-gray-600 hidden md:block" x-text="`${user.full_name} (${user.role})`"></span> <span class="text-sm text-gray-300 hidden md:block" x-text="`${user.full_name} (${user.role})`"></span>
<button @click="logout()" class="px-3 py-1.5 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors"> <button @click="logout()" class="px-3 py-1.5 text-sm text-gold hover:text-gold-light hover:bg-denya-800/50 rounded-md transition-colors border border-denya-600">
Logout Logout
</button> </button>
</div> </div>
@@ -99,19 +110,19 @@
</div> </div>
</nav> </nav>
<!-- Mobile Nav (CS) --> <!-- Mobile Nav -->
<div class="md:hidden border-b bg-white" x-show="isLoggedIn" x-cloak> <div class="md:hidden border-b bg-[#0d2b18] border-denya-800" x-show="isLoggedIn" x-cloak>
<template x-if="isCS || isFM"> <template x-if="isCS || isFM">
<div class="flex overflow-x-auto px-4 py-2 space-x-2"> <div class="flex overflow-x-auto px-4 py-2 space-x-2">
<a href="/dashboard/cs" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/cs' ? 'bg-denya-50 text-denya-700' : 'text-gray-500'" x-text="isCS ? 'CS Dashboard' : 'FM Dashboard'"></a> <a href="/dashboard/cs" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/cs' ? 'bg-denya-800 text-gold' : 'text-gray-300'">Dashboard</a>
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-50 text-denya-700' : 'text-gray-500'">Issues</a> <a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300'">Issues</a>
<a href="/tickets/new" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-500">New Issue</a> <a href="/tickets/new" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">New Issue</a>
</div> </div>
</template> </template>
<template x-if="isExecutive"> <template x-if="isExecutive">
<div class="flex overflow-x-auto px-4 py-2 space-x-2"> <div class="flex overflow-x-auto px-4 py-2 space-x-2">
<a href="/dashboard/ceo" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-50 text-denya-700' : 'text-gray-500'">CEO Dashboard</a> <a href="/dashboard/ceo" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-800 text-gold' : 'text-gray-300'">CEO Dashboard</a>
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-500">Issues</a> <a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">Issues</a>
</div> </div>
</template> </template>
</div> </div>
@@ -122,13 +133,13 @@
</main> </main>
<!-- Loading Overlay --> <!-- Loading Overlay -->
<div x-show="loading" class="fixed inset-0 bg-black bg-opacity-30 z-50 flex items-center justify-center" x-cloak> <div x-show="loading" class="fixed inset-0 bg-[#0d2b18]/40 z-50 flex items-center justify-center" x-cloak>
<div class="bg-white rounded-lg p-6 flex items-center space-x-3 shadow-xl"> <div class="bg-white rounded-xl p-6 flex items-center space-x-3 shadow-2xl border border-denya-200">
<svg class="animate-spin h-6 w-6 text-denya-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg class="animate-spin h-6 w-6 text-denya-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg> </svg>
<span class="text-gray-700 font-medium" x-text="loadingMessage || 'Loading...'"></span> <span class="text-denya-800 font-medium" x-text="loadingMessage || 'Loading...'"></span>
</div> </div>
</div> </div>
@@ -136,7 +147,7 @@
<div class="fixed bottom-4 right-4 z-50 space-y-2"> <div class="fixed bottom-4 right-4 z-50 space-y-2">
<template x-for="toast in toasts" :key="toast.id"> <template x-for="toast in toasts" :key="toast.id">
<div class="px-4 py-3 rounded-lg shadow-lg text-white text-sm font-medium transition-all duration-300" <div class="px-4 py-3 rounded-lg shadow-lg text-white text-sm font-medium transition-all duration-300"
:class="{'bg-green-600': toast.type === 'success', 'bg-red-600': toast.type === 'error', 'bg-blue-600': toast.type === 'info', 'bg-yellow-600': toast.type === 'warning'}" :class="{'bg-denya-600': toast.type === 'success', 'bg-red-600': toast.type === 'error', 'bg-denya-500': toast.type === 'info', 'bg-gold': toast.type === 'warning'}"
x-init="setTimeout(() => { toasts = toasts.filter(t => t.id !== toast.id) }, toast.duration || 4000)"> x-init="setTimeout(() => { toasts = toasts.filter(t => t.id !== toast.id) }, toast.duration || 4000)">
<span x-text="toast.message"></span> <span x-text="toast.message"></span>
</div> </div>
@@ -164,12 +175,10 @@
get isAdmin() { return ['Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) }, get isAdmin() { return ['Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
init() { init() {
// Redirect to login if not logged in (skip for login page)
if (!this.isLoggedIn && this.currentPath !== '/login') { if (!this.isLoggedIn && this.currentPath !== '/login') {
window.location.href = '/login'; window.location.href = '/login';
return; return;
} }
// Verify token on load
if (this.isLoggedIn) { if (this.isLoggedIn) {
this.fetchMe(); this.fetchMe();
} }
@@ -209,7 +218,7 @@
opts.body = JSON.stringify(body); opts.body = JSON.stringify(body);
} else if (body instanceof FormData) { } else if (body instanceof FormData) {
opts.body = body; opts.body = body;
delete opts.headers['Content-Type']; // Let browser set multipart boundary delete opts.headers['Content-Type'];
opts.headers = { 'Authorization': `Bearer ${this.token}` }; opts.headers = { 'Authorization': `Bearer ${this.token}` };
} }
const res = await fetch(url, opts); const res = await fetch(url, opts);
@@ -253,13 +262,11 @@
localStorage.setItem('refresh_token', data.refresh_token); localStorage.setItem('refresh_token', data.refresh_token);
this.token = data.access_token; this.token = data.access_token;
// Fetch user info
const me = await this.apiGet('/api/auth/me'); const me = await this.apiGet('/api/auth/me');
this.user = me; this.user = me;
localStorage.setItem('user', JSON.stringify(me)); localStorage.setItem('user', JSON.stringify(me));
this.isLoggedIn = true; this.isLoggedIn = true;
// Role-based redirect
const role = me.role; const role = me.role;
if (['CS Rep', 'CS Manager'].includes(role)) { if (['CS Rep', 'CS Manager'].includes(role)) {
window.location.href = '/dashboard/cs'; window.location.href = '/dashboard/cs';
+30 -11
View File
@@ -171,10 +171,22 @@
async loadData() { async loadData() {
try { try {
const allData = await app().apiGet('/api/tickets?page_size=500'); // Fetch ALL tickets via pagination. The API caps page_size at 200
if (!allData?.items) return; // (app/routers/tickets.py), so a single page_size=500 request returns 422
const all = allData.items; // and the dashboard renders empty KPIs. Loop pages until we have `total`
const total = allData.total || all.length; // tickets so KPIs stay accurate as volume grows past 200.
const all = [];
const pageSize = 200;
let page = 1;
let total = Infinity;
while (all.length < total && page <= 1000) { // 1000-page safety bound
const allData = await app().apiGet(`/api/tickets?page=${page}&page_size=${pageSize}`);
if (!allData?.items || !allData.items.length) break;
all.push(...allData.items);
total = allData.total ?? all.length;
page += 1;
}
if (!all.length) return;
// Basic KPIs // Basic KPIs
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status)); const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
@@ -218,15 +230,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,
}; };
+16 -20
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 // Users list not exposed via API directly, so use a known list
try { // In a real system we'd have GET /api/users
const me = await app().apiGet('/api/auth/me'); this.technicians = [
// Users list not exposed via API directly, so use a known list { id: 9, full_name: 'Prosper' },
// In a real system we'd have GET /api/users { id: 10, full_name: 'Sam' },
this.technicians = [ { id: 11, full_name: 'Steven' },
{ id: 8, full_name: 'Prosper' }, { id: 12, full_name: 'Junior (Samuel)' },
{ id: 9, full_name: 'Sam' }, { id: 13, full_name: 'Francis' },
{ id: 10, full_name: 'Steven' }, { id: 14, full_name: 'Desmond Afful' },
{ id: 11, full_name: 'Junior (Samuel)' }, { id: 15, full_name: 'Afful' },
{ id: 12, full_name: 'Francis' }, ];
{ id: 13, full_name: 'Desmond 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);
+6
View File
@@ -22,8 +22,14 @@ build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
include = ["app*"] include = ["app*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = ["."]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest>=8.0", "pytest>=8.0",
"pytest-asyncio>=0.24",
"httpx>=0.27.0", "httpx>=0.27.0",
] ]
+66
View File
@@ -0,0 +1,66 @@
"""Shared fixtures for the Denya OneCare test suite.
Sets DATABASE_URL to an isolated temp SQLite file BEFORE importing any app
module (the engine is created at import time), then provisions tables and
seed data per test.
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
_TMP_DIR = tempfile.mkdtemp(prefix="denya-test-")
os.environ["DATABASE_URL"] = f"sqlite+aiosqlite:///{_TMP_DIR}/test.db"
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from app.core.database import Base, async_session_factory, engine
from app.main import app
from app.models.ticket import Ticket
from app.services.seed import seed_categories, seed_units, seed_users
@pytest_asyncio.fixture
async def client():
"""Async test client with a fresh, seeded database per test."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with async_session_factory() as session:
await seed_users(session)
await session.commit()
# json_path=None → built-in fallback units (apartment_mapping.json is not committed)
await seed_units(session, json_path=None)
await session.commit()
await seed_categories(session)
await session.commit()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture
async def seed_tickets():
"""Insert `n` tickets directly into the DB; returns the count inserted."""
async def _seed(n: int) -> int:
async with async_session_factory() as session:
for i in range(n):
session.add(
Ticket(
ticket_number=f"PAV-TEST-{i:05d}",
status="Logged",
priority="medium",
description=f"Test ticket {i}",
)
)
await session.commit()
return n
return _seed
+56
View File
@@ -0,0 +1,56 @@
"""Tests anchoring ticket list pagination behavior.
The CEO dashboard (app/templates/dashboard/ceo.html) previously requested
`page_size=500`; the API caps page_size at 200 (`le=200` in
app/routers/tickets.py), so that request returned 422 and the dashboard
rendered empty KPIs. These tests pin the API contract the frontend now
relies on: page_size=200 + page loops that collect every ticket.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
async def test_page_size_over_cap_returns_422(client):
"""Requests above the page_size cap must be rejected (the original bug)."""
resp = await client.get("/api/tickets", params={"page_size": 500})
assert resp.status_code == 422
async def test_page_size_at_cap_returns_items_and_total(client, seed_tickets):
"""page_size=200 is the max legal value and returns the full response shape."""
await seed_tickets(14)
resp = await client.get("/api/tickets", params={"page": 1, "page_size": 200})
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 14
assert len(data["items"]) == 14
assert data["page"] == 1
assert data["page_size"] == 200
async def test_paginated_loop_collects_all_tickets(client, seed_tickets):
"""The frontend's page loop (page_size=200 until total reached) collects every ticket."""
total_seeded = await seed_tickets(450) # 3 pages of 200
collected: list[dict] = []
total = float("inf")
page = 1
page_size = 200
while len(collected) < total and page <= 1000:
resp = await client.get("/api/tickets", params={"page": page, "page_size": page_size})
assert resp.status_code == 200
data = resp.json()
assert data["items"], "expected a non-empty page"
collected.extend(data["items"])
total = data["total"] or len(collected)
page += 1
assert total == total_seeded
assert len(collected) == total_seeded
# No duplicate tickets across pages
ids = [t["id"] for t in collected]
assert len(set(ids)) == len(ids)