feat(month): add routes, templates, and nav between budget and months
Non-existent months return a page with a single "Create this month" button; create POSTs return HX-Redirect to the newly-created month. Each entry row carries three inline HTMX-wired inputs (name, planned, applied) that trigger on change, posting only the field that changed. Edits swap the section partial so totals and deviation tags update together. Deleting a debt minimum in a month also re-renders the target card via OOB swap. The budget page grows a This-month link and a month picker; each month page has prev / next / picker / back-to- config controls. Refs #3 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ed038fd974
commit
e7354ba8d6
10 changed files with 564 additions and 1 deletions
|
|
@ -6,6 +6,7 @@ from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from quartermaster.routes import router
|
from quartermaster.routes import router
|
||||||
|
from quartermaster.routes_month import router as month_router
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
|
|
@ -14,6 +15,7 @@ def create_app() -> FastAPI:
|
||||||
app = FastAPI(title="Quartermaster", version="0.1.0")
|
app = FastAPI(title="Quartermaster", version="0.1.0")
|
||||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
|
app.include_router(month_router)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from quartermaster import 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, Section
|
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]
|
sections = [_section_view(db, s) for s in Section]
|
||||||
target = service.get_debt_target(db)
|
target = service.get_debt_target(db)
|
||||||
debt_minimums = service.list_entries(db, Section.debt_minimum)
|
debt_minimums = service.list_entries(db, Section.debt_minimum)
|
||||||
|
current_ym = month_service.current_year_month()
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"index.html",
|
"index.html",
|
||||||
|
|
@ -69,6 +70,8 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse:
|
||||||
"sections": sections,
|
"sections": sections,
|
||||||
"target": target,
|
"target": target,
|
||||||
"debt_minimums": debt_minimums,
|
"debt_minimums": debt_minimums,
|
||||||
|
"current_year_month": current_ym,
|
||||||
|
"all_months": month_service.list_months(db),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
224
src/quartermaster/routes_month.py
Normal file
224
src/quartermaster/routes_month.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -156,3 +156,124 @@ button[type=submit] {
|
||||||
.target-section .section-header {
|
.target-section .section-header {
|
||||||
border-bottom-style: dashed;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,23 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="budget">
|
<div class="budget">
|
||||||
|
<nav class="month-nav budget-nav">
|
||||||
|
<span class="month-label">Budget configuration</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<a class="nav-link" href="/month/{{ current_year_month }}">This month ({{ current_year_month }})</a>
|
||||||
|
{% if all_months %}
|
||||||
|
<select
|
||||||
|
class="month-picker"
|
||||||
|
onchange="if(this.value){window.location=this.value}"
|
||||||
|
aria-label="Jump to month"
|
||||||
|
>
|
||||||
|
<option value="">Jump to...</option>
|
||||||
|
{% for m in all_months %}
|
||||||
|
<option value="/month/{{ m.year_month }}">{{ m.year_month }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
{% for section in sections %}
|
{% for section in sections %}
|
||||||
{% if section.section.value == 'debt_minimum' %}
|
{% if section.section.value == 'debt_minimum' %}
|
||||||
{% include "partials/section.html" %}
|
{% include "partials/section.html" %}
|
||||||
|
|
|
||||||
14
src/quartermaster/templates/month.html
Normal file
14
src/quartermaster/templates/month.html
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="budget">
|
||||||
|
{% include "partials/month_nav.html" %}
|
||||||
|
{% for section in sections %}
|
||||||
|
{% if section.section.value == 'debt_minimum' %}
|
||||||
|
{% include "partials/month_section.html" %}
|
||||||
|
{% include "partials/month_target.html" %}
|
||||||
|
{% else %}
|
||||||
|
{% include "partials/month_section.html" %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
23
src/quartermaster/templates/month_create.html
Normal file
23
src/quartermaster/templates/month_create.html
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="budget">
|
||||||
|
{% include "partials/month_nav.html" %}
|
||||||
|
<section class="section month-missing">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>No snapshot yet</h2>
|
||||||
|
</div>
|
||||||
|
<p class="month-missing-body">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
hx-post="/month/{{ year_month }}/create"
|
||||||
|
hx-swap="none"
|
||||||
|
class="month-create-form"
|
||||||
|
>
|
||||||
|
<button type="submit">Create {{ year_month }}</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
22
src/quartermaster/templates/partials/month_nav.html
Normal file
22
src/quartermaster/templates/partials/month_nav.html
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<nav class="month-nav">
|
||||||
|
<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>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
{% if all_months %}
|
||||||
|
<select
|
||||||
|
class="month-picker"
|
||||||
|
onchange="if(this.value){window.location=this.value}"
|
||||||
|
aria-label="Jump to month"
|
||||||
|
>
|
||||||
|
<option value="">Jump to...</option>
|
||||||
|
{% for m in all_months %}
|
||||||
|
<option
|
||||||
|
value="/month/{{ m.year_month }}"
|
||||||
|
{% if m.year_month == year_month %}selected{% endif %}
|
||||||
|
>{{ m.year_month }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
<a class="nav-link" href="/">Budget config</a>
|
||||||
|
</nav>
|
||||||
91
src/quartermaster/templates/partials/month_section.html
Normal file
91
src/quartermaster/templates/partials/month_section.html
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<section class="section" id="section-{{ section.section.value }}">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>{{ section.label }}</h2>
|
||||||
|
<span class="total" data-testid="total-{{ section.section.value }}">
|
||||||
|
<span class="applied">${{ '%.2f' | format(section.total_applied) }}</span>
|
||||||
|
<span class="divider">/</span>
|
||||||
|
<span class="planned">${{ '%.2f' | format(section.total_planned) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<table class="entries month-entries">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-name">Name</th>
|
||||||
|
<th class="col-planned">Planned</th>
|
||||||
|
<th class="col-applied">Applied</th>
|
||||||
|
<th class="col-actions"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in section.rows %}
|
||||||
|
<tr class="entry state-{{ row.state.value }}" data-entry-id="{{ row.entry.id }}">
|
||||||
|
<td class="entry-name">
|
||||||
|
<input
|
||||||
|
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 row.state.value == 'edited' %}
|
||||||
|
<span class="tag tag-edited">modified</span>
|
||||||
|
{% elif row.state.value == 'new_in_month' %}
|
||||||
|
<span class="tag tag-new">new this month</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="entry-amount">
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="entry-amount">
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
46
src/quartermaster/templates/partials/month_target.html
Normal file
46
src/quartermaster/templates/partials/month_target.html
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<section class="section target-section" id="section-debt_target" hx-swap-oob="outerHTML">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Primary Debt Target</h2>
|
||||||
|
{% if target.entry %}
|
||||||
|
<span class="total">
|
||||||
|
<span class="applied">${{ '%.2f' | format(target.entry.applied) }}</span>
|
||||||
|
<span class="divider">/</span>
|
||||||
|
<span class="planned">${{ '%.2f' | format(target.entry.planned) }}</span>
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="total empty">$0.00</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<table class="entries">
|
||||||
|
<tbody>
|
||||||
|
<tr class="entry">
|
||||||
|
<td class="entry-name">
|
||||||
|
{% if target.entry %}{{ target.entry.name }}{% else %}<span class="muted">No target selected.</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<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>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
Loading…
Reference in a new issue