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) <noreply@anthropic.com>
This commit is contained in:
archeious 2026-04-17 13:04:03 -06:00
parent fa9a397d83
commit 1df3c1c218
5 changed files with 243 additions and 69 deletions

View file

@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from quartermaster import month_service, service from quartermaster import month_service, service
from quartermaster.db import get_session 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 from quartermaster.routes import templates
router = APIRouter(prefix="/month/{year_month}", tags=["month"]) router = APIRouter(prefix="/month/{year_month}", tags=["month"])
@ -42,6 +42,15 @@ def _require_month(db: Session, year_month: str) -> Month:
return 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]: def _section_views(month: Month) -> list[month_service.MonthSectionView]:
return [ return [
month_service.section_view(month, s, SECTION_LABELS[s]) for s in Section month_service.section_view(month, s, SECTION_LABELS[s]) for s in Section
@ -55,7 +64,11 @@ def _render_section(
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"partials/month_section.html", "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( return templates.TemplateResponse(
request, request,
"partials/month_target.html", "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) 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( return templates.TemplateResponse(
request, request,
"month.html", "month.html",
@ -137,6 +160,9 @@ def view_month(
"zero": zero, "zero": zero,
"planned_tone": service.zero_tone(zero.planned), "planned_tone": service.zero_tone(zero.planned),
"applied_tone": service.zero_tone(zero.applied), "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), notes: str | None = Form(None),
db: Session = Depends(get_session), db: Session = Depends(get_session),
) -> HTMLResponse: ) -> HTMLResponse:
month = _require_month(db, year_month) month = _require_editable_month(db, year_month)
clean_name = name.strip() clean_name = name.strip()
if not clean_name: if not clean_name:
raise HTTPException(status_code=400, detail="name is required") raise HTTPException(status_code=400, detail="name is required")
@ -189,7 +215,7 @@ def delete_month_entry(
request: Request, request: Request,
db: Session = Depends(get_session), db: Session = Depends(get_session),
) -> HTMLResponse: ) -> HTMLResponse:
month = _require_month(db, year_month) month = _require_editable_month(db, year_month)
section = month_service.delete_month_entry(db, month, entry_id) section = month_service.delete_month_entry(db, month, entry_id)
if section is None: if section is None:
raise HTTPException(status_code=404, detail="entry not found") raise HTTPException(status_code=404, detail="entry not found")
@ -214,7 +240,7 @@ def update_month_entry(
notes: str | None = Form(None), notes: str | None = Form(None),
db: Session = Depends(get_session), db: Session = Depends(get_session),
) -> HTMLResponse: ) -> HTMLResponse:
month = _require_month(db, year_month) month = _require_editable_month(db, year_month)
clean_name: str | None = None clean_name: str | None = None
if name is not None: if name is not None:
clean_name = name.strip() 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) @router.post("/target", response_class=HTMLResponse)
def update_month_target( def update_month_target(
year_month: str, year_month: str,
@ -247,7 +321,7 @@ def update_month_target(
month_entry_id: str = Form(""), month_entry_id: str = Form(""),
db: Session = Depends(get_session), db: Session = Depends(get_session),
) -> HTMLResponse: ) -> HTMLResponse:
month = _require_month(db, year_month) month = _require_editable_month(db, year_month)
raw = month_entry_id.strip() raw = month_entry_id.strip()
target_id: int | None target_id: int | None
if raw == "": if raw == "":

View file

@ -336,6 +336,66 @@ details.group > .section:last-child {
background: #f0ece0; 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 { .month-nav .spacer {
flex: 1; 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> <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> <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> <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> <span class="spacer"></span>
{% if all_months %} {% if all_months %}
<select <select

View file

@ -24,10 +24,12 @@
type="text" type="text"
name="name" name="name"
value="{{ row.entry.name }}" value="{{ row.entry.name }}"
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}" {% if editable %}
hx-trigger="change" hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-target="#section-{{ section.section.value }}" hx-trigger="change"
hx-swap="outerHTML" hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
{% else %}disabled{% endif %}
> >
{% if row.state.value == 'edited' %} {% if row.state.value == 'edited' %}
<span class="tag tag-edited">modified</span> <span class="tag tag-edited">modified</span>
@ -40,10 +42,12 @@
type="number" step="0.01" min="0" type="number" step="0.01" min="0"
name="planned" name="planned"
value="{{ '%.2f' | format(row.entry.planned) }}" value="{{ '%.2f' | format(row.entry.planned) }}"
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}" {% if editable %}
hx-trigger="change" hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-target="#section-{{ section.section.value }}" hx-trigger="change"
hx-swap="outerHTML" hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
{% else %}disabled{% endif %}
> >
</td> </td>
<td class="entry-amount"> <td class="entry-amount">
@ -51,21 +55,25 @@
type="number" step="0.01" min="0" type="number" step="0.01" min="0"
name="applied" name="applied"
value="{{ '%.2f' | format(row.entry.applied) }}" value="{{ '%.2f' | format(row.entry.applied) }}"
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}" {% if editable %}
hx-trigger="change" hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-target="#section-{{ section.section.value }}" hx-trigger="change"
hx-swap="outerHTML" hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
{% else %}disabled{% endif %}
> >
</td> </td>
<td class="entry-actions"> <td class="entry-actions">
<button {% if editable %}
class="delete" <button
type="button" class="delete"
hx-delete="/month/{{ month.year_month }}/entries/{{ row.entry.id }}" type="button"
hx-target="#section-{{ section.section.value }}" hx-delete="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-swap="outerHTML" hx-target="#section-{{ section.section.value }}"
aria-label="Delete {{ row.entry.name }}" hx-swap="outerHTML"
>&times;</button> aria-label="Delete {{ row.entry.name }}"
>&times;</button>
{% endif %}
</td> </td>
</tr> </tr>
<tr class="entry-notes-row"> <tr class="entry-notes-row">
@ -76,33 +84,37 @@
name="notes" name="notes"
value="{{ row.entry.notes or '' }}" value="{{ row.entry.notes or '' }}"
placeholder="notes..." placeholder="notes..."
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}" {% if editable %}
hx-trigger="change" hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-target="#section-{{ section.section.value }}" hx-trigger="change"
hx-swap="outerHTML" hx-target="#section-{{ section.section.value }}"
aria-label="Notes for {{ row.entry.name }}" hx-swap="outerHTML"
aria-label="Notes for {{ row.entry.name }}"
{% else %}disabled{% endif %}
> >
</td> </td>
</tr> </tr>
{% else %} {% else %}
<tr class="empty"><td colspan="4">No entries.</td></tr> <tr class="empty"><td colspan="4">No entries.</td></tr>
{% endfor %} {% endfor %}
<tr class="add-row"> {% if editable %}
<td colspan="4"> <tr class="add-row">
<form <td colspan="4">
class="add-form month-add-form" <form
hx-post="/month/{{ month.year_month }}/sections/{{ section.section.value }}/entries" class="add-form month-add-form"
hx-target="#section-{{ section.section.value }}" hx-post="/month/{{ month.year_month }}/sections/{{ section.section.value }}/entries"
hx-swap="outerHTML" hx-target="#section-{{ section.section.value }}"
hx-on::after-request="if(event.detail.successful) this.reset()" hx-swap="outerHTML"
> hx-on::after-request="if(event.detail.successful) this.reset()"
<input type="text" name="name" placeholder="Name" required> >
<input type="number" name="planned" step="0.01" min="0" placeholder="Planned" required> <input type="text" name="name" placeholder="Name" required>
<button type="submit">Add</button> <input type="number" name="planned" step="0.01" min="0" placeholder="Planned" required>
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)"> <button type="submit">Add</button>
</form> <input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
</td> </form>
</tr> </td>
</tr>
{% endif %}
</tbody> </tbody>
</table> </table>
</section> </section>

View file

@ -20,27 +20,29 @@
<td class="entry-amount"></td> <td class="entry-amount"></td>
<td class="entry-actions"></td> <td class="entry-actions"></td>
</tr> </tr>
<tr class="add-row"> {% if editable %}
<td colspan="3"> <tr class="add-row">
<form <td colspan="3">
class="target-form" <form
hx-post="/month/{{ month.year_month }}/target" class="target-form"
hx-target="#section-debt_target" hx-post="/month/{{ month.year_month }}/target"
hx-swap="outerHTML" hx-target="#section-debt_target"
> hx-swap="outerHTML"
<select name="month_entry_id"> >
<option value="">(none)</option> <select name="month_entry_id">
{% for dm in debt_minimums %} <option value="">(none)</option>
<option {% for dm in debt_minimums %}
value="{{ dm.id }}" <option
{% if target.month_entry_id == dm.id %}selected{% endif %} value="{{ dm.id }}"
>{{ dm.name }}: ${{ '%.2f' | format(dm.planned) }}</option> {% if target.month_entry_id == dm.id %}selected{% endif %}
{% endfor %} >{{ dm.name }}: ${{ '%.2f' | format(dm.planned) }}</option>
</select> {% endfor %}
<button type="submit">Set</button> </select>
</form> <button type="submit">Set</button>
</td> </form>
</tr> </td>
</tr>
{% endif %}
</tbody> </tbody>
</table> </table>
</section> </section>