diff --git a/alembic/versions/cc60e7f73a1c_add_posting_ledger_drop_month_entry_.py b/alembic/versions/cc60e7f73a1c_add_posting_ledger_drop_month_entry_.py new file mode 100644 index 0000000..d45c445 --- /dev/null +++ b/alembic/versions/cc60e7f73a1c_add_posting_ledger_drop_month_entry_.py @@ -0,0 +1,123 @@ +"""add posting ledger, drop month_entry.applied + +Revision ID: cc60e7f73a1c +Revises: a4ec4f8f6e9f +Create Date: 2026-04-17 17:25:53.487094 + +Creates the posting table, seeds one "opening balance" posting per +existing month_entry whose applied value is non-zero, and drops the +applied column from month_entry. After this revision applied is +computed as SUM(posting.amount) over the entry's postings. +""" +from datetime import datetime, timezone +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "cc60e7f73a1c" +down_revision: Union[str, Sequence[str], None] = "a4ec4f8f6e9f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Create posting table + op.create_table( + "posting", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("month_entry_id", sa.Integer(), nullable=False), + sa.Column("occurred_on", sa.Date(), nullable=False), + sa.Column("amount", sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column("description", sa.String(length=256), nullable=True), + sa.Column("payee", sa.String(length=128), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["month_entry_id"], ["month_entry.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("posting", schema=None) as batch_op: + batch_op.create_index( + batch_op.f("ix_posting_month_entry_id"), + ["month_entry_id"], + unique=False, + ) + + # 2. Seed opening-balance postings from the existing month_entry.applied + conn = op.get_bind() + rows = conn.execute( + sa.text( + "SELECT me.id, me.applied, m.activated_at, m.created_at " + "FROM month_entry me JOIN month m ON m.id = me.month_id " + "WHERE me.applied IS NOT NULL AND me.applied != 0" + ) + ).fetchall() + now = datetime.now(timezone.utc).replace(tzinfo=None) + for row in rows: + me_id, applied, activated_at, created_at = row + occurred = activated_at or created_at + # Truncate the timestamp to a date for the DATE column + if hasattr(occurred, "date"): + occurred = occurred.date() + if isinstance(occurred, str): + # SQLite may give back an ISO string; take the date portion + occurred = occurred[:10] + conn.execute( + sa.text( + "INSERT INTO posting " + "(month_entry_id, occurred_on, amount, description, created_at, updated_at) " + "VALUES (:me_id, :occurred, :amount, :desc, :now, :now)" + ), + { + "me_id": me_id, + "occurred": occurred, + "amount": applied, + "desc": "opening balance", + "now": now, + }, + ) + + # 3. Drop the applied column from month_entry + with op.batch_alter_table("month_entry", schema=None) as batch_op: + batch_op.drop_column("applied") + + +def downgrade() -> None: + # Add the applied column back with a default of 0.00 for NOT NULL + with op.batch_alter_table("month_entry", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "applied", + sa.NUMERIC(precision=10, scale=2), + server_default=sa.text("'0.00'"), + nullable=False, + ) + ) + + # Restore applied as SUM of postings + conn = op.get_bind() + conn.execute( + sa.text( + "UPDATE month_entry SET applied = (" + "SELECT COALESCE(SUM(amount), 0) FROM posting " + "WHERE posting.month_entry_id = month_entry.id" + ")" + ) + ) + + with op.batch_alter_table("posting", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_posting_month_entry_id")) + op.drop_table("posting") diff --git a/src/quartermaster/models.py b/src/quartermaster/models.py index 6d8f25a..8c44d8f 100644 --- a/src/quartermaster/models.py +++ b/src/quartermaster/models.py @@ -1,11 +1,12 @@ from __future__ import annotations import enum -from datetime import datetime +from datetime import date, datetime from decimal import Decimal from sqlalchemy import ( CheckConstraint, + Date, DateTime, Enum, ForeignKey, @@ -132,10 +133,6 @@ class MonthEntry(Base): ) name: Mapped[str] = mapped_column(String(128), nullable=False) planned: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False) - applied: Mapped[Decimal] = mapped_column( - Numeric(10, 2), nullable=False, default=Decimal("0.00"), - server_default="0.00", - ) notes: Mapped[str | None] = mapped_column(String(1024), nullable=True) origin_name: Mapped[str | None] = mapped_column(String(128), nullable=True) origin_planned: Mapped[Decimal | None] = mapped_column( @@ -155,6 +152,18 @@ class MonthEntry(Base): ) month: Mapped[Month] = relationship(back_populates="entries") + postings: Mapped[list["Posting"]] = relationship( + back_populates="entry", + cascade="all, delete-orphan", + order_by="Posting.occurred_on.desc(), Posting.id.desc()", + lazy="selectin", + ) + + @property + def applied(self) -> Decimal: + return sum((p.amount for p in self.postings), Decimal("0")).quantize( + Decimal("0.01") + ) class MonthDebtTarget(Base): @@ -177,3 +186,29 @@ class MonthDebtTarget(Base): month: Mapped[Month] = relationship(back_populates="target") entry: Mapped[MonthEntry | None] = relationship(MonthEntry, lazy="joined") + + +class Posting(Base): + __tablename__ = "posting" + + id: Mapped[int] = mapped_column(primary_key=True) + month_entry_id: Mapped[int] = mapped_column( + ForeignKey("month_entry.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + occurred_on: Mapped[date] = mapped_column(Date, nullable=False) + amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False) + description: Mapped[str | None] = mapped_column(String(256), nullable=True) + payee: Mapped[str | None] = mapped_column(String(128), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + entry: Mapped[MonthEntry] = relationship(back_populates="postings") diff --git a/src/quartermaster/month_service.py b/src/quartermaster/month_service.py index 097adb1..9498345 100644 --- a/src/quartermaster/month_service.py +++ b/src/quartermaster/month_service.py @@ -3,7 +3,7 @@ from __future__ import annotations import re from dataclasses import dataclass from datetime import date, datetime, timezone -from decimal import Decimal +from decimal import Decimal, InvalidOperation from enum import Enum from sqlalchemy import select @@ -24,6 +24,7 @@ from quartermaster.models import ( MonthDebtTarget, MonthEntry, MonthState, + Posting, Section, ) @@ -125,7 +126,6 @@ def create_month(db: Session, year_month: str) -> Month: section=e.section, name=e.name, planned=e.amount, - applied=Decimal("0.00"), notes=e.notes, origin_name=e.name, origin_planned=e.amount, @@ -238,7 +238,6 @@ def add_month_entry( section=section, name=name.strip(), planned=planned, - applied=Decimal("0.00"), notes=_clean_notes(notes), origin_name=None, origin_planned=None, @@ -277,7 +276,6 @@ def update_month_entry( *, name: str | None = None, planned: Decimal | None = None, - applied: Decimal | None = None, notes: str | None | object = _NOTES_SENTINEL, ) -> MonthEntry | None: entry = get_month_entry(db, month, entry_id) @@ -287,8 +285,6 @@ def update_month_entry( entry.name = name.strip() if planned is not None: entry.planned = planned - if applied is not None: - entry.applied = applied if notes is not _NOTES_SENTINEL: entry.notes = _clean_notes(notes) # type: ignore[arg-type] db.commit() @@ -296,6 +292,85 @@ def update_month_entry( return entry +def add_posting( + db: Session, + month: Month, + entry_id: int, + occurred_on: date, + amount: Decimal, + description: str | None = None, + payee: str | None = None, +) -> Posting | None: + entry = get_month_entry(db, month, entry_id) + if entry is None: + return None + posting = Posting( + month_entry_id=entry.id, + occurred_on=occurred_on, + amount=amount, + description=_clean_notes(description), + payee=_clean_notes(payee), + ) + db.add(posting) + db.commit() + db.refresh(posting) + return posting + + +def get_posting( + db: Session, month: Month, posting_id: int +) -> Posting | None: + posting = db.get(Posting, posting_id) + if posting is None: + return None + # Ensure posting belongs to this month via its entry + if posting.entry is None or posting.entry.month_id != month.id: + return None + return posting + + +_POSTING_SENTINEL = object() + + +def update_posting( + db: Session, + month: Month, + posting_id: int, + *, + occurred_on: date | None = None, + amount: Decimal | None = None, + description: str | None | object = _POSTING_SENTINEL, + payee: str | None | object = _POSTING_SENTINEL, +) -> Posting | None: + posting = get_posting(db, month, posting_id) + if posting is None: + return None + if occurred_on is not None: + posting.occurred_on = occurred_on + if amount is not None: + posting.amount = amount + if description is not _POSTING_SENTINEL: + posting.description = _clean_notes(description) # type: ignore[arg-type] + if payee is not _POSTING_SENTINEL: + posting.payee = _clean_notes(payee) # type: ignore[arg-type] + db.commit() + db.refresh(posting) + return posting + + +def delete_posting( + db: Session, month: Month, posting_id: int +) -> MonthEntry | None: + """Delete a posting; return the parent entry so callers can re-render its section.""" + posting = get_posting(db, month, posting_id) + if posting is None: + return None + entry = posting.entry + db.delete(posting) + db.commit() + return entry + + def get_month_target(db: Session, month: Month) -> MonthDebtTarget: if month.target is not None: return month.target diff --git a/src/quartermaster/routes_month.py b/src/quartermaster/routes_month.py index e64fc75..8ff228b 100644 --- a/src/quartermaster/routes_month.py +++ b/src/quartermaster/routes_month.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import date from decimal import Decimal, InvalidOperation from fastapi import APIRouter, Depends, Form, HTTPException, Request @@ -242,7 +243,6 @@ def update_month_entry( request: Request, name: str | None = Form(None), planned: str | None = Form(None), - applied: str | None = Form(None), notes: str | None = Form(None), db: Session = Depends(get_session), ) -> HTMLResponse: @@ -253,11 +253,9 @@ def update_month_entry( 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 kwargs: dict = dict( name=clean_name, planned=parsed_planned, - applied=parsed_applied, ) if notes is not None: kwargs["notes"] = notes @@ -320,6 +318,109 @@ def reopen_month( ) +def _parse_date(raw: str) -> date: + try: + return date.fromisoformat(raw.strip()) + except (ValueError, AttributeError) as exc: + raise HTTPException( + status_code=400, detail="date must be YYYY-MM-DD" + ) from exc + + +def _parse_signed_amount(raw: str) -> Decimal: + try: + amount = Decimal(raw.strip()) + except (InvalidOperation, AttributeError) as exc: + raise HTTPException( + status_code=400, detail="amount must be numeric" + ) from exc + return amount.quantize(Decimal("0.01")) + + +@router.post("/entries/{entry_id}/postings", response_class=HTMLResponse) +def create_posting( + year_month: str, + entry_id: int, + request: Request, + occurred_on: str = Form(...), + amount: str = Form(...), + description: str | None = Form(None), + payee: str | None = Form(None), + db: Session = Depends(get_session), +) -> HTMLResponse: + month = _require_editable_month(db, year_month) + posting = month_service.add_posting( + db, + month, + entry_id, + _parse_date(occurred_on), + _parse_signed_amount(amount), + description=description, + payee=payee, + ) + if posting is None: + raise HTTPException(status_code=404, detail="entry not found") + db.refresh(month) + entry = month_service.get_month_entry(db, month, entry_id) + return _append_oob( + _render_section(request, month, entry.section), + _render_zero(request, month), + _render_group_totals(request, month), + ) + + +@router.post("/postings/{posting_id}", response_class=HTMLResponse) +def update_posting( + year_month: str, + posting_id: int, + request: Request, + occurred_on: str | None = Form(None), + amount: str | None = Form(None), + description: str | None = Form(None), + payee: str | None = Form(None), + db: Session = Depends(get_session), +) -> HTMLResponse: + month = _require_editable_month(db, year_month) + kwargs: dict = {} + if occurred_on is not None: + kwargs["occurred_on"] = _parse_date(occurred_on) + if amount is not None: + kwargs["amount"] = _parse_signed_amount(amount) + if description is not None: + kwargs["description"] = description + if payee is not None: + kwargs["payee"] = payee + updated = month_service.update_posting(db, month, posting_id, **kwargs) + if updated is None: + raise HTTPException(status_code=404, detail="posting not found") + db.refresh(month) + entry = updated.entry + return _append_oob( + _render_section(request, month, entry.section), + _render_zero(request, month), + _render_group_totals(request, month), + ) + + +@router.delete("/postings/{posting_id}", response_class=HTMLResponse) +def delete_posting( + year_month: str, + posting_id: int, + request: Request, + db: Session = Depends(get_session), +) -> HTMLResponse: + month = _require_editable_month(db, year_month) + entry = month_service.delete_posting(db, month, posting_id) + if entry is None: + raise HTTPException(status_code=404, detail="posting not found") + db.refresh(month) + return _append_oob( + _render_section(request, month, entry.section), + _render_zero(request, month), + _render_group_totals(request, month), + ) + + @router.post("/target", response_class=HTMLResponse) def update_month_target( year_month: str, diff --git a/src/quartermaster/static/app.css b/src/quartermaster/static/app.css index 2574f91..328bbfe 100644 --- a/src/quartermaster/static/app.css +++ b/src/quartermaster/static/app.css @@ -421,6 +421,40 @@ tr.add-row td { display: block; } +/* Add-entry disclosure: collapsed trigger, expanded form */ +details.add-entry { margin: 0; } +details.add-entry > summary { + list-style: none; + cursor: pointer; + user-select: none; + display: inline-block; + padding: 0.1rem 0.25rem; +} +details.add-entry > summary::-webkit-details-marker { display: none; } +details.add-entry .add-trigger { + font-family: var(--sans); + font-size: 0.78rem; + font-weight: 500; + color: var(--muted); + letter-spacing: 0.18em; + text-transform: uppercase; + border-bottom: 1px dashed transparent; + padding-bottom: 1px; + transition: color 0.12s ease, border-color 0.12s ease; +} +details.add-entry > summary:hover .add-trigger { + color: var(--ink); + border-bottom-color: var(--rule); +} +details.add-entry[open] > summary .add-trigger { + color: var(--ink); + border-bottom-color: var(--ink); +} +details.add-entry > .add-form, +details.add-entry > .month-add-form { + margin-top: 0.45rem; +} + .entry-name { font-family: var(--sans); font-weight: 500; @@ -523,11 +557,10 @@ tr.entry-notes-row input.notes-input::placeholder { color: var(--rule); font-style: italic; } -tr.entry-notes-row:has(input:placeholder-shown) { display: none; } -tr.entry:hover + tr.entry-notes-row:has(input:placeholder-shown) { - display: block; - opacity: 0.5; -} +/* Empty notes render subtly (placeholder only) so they stay clickable. */ +tr.entry-notes-row:has(input:placeholder-shown) { opacity: 0.55; } +tr.entry-notes-row:hover, +tr.entry-notes-row:has(input:focus) { opacity: 1; } .tag { font-family: var(--sans); @@ -675,6 +708,300 @@ tr.entry:hover + tr.entry-notes-row:has(input:placeholder-shown) { padding: 0.15rem 0.4rem; } +/* =============== MONTH ENTRIES — details-based layout =============== */ + +.month-entries { + display: flex; + flex-direction: column; +} + +.entry-block { + border-bottom: 1px dotted var(--rule); + margin: 0; +} + +.entry-block > summary { + list-style: none; + cursor: pointer; + user-select: none; + display: grid; + grid-template-columns: 0.9rem minmax(0, 1fr) 5.5rem 9rem 1.2rem; + gap: 0.6rem; + align-items: baseline; + padding: 0.32rem 0.25rem 0.36rem; + position: relative; +} +.entry-block > summary::-webkit-details-marker { display: none; } +.entry-block > summary:hover { background: var(--paper-stripe); } + +/* Progress bar on the summary row */ +.entry-block > summary::after { + content: ""; + position: absolute; + left: 0; right: 0; bottom: -1px; + height: 2px; + background: var(--sage-soft); + width: min(100%, calc(var(--ratio, 1) * 100%)); + transition: width 0.25s ease; + opacity: 0.7; +} +.entry-block > summary.state-edited::after { background: var(--accent); opacity: 0.85; } +.entry-block > summary.state-new_in_month::after { background: var(--indigo); opacity: 0.55; } + +/* Caret: rotates on [open] */ +.entry-block .caret { + display: inline-block; + width: 0.7rem; + position: relative; + transform: rotate(0deg); + transition: transform 0.15s ease; + align-self: center; +} +.entry-block .caret::before { + content: "▸"; + font-size: 0.7rem; + color: var(--muted); + display: inline-block; + line-height: 1; +} +.entry-block[open] > summary .caret { transform: rotate(90deg); } + +/* Entry row cells */ +.entry-block .entry-name { + font-weight: 500; + font-size: 1rem; + min-width: 0; + overflow: hidden; +} +.entry-block .entry-name input, +.entry-block .entry-amount input { + font: inherit; + color: inherit; + background: transparent; + border: none; + border-bottom: 1px solid transparent; + padding: 0; + width: 100%; + outline: none; + transition: border-color 0.12s; +} +.entry-block .entry-name input:hover, +.entry-block .entry-amount input:hover { border-bottom-color: var(--rule); } +.entry-block .entry-name input:focus, +.entry-block .entry-amount input:focus { border-bottom-color: var(--ink); } + +.entry-block .entry-amount { + font-weight: 500; + font-size: 1rem; + text-align: right; + font-feature-settings: "lnum" 1, "tnum" 1; + min-width: 0; +} +.entry-block .entry-amount input { + text-align: right; + font-variant-numeric: tabular-nums; +} +.entry-block .entry-amount.planned input { color: var(--muted); font-weight: 400; } + +.entry-block .applied-cell { + display: flex; + justify-content: flex-end; + align-items: baseline; + gap: 0.35rem; + white-space: nowrap; + flex-wrap: nowrap; +} +.entry-block .applied-cell .value { + font-weight: 500; + white-space: nowrap; +} +.entry-block .applied-cell .count { + font-family: var(--sans); + font-size: 0.66rem; + letter-spacing: 0.14em; + text-transform: lowercase; + color: var(--muted); + white-space: nowrap; +} + +.entry-block .entry-actions button.delete { + font-size: 1.1rem; + line-height: 1; + color: var(--rule); + background: none; + border: none; + cursor: pointer; + padding: 0; + opacity: 0; + transition: color 0.12s ease, opacity 0.12s ease; + align-self: center; +} +.entry-block > summary:hover .entry-actions button.delete { opacity: 1; } +.entry-block .entry-actions button.delete:hover { color: var(--accent); } + +/* Expanded body */ +.entry-block .entry-body { + padding: 0.5rem 1.6rem 0.75rem; + background: var(--paper-soft); + border-top: 1px solid var(--rule-soft); +} + +.entry-block .entry-notes { + margin-bottom: 0.5rem; +} +.entry-block .entry-notes input { + font-family: var(--sans); + font-style: italic; + font-size: 0.85rem; + color: var(--ink); + width: 100%; + background: transparent; + border: none; + border-bottom: 1px dashed var(--rule); + padding: 0.15rem 0; + outline: none; +} +.entry-block .entry-notes input:focus { border-bottom-color: var(--ink); } +.entry-block .entry-notes.readonly { + font-family: var(--sans); + font-style: italic; + font-size: 0.85rem; + color: var(--muted); +} + +/* Transactions table */ +table.transactions { + width: 100%; + border-collapse: collapse; + font-family: var(--sans); + font-size: 0.88rem; + margin-bottom: 0.4rem; +} +table.transactions thead th { + text-align: left; + font-weight: 600; + font-size: 0.66rem; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--muted); + padding: 0.2rem 0.3rem 0.25rem; + border-bottom: 1px solid var(--rule); +} +table.transactions th.col-amount, +table.transactions td.col-amount { text-align: right; width: 5.5rem; } +table.transactions th.col-date, +table.transactions td.col-date { width: 7.5rem; } +table.transactions th.col-actions, +table.transactions td.col-actions { width: 1.2rem; } + +table.transactions td { + padding: 0.2rem 0.3rem; + border-bottom: 1px dotted var(--rule-soft); +} +table.transactions tr.posting:hover { background: var(--paper); } +table.transactions input { + font: inherit; + color: inherit; + background: transparent; + border: none; + border-bottom: 1px solid transparent; + padding: 0; + width: 100%; + outline: none; + transition: border-color 0.12s; +} +table.transactions input:hover { border-bottom-color: var(--rule); } +table.transactions input:focus { border-bottom-color: var(--ink); } +table.transactions input[type="number"] { + text-align: right; + font-variant-numeric: tabular-nums; +} +table.transactions input[type="date"] { + font-family: var(--sans); + color: var(--muted); +} +table.transactions tr.empty td { + color: var(--muted); + font-style: italic; + text-align: center; + padding: 0.5rem; +} + +table.transactions td .readonly { + color: var(--ink); +} + +table.transactions button.delete { + font-size: 1rem; + color: var(--rule); + background: none; + border: none; + cursor: pointer; + padding: 0; + opacity: 0; + transition: color 0.12s ease, opacity 0.12s ease; +} +table.transactions tr:hover button.delete { opacity: 1; } +table.transactions button.delete:hover { color: var(--accent); } + +/* Add-posting form */ +form.add-posting-form { + display: grid; + grid-template-columns: 7.5rem minmax(0, 1fr) minmax(0, 1fr) 5.5rem auto; + gap: 0.4rem; + align-items: center; + padding: 0.35rem 0.3rem 0.2rem; + border-top: 1px dashed var(--rule); +} +form.add-posting-form input { + font: inherit; + font-size: 0.88rem; + padding: 0.2rem 0.4rem; + border: 1px solid var(--rule); + background: var(--paper); + color: var(--ink); + outline: none; + transition: border-color 0.12s; +} +form.add-posting-form input[type="number"] { text-align: right; font-variant-numeric: tabular-nums; } +form.add-posting-form input:focus { border-color: var(--ink); } +form.add-posting-form button[type="submit"] { + font-family: var(--sans); + font-weight: 600; + font-size: 0.72rem; + padding: 0.25rem 0.75rem; + border: 1px solid var(--ink); + background: var(--paper-soft); + color: var(--ink); + letter-spacing: 0.12em; + text-transform: uppercase; + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; +} +form.add-posting-form button[type="submit"]:hover { background: var(--ink); color: var(--paper); } + +.empty-row { + padding: 0.5rem 0.5rem; + color: var(--muted); + font-style: italic; + font-size: 0.9rem; +} + +@media (max-width: 640px) { + .entry-block > summary { + grid-template-columns: 0.9rem minmax(0, 1fr) 4.2rem 7rem 1rem; + gap: 0.4rem; + } + .entry-block .entry-body { padding: 0.5rem 0.75rem 0.6rem; } + .entry-block .applied-cell .count { font-size: 0.58rem; letter-spacing: 0.1em; } + form.add-posting-form { + grid-template-columns: 1fr 1fr; + } + form.add-posting-form input[type="date"], + form.add-posting-form input[type="number"], + form.add-posting-form button[type="submit"] { grid-column: 1 / -1; } +} + /* Disabled inputs (closed month) */ input[disabled], select[disabled], diff --git a/src/quartermaster/templates/partials/month_section.html b/src/quartermaster/templates/partials/month_section.html index 8868b2c..4f1b3f0 100644 --- a/src/quartermaster/templates/partials/month_section.html +++ b/src/quartermaster/templates/partials/month_section.html @@ -2,68 +2,61 @@
| Name | -Planned | -Applied | -- | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
-
+ {% for row in section.rows %}
+ {% set applied = row.entry.applied %}
+
+ + + + {% if editable %} + + aria-label="Name" + > + {% else %} + {{ row.entry.name }} + {% endif %} {% if row.state.value == 'edited' %} modified {% elif row.state.value == 'new_in_month' %} new this month {% endif %} - |
- - + + {% if editable %} + - | -- - | -+ aria-label="Planned" + > + {% else %} + ${{ '{:,.2f}'.format(row.entry.planned) }} + {% endif %} + + + ${{ '{:,.2f}'.format(applied) }} + {% if row.entry.postings|length > 0 %} + · {{ row.entry.postings|length }} txn{% if row.entry.postings|length != 1 %}s{% endif %} + {% endif %} + + {% if editable %} {% endif %} - | -|||||||||||||||
|
-
+
+
+ {% if editable %}
+
+
- |
- ||||||||||||||||||
| No entries. | ||||||||||||||||||
|
+ aria-label="Notes"
+ >
+
+ {% elif row.entry.notes %}
+ {{ row.entry.notes }}
+ {% endif %}
+
|
- ||||||||||||||||||