diff --git a/src/quartermaster/main.py b/src/quartermaster/main.py index 3c1a2ad..30df371 100644 --- a/src/quartermaster/main.py +++ b/src/quartermaster/main.py @@ -6,6 +6,7 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from quartermaster.routes import router +from quartermaster.routes_month import router as month_router STATIC_DIR = Path(__file__).parent / "static" @@ -14,6 +15,7 @@ def create_app() -> FastAPI: app = FastAPI(title="Quartermaster", version="0.1.0") app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") app.include_router(router) + app.include_router(month_router) return app diff --git a/src/quartermaster/routes.py b/src/quartermaster/routes.py index eb7c6c9..b208268 100644 --- a/src/quartermaster/routes.py +++ b/src/quartermaster/routes.py @@ -8,7 +8,7 @@ from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session -from quartermaster import service +from quartermaster import month_service, service from quartermaster.db import get_session from quartermaster.models import SECTION_LABELS, Section @@ -62,6 +62,7 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse: sections = [_section_view(db, s) for s in Section] target = service.get_debt_target(db) debt_minimums = service.list_entries(db, Section.debt_minimum) + current_ym = month_service.current_year_month() return templates.TemplateResponse( request, "index.html", @@ -69,6 +70,8 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse: "sections": sections, "target": target, "debt_minimums": debt_minimums, + "current_year_month": current_ym, + "all_months": month_service.list_months(db), }, ) diff --git a/src/quartermaster/routes_month.py b/src/quartermaster/routes_month.py new file mode 100644 index 0000000..9908829 --- /dev/null +++ b/src/quartermaster/routes_month.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +from decimal import Decimal, InvalidOperation + +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, Response +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.routes import templates + +router = APIRouter(prefix="/month/{year_month}", tags=["month"]) + + +def _parse_amount(raw: str, *, allow_zero: bool = True) -> Decimal: + try: + amount = Decimal(raw.strip()) + except (InvalidOperation, AttributeError) as exc: + raise HTTPException(status_code=400, detail="amount must be numeric") from exc + if amount < 0: + raise HTTPException(status_code=400, detail="amount must be non-negative") + if amount == 0 and not allow_zero: + raise HTTPException(status_code=400, detail="amount must be positive") + return amount.quantize(Decimal("0.01")) + + +def _require_year_month(year_month: str) -> str: + if not month_service.valid_year_month(year_month): + raise HTTPException( + status_code=404, detail="year_month must be formatted as YYYY-MM" + ) + return year_month + + +def _require_month(db: Session, year_month: str) -> Month: + _require_year_month(year_month) + month = month_service.get_month(db, year_month) + if month is None: + raise HTTPException(status_code=404, detail="month has not been created yet") + 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 + ] + + +def _render_section( + request: Request, month: Month, section: Section +) -> HTMLResponse: + view = month_service.section_view(month, section, SECTION_LABELS[section]) + return templates.TemplateResponse( + request, + "partials/month_section.html", + {"month": month, "section": view}, + ) + + +def _render_target(request: Request, db: Session, month: Month) -> HTMLResponse: + target = month_service.get_month_target(db, month) + debt_minimums = [e for e in month.entries if e.section == Section.debt_minimum] + debt_minimums.sort(key=lambda e: e.id) + return templates.TemplateResponse( + request, + "partials/month_target.html", + {"month": month, "target": target, "debt_minimums": debt_minimums}, + ) + + +@router.get("", response_class=HTMLResponse) +def view_month( + year_month: str, + request: Request, + db: Session = Depends(get_session), +) -> HTMLResponse: + _require_year_month(year_month) + month = month_service.get_month(db, year_month) + prev_ym = month_service.shift_year_month(year_month, -1) + next_ym = month_service.shift_year_month(year_month, 1) + all_months = month_service.list_months(db) + if month is None: + return templates.TemplateResponse( + request, + "month_create.html", + { + "year_month": year_month, + "prev_year_month": prev_ym, + "next_year_month": next_ym, + "all_months": all_months, + }, + ) + return templates.TemplateResponse( + request, + "month.html", + { + "month": month, + "year_month": year_month, + "prev_year_month": prev_ym, + "next_year_month": next_ym, + "all_months": all_months, + "sections": _section_views(month), + "target": month_service.get_month_target(db, month), + "debt_minimums": sorted( + (e for e in month.entries if e.section == Section.debt_minimum), + key=lambda e: e.id, + ), + }, + ) + + +@router.post("/create") +def create_month( + year_month: str, + db: Session = Depends(get_session), +) -> Response: + _require_year_month(year_month) + try: + month_service.create_month(db, year_month) + except ValueError 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("/sections/{section}/entries", response_class=HTMLResponse) +def add_month_entry( + year_month: str, + section: Section, + request: Request, + name: str = Form(...), + planned: str = Form(...), + db: Session = Depends(get_session), +) -> HTMLResponse: + month = _require_month(db, year_month) + clean_name = name.strip() + if not clean_name: + raise HTTPException(status_code=400, detail="name is required") + month_service.add_month_entry( + db, month, section, clean_name, _parse_amount(planned) + ) + db.refresh(month) + return _render_section(request, month, section) + + +@router.delete("/entries/{entry_id}", response_class=HTMLResponse) +def delete_month_entry( + year_month: str, + entry_id: int, + request: Request, + db: Session = Depends(get_session), +) -> HTMLResponse: + month = _require_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") + db.refresh(month) + response = _render_section(request, month, section) + if section == Section.debt_minimum: + target_html = _render_target(request, db, month).body.decode() + response = HTMLResponse(response.body.decode() + target_html) + return response + + +@router.post("/entries/{entry_id}", response_class=HTMLResponse) +def update_month_entry( + year_month: str, + entry_id: int, + request: Request, + name: str | None = Form(None), + planned: str | None = Form(None), + applied: str | None = Form(None), + db: Session = Depends(get_session), +) -> HTMLResponse: + month = _require_month(db, year_month) + clean_name: str | None = None + if name is not None: + clean_name = name.strip() + if not clean_name: + raise HTTPException(status_code=400, detail="name must not be empty") + parsed_planned = _parse_amount(planned) if planned is not None else None + parsed_applied = _parse_amount(applied) if applied is not None else None + updated = month_service.update_month_entry( + db, + month, + entry_id, + name=clean_name, + planned=parsed_planned, + applied=parsed_applied, + ) + if updated is None: + raise HTTPException(status_code=404, detail="entry not found") + db.refresh(month) + return _render_section(request, month, updated.section) + + +@router.post("/target", response_class=HTMLResponse) +def update_month_target( + year_month: str, + request: Request, + month_entry_id: str = Form(""), + db: Session = Depends(get_session), +) -> HTMLResponse: + month = _require_month(db, year_month) + raw = month_entry_id.strip() + target_id: int | None + if raw == "": + target_id = None + else: + try: + target_id = int(raw) + except ValueError as exc: + raise HTTPException( + status_code=400, detail="month_entry_id must be an integer" + ) from exc + try: + month_service.set_month_target(db, month, target_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return _render_target(request, db, month) diff --git a/src/quartermaster/static/app.css b/src/quartermaster/static/app.css index 13c3aa8..167b549 100644 --- a/src/quartermaster/static/app.css +++ b/src/quartermaster/static/app.css @@ -156,3 +156,124 @@ button[type=submit] { .target-section .section-header { border-bottom-style: dashed; } + +/* Monthly view ----------------------------------------------------------- */ + +.month-nav { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0; + margin-top: 1rem; + flex-wrap: wrap; +} + +.month-nav .month-label { + font-size: 1.2rem; + font-weight: 600; + padding: 0 0.5rem; +} + +.month-nav .nav-link { + color: var(--accent); + text-decoration: none; + padding: 0.25rem 0.5rem; + border: 1px solid var(--rule); + border-radius: 3px; + background: #fff; + font-size: 0.9rem; +} + +.month-nav .nav-link:hover { + background: #f0ece0; +} + +.month-nav .spacer { + flex: 1; +} + +.month-nav .month-picker { + padding: 0.3rem 0.5rem; + font-size: 0.9rem; +} + +.total .divider { + color: var(--muted); + margin: 0 0.25rem; + font-weight: 400; +} + +.total .planned { + color: var(--muted); +} + +table.month-entries thead th { + text-align: left; + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + color: var(--muted); + font-weight: 500; + border-bottom: 1px solid var(--rule); +} + +table.month-entries th.col-planned, +table.month-entries th.col-applied { + text-align: right; + width: 8rem; +} + +table.month-entries th.col-actions { + width: 2.25rem; +} + +table.month-entries td.entry-name input[name="name"] { + width: 100%; + min-width: 8rem; +} + +table.month-entries td.entry-amount input { + width: 100%; + text-align: right; +} + +tr.entry.state-edited { + background: #fff7e0; +} + +tr.entry.state-new_in_month { + background: #e8f2ff; +} + +.tag { + display: inline-block; + margin-left: 0.4rem; + padding: 0.05rem 0.4rem; + border-radius: 3px; + font-size: 0.72rem; + font-weight: 600; + text-transform: lowercase; + letter-spacing: 0.02em; +} + +.tag-edited { + background: #f4d07a; + color: #5a3f00; +} + +.tag-new { + background: #9cc5ef; + color: #123a66; +} + +.month-add-form { + grid-template-columns: 1fr 9rem auto; +} + +.month-missing-body { + padding: 0.75rem 0.5rem; + color: var(--muted); +} + +.month-create-form { + padding: 0.25rem 0.5rem 0.75rem; +} diff --git a/src/quartermaster/templates/index.html b/src/quartermaster/templates/index.html index ec46dc5..4d48a18 100644 --- a/src/quartermaster/templates/index.html +++ b/src/quartermaster/templates/index.html @@ -1,6 +1,23 @@ {% extends "base.html" %} {% block content %}
+ This month has not been created. Creating it will snapshot the current + budget: every entry, its planned amount, and the current Primary Debt + Target. Applied amounts start at $0.00. +
+ +| Name | +Planned | +Applied | ++ |
|---|---|---|---|
| + + {% if row.state.value == 'edited' %} + modified + {% elif row.state.value == 'new_in_month' %} + new this month + {% endif %} + | ++ + | ++ + | ++ + | +
| No entries. | |||
| + + | +|||
| + {% if target.entry %}{{ target.entry.name }}{% else %}No target selected.{% endif %} + | ++ | + |
| + + | +||