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:
parent
fa9a397d83
commit
1df3c1c218
5 changed files with 243 additions and 69 deletions
|
|
@ -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 == "":
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,32 @@
|
|||
<a class="nav-link" href="/month/{{ prev_year_month }}" aria-label="Previous month">← {{ 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 }} →</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
|
||||
|
|
|
|||
|
|
@ -24,10 +24,12 @@
|
|||
type="text"
|
||||
name="name"
|
||||
value="{{ row.entry.name }}"
|
||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||
hx-trigger="change"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
{% 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) }}"
|
||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||
hx-trigger="change"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
{% 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,21 +55,25 @@
|
|||
type="number" step="0.01" min="0"
|
||||
name="applied"
|
||||
value="{{ '%.2f' | format(row.entry.applied) }}"
|
||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||
hx-trigger="change"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
{% 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">
|
||||
<button
|
||||
class="delete"
|
||||
type="button"
|
||||
hx-delete="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
aria-label="Delete {{ row.entry.name }}"
|
||||
>×</button>
|
||||
{% if editable %}
|
||||
<button
|
||||
class="delete"
|
||||
type="button"
|
||||
hx-delete="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
aria-label="Delete {{ row.entry.name }}"
|
||||
>×</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="entry-notes-row">
|
||||
|
|
@ -76,33 +84,37 @@
|
|||
name="notes"
|
||||
value="{{ row.entry.notes or '' }}"
|
||||
placeholder="notes..."
|
||||
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 }}"
|
||||
{% 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 %}
|
||||
<tr class="add-row">
|
||||
<td colspan="4">
|
||||
<form
|
||||
class="add-form month-add-form"
|
||||
hx-post="/month/{{ month.year_month }}/sections/{{ section.section.value }}/entries"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
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>
|
||||
<button type="submit">Add</button>
|
||||
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% if editable %}
|
||||
<tr class="add-row">
|
||||
<td colspan="4">
|
||||
<form
|
||||
class="add-form month-add-form"
|
||||
hx-post="/month/{{ month.year_month }}/sections/{{ section.section.value }}/entries"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
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>
|
||||
<button type="submit">Add</button>
|
||||
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -20,27 +20,29 @@
|
|||
<td class="entry-amount"></td>
|
||||
<td class="entry-actions"></td>
|
||||
</tr>
|
||||
<tr class="add-row">
|
||||
<td colspan="3">
|
||||
<form
|
||||
class="target-form"
|
||||
hx-post="/month/{{ month.year_month }}/target"
|
||||
hx-target="#section-debt_target"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<select name="month_entry_id">
|
||||
<option value="">(none)</option>
|
||||
{% for dm in debt_minimums %}
|
||||
<option
|
||||
value="{{ dm.id }}"
|
||||
{% if target.month_entry_id == dm.id %}selected{% endif %}
|
||||
>{{ dm.name }}: ${{ '%.2f' | format(dm.planned) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Set</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% if editable %}
|
||||
<tr class="add-row">
|
||||
<td colspan="3">
|
||||
<form
|
||||
class="target-form"
|
||||
hx-post="/month/{{ month.year_month }}/target"
|
||||
hx-target="#section-debt_target"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<select name="month_entry_id">
|
||||
<option value="">(none)</option>
|
||||
{% for dm in debt_minimums %}
|
||||
<option
|
||||
value="{{ dm.id }}"
|
||||
{% if target.month_entry_id == dm.id %}selected{% endif %}
|
||||
>{{ dm.name }}: ${{ '%.2f' | format(dm.planned) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Set</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
|
|
|||
Loading…
Reference in a new issue