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

-
-
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 @@
-
+

Active Tickets

- View All → +
+ + + View All → +
-
+
@@ -160,6 +167,32 @@
+
+ +
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
- +
- -
+ +
@@ -125,6 +159,53 @@
+ + +
+ +
+ No tickets match your filters +
+