From 1df3c1c2188020773e4f624c930e371fd6ba4cc6 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 13:04:03 -0600 Subject: [PATCH] feat(lifecycle): transition routes, state badge, and edit-locking UI POST /month/{ym}/activate, /close, /reopen each return 204 with HX-Redirect so the page re-renders in the new state. All existing mutation routes now go through _require_editable_month, which 400s on closed months. Month nav grows a state badge and a context-appropriate lifecycle button. Close is rendered with a disabled attribute and tooltip when applied zero != 0. On closed months, name / planned / applied / notes inputs carry the disabled attribute; delete buttons, add forms, and the target form are omitted entirely. Refs #15 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/quartermaster/routes_month.py | 88 +++++++++++++++-- src/quartermaster/static/app.css | 60 ++++++++++++ .../templates/partials/month_nav.html | 26 +++++ .../templates/partials/month_section.html | 94 +++++++++++-------- .../templates/partials/month_target.html | 44 ++++----- 5 files changed, 243 insertions(+), 69 deletions(-) diff --git a/src/quartermaster/routes_month.py b/src/quartermaster/routes_month.py index b6ee5e2..729dee6 100644 --- a/src/quartermaster/routes_month.py +++ b/src/quartermaster/routes_month.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from quartermaster import month_service, service from quartermaster.db import get_session -from quartermaster.models import SECTION_LABELS, Month, Section +from quartermaster.models import SECTION_LABELS, Month, MonthState, Section from quartermaster.routes import templates router = APIRouter(prefix="/month/{year_month}", tags=["month"]) @@ -42,6 +42,15 @@ def _require_month(db: Session, year_month: str) -> Month: return month +def _require_editable_month(db: Session, year_month: str) -> Month: + month = _require_month(db, year_month) + try: + month_service.ensure_editable(month) + except month_service.MonthLifecycleError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return month + + def _section_views(month: Month) -> list[month_service.MonthSectionView]: return [ month_service.section_view(month, s, SECTION_LABELS[s]) for s in Section @@ -55,7 +64,11 @@ def _render_section( return templates.TemplateResponse( request, "partials/month_section.html", - {"month": month, "section": view}, + { + "month": month, + "section": view, + "editable": month.state != MonthState.closed, + }, ) @@ -66,7 +79,12 @@ def _render_target(request: Request, db: Session, month: Month) -> HTMLResponse: return templates.TemplateResponse( request, "partials/month_target.html", - {"month": month, "target": target, "debt_minimums": debt_minimums}, + { + "month": month, + "target": target, + "debt_minimums": debt_minimums, + "editable": month.state != MonthState.closed, + }, ) @@ -119,6 +137,11 @@ def view_month( }, ) zero = month_service.month_zero(month) + editable = month.state != MonthState.closed + can_close = ( + month.state == MonthState.active + and zero.applied == Decimal("0.00") + ) return templates.TemplateResponse( request, "month.html", @@ -137,6 +160,9 @@ def view_month( "zero": zero, "planned_tone": service.zero_tone(zero.planned), "applied_tone": service.zero_tone(zero.applied), + "state": month.state.value, + "editable": editable, + "can_close": can_close, }, ) @@ -167,7 +193,7 @@ def add_month_entry( notes: str | None = Form(None), db: Session = Depends(get_session), ) -> HTMLResponse: - month = _require_month(db, year_month) + month = _require_editable_month(db, year_month) clean_name = name.strip() if not clean_name: raise HTTPException(status_code=400, detail="name is required") @@ -189,7 +215,7 @@ def delete_month_entry( request: Request, db: Session = Depends(get_session), ) -> HTMLResponse: - month = _require_month(db, year_month) + month = _require_editable_month(db, year_month) section = month_service.delete_month_entry(db, month, entry_id) if section is None: raise HTTPException(status_code=404, detail="entry not found") @@ -214,7 +240,7 @@ def update_month_entry( notes: str | None = Form(None), db: Session = Depends(get_session), ) -> HTMLResponse: - month = _require_month(db, year_month) + month = _require_editable_month(db, year_month) clean_name: str | None = None if name is not None: clean_name = name.strip() @@ -240,6 +266,54 @@ def update_month_entry( ) +@router.post("/activate") +def activate_month( + year_month: str, + db: Session = Depends(get_session), +) -> Response: + month = _require_month(db, year_month) + try: + month_service.activate_month(db, month) + except month_service.MonthLifecycleError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return Response( + status_code=204, + headers={"HX-Redirect": f"/month/{year_month}"}, + ) + + +@router.post("/close") +def close_month( + year_month: str, + db: Session = Depends(get_session), +) -> Response: + month = _require_month(db, year_month) + try: + month_service.close_month(db, month) + except month_service.MonthLifecycleError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return Response( + status_code=204, + headers={"HX-Redirect": f"/month/{year_month}"}, + ) + + +@router.post("/reopen") +def reopen_month( + year_month: str, + db: Session = Depends(get_session), +) -> Response: + month = _require_month(db, year_month) + try: + month_service.reopen_month(db, month) + except month_service.MonthLifecycleError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return Response( + status_code=204, + headers={"HX-Redirect": f"/month/{year_month}"}, + ) + + @router.post("/target", response_class=HTMLResponse) def update_month_target( year_month: str, @@ -247,7 +321,7 @@ def update_month_target( month_entry_id: str = Form(""), db: Session = Depends(get_session), ) -> HTMLResponse: - month = _require_month(db, year_month) + month = _require_editable_month(db, year_month) raw = month_entry_id.strip() target_id: int | None if raw == "": diff --git a/src/quartermaster/static/app.css b/src/quartermaster/static/app.css index 5106d1e..c8cdf8d 100644 --- a/src/quartermaster/static/app.css +++ b/src/quartermaster/static/app.css @@ -336,6 +336,66 @@ details.group > .section:last-child { background: #f0ece0; } +.month-nav .state-badge { + padding: 0.15rem 0.6rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.state-badge.state-planning { + background: #e9ecf5; + color: #3a4fa1; +} + +.state-badge.state-active { + background: #d9f0e4; + color: #1f6b44; +} + +.state-badge.state-closed { + background: #e8e6dd; + color: #4a4a40; + text-transform: none; + letter-spacing: 0; +} + +.month-nav .lifecycle-form { + margin: 0; +} + +.month-nav .lifecycle-form button { + padding: 0.35rem 0.9rem; + font-size: 0.9rem; + border: 1px solid var(--rule); + border-radius: 3px; + cursor: pointer; + font: inherit; + background: var(--accent); + color: #fff; +} + +.month-nav .lifecycle-form button.secondary { + background: #fff; + color: var(--accent); +} + +.month-nav .lifecycle-form button[disabled] { + opacity: 0.5; + cursor: not-allowed; + background: #c5d4ce; + color: #fff; +} + +input[disabled], +select[disabled] { + background: #f4f3ef; + color: #6b6b6b; + cursor: not-allowed; +} + .month-nav .spacer { flex: 1; } diff --git a/src/quartermaster/templates/partials/month_nav.html b/src/quartermaster/templates/partials/month_nav.html index 3d3c636..bb60abc 100644 --- a/src/quartermaster/templates/partials/month_nav.html +++ b/src/quartermaster/templates/partials/month_nav.html @@ -2,6 +2,32 @@ ← {{ prev_year_month }} {{ year_month }} {{ next_year_month }} → + {% if state %} + + {% if state == 'closed' and month.closed_at %} + Closed {{ month.closed_at.strftime('%Y-%m-%d') }} + {% else %} + {{ state | capitalize }} + {% endif %} + + {% if state == 'planning' %} +
+ +
+ {% elif state == 'active' %} +
+ +
+ {% elif state == 'closed' %} +
+ +
+ {% endif %} + {% endif %} {% if all_months %} - - - - - - + {% if editable %} + + +
+ + + + +
+ + + {% endif %} diff --git a/src/quartermaster/templates/partials/month_target.html b/src/quartermaster/templates/partials/month_target.html index 5f0b73a..ca36f64 100644 --- a/src/quartermaster/templates/partials/month_target.html +++ b/src/quartermaster/templates/partials/month_target.html @@ -20,27 +20,29 @@ - - -
- - -
- - + {% if editable %} + + +
+ + +
+ + + {% endif %}