Month lifecycle: Planning, Active, Closed with reconciliation gate #16

Merged
claude-code merged 4 commits from feat/15-month-lifecycle into main 2026-04-17 13:05:00 -06:00
5 changed files with 243 additions and 69 deletions
Showing only changes of commit 1df3c1c218 - Show all commits

View file

@ -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 == "":

View file

@ -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;
}

View file

@ -2,6 +2,32 @@
<a class="nav-link" href="/month/{{ prev_year_month }}" aria-label="Previous month">&larr; {{ prev_year_month }}</a>
<span class="month-label">{{ year_month }}</span>
<a class="nav-link" href="/month/{{ next_year_month }}" aria-label="Next month">{{ next_year_month }} &rarr;</a>
{% if state %}
<span class="state-badge state-{{ state }}">
{% if state == 'closed' and month.closed_at %}
Closed {{ month.closed_at.strftime('%Y-%m-%d') }}
{% else %}
{{ state | capitalize }}
{% endif %}
</span>
{% if state == 'planning' %}
<form class="lifecycle-form" hx-post="/month/{{ year_month }}/activate" hx-swap="none">
<button type="submit" class="primary">Activate</button>
</form>
{% elif state == 'active' %}
<form class="lifecycle-form" hx-post="/month/{{ year_month }}/close" hx-swap="none">
<button
type="submit"
class="primary"
{% if not can_close %}disabled title="Applied balance must equal $0.00 to close"{% endif %}
>Close</button>
</form>
{% elif state == 'closed' %}
<form class="lifecycle-form" hx-post="/month/{{ year_month }}/reopen" hx-swap="none">
<button type="submit" class="secondary">Reopen</button>
</form>
{% endif %}
{% endif %}
<span class="spacer"></span>
{% if all_months %}
<select

View file

@ -24,10 +24,12 @@
type="text"
name="name"
value="{{ row.entry.name }}"
{% if editable %}
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-trigger="change"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
{% else %}disabled{% endif %}
>
{% if row.state.value == 'edited' %}
<span class="tag tag-edited">modified</span>
@ -40,10 +42,12 @@
type="number" step="0.01" min="0"
name="planned"
value="{{ '%.2f' | format(row.entry.planned) }}"
{% if editable %}
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-trigger="change"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
{% else %}disabled{% endif %}
>
</td>
<td class="entry-amount">
@ -51,13 +55,16 @@
type="number" step="0.01" min="0"
name="applied"
value="{{ '%.2f' | format(row.entry.applied) }}"
{% if editable %}
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-trigger="change"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
{% else %}disabled{% endif %}
>
</td>
<td class="entry-actions">
{% if editable %}
<button
class="delete"
type="button"
@ -66,6 +73,7 @@
hx-swap="outerHTML"
aria-label="Delete {{ row.entry.name }}"
>&times;</button>
{% endif %}
</td>
</tr>
<tr class="entry-notes-row">
@ -76,17 +84,20 @@
name="notes"
value="{{ row.entry.notes or '' }}"
placeholder="notes..."
{% if editable %}
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-trigger="change"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
aria-label="Notes for {{ row.entry.name }}"
{% else %}disabled{% endif %}
>
</td>
</tr>
{% else %}
<tr class="empty"><td colspan="4">No entries.</td></tr>
{% endfor %}
{% if editable %}
<tr class="add-row">
<td colspan="4">
<form
@ -103,6 +114,7 @@
</form>
</td>
</tr>
{% endif %}
</tbody>
</table>
</section>

View file

@ -20,6 +20,7 @@
<td class="entry-amount"></td>
<td class="entry-actions"></td>
</tr>
{% if editable %}
<tr class="add-row">
<td colspan="3">
<form
@ -41,6 +42,7 @@
</form>
</td>
</tr>
{% endif %}
</tbody>
</table>
</section>