diff --git a/alembic/versions/a4ec4f8f6e9f_add_month_lifecycle_state.py b/alembic/versions/a4ec4f8f6e9f_add_month_lifecycle_state.py new file mode 100644 index 0000000..fd89b7c --- /dev/null +++ b/alembic/versions/a4ec4f8f6e9f_add_month_lifecycle_state.py @@ -0,0 +1,40 @@ +"""add month lifecycle state + +Revision ID: a4ec4f8f6e9f +Revises: ec804bdf366d +Create Date: 2026-04-17 12:59:25.811354 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a4ec4f8f6e9f' +down_revision: Union[str, Sequence[str], None] = 'ec804bdf366d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('month', schema=None) as batch_op: + batch_op.add_column(sa.Column('state', sa.Enum('planning', 'active', 'closed', name='monthstate', native_enum=False, length=16), server_default='planning', nullable=False)) + batch_op.add_column(sa.Column('activated_at', sa.DateTime(timezone=True), nullable=True)) + batch_op.add_column(sa.Column('closed_at', sa.DateTime(timezone=True), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('month', schema=None) as batch_op: + batch_op.drop_column('closed_at') + batch_op.drop_column('activated_at') + batch_op.drop_column('state') + + # ### end Alembic commands ### diff --git a/src/quartermaster/models.py b/src/quartermaster/models.py index c2edacb..6d8f25a 100644 --- a/src/quartermaster/models.py +++ b/src/quartermaster/models.py @@ -27,6 +27,12 @@ class Section(str, enum.Enum): sinking_fund = "sinking_fund" +class MonthState(str, enum.Enum): + planning = "planning" + active = "active" + closed = "closed" + + SECTION_LABELS: dict[Section, str] = { Section.income: "Incomes", Section.fixed_bill: "Fixed Amount Bills", @@ -86,6 +92,18 @@ class Month(Base): id: Mapped[int] = mapped_column(primary_key=True) year_month: Mapped[str] = mapped_column(String(7), nullable=False, unique=True) + state: Mapped[MonthState] = mapped_column( + Enum(MonthState, native_enum=False, length=16), + nullable=False, + default=MonthState.planning, + server_default=MonthState.planning.value, + ) + activated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + closed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) diff --git a/src/quartermaster/month_service.py b/src/quartermaster/month_service.py index 8928aad..9ecc0b9 100644 --- a/src/quartermaster/month_service.py +++ b/src/quartermaster/month_service.py @@ -2,7 +2,7 @@ from __future__ import annotations import re from dataclasses import dataclass -from datetime import date +from datetime import date, datetime, timezone from decimal import Decimal from enum import Enum @@ -23,6 +23,7 @@ from quartermaster.models import ( Month, MonthDebtTarget, MonthEntry, + MonthState, Section, ) @@ -306,3 +307,54 @@ def set_month_target( db.commit() db.refresh(target) return target + + +class MonthLifecycleError(Exception): + """Raised when a state transition or edit is rejected.""" + + +def activate_month(db: Session, month: Month) -> Month: + if month.state != MonthState.planning: + raise MonthLifecycleError( + f"cannot activate a {month.state.value} month" + ) + month.state = MonthState.active + month.activated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(month) + return month + + +def close_month(db: Session, month: Month) -> Month: + if month.state != MonthState.active: + raise MonthLifecycleError( + f"cannot close a {month.state.value} month" + ) + zero = month_zero(month) + if zero.applied != Decimal("0.00"): + raise MonthLifecycleError( + "applied balance must equal $0.00 before closing; " + f"currently ${zero.applied}" + ) + month.state = MonthState.closed + month.closed_at = datetime.now(timezone.utc) + db.commit() + db.refresh(month) + return month + + +def reopen_month(db: Session, month: Month) -> Month: + if month.state != MonthState.closed: + raise MonthLifecycleError( + f"cannot reopen a {month.state.value} month" + ) + month.state = MonthState.active + month.closed_at = None + db.commit() + db.refresh(month) + return month + + +def ensure_editable(month: Month) -> None: + if month.state == MonthState.closed: + raise MonthLifecycleError("month is closed; reopen to edit") 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 %}