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 %}