Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf361d76bc | ||
|
|
6b92c9098c | ||
|
|
237284b012 | ||
|
|
1dd88a141e | ||
|
|
ffed595dbd | ||
|
|
5e9ec29439 | ||
|
|
663354eeaf | ||
|
|
db6826cb49 | ||
|
|
ae17005932 | ||
|
|
2407f294f4 | ||
|
|
2e995ee758 | ||
|
|
d4ef96f17c | ||
|
|
d074f347dc | ||
|
|
47be148240 | ||
|
|
f84021bc14 | ||
|
|
cf55576d10 | ||
|
|
4afdc36765 | ||
|
|
c9f5ac4380 | ||
|
|
3920cf14a1 | ||
|
|
01e28bbcc7 | ||
|
|
ef2297ea87 | ||
|
|
a9c17d0703 | ||
|
|
5560496653 | ||
|
|
7fab1ede51 | ||
|
|
3dc383e62d | ||
|
|
9aa7971a28 |
@@ -6,6 +6,7 @@ __pycache__/
|
|||||||
.venv/
|
.venv/
|
||||||
uploads/
|
uploads/
|
||||||
test_*.py
|
test_*.py
|
||||||
|
!tests/test_*.py
|
||||||
venv/
|
venv/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ Users, units, and categories are auto-seeded on first startup via lifespan hook:
|
|||||||
- 120 apartment units (East/West, 10 floors × 6 apts per wing)
|
- 120 apartment units (East/West, 10 floors × 6 apts per wing)
|
||||||
- Default password for all seed users: `denya123`
|
- Default password for all seed users: `denya123`
|
||||||
- Units load from `apartment_mapping.json` if present, else built-in fallback
|
- Units load from `apartment_mapping.json` if present, else built-in fallback
|
||||||
- Categories: 20 top-level (10 Maintenance, 6 CS, 4 Emergency) with sub-categories, seeded from `app/services/seed.py::SEED_CATEGORIES_DATA`
|
- Categories: 23 top-level (13 Maintenance, 6 CS, 4 Emergency) with sub-categories, seeded from `app/services/seed.py::SEED_CATEGORIES_DATA`
|
||||||
|
|
||||||
## Key API endpoints
|
## Key API endpoints
|
||||||
|
|
||||||
@@ -50,22 +52,38 @@ Users, units, and categories are auto-seeded on first startup via lifespan hook:
|
|||||||
| POST | `/api/auth/refresh` | Token | Refresh tokens |
|
| POST | `/api/auth/refresh` | Token | Refresh tokens |
|
||||||
| GET | `/api/auth/me` | Bearer | Current user |
|
| GET | `/api/auth/me` | Bearer | Current user |
|
||||||
| GET | `/api/auth/admin-only` | Admin/Jerome, Admin/Wahab | RBAC demo endpoint |
|
| 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 |
|
| POST | `/api/whatsapp/mock` | No | Mock WhatsApp |
|
||||||
| GET | `/api/whatsapp/mock-log` | No | Recent mock submissions |
|
| GET | `/api/whatsapp/mock-log` | No | Recent mock submissions |
|
||||||
|
|
||||||
|
### Pages (Sprint 3) — Jinja2 templates at `app/templates/`
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| GET | `/login` | No | Login page |
|
||||||
|
| GET | `/dashboard/cs` | Client | CS dashboard |
|
||||||
|
| GET | `/dashboard/fm` | Client | FM dashboard |
|
||||||
|
| GET | `/dashboard/ceo` | Client | CEO dashboard |
|
||||||
|
| GET | `/tickets` | Client | All Issues filterable table |
|
||||||
|
| GET | `/tickets/new` | Client | Create Issue form |
|
||||||
|
| GET | `/tickets/{id}` | Client | Issue detail with timeline |
|
||||||
|
|
||||||
|
Frontend: Alpine.js (CDN) + Tailwind CSS (CDN). Auth state in localStorage. Role-based nav routing in `base.html`.
|
||||||
|
|
||||||
### Tickets (Sprint 2)
|
### Tickets (Sprint 2)
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
|--------|------|------|-------------|
|
|--------|------|------|-------------|
|
||||||
| POST | `/api/tickets` | Bearer | Create ticket (auto-number PAV-YYYY-NNNNN) |
|
| POST | `/api/tickets` | Bearer | Create ticket (auto-number PAV-YYYY-NNNNN) |
|
||||||
| GET | `/api/tickets` | No | List tickets (filter: status, priority, property, category_id, assigned_to, date_from, date_to) |
|
| 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 |
|
| GET | `/api/tickets/{id}` | No | Get ticket detail with timeline, photos, SLA status, nested unit/category, phone |
|
||||||
| PATCH | `/api/tickets/{id}` | Bearer | Update ticket (validates status transitions) |
|
| 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 |
|
| POST | `/api/tickets/{id}/status` | Bearer | Change status with note |
|
||||||
| GET | `/api/tickets/{id}/sla` | No | Check SLA breach status |
|
| GET | `/api/tickets/{id}/sla` | No | Check SLA breach status |
|
||||||
| POST | `/api/tickets/{id}/photos` | Bearer | Upload photos (multipart, is_before param) |
|
| POST | `/api/tickets/{id}/photos` | Bearer | Upload photos (multipart, is_before param) |
|
||||||
| GET | `/api/tickets/{id}/photos` | No | List photos |
|
| GET | `/api/tickets/{id}/photos` | No | List photos |
|
||||||
| GET | `/api/tickets/categories` | No | Category tree (optional `?type=` filter) |
|
| GET | `/api/tickets/categories` | No | Category tree (filters: `type`; alert-only hidden unless `include_hidden=true`) |
|
||||||
| GET | `/api/tickets/categories/flat` | No | Flat category list |
|
| GET | `/api/tickets/categories/flat` | No | Flat category list (same `type`/`include_hidden` filters) |
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
@@ -76,8 +94,11 @@ Users, units, and categories are auto-seeded on first startup via lifespan hook:
|
|||||||
|
|
||||||
## Ticket System (Sprint 2)
|
## Ticket System (Sprint 2)
|
||||||
|
|
||||||
### Status Lifecycle (15 statuses)
|
### 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
|
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.
|
Valid transitions defined in `app/services/ticket.py::VALID_TRANSITIONS`. Invalid transitions return 400.
|
||||||
|
|
||||||
@@ -101,6 +122,23 @@ Stored under `uploads/` with UUID filenames. Static-files mounted at `/uploads/`
|
|||||||
SQLite via aiosqlite with async SQLAlchemy 2.0. Alembic for migrations.
|
SQLite via aiosqlite with async SQLAlchemy 2.0. Alembic for migrations.
|
||||||
Tables: users, units, categories, tickets, ticket_timeline, ticket_photos, escalations, whatsapp_log
|
Tables: users, units, categories, tickets, ticket_timeline, ticket_photos, escalations, whatsapp_log
|
||||||
|
|
||||||
|
## Categories & location (Sprint A)
|
||||||
|
|
||||||
|
- `Category.show_in_form` (default True) marks alert-only categories: `Gas Leak` is hidden
|
||||||
|
from the issue picker but keeps `sla_urgency="urgent"` for SLA/alert/reporting. Pickers
|
||||||
|
(`/api/tickets/categories[/flat]`) exclude them unless `include_hidden=true` (used by the
|
||||||
|
emergency quick path on `tickets/new.html`). Seed taxonomy lives in `app/services/seed.py`
|
||||||
|
(`SEED_CATEGORIES_DATA`); the Lost Property → Missing Item rename is a data migration
|
||||||
|
(alembic `b2f4a6c8e0d2`), with a startup self-heal (`app/main.py::ensure_legacy_schema`)
|
||||||
|
applying the same column/rename fix to legacy create_all databases. Seeds are idempotent
|
||||||
|
and sync `show_in_form` on existing rows.
|
||||||
|
- Location hierarchy is Property → Building → Apartment (uses `Unit.building`).
|
||||||
|
`GET /api/tickets/units/grouped` returns `{property: {building: [units]}}`;
|
||||||
|
`/api/tickets` accepts additive `building`/`unit_id` filters. Unit data is deterministic
|
||||||
|
from the committed `apartment_mapping.json` (built-in fallback in `seed.py`).
|
||||||
|
- Priority grouping ("Group by priority") is client-side via `app().groupByPriority()` in
|
||||||
|
`app/templates/base.html`; used by `tickets/list.html` and `dashboard/fm.html`.
|
||||||
|
|
||||||
## Maintaining this file
|
## Maintaining this file
|
||||||
|
|
||||||
Keep this file for knowledge useful to almost every future agent session in this project.
|
Keep this file for knowledge useful to almost every future agent session in this project.
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ Close Ticket
|
|||||||
Archive → Executive Reporting
|
Archive → Executive Reporting
|
||||||
```
|
```
|
||||||
|
|
||||||
### Detailed Ticket Lifecycle (15 Statuses)
|
### Detailed Ticket Lifecycle (16 Statuses)
|
||||||
|
|
||||||
| # | Status | Description | Who Sets |
|
| # | Status | Description | Who Sets |
|
||||||
|---|--------|-------------|----------|
|
|---|--------|-------------|----------|
|
||||||
@@ -139,6 +139,7 @@ Archive → Executive Reporting
|
|||||||
| 13 | **Wahab Review** | Final QA oversight and closure approval | Wahab |
|
| 13 | **Wahab Review** | Final QA oversight and closure approval | Wahab |
|
||||||
| 14 | **Closed** | Ticket officially closed | Wahab / Nicholas |
|
| 14 | **Closed** | Ticket officially closed | Wahab / Nicholas |
|
||||||
| 15 | **Reopened** | Issue resurfaces within 7 days | Agent / System |
|
| 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
|
### 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
|
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
|
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.
|
- **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.
|
- **Reassignment:** A technician who cannot complete a job can request: `#reassign PAV-001 [technician name]` → Nicholas receives notification and approves/reassigns in dashboard.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""category show_in_form column + Lost Property → Missing Item rename
|
||||||
|
|
||||||
|
Revision ID: b2f4a6c8e0d2
|
||||||
|
Revises: 795c6b95f637
|
||||||
|
Create Date: 2026-08-02 00:00:00.000000
|
||||||
|
|
||||||
|
Sprint A (Wahab feedback batch): one schema change + one data rename.
|
||||||
|
|
||||||
|
* Add ``categories.show_in_form`` (bool, default True) and backfill existing
|
||||||
|
rows to True; flip Gas Leak to False (alert-only: still urgent for SLA/alert
|
||||||
|
and reporting, but hidden from the new-issue category picker).
|
||||||
|
* Rename the Customer Service category ``Lost Property`` → ``Missing Item``.
|
||||||
|
The seed is insert-if-missing, so without this data migration an existing
|
||||||
|
database would keep the old row and the seed would create a duplicate.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'b2f4a6c8e0d2'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '795c6b95f637'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add show_in_form, backfill True, hide Gas Leak, rename Lost Property."""
|
||||||
|
op.add_column(
|
||||||
|
'categories',
|
||||||
|
sa.Column(
|
||||||
|
'show_in_form',
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.true(),
|
||||||
|
comment='Visible in the new-issue category picker (False = alert-only, e.g. Gas Leak)',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Backfill: Gas Leak stays in the SLA/alert table (urgent) but is hidden
|
||||||
|
# from the new-issue picker.
|
||||||
|
op.execute(
|
||||||
|
"UPDATE categories SET show_in_form = 0 "
|
||||||
|
"WHERE type = 'emergency' AND name = 'Gas Leak' AND parent_id IS NULL"
|
||||||
|
)
|
||||||
|
# Rename Lost Property → Missing Item (data migration; seed is insert-if-missing).
|
||||||
|
op.execute(
|
||||||
|
"UPDATE categories SET name = 'Missing Item' "
|
||||||
|
"WHERE type = 'cs' AND name = 'Lost Property'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Reverse the rename and drop the column."""
|
||||||
|
op.execute(
|
||||||
|
"UPDATE categories SET name = 'Lost Property' "
|
||||||
|
"WHERE type = 'cs' AND name = 'Missing Item'"
|
||||||
|
)
|
||||||
|
op.drop_column('categories', 'show_in_form')
|
||||||
@@ -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')
|
||||||
@@ -0,0 +1,726 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"property": "Pavilion Accra",
|
||||||
|
"units": [
|
||||||
|
{
|
||||||
|
"apartment_code": "011E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "012E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "013E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "014E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "015E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "016E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "021E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "022E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "023E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "024E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "025E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "026E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "031E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "032E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "033E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "034E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "035E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "036E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "041E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "042E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "043E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "044E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "045E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "046E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "051E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "052E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "053E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "054E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "055E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "056E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "061E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "062E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "063E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "064E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "065E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "066E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "071E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "072E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "073E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "074E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "075E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "076E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "081E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "082E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "083E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "084E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "085E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "086E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "091E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "092E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "093E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "094E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "095E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "096E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "101E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "102E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "103E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "104E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "105E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "106E",
|
||||||
|
"property": "East",
|
||||||
|
"building": "Pavilion East",
|
||||||
|
"floor": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "011W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "012W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "013W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "014W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "015W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "016W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "021W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "022W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "023W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "024W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "025W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "026W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "031W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "032W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "033W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "034W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "035W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "036W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "041W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "042W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "043W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "044W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "045W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "046W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "051W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "052W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "053W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "054W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "055W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "056W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "061W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "062W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "063W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "064W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "065W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "066W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "071W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "072W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "073W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "074W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "075W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "076W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "081W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "082W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "083W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "084W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "085W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "086W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "091W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "092W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "093W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "094W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "095W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "096W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "101W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "102W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "103W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "104W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "105W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apartment_code": "106W",
|
||||||
|
"property": "West",
|
||||||
|
"building": "Pavilion West",
|
||||||
|
"floor": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from functools import wraps
|
|
||||||
from typing import Annotated, Any, Callable
|
from typing import Annotated, Any, Callable
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
|||||||
+35
-1
@@ -9,21 +9,54 @@ from pathlib import Path
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import Base, async_session_factory, engine
|
from app.core.database import Base, async_session_factory, engine
|
||||||
from app.routers import auth, health, tickets, whatsapp
|
from app.routers import auth, health, pages, tickets, whatsapp
|
||||||
from app.services.seed import seed_categories, seed_units, seed_users
|
from app.services.seed import seed_categories, seed_units, seed_users
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_legacy_schema(conn) -> None:
|
||||||
|
"""Add columns/data changes from alembic migrations that legacy create_all databases lack."""
|
||||||
|
result = await conn.execute(text("PRAGMA table_info(categories)"))
|
||||||
|
columns = {row[1] for row in result}
|
||||||
|
if "show_in_form" not in columns:
|
||||||
|
await conn.execute(
|
||||||
|
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' "
|
||||||
|
"WHERE type = 'cs' AND name = 'Lost Property' AND parent_id IS NULL "
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM categories c2 "
|
||||||
|
"WHERE c2.type = 'cs' AND c2.name = 'Missing Item' AND c2.parent_id IS NULL)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if result.rowcount:
|
||||||
|
logger.info("Renamed legacy 'Lost Property' category to 'Missing Item'")
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Initialise database and seed data on startup."""
|
"""Initialise database and seed data on startup."""
|
||||||
logger.info("Starting Denya OneCare …")
|
logger.info("Starting Denya OneCare …")
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
await ensure_legacy_schema(conn)
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
await seed_users(session)
|
await seed_users(session)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -61,3 +94,4 @@ app.include_router(health.router)
|
|||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(whatsapp.router)
|
app.include_router(whatsapp.router)
|
||||||
app.include_router(tickets.router)
|
app.include_router(tickets.router)
|
||||||
|
app.include_router(pages.router)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Integer, String
|
from sqlalchemy import Boolean, ForeignKey, Integer, String, true
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -28,6 +28,13 @@ class Category(Base):
|
|||||||
nullable=True,
|
nullable=True,
|
||||||
comment="urgent, high, medium, low",
|
comment="urgent, high, medium, low",
|
||||||
)
|
)
|
||||||
|
show_in_form: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
nullable=False,
|
||||||
|
default=True,
|
||||||
|
server_default=true(),
|
||||||
|
comment="Visible in the new-issue category picker (False = alert-only, e.g. Gas Leak)",
|
||||||
|
)
|
||||||
|
|
||||||
# self-referencing relationship
|
# self-referencing relationship
|
||||||
children: Mapped[list[Category]] = relationship("Category", back_populates="parent", cascade="all, delete-orphan")
|
children: Mapped[list[Category]] = relationship("Category", back_populates="parent", cascade="all, delete-orphan")
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class Ticket(Base):
|
|||||||
comment=(
|
comment=(
|
||||||
"New, Logged, Triage, Assigned, Accepted, Travelling, On Site, "
|
"New, Logged, Triage, Assigned, Accepted, Travelling, On Site, "
|
||||||
"In Progress, Waiting Parts, Escalated, Completed, "
|
"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)
|
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",
|
comment="urgent, high, medium, low",
|
||||||
)
|
)
|
||||||
reporter: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
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(
|
reported_via: Mapped[str | None] = mapped_column(
|
||||||
String(20),
|
String(20),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
@@ -70,6 +71,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})>"
|
||||||
|
|
||||||
|
|||||||
@@ -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}>"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import get_db
|
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 = 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)
|
@router.post("/register", response_model=UserOut, status_code=201)
|
||||||
async def register(
|
async def register(
|
||||||
body: RegisterRequest,
|
body: RegisterRequest,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Frontend page routes — serve Jinja2 HTML templates for the SPA-like dashboard."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
router = APIRouter(tags=["pages"])
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
|
async def login_page(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "login.html")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/cs", response_class=HTMLResponse)
|
||||||
|
async def cs_dashboard(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "dashboard/cs.html")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/fm", response_class=HTMLResponse)
|
||||||
|
async def fm_dashboard(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "dashboard/fm.html")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/ceo", response_class=HTMLResponse)
|
||||||
|
async def ceo_dashboard(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "dashboard/ceo.html")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tickets", response_class=HTMLResponse)
|
||||||
|
async def ticket_list(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "tickets/list.html")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tickets/new", response_class=HTMLResponse)
|
||||||
|
async def create_ticket_page(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "tickets/new.html")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tickets/{ticket_id}", response_class=HTMLResponse)
|
||||||
|
async def ticket_detail_page(request: Request, ticket_id: int):
|
||||||
|
return templates.TemplateResponse(request, "tickets/detail.html", context={"ticket_id": ticket_id})
|
||||||
+134
-23
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -13,9 +13,10 @@ 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.config import settings
|
||||||
from app.core.database import get_db
|
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.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,10 +28,13 @@ 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
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/tickets", tags=["tickets"])
|
router = APIRouter(prefix="/api/tickets", tags=["tickets"])
|
||||||
|
|
||||||
# Ensure uploads directory exists
|
# Ensure uploads directory exists
|
||||||
@@ -38,24 +42,66 @@ UPLOADS_DIR = settings.BASE_DIR / "uploads"
|
|||||||
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
# ── Categories ──────────────────────────────────────────────────────
|
# ── Units ──────────────────────────────────────────────────────────
|
||||||
async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -> list[CategoryTreeOut]:
|
@router.get("/units", response_model=list[UnitOut])
|
||||||
"""Build a nested category tree."""
|
async def list_units(
|
||||||
result = await db.execute(
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
select(Category)
|
property_filter: str | None = Query(None, alias="property"),
|
||||||
.where(Category.parent_id == parent_id)
|
building: str | None = Query(None),
|
||||||
.order_by(Category.name)
|
) -> list[Unit]:
|
||||||
|
"""List all units, optionally filtered by property (East/West) and building."""
|
||||||
|
query = select(Unit).order_by(Unit.apartment_code)
|
||||||
|
if property_filter:
|
||||||
|
query = query.where(Unit.property == property_filter)
|
||||||
|
if building:
|
||||||
|
query = query.where(Unit.building == building)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/units/grouped")
|
||||||
|
async def list_units_grouped(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
property_filter: str | None = Query(None, alias="property"),
|
||||||
|
) -> dict[str, dict[str, list[dict]]]:
|
||||||
|
"""Return units grouped as ``{property: {building: [units]}}``.
|
||||||
|
|
||||||
|
Additive convenience variant of ``GET /api/tickets/units`` for the
|
||||||
|
Property → Building → Apartment cascade. Units without a building are
|
||||||
|
grouped under an empty-string key.
|
||||||
|
"""
|
||||||
|
query = select(Unit).order_by(Unit.property, Unit.building, Unit.apartment_code)
|
||||||
|
if property_filter:
|
||||||
|
query = query.where(Unit.property == property_filter)
|
||||||
|
result = await db.execute(query)
|
||||||
|
grouped: dict[str, dict[str, list[dict]]] = {}
|
||||||
|
for unit in result.scalars().all():
|
||||||
|
prop = unit.property or ""
|
||||||
|
building = unit.building or ""
|
||||||
|
grouped.setdefault(prop, {}).setdefault(building, []).append(
|
||||||
|
UnitOut.model_validate(unit).model_dump()
|
||||||
)
|
)
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
# ── Categories ──────────────────────────────────────────────────────
|
||||||
|
async def _build_category_tree(db: AsyncSession, parent_id: int | None = None, include_hidden: bool = False) -> list[CategoryTreeOut]:
|
||||||
|
"""Build a nested category tree."""
|
||||||
|
query = select(Category).where(Category.parent_id == parent_id).order_by(Category.name)
|
||||||
|
if not include_hidden:
|
||||||
|
query = query.where(Category.show_in_form.is_(True))
|
||||||
|
result = await db.execute(query)
|
||||||
categories = result.scalars().all()
|
categories = result.scalars().all()
|
||||||
tree = []
|
tree = []
|
||||||
for cat in categories:
|
for cat in categories:
|
||||||
children = await _build_category_tree(db, cat.id)
|
children = await _build_category_tree(db, cat.id, include_hidden=include_hidden)
|
||||||
tree.append(CategoryTreeOut(
|
tree.append(CategoryTreeOut(
|
||||||
id=cat.id,
|
id=cat.id,
|
||||||
type=cat.type,
|
type=cat.type,
|
||||||
name=cat.name,
|
name=cat.name,
|
||||||
parent_id=cat.parent_id,
|
parent_id=cat.parent_id,
|
||||||
sla_urgency=cat.sla_urgency,
|
sla_urgency=cat.sla_urgency,
|
||||||
|
show_in_form=cat.show_in_form,
|
||||||
children=children,
|
children=children,
|
||||||
))
|
))
|
||||||
return tree
|
return tree
|
||||||
@@ -65,23 +111,26 @@ async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -
|
|||||||
async def list_categories(
|
async def list_categories(
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
type_filter: str | None = Query(None, alias="type"),
|
type_filter: str | None = Query(None, alias="type"),
|
||||||
|
include_hidden: bool = Query(False, description="Include alert-only categories (e.g. Gas Leak)"),
|
||||||
) -> list[CategoryTreeOut]:
|
) -> list[CategoryTreeOut]:
|
||||||
"""Return the full category tree, optionally filtered by type (maintenance, cs, emergency)."""
|
"""Return the full category tree, optionally filtered by type (maintenance, cs, emergency).
|
||||||
|
|
||||||
|
Alert-only categories (``show_in_form=False``, e.g. Gas Leak) are excluded
|
||||||
|
unless ``include_hidden=true`` is passed (used by the emergency quick path).
|
||||||
|
"""
|
||||||
if type_filter:
|
if type_filter:
|
||||||
# Return flat list of top-level categories of the given type
|
# Return flat list of top-level categories of the given type
|
||||||
result = await db.execute(
|
query = select(Category).where(Category.parent_id.is_(None), Category.type == type_filter).order_by(Category.name)
|
||||||
select(Category)
|
if not include_hidden:
|
||||||
.where(Category.parent_id.is_(None), Category.type == type_filter)
|
query = query.where(Category.show_in_form.is_(True))
|
||||||
.order_by(Category.name)
|
result = await db.execute(query)
|
||||||
)
|
|
||||||
parents = result.scalars().all()
|
parents = result.scalars().all()
|
||||||
tree = []
|
tree = []
|
||||||
for parent in parents:
|
for parent in parents:
|
||||||
children_result = await db.execute(
|
children_query = select(Category).where(Category.parent_id == parent.id).order_by(Category.name)
|
||||||
select(Category)
|
if not include_hidden:
|
||||||
.where(Category.parent_id == parent.id)
|
children_query = children_query.where(Category.show_in_form.is_(True))
|
||||||
.order_by(Category.name)
|
children_result = await db.execute(children_query)
|
||||||
)
|
|
||||||
children = children_result.scalars().all()
|
children = children_result.scalars().all()
|
||||||
tree.append(CategoryTreeOut(
|
tree.append(CategoryTreeOut(
|
||||||
id=parent.id,
|
id=parent.id,
|
||||||
@@ -89,24 +138,43 @@ async def list_categories(
|
|||||||
name=parent.name,
|
name=parent.name,
|
||||||
parent_id=parent.parent_id,
|
parent_id=parent.parent_id,
|
||||||
sla_urgency=parent.sla_urgency,
|
sla_urgency=parent.sla_urgency,
|
||||||
|
show_in_form=parent.show_in_form,
|
||||||
children=[CategoryOut.model_validate(c) for c in children],
|
children=[CategoryOut.model_validate(c) for c in children],
|
||||||
))
|
))
|
||||||
return tree
|
return tree
|
||||||
return await _build_category_tree(db)
|
return await _build_category_tree(db, include_hidden=include_hidden)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/categories/flat", response_model=list[CategoryOut])
|
@router.get("/categories/flat", response_model=list[CategoryOut])
|
||||||
async def list_categories_flat(
|
async def list_categories_flat(
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
type_filter: str | None = Query(None, alias="type"),
|
type_filter: str | None = Query(None, alias="type"),
|
||||||
|
include_hidden: bool = Query(False, description="Include alert-only categories (e.g. Gas Leak)"),
|
||||||
) -> list[CategoryOut]:
|
) -> list[CategoryOut]:
|
||||||
"""Return a flat list of all categories (no nesting), optionally filtered by type."""
|
"""Return a flat list of all categories (no nesting), optionally filtered by type.
|
||||||
|
|
||||||
|
Alert-only categories are excluded by default; children of alert-only parents
|
||||||
|
are excluded too so no orphaned picker entries leak through.
|
||||||
|
"""
|
||||||
query = select(Category).order_by(Category.type, Category.name)
|
query = select(Category).order_by(Category.type, Category.name)
|
||||||
if type_filter:
|
if type_filter:
|
||||||
query = query.where(Category.type == type_filter)
|
query = query.where(Category.type == type_filter)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
categories = result.scalars().all()
|
categories = result.scalars().all()
|
||||||
|
if include_hidden:
|
||||||
return [CategoryOut.model_validate(c) for c in categories]
|
return [CategoryOut.model_validate(c) for c in categories]
|
||||||
|
# Exclude alert-only categories and any children whose parent is alert-only.
|
||||||
|
hidden_ids = {
|
||||||
|
c.id
|
||||||
|
for c in categories
|
||||||
|
if not c.show_in_form and c.parent_id is None
|
||||||
|
}
|
||||||
|
visible = [
|
||||||
|
c
|
||||||
|
for c in categories
|
||||||
|
if c.show_in_form and (c.parent_id is None or c.parent_id not in hidden_ids)
|
||||||
|
]
|
||||||
|
return [CategoryOut.model_validate(c) for c in visible]
|
||||||
|
|
||||||
|
|
||||||
# ── Ticket CRUD ──────────────────────────────────────────────────────
|
# ── Ticket CRUD ──────────────────────────────────────────────────────
|
||||||
@@ -133,6 +201,8 @@ async def list_tickets(
|
|||||||
status: str | None = Query(None),
|
status: str | None = Query(None),
|
||||||
priority: str | None = Query(None),
|
priority: str | None = Query(None),
|
||||||
property: str | None = Query(None),
|
property: str | None = Query(None),
|
||||||
|
building: str | None = Query(None),
|
||||||
|
unit_id: int | None = Query(None),
|
||||||
category_id: int | None = Query(None),
|
category_id: int | None = Query(None),
|
||||||
assigned_to: int | None = Query(None),
|
assigned_to: int | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: datetime | None = Query(None),
|
||||||
@@ -144,6 +214,8 @@ async def list_tickets(
|
|||||||
status_filter=status,
|
status_filter=status,
|
||||||
priority_filter=priority,
|
priority_filter=priority,
|
||||||
property_filter=property,
|
property_filter=property,
|
||||||
|
building_filter=building,
|
||||||
|
unit_id=unit_id,
|
||||||
category_id=category_id,
|
category_id=category_id,
|
||||||
assigned_to=assigned_to,
|
assigned_to=assigned_to,
|
||||||
date_from=date_from,
|
date_from=date_from,
|
||||||
@@ -155,6 +227,19 @@ async def list_tickets(
|
|||||||
return TicketListResponse(items=items, total=total, page=page, page_size=page_size)
|
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)
|
@router.get("/{ticket_id}", response_model=TicketOut)
|
||||||
async def get_ticket(
|
async def get_ticket(
|
||||||
ticket_id: int,
|
ticket_id: int,
|
||||||
@@ -181,6 +266,32 @@ async def update_ticket(
|
|||||||
return 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) ───────────────────────
|
# ── Status Transitions (convenience endpoints) ───────────────────────
|
||||||
@router.post("/{ticket_id}/status", response_model=TicketOut)
|
@router.post("/{ticket_id}/status", response_model=TicketOut)
|
||||||
async def change_ticket_status(
|
async def change_ticket_status(
|
||||||
|
|||||||
+134
-13
@@ -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),
|
||||||
|
hub_challenge: str | None = Query(None),
|
||||||
|
) -> dict:
|
||||||
|
"""Handle incoming WhatsApp webhook from Meta."""
|
||||||
|
|
||||||
|
# ── Verification GET request (Meta sends this on webhook setup) ──
|
||||||
|
if mode and hub_challenge:
|
||||||
|
if hub_verify_token != settings.WHATSAPP_VERIFY_TOKEN:
|
||||||
|
return {"error": "Verify token mismatch"}
|
||||||
|
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(
|
log = WhatsAppLog(
|
||||||
command=body.command,
|
from_number=sender,
|
||||||
from_number=body.from_number,
|
message_text=message_text,
|
||||||
ticket_id=body.ticket_id,
|
wa_message_id=wa_msg_id,
|
||||||
|
ticket_id=None,
|
||||||
|
ticket_number=None,
|
||||||
received_at=datetime.now(timezone.utc),
|
received_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
db.add(log)
|
db.add(log)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return MockWhatsAppResponse()
|
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)
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
|
|||||||
+21
-1
@@ -5,7 +5,18 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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 ─────────────────────────────────────────────────────────
|
||||||
@@ -15,6 +26,7 @@ class CategoryOut(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
parent_id: int | None = None
|
parent_id: int | None = None
|
||||||
sla_urgency: str | None = None
|
sla_urgency: str | None = None
|
||||||
|
show_in_form: bool = True
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -32,6 +44,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):
|
||||||
@@ -40,12 +54,14 @@ class TicketUpdate(BaseModel):
|
|||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
priority: str | None = None
|
priority: str | None = None
|
||||||
reporter: str | None = None
|
reporter: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
reported_via: str | None = None
|
reported_via: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
assigned_to: int | None = None
|
assigned_to: int | None = None
|
||||||
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,7 +94,9 @@ 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
|
||||||
|
phone: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
sla_deadline: datetime | None = None
|
sla_deadline: datetime | None = None
|
||||||
reopen_count: int = 0
|
reopen_count: int = 0
|
||||||
@@ -97,6 +115,8 @@ class TicketOut(TicketBrief):
|
|||||||
customer_rating: int | None = None
|
customer_rating: int | None = None
|
||||||
timeline: list[TicketTimelineOut] = []
|
timeline: list[TicketTimelineOut] = []
|
||||||
photos: list[TicketPhotoOut] = []
|
photos: list[TicketPhotoOut] = []
|
||||||
|
unit: UnitOut | None = None
|
||||||
|
category: CategoryOut | None = None
|
||||||
sla_status: dict | None = None
|
sla_status: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+61
-12
@@ -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}
|
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from jose import JWTError, jwt
|
|
||||||
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.security import (
|
from app.core.security import (
|
||||||
create_access_token,
|
create_access_token,
|
||||||
create_refresh_token,
|
create_refresh_token,
|
||||||
|
|||||||
+25
-3
@@ -135,23 +135,39 @@ SEED_CATEGORIES_DATA: list[dict] = [
|
|||||||
{"type": "maintenance", "name": "Security", "subs": ["Lock broken", "Door not closing", "Window latch", "CCTV issue"]},
|
{"type": "maintenance", "name": "Security", "subs": ["Lock broken", "Door not closing", "Window latch", "CCTV issue"]},
|
||||||
{"type": "maintenance", "name": "Internet", "subs": ["WiFi down", "Slow speed", "Router reset"]},
|
{"type": "maintenance", "name": "Internet", "subs": ["WiFi down", "Slow speed", "Router reset"]},
|
||||||
{"type": "maintenance", "name": "Structural", "subs": ["Wall crack", "Ceiling leak", "Floor tile", "Paint touch-up"]},
|
{"type": "maintenance", "name": "Structural", "subs": ["Wall crack", "Ceiling leak", "Floor tile", "Paint touch-up"]},
|
||||||
|
{"type": "maintenance", "name": "Aluminum/Glass",
|
||||||
|
"subs": ["Window repair", "Window replacement", "Door repair (sliding)", "Door repair (fixed)",
|
||||||
|
"Glass replacement", "Aluminum frame repair", "Shower screen", "Mirror replacement"]},
|
||||||
|
{"type": "maintenance", "name": "Carpentry",
|
||||||
|
"subs": ["Door repair", "Door replacement", "Door frame", "Cabinet repair", "Wardrobe repair",
|
||||||
|
"Shelving", "Timber repair", "Loose hinge/lock plate"]},
|
||||||
|
{"type": "maintenance", "name": "Mould & Damp",
|
||||||
|
"subs": ["Wall mould", "Ceiling damp patch", "Bathroom mould", "Mould after leak",
|
||||||
|
"Damp proofing / remediation"],
|
||||||
|
"sla_urgency": "medium"},
|
||||||
# ── Customer Service ─────────────────────────────────────────
|
# ── Customer Service ─────────────────────────────────────────
|
||||||
{"type": "cs", "name": "Check-in", "subs": ["Early check-in", "Key handover", "Welcome instructions"]},
|
{"type": "cs", "name": "Check-in", "subs": ["Early check-in", "Key handover", "Welcome instructions"]},
|
||||||
{"type": "cs", "name": "Check-out", "subs": ["Late checkout", "Key return", "Inspection"]},
|
{"type": "cs", "name": "Check-out", "subs": ["Late checkout", "Key return", "Inspection"]},
|
||||||
{"type": "cs", "name": "Housekeeping", "subs": ["Mid-stay cleaning", "Linen change", "Restocking"]},
|
{"type": "cs", "name": "Housekeeping", "subs": ["Mid-stay cleaning", "Linen change", "Restocking"]},
|
||||||
{"type": "cs", "name": "Lost Property", "subs": ["Guest left items behind"]},
|
{"type": "cs", "name": "Missing Item", "subs": ["Guest left items behind", "Item search request"]},
|
||||||
{"type": "cs", "name": "Billing", "subs": ["Invoice question", "Payment issue", "Deposit query"]},
|
{"type": "cs", "name": "Billing", "subs": ["Invoice question", "Payment issue", "Deposit query"]},
|
||||||
{"type": "cs", "name": "Staff Behaviour", "subs": ["Staff conduct feedback"]},
|
{"type": "cs", "name": "Staff Behaviour", "subs": ["Staff conduct feedback"]},
|
||||||
# ── Emergency ────────────────────────────────────────────────
|
# ── Emergency ────────────────────────────────────────────────
|
||||||
{"type": "emergency", "name": "Fire", "subs": ["Smoke detected", "Fire alarm", "Sprinkler issue"], "sla_urgency": "urgent"},
|
{"type": "emergency", "name": "Fire", "subs": ["Smoke detected", "Fire alarm", "Sprinkler issue"], "sla_urgency": "urgent"},
|
||||||
{"type": "emergency", "name": "Flood", "subs": ["Major water leak", "Burst pipe", "Overflowing"], "sla_urgency": "urgent"},
|
{"type": "emergency", "name": "Flood", "subs": ["Major water leak", "Burst pipe", "Overflowing"], "sla_urgency": "urgent"},
|
||||||
{"type": "emergency", "name": "Gas Leak", "subs": ["Gas smell", "Suspected leak"], "sla_urgency": "urgent"},
|
{"type": "emergency", "name": "Gas Leak", "subs": ["Gas smell", "Suspected leak"], "sla_urgency": "urgent", "show_in_form": False},
|
||||||
{"type": "emergency", "name": "Electrical Hazard", "subs": ["Sparking", "Exposed wires", "Power outage multiple units"], "sla_urgency": "urgent"},
|
{"type": "emergency", "name": "Electrical Hazard", "subs": ["Sparking", "Exposed wires", "Power outage multiple units"], "sla_urgency": "urgent"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def seed_categories(db: AsyncSession) -> list[Category]:
|
async def seed_categories(db: AsyncSession) -> list[Category]:
|
||||||
"""Seed the categories table with the full PRD §7 taxonomy."""
|
"""Seed the categories table with the full PRD §7 taxonomy.
|
||||||
|
|
||||||
|
Idempotent: only inserts missing rows. ``show_in_form`` on existing
|
||||||
|
parents is synced when the seed data explicitly sets it (e.g. Gas Leak
|
||||||
|
is alert-only, so already-seeded databases get flipped to False on the
|
||||||
|
next startup).
|
||||||
|
"""
|
||||||
created: list[Category] = []
|
created: list[Category] = []
|
||||||
|
|
||||||
for group in SEED_CATEGORIES_DATA:
|
for group in SEED_CATEGORIES_DATA:
|
||||||
@@ -170,10 +186,16 @@ async def seed_categories(db: AsyncSession) -> list[Category]:
|
|||||||
name=group["name"],
|
name=group["name"],
|
||||||
parent_id=None,
|
parent_id=None,
|
||||||
sla_urgency=group.get("sla_urgency"),
|
sla_urgency=group.get("sla_urgency"),
|
||||||
|
show_in_form=group.get("show_in_form", True),
|
||||||
)
|
)
|
||||||
db.add(parent)
|
db.add(parent)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
created.append(parent)
|
created.append(parent)
|
||||||
|
elif "show_in_form" in group and parent.show_in_form != group["show_in_form"]:
|
||||||
|
# Sync visibility for existing rows (e.g. Gas Leak → alert-only)
|
||||||
|
parent.show_in_form = group["show_in_form"]
|
||||||
|
await db.flush()
|
||||||
|
created.append(parent)
|
||||||
|
|
||||||
# Seed sub-categories
|
# Seed sub-categories
|
||||||
for sub_name in group["subs"]:
|
for sub_name in group["subs"]:
|
||||||
|
|||||||
+5
-2
@@ -55,9 +55,12 @@ def should_escalate_on_response(ticket: Ticket) -> bool:
|
|||||||
return datetime.now(timezone.utc) > deadline
|
return datetime.now(timezone.utc) > deadline
|
||||||
|
|
||||||
|
|
||||||
|
TERMINAL_STATUSES = {"Closed", "Completed", "Cancelled"}
|
||||||
|
|
||||||
|
|
||||||
def is_sla_breached(ticket: Ticket) -> bool:
|
def is_sla_breached(ticket: Ticket) -> bool:
|
||||||
"""Return True if the ticket's resolution SLA deadline has passed."""
|
"""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
|
return False
|
||||||
deadline = ticket.sla_deadline
|
deadline = ticket.sla_deadline
|
||||||
if deadline.tzinfo is None:
|
if deadline.tzinfo is None:
|
||||||
@@ -88,7 +91,7 @@ async def get_sla_status(ticket: Ticket) -> dict:
|
|||||||
rd = resolution_deadline
|
rd = resolution_deadline
|
||||||
if rd.tzinfo is None:
|
if rd.tzinfo is None:
|
||||||
rd = rd.replace(tzinfo=timezone.utc)
|
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 {
|
return {
|
||||||
"priority": ticket.priority,
|
"priority": ticket.priority,
|
||||||
|
|||||||
+91
-25
@@ -6,11 +6,11 @@ from datetime import datetime, timezone
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
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.unit import Unit
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.sla import compute_sla_deadline
|
from app.services.sla import compute_sla_deadline
|
||||||
@@ -18,21 +18,23 @@ from app.services.sla import compute_sla_deadline
|
|||||||
# ── Status Transition Map ────────────────────────────────────────────
|
# ── Status Transition Map ────────────────────────────────────────────
|
||||||
# Keys: current status → list of valid next statuses
|
# Keys: current status → list of valid next statuses
|
||||||
VALID_TRANSITIONS: dict[str, list[str]] = {
|
VALID_TRANSITIONS: dict[str, list[str]] = {
|
||||||
"New": ["Logged"],
|
"New": ["Logged", "Cancelled"],
|
||||||
"Logged": ["Triage", "Closed"],
|
"Logged": ["Triage", "Closed", "Cancelled"],
|
||||||
"Triage": ["Assigned", "Escalated"],
|
"Triage": ["Assigned", "Escalated", "Cancelled"],
|
||||||
"Assigned": ["Accepted", "Triage"],
|
"Assigned": ["Accepted", "Triage", "Cancelled"],
|
||||||
"Accepted": ["Travelling", "Triage"],
|
"Accepted": ["Travelling", "Triage", "Cancelled"],
|
||||||
"Travelling": ["On Site", "Triage"],
|
"Travelling": ["On Site", "Triage", "Cancelled"],
|
||||||
"On Site": ["In Progress", "Triage"],
|
"On Site": ["In Progress", "Triage", "Cancelled"],
|
||||||
"In Progress": ["Waiting Parts", "Escalated", "Completed"],
|
"In Progress": ["Waiting Parts", "Escalated", "Completed", "Cancelled"],
|
||||||
"Waiting Parts": ["In Progress", "Escalated"],
|
"Waiting Parts": ["In Progress", "Escalated", "Cancelled"],
|
||||||
"Escalated": ["Triage", "In Progress", "Completed", "Closed"],
|
"Escalated": ["Triage", "In Progress", "Completed", "Closed", "Cancelled"],
|
||||||
"Completed": ["On-Field Verification", "In Progress"],
|
"Completed": ["On-Field Verification", "In Progress", "Cancelled"],
|
||||||
"On-Field Verification": ["Wahab Review", "Completed", "Closed"],
|
"On-Field Verification": ["Wahab Review", "Completed", "Closed", "Cancelled"],
|
||||||
"Wahab Review": ["Closed", "On-Field Verification"],
|
"Wahab Review": ["Closed", "On-Field Verification", "Cancelled"],
|
||||||
"Closed": ["Reopened"],
|
"Closed": ["Reopened"],
|
||||||
"Reopened": ["Triage", "Logged"],
|
"Reopened": ["Triage", "Logged", "Cancelled"],
|
||||||
|
# Terminal: cancelled tickets cannot resume work.
|
||||||
|
"Cancelled": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
REOPEN_WINDOW_DAYS = 7
|
REOPEN_WINDOW_DAYS = 7
|
||||||
@@ -41,15 +43,25 @@ SLA_ACK_USER = "Ama"
|
|||||||
|
|
||||||
# ── Helpers ──────────────────────────────────────────────────────────
|
# ── Helpers ──────────────────────────────────────────────────────────
|
||||||
async def _generate_ticket_number(db: AsyncSession) -> str:
|
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
|
year = datetime.now(timezone.utc).year
|
||||||
prefix = f"PAV-{year}-"
|
prefix = f"PAV-{year}-"
|
||||||
# Count existing tickets this year
|
|
||||||
result = await db.execute(
|
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
|
max_number = result.scalar()
|
||||||
return f"{prefix}{count + 1:05d}"
|
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(
|
async def _log_status_change(
|
||||||
@@ -109,7 +121,8 @@ 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"),
|
||||||
|
phone=data.get("phone"),
|
||||||
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"),
|
||||||
@@ -156,6 +169,8 @@ async def list_tickets(
|
|||||||
status_filter: str | None = None,
|
status_filter: str | None = None,
|
||||||
priority_filter: str | None = None,
|
priority_filter: str | None = None,
|
||||||
property_filter: str | None = None,
|
property_filter: str | None = None,
|
||||||
|
building_filter: str | 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,
|
||||||
date_from: datetime | None = None,
|
date_from: datetime | None = None,
|
||||||
@@ -177,6 +192,12 @@ async def list_tickets(
|
|||||||
# Join unit to filter by property
|
# Join unit to filter by property
|
||||||
query = query.join(Ticket.unit).where(Unit.property == property_filter)
|
query = query.join(Ticket.unit).where(Unit.property == property_filter)
|
||||||
count_query = count_query.join(Ticket.unit).where(Unit.property == property_filter)
|
count_query = count_query.join(Ticket.unit).where(Unit.property == property_filter)
|
||||||
|
if building_filter:
|
||||||
|
query = query.join(Ticket.unit).where(Unit.building == building_filter)
|
||||||
|
count_query = count_query.join(Ticket.unit).where(Unit.building == building_filter)
|
||||||
|
if unit_id:
|
||||||
|
query = query.where(Ticket.unit_id == unit_id)
|
||||||
|
count_query = count_query.where(Ticket.unit_id == unit_id)
|
||||||
if category_id:
|
if category_id:
|
||||||
query = query.where(Ticket.category_id == category_id)
|
query = query.where(Ticket.category_id == category_id)
|
||||||
count_query = count_query.where(Ticket.category_id == category_id)
|
count_query = count_query.where(Ticket.category_id == category_id)
|
||||||
@@ -196,7 +217,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 +235,9 @@ 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
|
||||||
|
status_changed = new_status is not None and old_status != new_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,8 +297,36 @@ async def update_ticket(
|
|||||||
|
|
||||||
ticket.status = new_status
|
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 status_changed and not advanced_to_assigned:
|
||||||
|
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", "phone", "reported_via",
|
||||||
"description", "assigned_to", "eta", "cost", "parts_used"):
|
"description", "assigned_to", "eta", "cost", "parts_used"):
|
||||||
if field in data:
|
if field in data:
|
||||||
setattr(ticket, field, data[field])
|
setattr(ticket, field, data[field])
|
||||||
@@ -290,3 +340,19 @@ async def update_ticket(
|
|||||||
return 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Denya OneCare</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
denya: {
|
||||||
|
50: '#e8f0ea',
|
||||||
|
100: '#c5d9cb',
|
||||||
|
200: '#9ebfaa',
|
||||||
|
300: '#74a589',
|
||||||
|
400: '#4d8c69',
|
||||||
|
500: '#2d734d',
|
||||||
|
600: '#1d5a3a',
|
||||||
|
700: '#0d2b18',
|
||||||
|
800: '#0a2012',
|
||||||
|
900: '#07150c',
|
||||||
|
},
|
||||||
|
gold: {
|
||||||
|
DEFAULT: '#c8a96e',
|
||||||
|
light: '#e8d5a8',
|
||||||
|
dark: '#a88a4e',
|
||||||
|
},
|
||||||
|
cream: '#faf8f5',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
.status-new { @apply bg-gray-100 text-gray-800; }
|
||||||
|
.status-logged { @apply bg-blue-100 text-blue-800; }
|
||||||
|
.status-triage { @apply bg-yellow-100 text-yellow-800; }
|
||||||
|
.status-assigned { @apply bg-indigo-100 text-indigo-800; }
|
||||||
|
.status-accepted { @apply bg-purple-100 text-purple-800; }
|
||||||
|
.status-travelling { @apply bg-cyan-100 text-cyan-800; }
|
||||||
|
.status-onsite { @apply bg-teal-100 text-teal-800; }
|
||||||
|
.status-inprogress { @apply bg-sky-100 text-sky-800; }
|
||||||
|
.status-waitingparts { @apply bg-orange-100 text-orange-800; }
|
||||||
|
.status-escalated { @apply bg-red-100 text-red-800; }
|
||||||
|
.status-completed { @apply bg-green-100 text-green-800; }
|
||||||
|
.status-on-field-verification { @apply bg-emerald-100 text-emerald-800; }
|
||||||
|
.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; }
|
||||||
|
.priority-low { @apply bg-green-100 text-green-800 border-green-300; }
|
||||||
|
.brand-gradient { background: linear-gradient(135deg, #0d2b18 0%, #1a3d24 100%); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-cream min-h-screen text-[#1a1a1a]" x-data="app()" x-init="init()">
|
||||||
|
<!-- Nav Bar -->
|
||||||
|
<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="flex items-center justify-between h-16">
|
||||||
|
<!-- Left side -->
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<a href="/dashboard/cs" class="flex items-center space-x-3">
|
||||||
|
<!-- Denya Developers Logo Mark -->
|
||||||
|
<div class="w-9 h-9 bg-gold rounded-lg flex items-center justify-center shadow-sm">
|
||||||
|
<svg class="w-5 h-5 text-[#0d2b18]" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||||
|
<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>
|
||||||
|
<!-- Nav Links -->
|
||||||
|
<template x-if="isCS">
|
||||||
|
<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 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 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-300 hover:text-white hover:bg-denya-800/50 transition-colors">Create Issue</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="isFM">
|
||||||
|
<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 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 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-300 hover:text-white hover:bg-denya-800/50 transition-colors">Create Issue</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="isExecutive">
|
||||||
|
<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 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 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>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<!-- Right side -->
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<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-gold hover:text-gold-light hover:bg-denya-800/50 rounded-md transition-colors border border-denya-600">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Mobile Nav -->
|
||||||
|
<div class="md:hidden border-b bg-[#0d2b18] border-denya-800" x-show="isLoggedIn" x-cloak>
|
||||||
|
<template x-if="isCS || isFM">
|
||||||
|
<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-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-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-300">New Issue</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="isExecutive">
|
||||||
|
<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-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-300">Issues</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Loading Overlay -->
|
||||||
|
<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-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-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>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-denya-800 font-medium" x-text="loadingMessage || 'Loading...'"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast Notifications -->
|
||||||
|
<div class="fixed bottom-4 right-4 z-50 space-y-2">
|
||||||
|
<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"
|
||||||
|
: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)">
|
||||||
|
<span x-text="toast.message"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function app() {
|
||||||
|
return {
|
||||||
|
// Auth state
|
||||||
|
user: JSON.parse(localStorage.getItem('user') || '{}'),
|
||||||
|
token: localStorage.getItem('access_token') || null,
|
||||||
|
isLoggedIn: !!localStorage.getItem('access_token'),
|
||||||
|
currentPath: window.location.pathname,
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
loading: false,
|
||||||
|
loadingMessage: '',
|
||||||
|
toasts: [],
|
||||||
|
|
||||||
|
// Role helpers
|
||||||
|
get isCS() { return ['CS Rep', 'CS Manager'].includes(this.user.role) },
|
||||||
|
get isFM() { return ['FM Dispatcher', 'Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
|
||||||
|
get isExecutive() { return ['CEO', 'Director'].includes(this.user.role) },
|
||||||
|
get isAdmin() { return ['Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (!this.isLoggedIn && this.currentPath !== '/login') {
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.isLoggedIn) {
|
||||||
|
this.fetchMe();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchMe() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/me', {
|
||||||
|
headers: this.authHeaders()
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const userData = await res.json();
|
||||||
|
this.user = userData;
|
||||||
|
localStorage.setItem('user', JSON.stringify(userData));
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
this.logout();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Auth check failed', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
authHeaders() {
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
||||||
|
return headers;
|
||||||
|
},
|
||||||
|
|
||||||
|
async api(method, url, body = null) {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: this.authHeaders()
|
||||||
|
};
|
||||||
|
if (body && !(body instanceof FormData)) {
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
} else if (body instanceof FormData) {
|
||||||
|
opts.body = body;
|
||||||
|
delete opts.headers['Content-Type'];
|
||||||
|
opts.headers = { 'Authorization': `Bearer ${this.token}` };
|
||||||
|
}
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
if (res.status === 401 && this.currentPath !== '/login') {
|
||||||
|
this.logout();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||||
|
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
this.showToast(e.message, 'error');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
apiGet(url) { return this.api('GET', url); },
|
||||||
|
apiPost(url, body) { return this.api('POST', url, body); },
|
||||||
|
apiPatch(url, body) { return this.api('PATCH', url, body); },
|
||||||
|
apiDelete(url) { return this.api('DELETE', url); },
|
||||||
|
|
||||||
|
async login(email, password) {
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
this.loadingMessage = 'Signing in...';
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ detail: 'Login failed' }));
|
||||||
|
throw new Error(err.detail);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
localStorage.setItem('access_token', data.access_token);
|
||||||
|
localStorage.setItem('refresh_token', data.refresh_token);
|
||||||
|
this.token = data.access_token;
|
||||||
|
|
||||||
|
const me = await this.apiGet('/api/auth/me');
|
||||||
|
this.user = me;
|
||||||
|
localStorage.setItem('user', JSON.stringify(me));
|
||||||
|
this.isLoggedIn = true;
|
||||||
|
|
||||||
|
const role = me.role;
|
||||||
|
if (['CS Rep', 'CS Manager'].includes(role)) {
|
||||||
|
window.location.href = '/dashboard/cs';
|
||||||
|
} else if (['FM Dispatcher', 'Admin/Jerome', 'Admin/Wahab'].includes(role)) {
|
||||||
|
window.location.href = '/dashboard/fm';
|
||||||
|
} else if (['CEO', 'Director'].includes(role)) {
|
||||||
|
window.location.href = '/dashboard/ceo';
|
||||||
|
} else {
|
||||||
|
window.location.href = '/tickets';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.showToast(e.message, 'error');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
this.loadingMessage = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
this.user = {};
|
||||||
|
this.token = null;
|
||||||
|
this.isLoggedIn = false;
|
||||||
|
window.location.href = '/login';
|
||||||
|
},
|
||||||
|
|
||||||
|
showToast(message, type = 'info', duration = 4000) {
|
||||||
|
const id = Date.now() + Math.random();
|
||||||
|
this.toasts.push({ id, message, type, duration });
|
||||||
|
},
|
||||||
|
|
||||||
|
statusClass(status) {
|
||||||
|
if (!status) return 'bg-gray-100 text-gray-800';
|
||||||
|
const map = {
|
||||||
|
'new': 'status-new',
|
||||||
|
'logged': 'status-logged',
|
||||||
|
'triage': 'status-triage',
|
||||||
|
'assigned': 'status-assigned',
|
||||||
|
'accepted': 'status-accepted',
|
||||||
|
'travelling': 'status-travelling',
|
||||||
|
'on site': 'status-onsite',
|
||||||
|
'in progress': 'status-inprogress',
|
||||||
|
'waiting parts': 'status-waitingparts',
|
||||||
|
'escalated': 'status-escalated',
|
||||||
|
'completed': 'status-completed',
|
||||||
|
'on-field verification': 'status-on-field-verification',
|
||||||
|
'wahab review': 'status-wahab-review',
|
||||||
|
'closed': 'status-closed',
|
||||||
|
'reopened': 'status-reopened',
|
||||||
|
'cancelled': 'status-cancelled'
|
||||||
|
};
|
||||||
|
return map[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
|
||||||
|
},
|
||||||
|
|
||||||
|
priorityBadge(priority) {
|
||||||
|
const labels = { urgent: '🔴 Urgent', high: '🟠 High', medium: '🟡 Medium', low: '🟢 Low' };
|
||||||
|
return labels[priority] || priority;
|
||||||
|
},
|
||||||
|
|
||||||
|
priorityClass(priority) {
|
||||||
|
const map = { urgent: 'priority-urgent', high: 'priority-high', medium: 'priority-medium', low: 'priority-low' };
|
||||||
|
return map[priority] || 'bg-gray-100 text-gray-800';
|
||||||
|
},
|
||||||
|
|
||||||
|
formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
},
|
||||||
|
|
||||||
|
formatDateShort(dateStr) {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
|
},
|
||||||
|
|
||||||
|
timeSince(dateStr) {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const now = new Date();
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const diffMs = now - d;
|
||||||
|
const diffHrs = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
if (diffHrs < 1) return '< 1h';
|
||||||
|
if (diffHrs < 24) return `${diffHrs}h`;
|
||||||
|
return `${diffDays}d`;
|
||||||
|
},
|
||||||
|
|
||||||
|
groupByPriority(tickets, ageDir = 'desc') {
|
||||||
|
// Group tickets into Urgent/High/Medium/Low + an other bucket,
|
||||||
|
// each sorted by age (desc = newest first, asc = oldest first).
|
||||||
|
const order = ['urgent', 'high', 'medium', 'low'];
|
||||||
|
const groups = { urgent: [], high: [], medium: [], low: [], other: [] };
|
||||||
|
const age = (t) => new Date(t.created_at).getTime() || 0;
|
||||||
|
(tickets || []).forEach(t => {
|
||||||
|
const p = String(t.priority || '').toLowerCase();
|
||||||
|
groups[order.includes(p) ? p : 'other'].push(t);
|
||||||
|
});
|
||||||
|
Object.keys(groups).forEach(k => {
|
||||||
|
groups[k].sort((a, b) => (ageDir === 'asc' ? age(a) - age(b) : age(b) - age(a)));
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
},
|
||||||
|
|
||||||
|
priorityGroupMeta() {
|
||||||
|
return [
|
||||||
|
{ key: 'urgent', label: '🔴 Urgent', cls: 'border-red-200 bg-red-50' },
|
||||||
|
{ key: 'high', label: '🟠 High', cls: 'border-orange-200 bg-orange-50' },
|
||||||
|
{ key: 'medium', label: '🟡 Medium', cls: 'border-yellow-200 bg-yellow-50' },
|
||||||
|
{ key: 'low', label: '🟢 Low', cls: 'border-green-200 bg-green-50' },
|
||||||
|
{ key: 'other', label: '⚪ No priority / Unknown', cls: 'border-gray-200 bg-gray-50' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div x-data="ceoDashboard()" x-init="init()">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Executive Dashboard</h1>
|
||||||
|
<p class="text-gray-500 mt-1">Read-only strategic overview</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Executive KPI Cards -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||||
|
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Open Tickets</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.openTickets || 0"></p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||||
|
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Critical (Urgent)</p>
|
||||||
|
<p class="text-3xl font-bold text-red-600 mt-1" x-text="kpi.criticalCount || 0"></p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||||
|
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">SLA Compliance</p>
|
||||||
|
<p class="text-3xl font-bold mt-1" :class="kpi.slaCompliance >= 90 ? 'text-green-600' : kpi.slaCompliance >= 70 ? 'text-yellow-600' : 'text-red-600'" x-text="(kpi.slaCompliance || 0) + '%'"></p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||||
|
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Avg Resolution</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.avgResolutionTime || '—'"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||||
|
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Avg Response</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900 mt-1" x-text="kpi.avgResponseTime || '—'"></p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||||
|
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Reopened This Week</p>
|
||||||
|
<p class="text-2xl font-bold text-orange-600 mt-1" x-text="kpi.reopenedThisWeek || 0"></p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||||
|
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Total Tickets</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900 mt-1" x-text="kpi.totalTickets || 0"></p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||||
|
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Completion Rate</p>
|
||||||
|
<p class="text-2xl font-bold mt-1" :class="kpi.completionRate >= 70 ? 'text-green-600' : 'text-yellow-600'" x-text="(kpi.completionRate || 0) + '%'"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Row -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
<!-- Complaints by Property (Simple Bar) -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Complaints by Property</h3>
|
||||||
|
<div class="flex items-end space-x-6 h-40 px-2">
|
||||||
|
<div class="flex-1 flex flex-col items-center">
|
||||||
|
<span class="text-lg font-bold text-denya-600" x-text="charts.byProperty?.east || 0"></span>
|
||||||
|
<div class="w-full bg-denya-200 rounded-t-lg mt-1 transition-all" :style="'height: ' + Math.max((charts.byProperty?.east || 0) / Math.max(charts.byProperty?.max || 1, 1) * 120, 8) + 'px'" style="height: 8px;"></div>
|
||||||
|
<span class="text-xs text-gray-500 mt-2">East</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 flex flex-col items-center">
|
||||||
|
<span class="text-lg font-bold text-denya-600" x-text="charts.byProperty?.west || 0"></span>
|
||||||
|
<div class="w-full bg-denya-400 rounded-t-lg mt-1 transition-all" :style="'height: ' + Math.max((charts.byProperty?.west || 0) / Math.max(charts.byProperty?.max || 1, 1) * 120, 8) + 'px'" style="height: 8px;"></div>
|
||||||
|
<span class="text-xs text-gray-500 mt-2">West</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Complaints by Category -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Complaints by Category</h3>
|
||||||
|
<div class="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
<template x-for="item in charts.byCategory" :key="item.name">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="text-xs text-gray-600 w-20 truncate" x-text="item.name"></span>
|
||||||
|
<div class="flex-1 bg-gray-100 rounded-full h-4 overflow-hidden">
|
||||||
|
<div class="h-full rounded-full transition-all" :style="'width: ' + (item.count / Math.max(charts.byCategoryMax, 1) * 100) + '%'" :class="item.color || 'bg-denya-500'"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-medium text-gray-600 w-8 text-right" x-text="item.count"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div x-show="!charts.byCategory?.length" class="text-gray-400 text-sm py-6 text-center">No data yet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
<!-- Priority Distribution -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Tickets by Priority</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-medium text-red-600">🔴 Urgent</span>
|
||||||
|
<span class="text-2xl font-bold" x-text="charts.byPriority?.urgent || 0"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-medium text-orange-600">🟠 High</span>
|
||||||
|
<span class="text-2xl font-bold" x-text="charts.byPriority?.high || 0"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-medium text-yellow-600">🟡 Medium</span>
|
||||||
|
<span class="text-2xl font-bold" x-text="charts.byPriority?.medium || 0"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-medium text-green-600">🟢 Low</span>
|
||||||
|
<span class="text-2xl font-bold" x-text="charts.byPriority?.low || 0"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Monthly Trends (Simple) -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Monthly Trends</h3>
|
||||||
|
<div class="flex items-end space-x-1 h-40 overflow-x-auto pb-2">
|
||||||
|
<template x-for="(item, idx) in charts.monthlyTrend" :key="idx">
|
||||||
|
<div class="flex-1 flex flex-col items-center min-w-[30px]">
|
||||||
|
<span class="text-xs font-medium text-gray-600 mb-1" x-text="item.count"></span>
|
||||||
|
<div class="w-full bg-denya-300 rounded-t transition-all" :style="'height: ' + Math.max(item.count / Math.max(charts.monthlyMax || 1, 1) * 100, 4) + 'px'"></div>
|
||||||
|
<span class="text-xs text-gray-400 mt-1" x-text="item.month"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div x-show="!charts.monthlyTrend?.length" class="text-gray-400 text-sm py-6 text-center w-full">No data yet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Risk Indicators -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-8">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Risk Indicators</h3>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div class="p-3 rounded-lg" :class="indicators.openEmergencies > 0 ? 'bg-red-50 border border-red-200' : 'bg-green-50 border border-green-200'">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-3 h-3 rounded-full" :class="indicators.openEmergencies > 0 ? 'bg-red-500 animate-pulse' : 'bg-green-500'"></div>
|
||||||
|
<span class="text-xs font-medium">Open Emergencies</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg font-bold mt-1" x-text="indicators.openEmergencies || 0"></p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 rounded-lg" :class="indicators.reopenedThisWeek > 0 ? 'bg-orange-50 border border-orange-200' : 'bg-green-50 border border-green-200'">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-3 h-3 rounded-full" :class="indicators.reopenedThisWeek > 0 ? 'bg-orange-500' : 'bg-green-500'"></div>
|
||||||
|
<span class="text-xs font-medium">Reopened This Week</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg font-bold mt-1" x-text="indicators.reopenedThisWeek || 0"></p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 rounded-lg" :class="indicators.overdueJobs > 0 ? 'bg-red-50 border border-red-200' : 'bg-green-50 border border-green-200'">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-3 h-3 rounded-full" :class="indicators.overdueJobs > 0 ? 'bg-red-500' : 'bg-green-500'"></div>
|
||||||
|
<span class="text-xs font-medium">Overdue (Past SLA)</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg font-bold mt-1" x-text="indicators.overdueJobs || 0"></p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 rounded-lg" :class="indicators.pendingVerification > 0 ? 'bg-yellow-50 border border-yellow-200' : 'bg-green-50 border border-green-200'">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-3 h-3 rounded-full" :class="indicators.pendingVerification > 0 ? 'bg-yellow-500' : 'bg-green-500'"></div>
|
||||||
|
<span class="text-xs font-medium">Pending Verification</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg font-bold mt-1" x-text="indicators.pendingVerification || 0"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function ceoDashboard() {
|
||||||
|
return {
|
||||||
|
kpi: {},
|
||||||
|
charts: { byProperty: {}, byCategory: [], byPriority: {}, monthlyTrend: [], byCategoryMax: 1, monthlyMax: 1 },
|
||||||
|
indicators: {},
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.loadData();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
try {
|
||||||
|
// Fetch ALL tickets via pagination. The API caps page_size at 200
|
||||||
|
// (app/routers/tickets.py), so a single page_size=500 request returns 422
|
||||||
|
// and the dashboard renders empty KPIs. Loop pages until we have `total`
|
||||||
|
// 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
|
||||||
|
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', 'Cancelled'].includes(t.status));
|
||||||
|
|
||||||
|
// Monthly restored
|
||||||
|
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const reopenedThisWeek = all.filter(t => t.status === 'Reopened' && new Date(t.updated_at) >= oneWeekAgo).length;
|
||||||
|
|
||||||
|
this.kpi = {
|
||||||
|
openTickets: open.length,
|
||||||
|
criticalCount: urgent.length,
|
||||||
|
slaCompliance: withSLA.length ? Math.round((slaMet.length / withSLA.length) * 100) : 100,
|
||||||
|
avgResolutionTime: this.calcAvgResolution(all),
|
||||||
|
avgResponseTime: this.calcAvgResponse(all),
|
||||||
|
reopenedThisWeek,
|
||||||
|
totalTickets: total,
|
||||||
|
completionRate: total ? Math.round((closed.length / total) * 100) : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chart data
|
||||||
|
const byPriority = { urgent: 0, high: 0, medium: 0, low: 0 };
|
||||||
|
open.forEach(t => { if (t.priority) byPriority[t.priority]++; });
|
||||||
|
this.charts.byPriority = byPriority;
|
||||||
|
|
||||||
|
// Monthly trends (last 6 months)
|
||||||
|
const months = {};
|
||||||
|
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
all.forEach(t => {
|
||||||
|
const d = new Date(t.created_at);
|
||||||
|
const key = `${d.getFullYear()}-${d.getMonth()}`;
|
||||||
|
months[key] = (months[key] || 0) + 1;
|
||||||
|
});
|
||||||
|
const sortedMonths = Object.keys(months).sort().slice(-6);
|
||||||
|
this.charts.monthlyTrend = sortedMonths.map(k => {
|
||||||
|
const [y, m] = k.split('-').map(Number);
|
||||||
|
return { month: monthNames[m], count: months[k] };
|
||||||
|
});
|
||||||
|
this.charts.monthlyMax = Math.max(...this.charts.monthlyTrend.map(m => m.count), 1);
|
||||||
|
|
||||||
|
// By property — load units once and compute from ticket data
|
||||||
|
const propData = { east: 0, west: 0 };
|
||||||
|
try {
|
||||||
|
const units = await app().apiGet('/api/tickets/units');
|
||||||
|
if (units && units.length > 0) {
|
||||||
|
const unitPropertyMap = {};
|
||||||
|
units.forEach(u => { unitPropertyMap[u.id] = u.property; });
|
||||||
|
all.forEach(t => {
|
||||||
|
if (t.unit_id && unitPropertyMap[t.unit_id]) {
|
||||||
|
const p = unitPropertyMap[t.unit_id].toLowerCase();
|
||||||
|
if (p === 'east') propData.east++;
|
||||||
|
else if (p === 'west') propData.west++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('Property stats error', e); }
|
||||||
|
this.charts.byProperty = { east: propData.east, west: propData.west, max: Math.max(propData.east, propData.west, 1) };
|
||||||
|
|
||||||
|
// By category — use categories endpoint
|
||||||
|
// include_hidden=true so alert-only categories (Gas Leak) stay labeled in reporting
|
||||||
|
try {
|
||||||
|
const cats = await app().apiGet('/api/tickets/categories/flat?include_hidden=true');
|
||||||
|
const catCounts = {};
|
||||||
|
all.forEach(t => {
|
||||||
|
if (t.category_id) {
|
||||||
|
const cat = cats?.find(c => c.id === t.category_id);
|
||||||
|
const name = cat ? cat.name : `Cat #${t.category_id}`;
|
||||||
|
catCounts[name] = (catCounts[name] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const colors = ['bg-blue-500', 'bg-green-500', 'bg-yellow-500', 'bg-red-500', 'bg-purple-500', 'bg-pink-500', 'bg-indigo-500', 'bg-teal-500'];
|
||||||
|
this.charts.byCategory = Object.entries(catCounts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(([name, count], i) => ({ name, count, color: colors[i % colors.length] }));
|
||||||
|
this.charts.byCategoryMax = Math.max(...this.charts.byCategory.map(c => c.count), 1);
|
||||||
|
} catch (e) { console.error('Category stats error', e); }
|
||||||
|
|
||||||
|
// Risk indicators
|
||||||
|
this.indicators = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (e) { console.error('CEO data load error', e); }
|
||||||
|
},
|
||||||
|
|
||||||
|
calcAvgResponse(all) {
|
||||||
|
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`;
|
||||||
|
return `${Math.round(avgHrs)}h`;
|
||||||
|
},
|
||||||
|
|
||||||
|
calcAvgResolution(all) {
|
||||||
|
const closed = all.filter(t => ['Closed', 'Completed'].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);
|
||||||
|
return sum + diff;
|
||||||
|
}, 0) / closed.length;
|
||||||
|
if (avgHrs < 1) return `${Math.round(avgHrs * 60)}m`;
|
||||||
|
return `${Math.round(avgHrs)}h`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div x-data="csDashboard()" x-init="init()">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Customer Service Dashboard</h1>
|
||||||
|
<p class="text-gray-500 mt-1">Welcome back, <span x-text="user.full_name"></span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI Cards -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 font-medium">New Today</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.newToday || 0"></p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 font-medium">Open Tickets</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.openTickets || 0"></p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 font-medium">Avg Response Time</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.avgResponseTime || '—'"></p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 font-medium">SLA Compliance</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="(kpi.slaCompliance || 0) + '%'"></p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Priority Breakdown + Escalated -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||||
|
<!-- Open by Priority -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Open by Priority</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<a href="/tickets?group=priority&priority=urgent" class="flex items-center justify-between rounded-lg px-2 py-1.5 hover:bg-red-50 transition">
|
||||||
|
<span class="text-red-600 font-medium">🔴 Urgent</span>
|
||||||
|
<span class="text-2xl font-bold" x-text="kpi.byPriority?.urgent || 0"></span>
|
||||||
|
</a>
|
||||||
|
<a href="/tickets?group=priority&priority=high" class="flex items-center justify-between rounded-lg px-2 py-1.5 hover:bg-orange-50 transition">
|
||||||
|
<span class="text-orange-600 font-medium">🟠 High</span>
|
||||||
|
<span class="text-2xl font-bold" x-text="kpi.byPriority?.high || 0"></span>
|
||||||
|
</a>
|
||||||
|
<a href="/tickets?group=priority&priority=medium" class="flex items-center justify-between rounded-lg px-2 py-1.5 hover:bg-yellow-50 transition">
|
||||||
|
<span class="text-yellow-600 font-medium">🟡 Medium</span>
|
||||||
|
<span class="text-2xl font-bold" x-text="kpi.byPriority?.medium || 0"></span>
|
||||||
|
</a>
|
||||||
|
<a href="/tickets?group=priority&priority=low" class="flex items-center justify-between rounded-lg px-2 py-1.5 hover:bg-green-50 transition">
|
||||||
|
<span class="text-green-600 font-medium">🟢 Low</span>
|
||||||
|
<span class="text-2xl font-bold" x-text="kpi.byPriority?.low || 0"></span>
|
||||||
|
</a>
|
||||||
|
<p class="text-[11px] text-gray-400 pt-1">Click a row to open the priority-grouped queue.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Oldest Unassigned -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Oldest Unassigned</h3>
|
||||||
|
<div x-show="!oldestUnassigned" class="text-gray-400 text-sm py-8 text-center">No unassigned tickets</div>
|
||||||
|
<template x-if="oldestUnassigned">
|
||||||
|
<div>
|
||||||
|
<a :href="'/tickets/' + oldestUnassigned.id" class="block p-3 bg-orange-50 rounded-lg border border-orange-200 hover:bg-orange-100 transition">
|
||||||
|
<div class="font-medium text-sm" x-text="oldestUnassigned.ticket_number"></div>
|
||||||
|
<div class="text-xs text-gray-600 mt-1" x-text="oldestUnassigned.description?.substring(0, 80)"></div>
|
||||||
|
<div class="text-xs text-gray-400 mt-1">Waiting <span x-text="timeSince(oldestUnassigned.created_at)"></span></div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Escalated -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Escalated Tickets</h3>
|
||||||
|
<div class="text-3xl font-bold text-red-600 mb-3" x-text="kpi.escalatedCount || 0"></div>
|
||||||
|
<div class="space-y-2 max-h-48 overflow-y-auto">
|
||||||
|
<template x-for="ticket in escalatedTickets" :key="ticket.id">
|
||||||
|
<a :href="'/tickets/' + ticket.id" class="block p-2 bg-red-50 rounded border border-red-200 hover:bg-red-100 transition">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm font-medium" x-text="ticket.ticket_number"></span>
|
||||||
|
<span class="text-xs text-red-600 font-medium" x-text="ticket.priority"></span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 mt-0.5" x-text="ticket.description?.substring(0, 60)"></div>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
<div x-show="!escalatedTickets.length" class="text-gray-400 text-sm py-4 text-center">No escalated tickets</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Tickets Table -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||||
|
<div class="px-5 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Recent Tickets</h3>
|
||||||
|
<a href="/tickets" class="text-sm text-denya-600 hover:text-denya-800">View All →</a>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th class="px-5 py-3 text-left">Ticket</th>
|
||||||
|
<th class="px-5 py-3 text-left">Status</th>
|
||||||
|
<th class="px-5 py-3 text-left">Priority</th>
|
||||||
|
<th class="px-5 py-3 text-left">Description</th>
|
||||||
|
<th class="px-5 py-3 text-left">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
<template x-for="ticket in recentTickets" :key="ticket.id">
|
||||||
|
<tr class="hover:bg-gray-50 transition cursor-pointer" @click="window.location.href='/tickets/'+ticket.id">
|
||||||
|
<td class="px-5 py-3 font-medium text-denya-600" x-text="ticket.ticket_number"></td>
|
||||||
|
<td class="px-5 py-3"><span class="px-2 py-1 rounded-full text-xs font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span></td>
|
||||||
|
<td class="px-5 py-3"><span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></td>
|
||||||
|
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
|
||||||
|
<td class="px-5 py-3 text-gray-500 text-xs" x-text="formatDate(ticket.created_at)"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr x-show="!recentTickets.length">
|
||||||
|
<td colspan="5" class="px-5 py-12 text-center text-gray-400">No tickets found</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function csDashboard() {
|
||||||
|
return {
|
||||||
|
kpi: { byPriority: {} },
|
||||||
|
recentTickets: [],
|
||||||
|
escalatedTickets: [],
|
||||||
|
oldestUnassigned: null,
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.loadKPIs();
|
||||||
|
await this.loadRecentTickets();
|
||||||
|
await this.loadEscalated();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadKPIs() {
|
||||||
|
try {
|
||||||
|
const tickets = await app().apiGet('/api/tickets?page_size=200');
|
||||||
|
if (!tickets?.items) return;
|
||||||
|
const all = tickets.items;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// New today
|
||||||
|
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', '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', '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', 'Cancelled'].includes(t.status));
|
||||||
|
|
||||||
|
this.kpi = {
|
||||||
|
newToday,
|
||||||
|
openTickets: open.length,
|
||||||
|
byPriority,
|
||||||
|
escalatedCount: all.filter(t => t.status === 'Escalated').length,
|
||||||
|
avgResponseTime: this.calcAvgResponse(all),
|
||||||
|
slaCompliance: withSLA.length ? Math.round((slaMet.length / withSLA.length) * 100) : 100
|
||||||
|
};
|
||||||
|
} catch (e) { console.error('KPI load error', e); }
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadRecentTickets() {
|
||||||
|
try {
|
||||||
|
const data = await app().apiGet('/api/tickets?page_size=20');
|
||||||
|
this.recentTickets = data?.items || [];
|
||||||
|
} catch (e) { console.error('Recent tickets load error', e); }
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadEscalated() {
|
||||||
|
try {
|
||||||
|
const data = await app().apiGet('/api/tickets?status=Escalated&page_size=10');
|
||||||
|
this.escalatedTickets = data?.items || [];
|
||||||
|
} catch (e) { console.error('Escalated load error', e); }
|
||||||
|
},
|
||||||
|
|
||||||
|
calcAvgResponse(all) {
|
||||||
|
// Rough approximation — in real system this would come from timeline analysis
|
||||||
|
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);
|
||||||
|
return sum + Math.min(diff, 168); // cap at 1 week
|
||||||
|
}, 0) / open.length;
|
||||||
|
if (avgHrs < 1) return `${Math.round(avgHrs * 60)}m`;
|
||||||
|
return `${Math.round(avgHrs)}h`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div x-data="fmDashboard()" x-init="init()">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Facilities Management Dashboard</h1>
|
||||||
|
<p class="text-gray-500 mt-1">Welcome back, <span x-text="user.full_name"></span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Emergency Alert -->
|
||||||
|
<div x-show="emergencyCount > 0" class="mb-6 p-4 bg-red-50 border-2 border-red-300 rounded-xl flex items-center space-x-3">
|
||||||
|
<svg class="w-8 h-8 text-red-600 flex-shrink-0 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="font-bold text-red-800"><span x-text="emergencyCount"></span> Emergency <span x-text="emergencyCount === 1 ? 'Job' : 'Jobs'"></span> Require Immediate Attention!</p>
|
||||||
|
<p class="text-sm text-red-600">View in the table below</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI Cards -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 font-medium">Active Jobs</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.activeJobs || 0"></p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 font-medium">Due Today</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.dueToday || 0"></p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 font-medium">Waiting Parts</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.waitingParts || 0"></p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 font-medium">Jobs East / West</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 mt-1"><span x-text="kpi.eastJobs || 0"></span> / <span x-text="kpi.westJobs || 0"></span></p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<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"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Technician Workload + Aging Analysis -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
<!-- Tech Workload -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Technician Workload</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<template x-for="tech in techWorkload" :key="tech.id">
|
||||||
|
<div class="flex items-center justify-between p-2 hover:bg-gray-50 rounded">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="w-8 h-8 bg-denya-100 rounded-full flex items-center justify-center text-sm font-medium text-denya-700" x-text="tech.name.charAt(0)"></div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-700" x-text="tech.name"></p>
|
||||||
|
<p class="text-xs text-gray-400" x-text="tech.specialty || 'Technician'"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="text-lg font-bold" :class="tech.activeJobs > 3 ? 'text-red-600' : tech.activeJobs > 1 ? 'text-yellow-600' : 'text-green-600'" x-text="tech.activeJobs"></span>
|
||||||
|
<span class="text-xs text-gray-400">active</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div x-show="!techWorkload.length" class="text-gray-400 text-sm py-6 text-center">No active assignments</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aging Analysis -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Aging Analysis</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between p-3 bg-green-50 rounded-lg">
|
||||||
|
<span class="text-sm font-medium text-green-700">< 24h</span>
|
||||||
|
<span class="text-2xl font-bold text-green-700" x-text="aging.under24h || 0"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between p-3 bg-yellow-50 rounded-lg">
|
||||||
|
<span class="text-sm font-medium text-yellow-700">1-2 days</span>
|
||||||
|
<span class="text-2xl font-bold text-yellow-700" x-text="aging.oneToTwoDays || 0"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between p-3 bg-orange-50 rounded-lg">
|
||||||
|
<span class="text-sm font-medium text-orange-700">3-5 days</span>
|
||||||
|
<span class="text-2xl font-bold text-orange-700" x-text="aging.threeToFiveDays || 0"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between p-3 bg-red-50 rounded-lg">
|
||||||
|
<span class="text-sm font-medium text-red-700">Overdue > 5d</span>
|
||||||
|
<span class="text-2xl font-bold text-red-700" x-text="aging.overFiveDays || 0"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Tickets Table -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||||
|
<div class="px-5 py-4 border-b border-gray-200 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Active Tickets</h3>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<label class="flex items-center space-x-2 text-sm font-medium text-gray-700 cursor-pointer select-none">
|
||||||
|
<input type="checkbox" x-model="groupByPriority" class="rounded border-gray-300 text-denya-600 focus:ring-denya-500">
|
||||||
|
<span>Group by priority</span>
|
||||||
|
</label>
|
||||||
|
<button x-show="groupByPriority" @click="ageDir = ageDir === 'asc' ? 'desc' : 'asc'" class="px-2 py-1 text-xs border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-600" x-text="ageDir === 'desc' ? 'Newest first ↓' : 'Oldest first ↑'"></button>
|
||||||
|
<a href="/tickets" class="text-sm text-denya-600 hover:text-denya-800">View All →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto" x-show="!groupByPriority">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th class="px-5 py-3 text-left">Ticket</th>
|
||||||
|
<th class="px-5 py-3 text-left">Status</th>
|
||||||
|
<th class="px-5 py-3 text-left">Priority</th>
|
||||||
|
<th class="px-5 py-3 text-left">Technician</th>
|
||||||
|
<th class="px-5 py-3 text-left">Description</th>
|
||||||
|
<th class="px-5 py-3 text-left">Age</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
<template x-for="ticket in activeTickets" :key="ticket.id">
|
||||||
|
<tr class="hover:bg-gray-50 transition cursor-pointer" :class="ticket.priority === 'urgent' ? 'bg-red-50' : ''" @click="window.location.href='/tickets/'+ticket.id">
|
||||||
|
<td class="px-5 py-3 font-medium text-denya-600" x-text="ticket.ticket_number"></td>
|
||||||
|
<td class="px-5 py-3"><span class="px-2 py-1 rounded-full text-xs font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span></td>
|
||||||
|
<td class="px-5 py-3"><span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></td>
|
||||||
|
<td class="px-5 py-3 text-gray-600" x-text="ticket.assigned_technician_name || 'Unassigned'"></td>
|
||||||
|
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
|
||||||
|
<td class="px-5 py-3 text-xs font-medium" :class="timeSince(ticket.created_at).includes('d') && parseInt(timeSince(ticket.created_at)) > 3 ? 'text-red-600' : 'text-gray-500'" x-text="timeSince(ticket.created_at)"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr x-show="!activeTickets.length">
|
||||||
|
<td colspan="6" class="px-5 py-12 text-center text-gray-400">No active tickets</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 p-3" x-show="groupByPriority">
|
||||||
|
<template x-for="meta in priorityGroupMeta()" :key="meta.key">
|
||||||
|
<div x-show="(groupedActiveTickets[meta.key] || []).length" class="rounded-xl border border-gray-200 overflow-hidden">
|
||||||
|
<div class="px-4 py-2.5 flex items-center justify-between border-b border-gray-100" :class="meta.cls">
|
||||||
|
<span class="text-sm font-semibold text-gray-800" x-text="`${meta.label} (${groupedActiveTickets[meta.key].length})`"></span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
<template x-for="ticket in groupedActiveTickets[meta.key]" :key="ticket.id">
|
||||||
|
<tr class="hover:bg-gray-50 transition cursor-pointer" @click="window.location.href='/tickets/'+ticket.id">
|
||||||
|
<td class="px-5 py-3 font-medium text-denya-600" x-text="ticket.ticket_number"></td>
|
||||||
|
<td class="px-5 py-3"><span class="px-2 py-1 rounded-full text-xs font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span></td>
|
||||||
|
<td class="px-5 py-3"><span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></td>
|
||||||
|
<td class="px-5 py-3 text-gray-600" x-text="ticket.assigned_technician_name || 'Unassigned'"></td>
|
||||||
|
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
|
||||||
|
<td class="px-5 py-3 text-xs font-medium" :class="timeSince(ticket.created_at).includes('d') && parseInt(timeSince(ticket.created_at)) > 3 ? 'text-red-600' : 'text-gray-500'" x-text="timeSince(ticket.created_at)"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div x-show="!activeTickets.length" class="px-5 py-12 text-center text-gray-400">No active tickets</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function fmDashboard() {
|
||||||
|
return {
|
||||||
|
kpi: {},
|
||||||
|
techWorkload: [],
|
||||||
|
aging: {},
|
||||||
|
activeTickets: [],
|
||||||
|
emergencyCount: 0,
|
||||||
|
groupByPriority: false,
|
||||||
|
ageDir: 'desc',
|
||||||
|
|
||||||
|
get groupedActiveTickets() {
|
||||||
|
return app().groupByPriority(this.activeTickets, this.ageDir);
|
||||||
|
},
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.loadData();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
try {
|
||||||
|
const data = await app().apiGet('/api/tickets?page_size=200');
|
||||||
|
if (!data?.items) return;
|
||||||
|
const all = data.items;
|
||||||
|
|
||||||
|
// Active: not closed/completed
|
||||||
|
const active = all.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status));
|
||||||
|
this.activeTickets = active;
|
||||||
|
|
||||||
|
// KPIs
|
||||||
|
this.kpi.activeJobs = active.length;
|
||||||
|
this.kpi.waitingParts = active.filter(t => t.status === 'Waiting Parts').length;
|
||||||
|
|
||||||
|
// Due today
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(23, 59, 59, 0);
|
||||||
|
const dueToday = active.filter(t => {
|
||||||
|
if (!t.sla_deadline) return false;
|
||||||
|
const deadline = new Date(t.sla_deadline);
|
||||||
|
return deadline <= today;
|
||||||
|
});
|
||||||
|
this.kpi.dueToday = dueToday.length;
|
||||||
|
|
||||||
|
// Emergency
|
||||||
|
this.emergencyCount = active.filter(t => t.priority === 'urgent').length;
|
||||||
|
|
||||||
|
// East vs West — compute from loaded tickets using unit map
|
||||||
|
try {
|
||||||
|
const units = await app().apiGet('/api/tickets/units');
|
||||||
|
const unitMap = {};
|
||||||
|
if (units) units.forEach(u => { unitMap[u.id] = u.property; });
|
||||||
|
this.kpi.eastJobs = active.filter(t => t.unit_id && unitMap[t.unit_id] === 'East').length;
|
||||||
|
this.kpi.westJobs = active.filter(t => t.unit_id && unitMap[t.unit_id] === 'West').length;
|
||||||
|
} catch (e) { console.error('Property stats error', e); }
|
||||||
|
|
||||||
|
// Tech workload (simulated from assigned_to counts)
|
||||||
|
const techMap = {};
|
||||||
|
active.forEach(t => {
|
||||||
|
if (t.assigned_to) {
|
||||||
|
if (!techMap[t.assigned_to]) techMap[t.assigned_to] = { id: t.assigned_to, name: `Tech #${t.assigned_to}`, activeJobs: 0 };
|
||||||
|
techMap[t.assigned_to].activeJobs++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.techWorkload = Object.values(techMap).sort((a, b) => b.activeJobs - a.activeJobs);
|
||||||
|
|
||||||
|
// Aging
|
||||||
|
const now = new Date();
|
||||||
|
this.aging = {
|
||||||
|
under24h: active.filter(t => (now - new Date(t.created_at)) < 24 * 60 * 60 * 1000).length,
|
||||||
|
oneToTwoDays: active.filter(t => {
|
||||||
|
const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24);
|
||||||
|
return diff >= 1 && diff < 3;
|
||||||
|
}).length,
|
||||||
|
threeToFiveDays: active.filter(t => {
|
||||||
|
const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24);
|
||||||
|
return diff >= 3 && diff < 5;
|
||||||
|
}).length,
|
||||||
|
overFiveDays: active.filter(t => (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24) >= 5).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (e) { console.error('FM data load error', e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="min-h-[80vh] flex items-center justify-center">
|
||||||
|
<div class="w-full max-w-md" x-data="loginForm()">
|
||||||
|
<div class="bg-white rounded-2xl shadow-lg p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="mx-auto w-16 h-16 bg-denya-100 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<svg class="w-8 h-8 text-denya-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<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"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Denya OneCare</h1>
|
||||||
|
<p class="text-gray-500 mt-1">Sign in to your dashboard</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="submitLogin" class="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input type="email" x-model="email" required
|
||||||
|
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none transition"
|
||||||
|
placeholder="you@example.com">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||||
|
<input type="password" x-model="password" required
|
||||||
|
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none transition"
|
||||||
|
placeholder="••••••••">
|
||||||
|
</div>
|
||||||
|
<div x-show="error" class="text-red-600 text-sm bg-red-50 p-3 rounded-lg" x-text="error"></div>
|
||||||
|
<button type="submit" :disabled="submitting"
|
||||||
|
class="w-full py-2.5 px-4 bg-denya-600 hover:bg-denya-700 text-white font-medium rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2">
|
||||||
|
<svg x-show="submitting" class="animate-spin h-5 w-5" 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>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
<span x-text="submitting ? 'Signing in...' : 'Sign In'"></span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<p class="text-xs text-gray-400 text-center">
|
||||||
|
<strong>Demo Accounts:</strong><br>
|
||||||
|
bella@denya.com / denya123 — CS Rep<br>
|
||||||
|
nicholas@denya.com / denya123 — FM Dispatcher<br>
|
||||||
|
scott@denya.com / denya123 — CEO<br>
|
||||||
|
jerome@denya.com / denya123 — Admin
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function loginForm() {
|
||||||
|
return {
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
error: '',
|
||||||
|
submitting: false,
|
||||||
|
async submitLogin() {
|
||||||
|
this.error = '';
|
||||||
|
this.submitting = true;
|
||||||
|
try {
|
||||||
|
await app().login(this.email, this.password);
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message || 'Invalid credentials. Please try again.';
|
||||||
|
} finally {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div x-data="ticketDetail()" x-init="init()">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div x-show="loading && !ticket" class="flex items-center justify-center py-20">
|
||||||
|
<svg class="animate-spin h-8 w-8 text-denya-600" 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>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="ticket">
|
||||||
|
<div>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<a href="/tickets" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
|
||||||
|
</a>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900" x-text="ticket.ticket_number"></h1>
|
||||||
|
<span class="px-3 py-1 rounded-full text-sm font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span>
|
||||||
|
<span class="px-3 py-1 rounded text-sm font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-500 mt-1">Created <span x-text="formatDate(ticket.created_at)"></span></p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button @click="showStatusModal = true" class="px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition text-sm font-medium">Update Status</button>
|
||||||
|
<button @click="showAssignModal = true" x-show="isFM || isAdmin" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition text-sm font-medium">Assign</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main grid -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- Left column: Details -->
|
||||||
|
<div class="lg:col-span-2 space-y-6">
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Description</h3>
|
||||||
|
<p class="text-gray-700 whitespace-pre-wrap" x-text="ticket.description || 'No description provided'"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Timeline</h3>
|
||||||
|
<div class="space-y-0">
|
||||||
|
<template x-for="(entry, idx) in ticket.timeline" :key="entry.id">
|
||||||
|
<div class="relative pl-8 pb-6" :class="idx === ticket.timeline.length - 1 ? '' : ''">
|
||||||
|
<!-- Timeline connector -->
|
||||||
|
<div class="absolute left-3 top-1 bottom-0 w-0.5 bg-gray-200" x-show="idx < ticket.timeline.length - 1"></div>
|
||||||
|
<!-- Dot -->
|
||||||
|
<div class="absolute left-1.5 top-1 w-3 h-3 rounded-full border-2" :class="getTimelineColor(entry)"></div>
|
||||||
|
|
||||||
|
<div class="bg-gray-50 rounded-lg p-3">
|
||||||
|
<div class="flex items-center space-x-2 text-sm">
|
||||||
|
<template x-if="entry.from_status">
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium" :class="statusClass(entry.from_status)" x-text="entry.from_status"></span>
|
||||||
|
</template>
|
||||||
|
<template x-if="entry.from_status">
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||||
|
</template>
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium" :class="statusClass(entry.to_status)" x-text="entry.to_status"></span>
|
||||||
|
<span class="text-xs text-gray-400" x-text="formatDate(entry.created_at)"></span>
|
||||||
|
</div>
|
||||||
|
<p x-show="entry.note" class="text-sm text-gray-600 mt-1" x-text="entry.note"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div x-show="!ticket.timeline?.length" class="text-gray-400 text-sm py-6 text-center">No timeline entries</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photos -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Photos</h3>
|
||||||
|
<div x-show="!ticket.photos?.length" class="text-gray-400 text-sm py-6 text-center">No photos uploaded</div>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<template x-for="photo in ticket.photos" :key="photo.id">
|
||||||
|
<div class="rounded-lg overflow-hidden border border-gray-200">
|
||||||
|
<img :src="photo.photo_url" :alt="photo.is_before ? 'Before' : 'After'" class="w-full h-40 object-cover cursor-pointer hover:opacity-90 transition" @click="showPhoto(photo.photo_url)">
|
||||||
|
<div class="px-2 py-1 text-xs font-medium" :class="photo.is_before ? 'text-blue-600 bg-blue-50' : 'text-green-600 bg-green-50'" x-text="photo.is_before ? 'Before' : 'After'"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<!-- Upload more photos -->
|
||||||
|
<div class="mt-4" x-show="isFM || isCS || isAdmin">
|
||||||
|
<label class="inline-flex items-center space-x-2 text-sm text-denya-600 hover:text-denya-800 cursor-pointer">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||||
|
<span>Add Photos</span>
|
||||||
|
<input type="file" multiple accept="image/*" @change="uploadPhotos($event)" class="hidden">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column: Metadata -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Ticket Info -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Ticket Info</h3>
|
||||||
|
<dl class="space-y-3">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-sm text-gray-500">Unit</dt>
|
||||||
|
<dd class="text-sm font-medium text-gray-900" x-text="ticket.unit?.apartment_code || '—'"></dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-sm text-gray-500">Property</dt>
|
||||||
|
<dd class="text-sm font-medium text-gray-900" x-text="ticket.unit?.property || '—'"></dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-sm text-gray-500">Category</dt>
|
||||||
|
<dd class="text-sm font-medium text-gray-900" x-text="ticket.category?.name || '—'"></dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-sm text-gray-500">Assigned To</dt>
|
||||||
|
<dd class="text-sm font-medium text-gray-900" x-text="ticket.assigned_technician_name || 'Unassigned'"></dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-sm text-gray-500">Priority</dt>
|
||||||
|
<dd><span class="px-2 py-0.5 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-sm text-gray-500">SLA Deadline</dt>
|
||||||
|
<dd class="text-sm font-medium" :class="ticket.sla_status?.resolution_breached ? 'text-red-600' : 'text-gray-900'" x-text="ticket.sla_deadline ? formatDate(ticket.sla_deadline) : '—'"></dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-sm text-gray-500">Reopen Count</dt>
|
||||||
|
<dd class="text-sm font-medium text-gray-900" x-text="ticket.reopen_count || 0"></dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between" x-show="ticket.cost">
|
||||||
|
<dt class="text-sm text-gray-500">Cost</dt>
|
||||||
|
<dd class="text-sm font-medium text-gray-900" x-text="'$' + ticket.cost"></dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SLA Status -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5" x-show="ticket.sla_status">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">SLA Status</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-500">Response</span>
|
||||||
|
<span class="flex items-center space-x-1">
|
||||||
|
<span class="w-2 h-2 rounded-full" :class="ticket.sla_status?.response_breached ? 'bg-red-500' : 'bg-green-500'"></span>
|
||||||
|
<span class="text-sm font-medium" :class="ticket.sla_status?.response_breached ? 'text-red-600' : 'text-green-600'" x-text="ticket.sla_status?.response_breached ? 'Breached' : 'OK'"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-500">Resolution</span>
|
||||||
|
<span class="flex items-center space-x-1">
|
||||||
|
<span class="w-2 h-2 rounded-full" :class="ticket.sla_status?.resolution_breached ? 'bg-red-500' : 'bg-green-500'"></span>
|
||||||
|
<span class="text-sm font-medium" :class="ticket.sla_status?.resolution_breached ? 'text-red-600' : 'text-green-600'" x-text="ticket.sla_status?.resolution_breached ? 'Breached' : 'OK'"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div x-show="ticket.sla_status?.response_deadline">
|
||||||
|
<p class="text-xs text-gray-400">Response by: <span x-text="formatDate(ticket.sla_status.response_deadline)"></span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Quick Actions</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<button @click="showStatusModal = true" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded transition flex items-center space-x-2">
|
||||||
|
<svg class="w-4 h-4 text-denya-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||||
|
<span>Change Status</span>
|
||||||
|
</button>
|
||||||
|
<button @click="showAssignModal = true" x-show="isFM || isAdmin" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded transition flex items-center space-x-2">
|
||||||
|
<svg class="w-4 h-4 text-denya-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||||
|
<span>Assign Technician</span>
|
||||||
|
</button>
|
||||||
|
<button @click="showNoteModal = true" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded transition flex items-center space-x-2">
|
||||||
|
<svg class="w-4 h-4 text-denya-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||||
|
<span>Add Note</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Status Update Modal -->
|
||||||
|
<div x-show="showStatusModal" class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center" x-cloak @click.away="showStatusModal = false">
|
||||||
|
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md mx-4" @click.stop>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 mb-4">Update Status</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Current: <span class="font-bold" x-text="ticket.status"></span></label>
|
||||||
|
<select x-model="statusForm.newStatus" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 outline-none">
|
||||||
|
<option value="">Select new status</option>
|
||||||
|
<template x-for="target in availableTransitions" :key="target">
|
||||||
|
<option :value="target" x-text="target"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Note (optional)</label>
|
||||||
|
<textarea x-model="statusForm.note" rows="3" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 outline-none" placeholder="Add a note about this status change..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div x-show="statusError" class="text-sm text-red-600 bg-red-50 p-3 rounded-lg" x-text="statusError"></div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<button @click="submitStatusUpdate" :disabled="statusSubmitting" class="flex-1 px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition font-medium disabled:opacity-50">
|
||||||
|
<span x-text="statusSubmitting ? 'Updating...' : 'Update Status'"></span>
|
||||||
|
</button>
|
||||||
|
<button @click="showStatusModal = false" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assign Modal -->
|
||||||
|
<div x-show="showAssignModal" class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center" x-cloak @click.away="showAssignModal = false">
|
||||||
|
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md mx-4" @click.stop>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 mb-4">Assign Technician</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Technician</label>
|
||||||
|
<select x-model="assignForm.technicianId" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 outline-none">
|
||||||
|
<option value="">Select technician...</option>
|
||||||
|
<template x-for="tech in technicians" :key="tech.id">
|
||||||
|
<option :value="tech.id" x-text="tech.full_name"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div x-show="assignError" class="text-sm text-red-600 bg-red-50 p-3 rounded-lg" x-text="assignError"></div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<button @click="submitAssign" :disabled="assignSubmitting" class="flex-1 px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition font-medium disabled:opacity-50">
|
||||||
|
<span x-text="assignSubmitting ? 'Assigning...' : 'Assign'"></span>
|
||||||
|
</button>
|
||||||
|
<button @click="showAssignModal = false" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note Modal -->
|
||||||
|
<div x-show="showNoteModal" class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center" x-cloak @click.away="showNoteModal = false">
|
||||||
|
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md mx-4" @click.stop>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 mb-4">Add Note</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Note</label>
|
||||||
|
<textarea x-model="noteForm.note" rows="4" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 outline-none" placeholder="Enter your note..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<button @click="submitNote" :disabled="noteSubmitting" class="flex-1 px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition font-medium disabled:opacity-50">
|
||||||
|
<span x-text="noteSubmitting ? 'Saving...' : 'Save Note'"></span>
|
||||||
|
</button>
|
||||||
|
<button @click="showNoteModal = false" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photo Lightbox -->
|
||||||
|
<div x-show="lightboxUrl" class="fixed inset-0 bg-black bg-opacity-80 z-50 flex items-center justify-center" x-cloak @click.away="lightboxUrl = ''" @click="lightboxUrl = ''">
|
||||||
|
<img :src="lightboxUrl" class="max-w-full max-h-full p-4">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function ticketDetail() {
|
||||||
|
return {
|
||||||
|
ticket: null,
|
||||||
|
ticketId: {{ ticket_id }},
|
||||||
|
loading: true,
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
showStatusModal: false,
|
||||||
|
showAssignModal: false,
|
||||||
|
showNoteModal: false,
|
||||||
|
lightboxUrl: '',
|
||||||
|
|
||||||
|
// Status form
|
||||||
|
statusForm: { newStatus: '', note: '' },
|
||||||
|
statusSubmitting: false,
|
||||||
|
statusError: '',
|
||||||
|
availableTransitions: [],
|
||||||
|
|
||||||
|
// Assign form
|
||||||
|
assignForm: { technicianId: '' },
|
||||||
|
assignSubmitting: false,
|
||||||
|
assignError: '',
|
||||||
|
technicians: [],
|
||||||
|
|
||||||
|
// Note form
|
||||||
|
noteForm: { note: '' },
|
||||||
|
noteSubmitting: false,
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.loadTicket();
|
||||||
|
await this.loadTransitions();
|
||||||
|
if (app().isFM || app().isAdmin) {
|
||||||
|
await this.loadTechnicians();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadTicket() {
|
||||||
|
try {
|
||||||
|
this.ticket = await app().apiGet(`/api/tickets/${this.ticketId}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ticket load error', e);
|
||||||
|
this.loading = false;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadTransitions() {
|
||||||
|
try {
|
||||||
|
const data = await app().apiGet(`/api/tickets/${this.ticketId}/transitions`);
|
||||||
|
this.availableTransitions = (data && data.transitions) || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Transitions load error', e);
|
||||||
|
this.availableTransitions = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadTechnicians() {
|
||||||
|
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() {
|
||||||
|
this.statusError = '';
|
||||||
|
if (!this.statusForm.newStatus) {
|
||||||
|
this.statusError = 'Please select a status';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.statusSubmitting = true;
|
||||||
|
try {
|
||||||
|
const payload = { status: this.statusForm.newStatus };
|
||||||
|
if (this.statusForm.note) payload.note = this.statusForm.note;
|
||||||
|
|
||||||
|
const updated = await app().apiPatch(`/api/tickets/${this.ticketId}`, payload);
|
||||||
|
this.ticket = updated;
|
||||||
|
await this.loadTransitions();
|
||||||
|
this.showStatusModal = false;
|
||||||
|
this.statusForm = { newStatus: '', note: '' };
|
||||||
|
app().showToast('Status updated', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
this.statusError = e.message;
|
||||||
|
} finally {
|
||||||
|
this.statusSubmitting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitAssign() {
|
||||||
|
this.assignError = '';
|
||||||
|
if (!this.assignForm.technicianId) {
|
||||||
|
this.assignError = 'Please select a technician';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.assignSubmitting = true;
|
||||||
|
try {
|
||||||
|
const updated = await app().apiPatch(`/api/tickets/${this.ticketId}`, {
|
||||||
|
assigned_to: parseInt(this.assignForm.technicianId)
|
||||||
|
});
|
||||||
|
this.ticket = updated;
|
||||||
|
await this.loadTransitions();
|
||||||
|
this.showAssignModal = false;
|
||||||
|
app().showToast('Technician assigned', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
this.assignError = e.message;
|
||||||
|
} finally {
|
||||||
|
this.assignSubmitting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitNote() {
|
||||||
|
if (!this.noteForm.note.trim()) return;
|
||||||
|
this.noteSubmitting = true;
|
||||||
|
try {
|
||||||
|
// Use PATCH endpoint which now supports note-only updates
|
||||||
|
const updated = await app().apiPatch(`/api/tickets/${this.ticketId}`, {
|
||||||
|
note: this.noteForm.note
|
||||||
|
});
|
||||||
|
this.ticket = updated;
|
||||||
|
this.showNoteModal = false;
|
||||||
|
this.noteForm.note = '';
|
||||||
|
app().showToast('Note added', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
// No need to show error here — api() base method already shows it
|
||||||
|
} finally {
|
||||||
|
this.noteSubmitting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadPhotos(e) {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
if (!files.length) return;
|
||||||
|
app().loading = true;
|
||||||
|
app().loadingMessage = 'Uploading photos...';
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
files.forEach(f => formData.append('files', f));
|
||||||
|
const res = await fetch(`/api/tickets/${this.ticketId}/photos?is_before=true`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${app().token}` },
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
await this.loadTicket();
|
||||||
|
app().showToast('Photos uploaded', 'success');
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({ detail: 'Upload failed' }));
|
||||||
|
app().showToast(err.detail, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
app().showToast('Photo upload failed', 'error');
|
||||||
|
} finally {
|
||||||
|
app().loading = false;
|
||||||
|
app().loadingMessage = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
showPhoto(url) {
|
||||||
|
this.lightboxUrl = url;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTimelineColor(entry) {
|
||||||
|
const colors = {
|
||||||
|
'new': 'border-blue-500 bg-blue-500',
|
||||||
|
'completed': 'border-green-500 bg-green-500',
|
||||||
|
'closed': 'border-gray-500 bg-gray-500',
|
||||||
|
'escalated': 'border-red-500 bg-red-500',
|
||||||
|
};
|
||||||
|
const toStatus = entry.to_status?.toLowerCase() || '';
|
||||||
|
return colors[toStatus] || 'border-denya-500 bg-denya-500';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div x-data="ticketList()" x-init="init()">
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">All Issues</h1>
|
||||||
|
<p class="text-gray-500 mt-1" x-text="`${total} total tickets`"></p>
|
||||||
|
</div>
|
||||||
|
<a href="/tickets/new" class="px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition text-sm font-medium flex items-center space-x-1">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||||
|
<span>New Issue</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-8 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 font-medium mb-1">Search</label>
|
||||||
|
<input type="text" x-model="filters.search" @input.debounce="loadTickets()" placeholder="Ticket # or description..." class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 font-medium mb-1">Status</label>
|
||||||
|
<select x-model="filters.status" @change="loadTickets()" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="New">New</option>
|
||||||
|
<option value="Logged">Logged</option>
|
||||||
|
<option value="Triage">Triage</option>
|
||||||
|
<option value="Assigned">Assigned</option>
|
||||||
|
<option value="Accepted">Accepted</option>
|
||||||
|
<option value="Travelling">Travelling</option>
|
||||||
|
<option value="On Site">On Site</option>
|
||||||
|
<option value="In Progress">In Progress</option>
|
||||||
|
<option value="Waiting Parts">Waiting Parts</option>
|
||||||
|
<option value="Escalated">Escalated</option>
|
||||||
|
<option value="Completed">Completed</option>
|
||||||
|
<option value="On-Field Verification">On-Field Verification</option>
|
||||||
|
<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>
|
||||||
|
<label class="block text-xs text-gray-500 font-medium mb-1">Priority</label>
|
||||||
|
<select x-model="filters.priority" @change="loadTickets()" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none">
|
||||||
|
<option value="">All Priorities</option>
|
||||||
|
<option value="urgent">🔴 Urgent</option>
|
||||||
|
<option value="high">🟠 High</option>
|
||||||
|
<option value="medium">🟡 Medium</option>
|
||||||
|
<option value="low">🟢 Low</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 font-medium mb-1">Property</label>
|
||||||
|
<select x-model="filters.property" @change="onPropertyChange()" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none">
|
||||||
|
<option value="">All Properties</option>
|
||||||
|
<option value="East">Pavilion East</option>
|
||||||
|
<option value="West">Pavilion West</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 font-medium mb-1">Building</label>
|
||||||
|
<select x-model="filters.building" @change="onBuildingChange()" :disabled="!filters.property" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none disabled:bg-gray-50 disabled:text-gray-400">
|
||||||
|
<option value="">All Buildings</option>
|
||||||
|
<template x-for="b in buildings" :key="b">
|
||||||
|
<option :value="b" x-text="b"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<label class="block text-xs text-gray-500 font-medium mb-1">Apartment</label>
|
||||||
|
<input type="text" x-model="searchApartment" @focus="apartmentOpen = true" @input="apartmentOpen = true" @keydown.escape="apartmentOpen = false"
|
||||||
|
:disabled="!filters.building" @blur="closeApartment()"
|
||||||
|
:placeholder="filterUnit ? filterUnit.apartment_code : (filters.building ? 'Search apartment...' : 'Select building')"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none disabled:bg-gray-50 disabled:text-gray-400">
|
||||||
|
<div x-show="apartmentOpen && filteredApartments.length" class="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-40 overflow-y-auto">
|
||||||
|
<template x-for="unit in filteredApartments" :key="unit.id">
|
||||||
|
<button type="button" @mousedown.prevent="selectFilterUnit(unit)" class="block w-full text-left px-3 py-1.5 hover:bg-denya-50 text-xs">
|
||||||
|
<span class="font-medium text-gray-800" x-text="unit.apartment_code"></span>
|
||||||
|
<span class="text-gray-400" x-text="` · Floor ${unit.floor || '—'}`"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div x-show="apartmentOpen && !filteredApartments.length && filters.building" class="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg px-3 py-2 text-xs text-gray-400">
|
||||||
|
No apartments match
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 font-medium mb-1">From</label>
|
||||||
|
<input type="date" x-model="filters.dateFrom" @change="loadTickets()" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 font-medium mb-1">To</label>
|
||||||
|
<input type="date" x-model="filters.dateTo" @change="loadTickets()" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<label class="flex items-center space-x-2 text-sm font-medium text-gray-700 cursor-pointer select-none">
|
||||||
|
<input type="checkbox" x-model="groupByPriority" @change="syncUrl()" class="rounded border-gray-300 text-denya-600 focus:ring-denya-500">
|
||||||
|
<span>Group by priority</span>
|
||||||
|
</label>
|
||||||
|
<div x-show="groupByPriority" class="flex items-center space-x-2 text-xs">
|
||||||
|
<button @click="ageDir = ageDir === 'asc' ? 'desc' : 'asc'" class="px-2 py-1 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-600" x-text="ageDir === 'desc' ? 'Newest first ↓' : 'Oldest first ↑'"></button>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-500">Page <span x-text="page"></span> of <span x-text="totalPages"></span></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button @click="page > 1 && (page--, loadTickets())" :disabled="page <= 1" class="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">Prev</button>
|
||||||
|
<button @click="page < totalPages && (page++, loadTickets())" :disabled="page >= totalPages" class="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">Next</button>
|
||||||
|
<button @click="clearFilters()" class="px-3 py-1 text-sm text-gray-500 border rounded hover:bg-gray-50">Clear Filters</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tickets Table (flat) -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200" x-show="!groupByPriority">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th class="px-5 py-3 text-left cursor-pointer hover:text-gray-900" @click="sortBy('ticket_number')">
|
||||||
|
Ticket <span x-show="sortField === 'ticket_number'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
||||||
|
</th>
|
||||||
|
<th class="px-5 py-3 text-left">Status</th>
|
||||||
|
<th class="px-5 py-3 text-left">Priority</th>
|
||||||
|
<th class="px-5 py-3 text-left">Description</th>
|
||||||
|
<th class="px-5 py-3 text-left">Assigned To</th>
|
||||||
|
<th class="px-5 py-3 text-left cursor-pointer hover:text-gray-900" @click="sortBy('created_at')">
|
||||||
|
Created <span x-show="sortField === 'created_at'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
||||||
|
</th>
|
||||||
|
<th class="px-5 py-3 text-left">SLA</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
<template x-for="ticket in tickets" :key="ticket.id">
|
||||||
|
<tr class="hover:bg-gray-50 transition cursor-pointer" :class="ticket.priority === 'urgent' ? 'bg-red-50/50' : ''" @click="window.location.href='/tickets/'+ticket.id">
|
||||||
|
<td class="px-5 py-3 font-medium text-denya-600" x-text="ticket.ticket_number"></td>
|
||||||
|
<td class="px-5 py-3"><span class="px-2 py-1 rounded-full text-xs font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span></td>
|
||||||
|
<td class="px-5 py-3"><span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></td>
|
||||||
|
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
|
||||||
|
<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','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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr x-show="!tickets.length && !loading">
|
||||||
|
<td colspan="7" class="px-5 py-16 text-center text-gray-400">
|
||||||
|
<svg class="w-12 h-12 mx-auto text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
|
||||||
|
No tickets match your filters
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tickets grouped by priority -->
|
||||||
|
<div class="space-y-4" x-show="groupByPriority">
|
||||||
|
<template x-for="meta in priorityGroupMeta()" :key="meta.key">
|
||||||
|
<div x-show="(groupedTickets[meta.key] || []).length" class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="px-5 py-3 flex items-center justify-between border-b border-gray-100" :class="meta.cls">
|
||||||
|
<span class="text-sm font-semibold text-gray-800" x-text="`${meta.label} (${groupedTickets[meta.key].length})`"></span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th class="px-5 py-3 text-left">Ticket</th>
|
||||||
|
<th class="px-5 py-3 text-left">Status</th>
|
||||||
|
<th class="px-5 py-3 text-left">Priority</th>
|
||||||
|
<th class="px-5 py-3 text-left">Description</th>
|
||||||
|
<th class="px-5 py-3 text-left">Assigned To</th>
|
||||||
|
<th class="px-5 py-3 text-left">Created</th>
|
||||||
|
<th class="px-5 py-3 text-left">SLA</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
<template x-for="ticket in groupedTickets[meta.key]" :key="ticket.id">
|
||||||
|
<tr class="hover:bg-gray-50 transition cursor-pointer" @click="window.location.href='/tickets/'+ticket.id">
|
||||||
|
<td class="px-5 py-3 font-medium text-denya-600" x-text="ticket.ticket_number"></td>
|
||||||
|
<td class="px-5 py-3"><span class="px-2 py-1 rounded-full text-xs font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span></td>
|
||||||
|
<td class="px-5 py-3"><span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></td>
|
||||||
|
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
|
||||||
|
<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','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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div x-show="!groupedTotal && !loading" class="bg-white rounded-xl shadow-sm border border-gray-200 px-5 py-16 text-center text-gray-400">
|
||||||
|
No tickets match your filters
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function ticketList() {
|
||||||
|
return {
|
||||||
|
tickets: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
totalPages: 1,
|
||||||
|
sortField: 'created_at',
|
||||||
|
sortDir: 'desc',
|
||||||
|
groupByPriority: false,
|
||||||
|
ageDir: 'desc',
|
||||||
|
unitGroups: {},
|
||||||
|
buildings: [],
|
||||||
|
searchApartment: '',
|
||||||
|
apartmentOpen: false,
|
||||||
|
filterUnit: null,
|
||||||
|
filters: {
|
||||||
|
search: '',
|
||||||
|
status: '',
|
||||||
|
priority: '',
|
||||||
|
property: '',
|
||||||
|
building: '',
|
||||||
|
unitId: '',
|
||||||
|
dateFrom: '',
|
||||||
|
dateTo: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.applyUrlParams();
|
||||||
|
await this.loadUnits();
|
||||||
|
await this.loadTickets();
|
||||||
|
},
|
||||||
|
|
||||||
|
applyUrlParams() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('group') === 'priority') this.groupByPriority = true;
|
||||||
|
if (params.get('priority')) this.filters.priority = params.get('priority');
|
||||||
|
if (params.get('property')) this.filters.property = params.get('property');
|
||||||
|
if (params.get('building')) this.filters.building = params.get('building');
|
||||||
|
if (params.get('unit_id')) this.filters.unitId = params.get('unit_id');
|
||||||
|
},
|
||||||
|
|
||||||
|
syncUrl() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (this.groupByPriority) params.set('group', 'priority');
|
||||||
|
if (this.filters.priority) params.set('priority', this.filters.priority);
|
||||||
|
if (this.filters.property) params.set('property', this.filters.property);
|
||||||
|
if (this.filters.building) params.set('building', this.filters.building);
|
||||||
|
if (this.filters.unitId) params.set('unit_id', this.filters.unitId);
|
||||||
|
const qs = params.toString();
|
||||||
|
const url = qs ? `/tickets?${qs}` : '/tickets';
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadUnits() {
|
||||||
|
try {
|
||||||
|
const data = await app().apiGet('/api/tickets/units/grouped');
|
||||||
|
this.unitGroups = data || {};
|
||||||
|
if (this.filters.property) this.buildings = Object.keys(this.unitGroups[this.filters.property] || {});
|
||||||
|
if (this.filters.unitId) {
|
||||||
|
// Restore displayed apartment for a unit_id deep link
|
||||||
|
Object.values(this.unitGroups).forEach(prop => Object.values(prop).forEach(units => {
|
||||||
|
const u = (units || []).find(x => x.id == this.filters.unitId);
|
||||||
|
if (u) this.filterUnit = u;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('Units load error', e); }
|
||||||
|
},
|
||||||
|
|
||||||
|
onPropertyChange() {
|
||||||
|
this.filters.building = '';
|
||||||
|
this.filters.unitId = '';
|
||||||
|
this.filterUnit = null;
|
||||||
|
this.searchApartment = '';
|
||||||
|
this.buildings = this.filters.property ? Object.keys(this.unitGroups[this.filters.property] || {}) : [];
|
||||||
|
this.loadTickets();
|
||||||
|
this.syncUrl();
|
||||||
|
},
|
||||||
|
|
||||||
|
onBuildingChange() {
|
||||||
|
this.filters.unitId = '';
|
||||||
|
this.filterUnit = null;
|
||||||
|
this.searchApartment = '';
|
||||||
|
this.loadTickets();
|
||||||
|
this.syncUrl();
|
||||||
|
},
|
||||||
|
|
||||||
|
get buildingUnits() {
|
||||||
|
if (!this.filters.property || !this.filters.building) return [];
|
||||||
|
return this.unitGroups[this.filters.property]?.[this.filters.building] || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
get filteredApartments() {
|
||||||
|
const q = (this.searchApartment || '').toLowerCase().trim();
|
||||||
|
let units = this.buildingUnits;
|
||||||
|
if (q) {
|
||||||
|
units = units.filter(u =>
|
||||||
|
(u.apartment_code || '').toLowerCase().includes(q) ||
|
||||||
|
String(u.floor || '').includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return units;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeApartment() {
|
||||||
|
setTimeout(() => { this.apartmentOpen = false; }, 150);
|
||||||
|
},
|
||||||
|
|
||||||
|
selectFilterUnit(unit) {
|
||||||
|
this.filterUnit = unit;
|
||||||
|
this.filters.unitId = unit.id;
|
||||||
|
this.searchApartment = unit.apartment_code;
|
||||||
|
this.apartmentOpen = false;
|
||||||
|
this.loadTickets();
|
||||||
|
this.syncUrl();
|
||||||
|
},
|
||||||
|
|
||||||
|
get groupedTickets() {
|
||||||
|
return app().groupByPriority(this.tickets, this.ageDir);
|
||||||
|
},
|
||||||
|
|
||||||
|
get groupedTotal() {
|
||||||
|
return Object.values(this.groupedTickets).reduce((n, g) => n + g.length, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadTickets() {
|
||||||
|
try {
|
||||||
|
let url = `/api/tickets?page=${this.page}&page_size=${this.pageSize}`;
|
||||||
|
if (this.filters.status) url += `&status=${encodeURIComponent(this.filters.status)}`;
|
||||||
|
if (this.filters.priority) url += `&priority=${encodeURIComponent(this.filters.priority)}`;
|
||||||
|
if (this.filters.property) url += `&property=${encodeURIComponent(this.filters.property)}`;
|
||||||
|
if (this.filters.building) url += `&building=${encodeURIComponent(this.filters.building)}`;
|
||||||
|
if (this.filters.unitId) url += `&unit_id=${encodeURIComponent(this.filters.unitId)}`;
|
||||||
|
if (this.filters.dateFrom) url += `&date_from=${encodeURIComponent(this.filters.dateFrom)}`;
|
||||||
|
if (this.filters.dateTo) url += `&date_to=${encodeURIComponent(this.filters.dateTo)}`;
|
||||||
|
|
||||||
|
const data = await app().apiGet(url);
|
||||||
|
if (data) {
|
||||||
|
this.tickets = data.items || [];
|
||||||
|
this.total = data.total || 0;
|
||||||
|
this.totalPages = Math.ceil(this.total / this.pageSize) || 1;
|
||||||
|
|
||||||
|
// Client-side search filter for ticket number or description
|
||||||
|
if (this.filters.search) {
|
||||||
|
const q = this.filters.search.toLowerCase();
|
||||||
|
this.tickets = this.tickets.filter(t =>
|
||||||
|
(t.ticket_number && t.ticket_number.toLowerCase().includes(q)) ||
|
||||||
|
(t.description && t.description.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
this.applySort();
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('Tickets load error', e); }
|
||||||
|
},
|
||||||
|
|
||||||
|
sortBy(field) {
|
||||||
|
if (this.sortField === field) {
|
||||||
|
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
this.sortField = field;
|
||||||
|
this.sortDir = 'desc';
|
||||||
|
}
|
||||||
|
this.applySort();
|
||||||
|
},
|
||||||
|
|
||||||
|
applySort() {
|
||||||
|
this.tickets.sort((a, b) => {
|
||||||
|
let valA = a[this.sortField] || '';
|
||||||
|
let valB = b[this.sortField] || '';
|
||||||
|
if (this.sortField === 'created_at') {
|
||||||
|
valA = new Date(valA).getTime();
|
||||||
|
valB = new Date(valB).getTime();
|
||||||
|
}
|
||||||
|
if (typeof valA === 'string') valA = valA.toLowerCase();
|
||||||
|
if (typeof valB === 'string') valB = valB.toLowerCase();
|
||||||
|
if (valA < valB) return this.sortDir === 'asc' ? -1 : 1;
|
||||||
|
if (valA > valB) return this.sortDir === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearFilters() {
|
||||||
|
this.filters = { search: '', status: '', priority: '', property: '', building: '', unitId: '', dateFrom: '', dateTo: '' };
|
||||||
|
this.filterUnit = null;
|
||||||
|
this.searchApartment = '';
|
||||||
|
this.buildings = [];
|
||||||
|
this.page = 1;
|
||||||
|
this.loadTickets();
|
||||||
|
this.syncUrl();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div x-data="createTicket()" x-init="init()">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Create New Issue</h1>
|
||||||
|
<p class="text-gray-500 mt-1">Report a maintenance or customer service issue</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Report Mode: Standard / Emergency quick path -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-3xl mb-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<button type="button" @click="setMode('standard')" class="flex items-center justify-center space-x-2 px-4 py-3 rounded-xl border-2 text-sm font-medium transition"
|
||||||
|
:class="mode === 'standard' ? 'border-denya-500 bg-denya-50 text-denya-700' : 'border-gray-200 text-gray-600 hover:bg-gray-50'">
|
||||||
|
<span>Standard Issue</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="setMode('emergency')" class="flex items-center justify-center space-x-2 px-4 py-3 rounded-xl border-2 text-sm font-medium transition"
|
||||||
|
:class="mode === 'emergency' ? 'border-red-500 bg-red-50 text-red-700' : 'border-gray-200 text-gray-600 hover:bg-gray-50'">
|
||||||
|
<span>🚨 Emergency</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p x-show="mode === 'emergency'" class="mt-3 text-xs text-red-600 leading-relaxed">
|
||||||
|
Emergency reports (gas leak, fire, flood, electrical hazard) are created with 🔴 Urgent priority and urgent SLA targets apply.
|
||||||
|
The Gas Leak category is available here only — it stays hidden from the standard issue list.
|
||||||
|
</p>
|
||||||
|
<p x-show="mode === 'standard'" class="mt-3 text-xs text-gray-400">Standard maintenance or customer service issues.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-3xl">
|
||||||
|
<form @submit.prevent="submitTicket" class="space-y-5">
|
||||||
|
<!-- Row: Customer Name + Phone -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Customer Name</label>
|
||||||
|
<input type="text" x-model="form.customer_name" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none" placeholder="e.g. John Doe">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Phone</label>
|
||||||
|
<input type="text" x-model="form.phone" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none" placeholder="e.g. +233 XX XXX XXXX">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row: Property → Building → Apartment (3-level cascade + searchable combobox) -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Location <span class="text-red-500">*</span></label>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<select x-model="form.property" @change="onPropertyChange()" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
|
||||||
|
<option value="">Property</option>
|
||||||
|
<option value="East">Pavilion East</option>
|
||||||
|
<option value="West">Pavilion West</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<select x-model="form.building" @change="onBuildingChange()" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none" :disabled="!form.property">
|
||||||
|
<option value="">Building</option>
|
||||||
|
<template x-for="b in buildings" :key="b">
|
||||||
|
<option :value="b" x-text="b"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text" x-model="searchApartment" @focus="apartmentOpen = true" @input="apartmentOpen = true" @keydown.escape="apartmentOpen = false"
|
||||||
|
:disabled="!form.building" @blur="closeApartment()"
|
||||||
|
:placeholder="form.apartment_code ? form.apartment_code : (form.building ? 'Search apartment (e.g. 011E, 505, 10W)...' : 'Select building first')"
|
||||||
|
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none disabled:bg-gray-50 disabled:text-gray-400">
|
||||||
|
<div x-show="apartmentOpen && filteredApartments.length" class="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||||
|
<template x-for="unit in filteredApartments" :key="unit.id">
|
||||||
|
<button type="button" @mousedown.prevent="selectApartment(unit)" class="block w-full text-left px-3 py-2 hover:bg-denya-50 text-sm">
|
||||||
|
<span class="font-medium text-gray-800" x-text="unit.apartment_code"></span>
|
||||||
|
<span class="text-xs text-gray-400" x-text="` · Floor ${unit.floor || '—'} · ${unit.building || ''}`"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div x-show="apartmentOpen && !filteredApartments.length && form.building" class="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg px-3 py-2 text-xs text-gray-400">
|
||||||
|
No apartments match “<span x-text="searchApartment"></span>”
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-400">Property → Building → Apartment. Search matches apartment code, floor, or building.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Category <span class="text-red-500">*</span></label>
|
||||||
|
<select x-model="form.category_main" @change="onCategoryChange()" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
|
||||||
|
<option value="">Select Category</option>
|
||||||
|
<template x-for="cat in categories" :key="cat.id">
|
||||||
|
<option :value="cat.id" x-text="cat.name"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
<div x-show="selectedCategoryHint" class="mt-2 text-xs text-gray-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2" x-text="selectedCategoryHint"></div>
|
||||||
|
</div>
|
||||||
|
<div x-show="subCategories.length">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Sub-Category</label>
|
||||||
|
<select x-model="form.category_id" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
|
||||||
|
<option value="">Select Sub-Category</option>
|
||||||
|
<template x-for="cat in subCategories" :key="cat.id">
|
||||||
|
<option :value="cat.id" x-text="cat.name"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Priority -->
|
||||||
|
<div x-show="mode === 'emergency'">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Priority</label>
|
||||||
|
<div class="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700 font-medium">
|
||||||
|
🔴 Urgent — fixed for emergency reports (15 min response / 4 h resolution SLA)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-show="mode === 'standard'">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Priority</label>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
|
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:bg-gray-50" :class="form.priority === 'urgent' ? 'border-red-500 bg-red-50' : 'border-gray-200'">
|
||||||
|
<input type="radio" name="priority" value="urgent" x-model="form.priority" @change="priorityAuto = false" class="sr-only">
|
||||||
|
<span class="text-sm">🔴 Urgent</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:bg-gray-50" :class="form.priority === 'high' ? 'border-orange-500 bg-orange-50' : 'border-gray-200'">
|
||||||
|
<input type="radio" name="priority" value="high" x-model="form.priority" @change="priorityAuto = false" class="sr-only">
|
||||||
|
<span class="text-sm">🟠 High</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:bg-gray-50" :class="form.priority === 'medium' ? 'border-yellow-500 bg-yellow-50' : 'border-gray-200'">
|
||||||
|
<input type="radio" name="priority" value="medium" x-model="form.priority" @change="priorityAuto = false" class="sr-only">
|
||||||
|
<span class="text-sm">🟡 Medium</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:bg-gray-50" :class="form.priority === 'low' ? 'border-green-500 bg-green-50' : 'border-gray-200'">
|
||||||
|
<input type="radio" name="priority" value="low" x-model="form.priority" @change="priorityAuto = false" class="sr-only">
|
||||||
|
<span class="text-sm">🟢 Low</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-red-500">*</span></label>
|
||||||
|
<textarea x-model="form.description" rows="4" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none" placeholder="Describe the issue in detail..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row: Reporter + Reported Via -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Reporter</label>
|
||||||
|
<input type="text" x-model="form.reporter" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none" placeholder="Who reported this?">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Reported Via</label>
|
||||||
|
<select x-model="form.reported_via" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
|
||||||
|
<option value="">Select method</option>
|
||||||
|
<option value="phone">Phone</option>
|
||||||
|
<option value="walk-in">Walk-in</option>
|
||||||
|
<option value="whatsapp">WhatsApp</option>
|
||||||
|
<option value="agent">Agent</option>
|
||||||
|
<!-- QR Code excluded per Sprint 3 scope -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photo Upload -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Photos (Before)</label>
|
||||||
|
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-denya-400 transition cursor-pointer" @click="document.getElementById('photoInput').click()">
|
||||||
|
<svg class="w-8 h-8 mx-auto text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm text-gray-500">Click to upload photos</p>
|
||||||
|
<input type="file" id="photoInput" multiple accept="image/*" @change="handlePhotos" class="hidden">
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2 mt-2" x-show="photoFiles.length">
|
||||||
|
<template x-for="(photo, idx) in photoPreviews" :key="idx">
|
||||||
|
<div class="relative w-20 h-20 rounded-lg overflow-hidden border">
|
||||||
|
<img :src="photo" class="w-full h-full object-cover">
|
||||||
|
<button type="button" @click="removePhoto(idx)" class="absolute top-0.5 right-0.5 bg-red-500 text-white w-5 h-5 rounded-full flex items-center justify-center text-xs hover:bg-red-600">×</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Display -->
|
||||||
|
<div x-show="error" class="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="error"></div>
|
||||||
|
|
||||||
|
<!-- Submit -->
|
||||||
|
<div class="flex items-center space-x-3 pt-2">
|
||||||
|
<button type="submit" :disabled="submitting" class="px-6 py-2.5 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2">
|
||||||
|
<svg x-show="submitting" class="animate-spin h-4 w-4" 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"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||||
|
<span x-text="submitting ? 'Creating...' : (mode === 'emergency' ? 'Create Emergency Issue' : 'Create Issue')"></span>
|
||||||
|
</button>
|
||||||
|
<a href="/tickets" class="px-6 py-2.5 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition font-medium">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function createTicket() {
|
||||||
|
return {
|
||||||
|
mode: 'standard',
|
||||||
|
form: {
|
||||||
|
customer_name: '',
|
||||||
|
phone: '',
|
||||||
|
property: '',
|
||||||
|
building: '',
|
||||||
|
apartment_code: '',
|
||||||
|
unit: null,
|
||||||
|
category_main: '',
|
||||||
|
category_id: null,
|
||||||
|
priority: '',
|
||||||
|
description: '',
|
||||||
|
reporter: '',
|
||||||
|
reported_via: ''
|
||||||
|
},
|
||||||
|
priorityAuto: false,
|
||||||
|
categories: [],
|
||||||
|
subCategories: [],
|
||||||
|
unitGroups: {},
|
||||||
|
buildings: [],
|
||||||
|
searchApartment: '',
|
||||||
|
apartmentOpen: false,
|
||||||
|
selectedCategoryHint: '',
|
||||||
|
photoFiles: [],
|
||||||
|
photoPreviews: [],
|
||||||
|
submitting: false,
|
||||||
|
error: '',
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.loadCategories();
|
||||||
|
await this.loadUnits();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Report mode ──────────────────────────────────────────
|
||||||
|
setMode(mode) {
|
||||||
|
this.mode = mode;
|
||||||
|
// Reset category selection; priority follows mode
|
||||||
|
this.form.category_main = '';
|
||||||
|
this.form.category_id = null;
|
||||||
|
this.subCategories = [];
|
||||||
|
this.selectedCategoryHint = '';
|
||||||
|
if (mode === 'emergency') {
|
||||||
|
this.form.priority = 'urgent';
|
||||||
|
} else {
|
||||||
|
this.form.priority = '';
|
||||||
|
}
|
||||||
|
this.priorityAuto = false;
|
||||||
|
this.loadCategories();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadCategories() {
|
||||||
|
try {
|
||||||
|
const url = this.mode === 'emergency'
|
||||||
|
? '/api/tickets/categories?type=emergency&include_hidden=true'
|
||||||
|
: '/api/tickets/categories';
|
||||||
|
const data = await app().apiGet(url);
|
||||||
|
// Top-level categories only
|
||||||
|
this.categories = data?.filter(c => !c.parent_id) || [];
|
||||||
|
} catch (e) { console.error('Categories load error', e); }
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadUnits() {
|
||||||
|
try {
|
||||||
|
const data = await app().apiGet('/api/tickets/units/grouped');
|
||||||
|
this.unitGroups = data || {};
|
||||||
|
} catch (e) { console.error('Units load error', e); }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Location cascade ─────────────────────────────────────
|
||||||
|
onPropertyChange() {
|
||||||
|
this.form.building = '';
|
||||||
|
this.form.apartment_code = '';
|
||||||
|
this.form.unit = null;
|
||||||
|
this.searchApartment = '';
|
||||||
|
const prop = this.form.property;
|
||||||
|
this.buildings = prop ? Object.keys(this.unitGroups[prop] || {}) : [];
|
||||||
|
},
|
||||||
|
|
||||||
|
onBuildingChange() {
|
||||||
|
this.form.apartment_code = '';
|
||||||
|
this.form.unit = null;
|
||||||
|
this.searchApartment = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
get buildingUnits() {
|
||||||
|
if (!this.form.property || !this.form.building) return [];
|
||||||
|
return this.unitGroups[this.form.property]?.[this.form.building] || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
get filteredApartments() {
|
||||||
|
const q = (this.searchApartment || '').toLowerCase().trim();
|
||||||
|
let units = this.buildingUnits;
|
||||||
|
if (q) {
|
||||||
|
units = units.filter(u =>
|
||||||
|
(u.apartment_code || '').toLowerCase().includes(q) ||
|
||||||
|
(u.building || '').toLowerCase().includes(q) ||
|
||||||
|
String(u.floor || '').includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return units;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeApartment() {
|
||||||
|
setTimeout(() => { this.apartmentOpen = false; }, 150);
|
||||||
|
},
|
||||||
|
|
||||||
|
selectApartment(unit) {
|
||||||
|
this.form.unit = unit;
|
||||||
|
this.form.apartment_code = unit.apartment_code;
|
||||||
|
this.searchApartment = unit.apartment_code;
|
||||||
|
this.apartmentOpen = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Category helpers ─────────────────────────────────────
|
||||||
|
onCategoryChange() {
|
||||||
|
this.form.category_id = null;
|
||||||
|
this.subCategories = [];
|
||||||
|
this.selectedCategoryHint = '';
|
||||||
|
if (!this.form.category_main) return;
|
||||||
|
const parent = this.categories.find(c => c.id == this.form.category_main);
|
||||||
|
if (parent?.children) {
|
||||||
|
this.subCategories = parent.children;
|
||||||
|
}
|
||||||
|
const hints = {
|
||||||
|
'Carpentry': 'Triage tip: use Carpentry for structural timber, door and frame work; Furniture for movable pieces (beds, chairs, tables, wardrobes); Security for lock, latch and door-closing functionality.',
|
||||||
|
'Mould & Damp': 'Damp / mould remediation. Priority defaults to 🟡 Medium unless you change it.',
|
||||||
|
'Aluminum/Glass': 'Windows, doors, sliding/fixed panels, glass and mirror replacement.'
|
||||||
|
};
|
||||||
|
if (parent?.name && hints[parent.name]) this.selectedCategoryHint = hints[parent.name];
|
||||||
|
// Mould & Damp defaults to Medium priority; clear the auto-default
|
||||||
|
// when switching away so a non-Mould ticket doesn't keep it silently
|
||||||
|
if (parent?.name !== 'Mould & Damp' && this.priorityAuto) {
|
||||||
|
this.form.priority = '';
|
||||||
|
this.priorityAuto = false;
|
||||||
|
}
|
||||||
|
if (parent?.name === 'Mould & Damp' && !this.form.priority) {
|
||||||
|
this.form.priority = 'medium';
|
||||||
|
this.priorityAuto = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handlePhotos(e) {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
files.forEach(file => {
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
app().showToast('Photo too large (max 5MB)', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.photoFiles.push(file);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = ev => this.photoPreviews.push(ev.target.result);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removePhoto(idx) {
|
||||||
|
this.photoFiles.splice(idx, 1);
|
||||||
|
this.photoPreviews.splice(idx, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitTicket() {
|
||||||
|
this.error = '';
|
||||||
|
this.submitting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate required fields
|
||||||
|
if (!this.form.property || !this.form.building || !this.form.unit || !this.form.description) {
|
||||||
|
this.error = 'Please fill in Property, Building, Apartment, and Description.';
|
||||||
|
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 = {
|
||||||
|
description: this.form.description,
|
||||||
|
priority: this.mode === 'emergency' ? 'urgent' : (this.form.priority || null),
|
||||||
|
reporter: this.form.reporter || this.form.customer_name || app().user.full_name,
|
||||||
|
reported_via: this.form.reported_via || 'walk-in',
|
||||||
|
category_id: this.form.category_id ? parseInt(this.form.category_id) : (this.form.category_main ? parseInt(this.form.category_main) : null),
|
||||||
|
unit_id: this.form.unit.id,
|
||||||
|
customer_name: this.form.customer_name || null,
|
||||||
|
phone: this.form.phone || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ticket = await app().apiPost('/api/tickets', payload);
|
||||||
|
|
||||||
|
// Upload photos if any
|
||||||
|
if (this.photoFiles.length > 0 && ticket?.id) {
|
||||||
|
const formData = new FormData();
|
||||||
|
this.photoFiles.forEach(f => formData.append('files', f));
|
||||||
|
try {
|
||||||
|
await fetch(`/api/tickets/${ticket.id}/photos?is_before=true`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${app().token}` },
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
} catch (e) { console.error('Photo upload error', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
app().showToast(`Ticket ${ticket.ticket_number} created successfully!`, 'success');
|
||||||
|
// Redirect to ticket detail
|
||||||
|
setTimeout(() => { window.location.href = `/tickets/${ticket.id}`; }, 1000);
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message || 'Failed to create ticket';
|
||||||
|
} finally {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
_TMP_DIR = tempfile.mkdtemp(prefix="denya-test-")
|
||||||
|
os.environ["DATABASE_URL"] = f"sqlite+aiosqlite:///{_TMP_DIR}/test.db"
|
||||||
|
|
||||||
|
import pytest_asyncio # noqa: E402 (DATABASE_URL must be set before app imports)
|
||||||
|
from httpx import ASGITransport, AsyncClient # noqa: E402
|
||||||
|
|
||||||
|
from app.core.database import Base, async_session_factory, engine # noqa: E402
|
||||||
|
from app.main import app # noqa: E402
|
||||||
|
from app.models.ticket import Ticket # noqa: E402
|
||||||
|
from app.services.seed import seed_categories, seed_units, seed_users # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@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 (same data as the committed
|
||||||
|
# apartment_mapping.json, kept deterministic for tests)
|
||||||
|
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
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"""Tests for the Sprint A category + property-hierarchy changes.
|
||||||
|
|
||||||
|
Anchors:
|
||||||
|
* Alert-only categories (Gas Leak) are hidden from the picker endpoints by
|
||||||
|
default but still present in the DB with urgent SLA urgency, and retrievable
|
||||||
|
via ``include_hidden=true`` (emergency quick path).
|
||||||
|
* New seed categories (Aluminum/Glass, Carpentry, Mould & Damp) and the
|
||||||
|
Lost Property → Missing Item rename are idempotent.
|
||||||
|
* ``GET /api/tickets/units`` gains a ``building`` filter and the grouped
|
||||||
|
variant ``GET /api/tickets/units/grouped`` returns ``{property: {building: [units]}}``.
|
||||||
|
* ``GET /api/tickets`` supports additive ``building``/``unit_id`` filters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
|
||||||
|
def _find(categories: list[dict], name: str) -> dict | None:
|
||||||
|
return next((c for c in categories if c["name"] == name), None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Category picker visibility ────────────────────────────────────────
|
||||||
|
async def test_gas_leak_hidden_from_category_tree_by_default(client):
|
||||||
|
"""Gas Leak must not appear in the default category picker."""
|
||||||
|
resp = await client.get("/api/tickets/categories")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
# Any nesting level: walk top-level only; Gas Leak is top-level emergency
|
||||||
|
assert _find(data, "Gas Leak") is None
|
||||||
|
names = [c["name"] for c in data]
|
||||||
|
assert "Fire" in names and "Flood" in names # other emergencies still visible
|
||||||
|
|
||||||
|
|
||||||
|
async def test_gas_leak_hidden_from_emergency_type_filter(client):
|
||||||
|
"""The `?type=emergency` picker also excludes Gas Leak by default."""
|
||||||
|
resp = await client.get("/api/tickets/categories", params={"type": "emergency"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert _find(data, "Gas Leak") is None
|
||||||
|
assert _find(data, "Fire") is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_include_hidden_returns_gas_leak(client):
|
||||||
|
"""include_hidden=true exposes alert-only categories (emergency quick path)."""
|
||||||
|
resp = await client.get("/api/tickets/categories", params={"include_hidden": "true"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
gas = _find(resp.json(), "Gas Leak")
|
||||||
|
assert gas is not None
|
||||||
|
assert gas["show_in_form"] is False
|
||||||
|
assert gas["sla_urgency"] == "urgent"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flat_list_hides_gas_leak_and_its_children(client):
|
||||||
|
"""Flat picker excludes Gas Leak and orphaned children of hidden parents."""
|
||||||
|
resp = await client.get("/api/tickets/categories/flat")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
names = [c["name"] for c in resp.json()]
|
||||||
|
assert "Gas Leak" not in names
|
||||||
|
assert "Gas smell" not in names # child of alert-only parent
|
||||||
|
assert "Suspected leak" not in names
|
||||||
|
assert "Fire" in names
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flat_list_include_hidden_keeps_gas_leak(client):
|
||||||
|
resp = await client.get("/api/tickets/categories/flat", params={"include_hidden": "true"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
names = [c["name"] for c in resp.json()]
|
||||||
|
assert "Gas Leak" in names
|
||||||
|
assert "Gas smell" in names
|
||||||
|
|
||||||
|
|
||||||
|
# ── Seed taxonomy ─────────────────────────────────────────────────────
|
||||||
|
async def test_seed_adds_new_categories(client):
|
||||||
|
"""Aluminum/Glass, Carpentry, Mould & Damp land as maintenance categories."""
|
||||||
|
resp = await client.get("/api/tickets/categories", params={"type": "maintenance"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
for name in ("Aluminum/Glass", "Carpentry", "Mould & Damp"):
|
||||||
|
cat = _find(data, name)
|
||||||
|
assert cat is not None, f"expected {name} in maintenance categories"
|
||||||
|
assert cat["children"], f"{name} should have sub-categories"
|
||||||
|
mould = _find(data, "Mould & Damp")
|
||||||
|
assert mould["sla_urgency"] == "medium"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_renames_lost_property_to_missing_item(client):
|
||||||
|
"""Seed must create 'Missing Item' (not 'Lost Property')."""
|
||||||
|
resp = await client.get("/api/tickets/categories", params={"type": "cs"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert _find(data, "Missing Item") is not None
|
||||||
|
assert _find(data, "Lost Property") is None
|
||||||
|
missing = _find(data, "Missing Item")
|
||||||
|
sub_names = [s["name"] for s in missing["children"]]
|
||||||
|
assert "Guest left items behind" in sub_names
|
||||||
|
assert "Item search request" in sub_names
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_is_idempotent(client):
|
||||||
|
"""Running the seed twice does not duplicate categories."""
|
||||||
|
from app.core.database import async_session_factory
|
||||||
|
from app.services.seed import seed_categories
|
||||||
|
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
created = await seed_categories(session)
|
||||||
|
await session.commit()
|
||||||
|
assert created == [] # nothing new to insert/sync on second pass
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_syncs_show_in_form_on_existing_rows(client):
|
||||||
|
"""Existing databases pick up Gas Leak's alert-only flag on next startup.
|
||||||
|
|
||||||
|
Simulates a pre-existing DB (Gas Leak seeded with show_in_form=True before
|
||||||
|
the flag existed) — re-running the seed must flip it back to False.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.core.database import async_session_factory
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.services.seed import seed_categories
|
||||||
|
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Category).where(Category.type == "emergency", Category.name == "Gas Leak", Category.parent_id.is_(None))
|
||||||
|
)
|
||||||
|
gas = result.scalar_one()
|
||||||
|
gas.show_in_form = True # simulate legacy DB before the flag existed
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
created = await seed_categories(session)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(gas)
|
||||||
|
assert gas.show_in_form is False
|
||||||
|
assert gas.sla_urgency == "urgent" # SLA/alert mapping unchanged
|
||||||
|
# Sync counts as a change, but no duplicates are created
|
||||||
|
assert len(created) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_legacy_db_self_heals_show_in_form_column():
|
||||||
|
"""A pre-Sprint-A categories table (no show_in_form) gets the column on startup."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.core.database import engine
|
||||||
|
from app.main import ensure_legacy_schema
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text("DROP TABLE IF EXISTS tickets"))
|
||||||
|
await conn.execute(text("DROP TABLE IF EXISTS categories"))
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE TABLE categories ("
|
||||||
|
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "
|
||||||
|
"type VARCHAR(20) NOT NULL, "
|
||||||
|
"name VARCHAR(100) NOT NULL, "
|
||||||
|
"parent_id INTEGER, "
|
||||||
|
"sla_urgency VARCHAR(10))"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await ensure_legacy_schema(conn)
|
||||||
|
result = await conn.execute(text("PRAGMA table_info(categories)"))
|
||||||
|
assert "show_in_form" in {row[1] for row in result}
|
||||||
|
|
||||||
|
# Idempotent on the next startup
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await ensure_legacy_schema(conn)
|
||||||
|
result = await conn.execute(text("PRAGMA table_info(categories)"))
|
||||||
|
assert "show_in_form" in {row[1] for row in result}
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text("DROP TABLE IF EXISTS categories"))
|
||||||
|
await conn.execute(text("DROP TABLE IF EXISTS tickets"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Unit hierarchy ────────────────────────────────────────────────────
|
||||||
|
async def test_units_building_filter(client):
|
||||||
|
"""GET /api/tickets/units?building= filters to one building."""
|
||||||
|
resp = await client.get("/api/tickets/units", params={"building": "Pavilion East"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
units = resp.json()
|
||||||
|
assert len(units) > 0
|
||||||
|
assert all(u["building"] == "Pavilion East" for u in units)
|
||||||
|
assert all(u["property"] == "East" for u in units)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_units_grouped_shape(client):
|
||||||
|
"""Grouped variant returns {property: {building: [units]}}."""
|
||||||
|
resp = await client.get("/api/tickets/units/grouped")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
grouped = resp.json()
|
||||||
|
assert "East" in grouped and "West" in grouped
|
||||||
|
east = grouped["East"]
|
||||||
|
assert "Pavilion East" in east
|
||||||
|
assert len(east["Pavilion East"]) == 60
|
||||||
|
unit = east["Pavilion East"][0]
|
||||||
|
assert {"id", "property", "apartment_code", "building", "floor"} <= set(unit.keys())
|
||||||
|
# Distinct apartment codes
|
||||||
|
codes = [u["apartment_code"] for u in east["Pavilion East"]]
|
||||||
|
assert len(set(codes)) == len(codes)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_units_grouped_property_filter(client):
|
||||||
|
resp = await client.get("/api/tickets/units/grouped", params={"property": "West"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
grouped = resp.json()
|
||||||
|
assert set(grouped.keys()) == {"West"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ticket list filters ───────────────────────────────────────────────
|
||||||
|
async def test_tickets_filter_by_building_and_unit(client):
|
||||||
|
"""Additive building/unit_id filters compose with the list endpoint."""
|
||||||
|
# Grab two units from different buildings/properties
|
||||||
|
resp = await client.get("/api/tickets/units", params={"building": "Pavilion East"})
|
||||||
|
east_unit = resp.json()[0]
|
||||||
|
resp = await client.get("/api/tickets/units", params={"building": "Pavilion West"})
|
||||||
|
west_unit = resp.json()[0]
|
||||||
|
|
||||||
|
# Two tickets, one per building
|
||||||
|
payloads = [
|
||||||
|
{"description": "east ticket", "unit_id": east_unit["id"], "priority": "medium"},
|
||||||
|
{"description": "west ticket", "unit_id": west_unit["id"], "priority": "high"},
|
||||||
|
]
|
||||||
|
token = await _login(client)
|
||||||
|
for p in payloads:
|
||||||
|
r = await client.post("/api/tickets", json=p, headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
|
||||||
|
# building filter
|
||||||
|
r = await client.get("/api/tickets", params={"building": "Pavilion East"})
|
||||||
|
data = r.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["items"][0]["description"] == "east ticket"
|
||||||
|
|
||||||
|
# unit_id filter
|
||||||
|
r = await client.get("/api/tickets", params={"unit_id": west_unit["id"]})
|
||||||
|
data = r.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["items"][0]["description"] == "west ticket"
|
||||||
|
|
||||||
|
|
||||||
|
async def _login(client) -> str:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"email": "wahab@denya.com", "password": "denya123"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["access_token"]
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"""Tests for the Wahab demo-prep hardening batch.
|
||||||
|
|
||||||
|
Anchors:
|
||||||
|
* ``Cancelled`` is a terminal status reachable from every active state, is
|
||||||
|
excluded from SLA breach reporting, and shows up in the status pickers.
|
||||||
|
* Customer ``phone`` collected on the new-issue form is persisted (it used to
|
||||||
|
be silently dropped).
|
||||||
|
* ``GET /api/tickets/{id}`` returns nested ``unit``/``category`` objects so the
|
||||||
|
detail page stops rendering "—" for Unit/Property/Category.
|
||||||
|
* ``DELETE /api/tickets/{id}`` is admin-only and removes ticket children.
|
||||||
|
* ``GET /api/auth/users`` powers the assign-technician dropdown.
|
||||||
|
* Ticket numbering uses max+1, so deleting tickets never re-issues a number.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
|
||||||
|
async def _login(client, email="wahab@denya.com", password="denya123") -> str:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"email": email, "password": password},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_ticket(client, token: str, **overrides) -> dict:
|
||||||
|
payload = {
|
||||||
|
"unit_id": 2,
|
||||||
|
"category_id": 3,
|
||||||
|
"priority": "medium",
|
||||||
|
"reporter": "Demo Prep Test",
|
||||||
|
"reported_via": "walk-in",
|
||||||
|
"description": "hardening batch test ticket",
|
||||||
|
**overrides,
|
||||||
|
}
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/tickets",
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cancelled status ──────────────────────────────────────────────────
|
||||||
|
async def test_cancelled_is_terminal(client):
|
||||||
|
"""A cancelled ticket has no valid next status."""
|
||||||
|
from app.services.ticket import VALID_TRANSITIONS
|
||||||
|
|
||||||
|
assert "Cancelled" in VALID_TRANSITIONS
|
||||||
|
assert VALID_TRANSITIONS["Cancelled"] == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cancelled_reachable_from_active_states(client):
|
||||||
|
"""Logged/Triage/In Progress → Cancelled are all valid transitions."""
|
||||||
|
from app.services.ticket import VALID_TRANSITIONS
|
||||||
|
|
||||||
|
for state in (
|
||||||
|
"New", "Logged", "Triage", "Assigned", "Accepted", "Travelling", "On Site",
|
||||||
|
"In Progress", "Waiting Parts", "Escalated", "On-Field Verification",
|
||||||
|
"Wahab Review", "Reopened",
|
||||||
|
):
|
||||||
|
assert "Cancelled" in VALID_TRANSITIONS[state], f"{state} should allow Cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cancel_ticket_via_api(client):
|
||||||
|
"""PATCH status=Cancelled works end-to-end and lands in the timeline."""
|
||||||
|
token = await _login(client)
|
||||||
|
ticket = await _create_ticket(client, token, description="cancel me")
|
||||||
|
|
||||||
|
resp = await client.patch(
|
||||||
|
f"/api/tickets/{ticket['id']}",
|
||||||
|
json={"status": "Cancelled", "note": "demo sample"},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "Cancelled"
|
||||||
|
assert data["timeline"][-1]["to_status"] == "Cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cancelled_ticket_not_sla_breached(client):
|
||||||
|
"""A cancelled ticket must not report SLA breach (like Closed/Completed)."""
|
||||||
|
token = await _login(client)
|
||||||
|
ticket = await _create_ticket(client, token, priority="urgent", description="cancel sla test")
|
||||||
|
|
||||||
|
# Force the SLA deadline into the past by patching priority (recomputes from now),
|
||||||
|
# then cancel; the breach flags must be False either way.
|
||||||
|
resp = await client.patch(
|
||||||
|
f"/api/tickets/{ticket['id']}",
|
||||||
|
json={"status": "Cancelled"},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
sla = resp.json()["sla_status"]
|
||||||
|
assert sla["resolution_breached"] is False
|
||||||
|
assert sla["response_breached"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Phone persistence ─────────────────────────────────────────────────
|
||||||
|
async def test_phone_persisted_on_create(client):
|
||||||
|
"""Customer phone sent at creation is stored and returned."""
|
||||||
|
token = await _login(client)
|
||||||
|
ticket = await _create_ticket(client, token, phone="+233123456789", description="phone test")
|
||||||
|
assert ticket["phone"] == "+233123456789"
|
||||||
|
|
||||||
|
detail = await client.get(f"/api/tickets/{ticket['id']}")
|
||||||
|
assert detail.json()["phone"] == "+233123456789"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Nested unit/category in detail ────────────────────────────────────
|
||||||
|
async def test_ticket_detail_returns_unit_and_category(client):
|
||||||
|
"""TicketOut includes nested unit/category objects (detail page fix)."""
|
||||||
|
token = await _login(client)
|
||||||
|
ticket = await _create_ticket(client, token, unit_id=2, category_id=3)
|
||||||
|
resp = await client.get(f"/api/tickets/{ticket['id']}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["unit"] is not None
|
||||||
|
assert data["unit"]["apartment_code"]
|
||||||
|
assert data["category"] is not None
|
||||||
|
assert data["category"]["name"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin-only DELETE ─────────────────────────────────────────────────
|
||||||
|
async def test_delete_ticket_requires_admin(client):
|
||||||
|
"""A non-admin (CS Rep) gets 403 on DELETE."""
|
||||||
|
token = await _login(client, email="bella@denya.com") # CS Rep
|
||||||
|
ticket = await _create_ticket(client, token, description="delete perm test")
|
||||||
|
resp = await client.delete(
|
||||||
|
f"/api/tickets/{ticket['id']}",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_ticket_removes_children(client):
|
||||||
|
"""Admin DELETE removes the ticket, timeline, and photos."""
|
||||||
|
token = await _login(client) # Admin/Wahab
|
||||||
|
ticket = await _create_ticket(client, token, description="delete me")
|
||||||
|
tid = ticket["id"]
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
f"/api/tickets/{tid}",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
gone = await client.get(f"/api/tickets/{tid}")
|
||||||
|
assert gone.status_code == 404
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from app.core.database import async_session_factory
|
||||||
|
from app.models.ticket import TicketTimeline
|
||||||
|
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
count = (await session.execute(
|
||||||
|
select(func.count(TicketTimeline.id)).where(TicketTimeline.ticket_id == tid)
|
||||||
|
)).scalar()
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Users endpoint for the assign picker ──────────────────────────────
|
||||||
|
async def test_users_endpoint_requires_auth(client):
|
||||||
|
resp = await client.get("/api/auth/users")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_users_endpoint_lists_technicians(client):
|
||||||
|
token = await _login(client)
|
||||||
|
resp = await client.get("/api/auth/users", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
users = resp.json()
|
||||||
|
assert any(u["role"] == "Tech" for u in users)
|
||||||
|
# Fields the assign dropdown needs
|
||||||
|
assert {"id", "full_name", "role"} <= set(users[0].keys())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Transitions helper (status dropdown) ─────────────────────────────
|
||||||
|
async def test_transitions_helper_returns_valid_targets(client):
|
||||||
|
"""GET /api/tickets/{id}/transitions returns the valid next statuses."""
|
||||||
|
from app.services.ticket import VALID_TRANSITIONS
|
||||||
|
|
||||||
|
token = await _login(client)
|
||||||
|
ticket = await _create_ticket(client, token, description="transitions helper") # status: Logged
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/tickets/{ticket['id']}/transitions")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["current_status"] == "Logged"
|
||||||
|
assert set(data["transitions"]) == set(VALID_TRANSITIONS["Logged"])
|
||||||
|
|
||||||
|
|
||||||
|
async def test_transitions_helper_terminal_is_empty(client):
|
||||||
|
"""A cancelled ticket exposes no selectable next statuses."""
|
||||||
|
token = await _login(client)
|
||||||
|
ticket = await _create_ticket(client, token, description="terminal transitions")
|
||||||
|
resp = await client.patch(
|
||||||
|
f"/api/tickets/{ticket['id']}",
|
||||||
|
json={"status": "Cancelled"},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
data = (await client.get(f"/api/tickets/{ticket['id']}/transitions")).json()
|
||||||
|
assert data["current_status"] == "Cancelled"
|
||||||
|
assert data["transitions"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Assign auto-advance ───────────────────────────────────────────────
|
||||||
|
async def _first_tech_id(client, token: str) -> int:
|
||||||
|
users = (await client.get("/api/auth/users", headers={"Authorization": f"Bearer {token}"})).json()
|
||||||
|
return next(u["id"] for u in users if u["role"] == "Tech")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_assign_auto_advances_to_assigned(client):
|
||||||
|
"""Assigning a pre-Assigned ticket advances it to Assigned with a timeline entry."""
|
||||||
|
token = await _login(client)
|
||||||
|
ticket = await _create_ticket(client, token, description="assign advance") # status: Logged
|
||||||
|
tech_id = await _first_tech_id(client, token)
|
||||||
|
|
||||||
|
resp = await client.patch(
|
||||||
|
f"/api/tickets/{ticket['id']}",
|
||||||
|
json={"assigned_to": tech_id},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "Assigned"
|
||||||
|
assert data["assigned_to"] == tech_id
|
||||||
|
assert data["timeline"][-1]["to_status"] == "Assigned"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_assign_does_not_regress_past_assigned(client):
|
||||||
|
"""Re-assigning a ticket already past Assigned leaves its status untouched."""
|
||||||
|
token = await _login(client)
|
||||||
|
ticket = await _create_ticket(client, token, description="no regression")
|
||||||
|
|
||||||
|
for step in ("Triage", "Assigned", "Accepted"):
|
||||||
|
resp = await client.patch(
|
||||||
|
f"/api/tickets/{ticket['id']}",
|
||||||
|
json={"status": step},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
users = (await client.get("/api/auth/users", headers={"Authorization": f"Bearer {token}"})).json()
|
||||||
|
techs = [u["id"] for u in users if u["role"] == "Tech"]
|
||||||
|
other_tech = techs[1] if len(techs) > 1 else techs[0]
|
||||||
|
|
||||||
|
resp = await client.patch(
|
||||||
|
f"/api/tickets/{ticket['id']}",
|
||||||
|
json={"assigned_to": other_tech},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["status"] == "Accepted"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ticket numbering survives deletions ───────────────────────────────
|
||||||
|
async def test_ticket_number_uses_max_plus_one(client, seed_tickets):
|
||||||
|
"""After deleting a middle ticket, numbering must skip over surviving numbers."""
|
||||||
|
from sqlalchemy import delete as sa_delete
|
||||||
|
from app.core.database import async_session_factory
|
||||||
|
from app.models.ticket import Ticket
|
||||||
|
|
||||||
|
token = await _login(client)
|
||||||
|
first = await _create_ticket(client, token, description="numbering a") # …001
|
||||||
|
second = await _create_ticket(client, token, description="numbering b") # …002
|
||||||
|
third = await _create_ticket(client, token, description="numbering c") # …003
|
||||||
|
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
# Delete the middle ticket; count+1 numbering would now re-issue …003
|
||||||
|
# (colliding with the surviving third ticket).
|
||||||
|
await session.execute(sa_delete(Ticket).where(Ticket.id == second["id"]))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
fourth = await _create_ticket(client, token, description="numbering d")
|
||||||
|
|
||||||
|
def suffix(tn: str) -> int:
|
||||||
|
return int(tn.rsplit("-", 1)[1])
|
||||||
|
|
||||||
|
survivors = {suffix(first["ticket_number"]), suffix(third["ticket_number"])}
|
||||||
|
assert suffix(fourth["ticket_number"]) not in survivors
|
||||||
|
assert suffix(fourth["ticket_number"]) > max(survivors)
|
||||||
Reference in New Issue
Block a user