diff --git a/AGENTS.md b/AGENTS.md
index 563eacd..1b1abc4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -116,6 +116,21 @@ Stored under `uploads/` with UUID filenames. Static-files mounted at `/uploads/`
SQLite via aiosqlite with async SQLAlchemy 2.0. Alembic for migrations.
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`). 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
Keep this file for knowledge useful to almost every future agent session in this project.
diff --git a/alembic/versions/b2f4a6c8e0d2_category_show_in_form_and_missing_item_rename.py b/alembic/versions/b2f4a6c8e0d2_category_show_in_form_and_missing_item_rename.py
new file mode 100644
index 0000000..1894cf9
--- /dev/null
+++ b/alembic/versions/b2f4a6c8e0d2_category_show_in_form_and_missing_item_rename.py
@@ -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')
diff --git a/apartment_mapping.json b/apartment_mapping.json
new file mode 100644
index 0000000..7870915
--- /dev/null
+++ b/apartment_mapping.json
@@ -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
+ }
+ ]
+}
diff --git a/app/models/category.py b/app/models/category.py
index b836c79..7ff031f 100644
--- a/app/models/category.py
+++ b/app/models/category.py
@@ -2,7 +2,7 @@
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 app.core.database import Base
@@ -28,6 +28,13 @@ class Category(Base):
nullable=True,
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
children: Mapped[list[Category]] = relationship("Category", back_populates="parent", cascade="all, delete-orphan")
diff --git a/app/routers/tickets.py b/app/routers/tickets.py
index d681940..d7748b6 100644
--- a/app/routers/tickets.py
+++ b/app/routers/tickets.py
@@ -45,33 +45,61 @@ UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
async def list_units(
db: Annotated[AsyncSession, Depends(get_db)],
property_filter: str | None = Query(None, alias="property"),
+ building: str | None = Query(None),
) -> list[Unit]:
- """List all units, optionally filtered by property (East/West)."""
+ """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) -> list[CategoryTreeOut]:
+async def _build_category_tree(db: AsyncSession, parent_id: int | None = None, include_hidden: bool = False) -> list[CategoryTreeOut]:
"""Build a nested category tree."""
- result = await db.execute(
- select(Category)
- .where(Category.parent_id == parent_id)
- .order_by(Category.name)
- )
+ 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()
tree = []
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(
id=cat.id,
type=cat.type,
name=cat.name,
parent_id=cat.parent_id,
sla_urgency=cat.sla_urgency,
+ show_in_form=cat.show_in_form,
children=children,
))
return tree
@@ -81,23 +109,26 @@ async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -
async def list_categories(
db: Annotated[AsyncSession, Depends(get_db)],
type_filter: str | None = Query(None, alias="type"),
+ include_hidden: bool = Query(False, description="Include alert-only categories (e.g. Gas Leak)"),
) -> 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:
# Return flat list of top-level categories of the given type
- result = await db.execute(
- select(Category)
- .where(Category.parent_id.is_(None), Category.type == type_filter)
- .order_by(Category.name)
- )
+ query = select(Category).where(Category.parent_id.is_(None), Category.type == type_filter).order_by(Category.name)
+ if not include_hidden:
+ query = query.where(Category.show_in_form.is_(True))
+ result = await db.execute(query)
parents = result.scalars().all()
tree = []
for parent in parents:
- children_result = await db.execute(
- select(Category)
- .where(Category.parent_id == parent.id)
- .order_by(Category.name)
- )
+ children_query = select(Category).where(Category.parent_id == parent.id).order_by(Category.name)
+ if not include_hidden:
+ children_query = children_query.where(Category.show_in_form.is_(True))
+ children_result = await db.execute(children_query)
children = children_result.scalars().all()
tree.append(CategoryTreeOut(
id=parent.id,
@@ -105,24 +136,43 @@ async def list_categories(
name=parent.name,
parent_id=parent.parent_id,
sla_urgency=parent.sla_urgency,
+ show_in_form=parent.show_in_form,
children=[CategoryOut.model_validate(c) for c in children],
))
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])
async def list_categories_flat(
db: Annotated[AsyncSession, Depends(get_db)],
type_filter: str | None = Query(None, alias="type"),
+ include_hidden: bool = Query(False, description="Include alert-only categories (e.g. Gas Leak)"),
) -> 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)
if type_filter:
query = query.where(Category.type == type_filter)
result = await db.execute(query)
categories = result.scalars().all()
- return [CategoryOut.model_validate(c) for c in categories]
+ if include_hidden:
+ 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 ──────────────────────────────────────────────────────
@@ -149,6 +199,8 @@ async def list_tickets(
status: str | None = Query(None),
priority: 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),
assigned_to: int | None = Query(None),
date_from: datetime | None = Query(None),
@@ -160,6 +212,8 @@ async def list_tickets(
status_filter=status,
priority_filter=priority,
property_filter=property,
+ building_filter=building,
+ unit_id=unit_id,
category_id=category_id,
assigned_to=assigned_to,
date_from=date_from,
diff --git a/app/schemas/ticket.py b/app/schemas/ticket.py
index e4bdfdf..303f1f7 100644
--- a/app/schemas/ticket.py
+++ b/app/schemas/ticket.py
@@ -26,6 +26,7 @@ class CategoryOut(BaseModel):
name: str
parent_id: int | None = None
sla_urgency: str | None = None
+ show_in_form: bool = True
model_config = {"from_attributes": True}
diff --git a/app/services/seed.py b/app/services/seed.py
index 222e58b..2474b2c 100644
--- a/app/services/seed.py
+++ b/app/services/seed.py
@@ -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": "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": "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 ─────────────────────────────────────────
{"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": "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": "Staff Behaviour", "subs": ["Staff conduct feedback"]},
# ── Emergency ────────────────────────────────────────────────
{"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": "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"},
]
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] = []
for group in SEED_CATEGORIES_DATA:
@@ -170,10 +186,16 @@ async def seed_categories(db: AsyncSession) -> list[Category]:
name=group["name"],
parent_id=None,
sla_urgency=group.get("sla_urgency"),
+ show_in_form=group.get("show_in_form", True),
)
db.add(parent)
await db.flush()
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
for sub_name in group["subs"]:
diff --git a/app/services/ticket.py b/app/services/ticket.py
index 2ec7c53..8dae348 100644
--- a/app/services/ticket.py
+++ b/app/services/ticket.py
@@ -156,6 +156,8 @@ async def list_tickets(
status_filter: str | None = None,
priority_filter: str | None = None,
property_filter: str | None = None,
+ building_filter: str | None = None,
+ unit_id: int | None = None,
category_id: int | None = None,
assigned_to: int | None = None,
date_from: datetime | None = None,
@@ -177,6 +179,12 @@ async def list_tickets(
# Join unit to filter by property
query = 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:
query = query.where(Ticket.category_id == category_id)
count_query = count_query.where(Ticket.category_id == category_id)
diff --git a/app/templates/base.html b/app/templates/base.html
index 3f6eec1..d12c5bf 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -355,6 +355,32 @@
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' },
+ ];
}
}
}
diff --git a/app/templates/dashboard/ceo.html b/app/templates/dashboard/ceo.html
index 35aa1b9..4c1232e 100644
--- a/app/templates/dashboard/ceo.html
+++ b/app/templates/dashboard/ceo.html
@@ -249,8 +249,9 @@
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');
+ const cats = await app().apiGet('/api/tickets/categories/flat?include_hidden=true');
const catCounts = {};
all.forEach(t => {
if (t.category_id) {
diff --git a/app/templates/dashboard/cs.html b/app/templates/dashboard/cs.html
index b5064b6..af8efd5 100644
--- a/app/templates/dashboard/cs.html
+++ b/app/templates/dashboard/cs.html
@@ -70,23 +70,24 @@
Open by Priority
-
-
+
-
-
-
+
+
Click a row to open the priority-grouped queue.
diff --git a/app/templates/dashboard/fm.html b/app/templates/dashboard/fm.html
index 5e5e89c..273e4ce 100644
--- a/app/templates/dashboard/fm.html
+++ b/app/templates/dashboard/fm.html
@@ -127,11 +127,18 @@
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
No active tickets
+
@@ -171,6 +204,12 @@
aging: {},
activeTickets: [],
emergencyCount: 0,
+ groupByPriority: false,
+ ageDir: 'desc',
+
+ get groupedActiveTickets() {
+ return app().groupByPriority(this.activeTickets, this.ageDir);
+ },
async init() {
await this.loadData();
diff --git a/app/templates/tickets/list.html b/app/templates/tickets/list.html
index cab1602..3b0d7f5 100644
--- a/app/templates/tickets/list.html
+++ b/app/templates/tickets/list.html
@@ -14,7 +14,7 @@
-
+
@@ -52,12 +52,39 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No apartments match
+
+
@@ -67,20 +94,27 @@
-
-
+
+
+
+
+
+
Page of
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Ticket |
+ Status |
+ Priority |
+ Description |
+ Assigned To |
+ Created |
+ SLA |
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+ —
+ |
+
+
+
+
+
+
+
+
+ No tickets match your filters
+
+