diff --git a/alembic/versions/ec804bdf366d_add_notes_column_to_entry_and_month_.py b/alembic/versions/ec804bdf366d_add_notes_column_to_entry_and_month_.py new file mode 100644 index 0000000..8dac030 --- /dev/null +++ b/alembic/versions/ec804bdf366d_add_notes_column_to_entry_and_month_.py @@ -0,0 +1,42 @@ +"""add notes column to entry and month_entry + +Revision ID: ec804bdf366d +Revises: 03ebe3c07262 +Create Date: 2026-04-17 12:47:41.533978 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ec804bdf366d' +down_revision: Union[str, Sequence[str], None] = '03ebe3c07262' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('entry', schema=None) as batch_op: + batch_op.add_column(sa.Column('notes', sa.String(length=1024), nullable=True)) + + with op.batch_alter_table('month_entry', schema=None) as batch_op: + batch_op.add_column(sa.Column('notes', sa.String(length=1024), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('month_entry', schema=None) as batch_op: + batch_op.drop_column('notes') + + with op.batch_alter_table('entry', schema=None) as batch_op: + batch_op.drop_column('notes') + + # ### end Alembic commands ### diff --git a/src/quartermaster/models.py b/src/quartermaster/models.py index ee81008..c2edacb 100644 --- a/src/quartermaster/models.py +++ b/src/quartermaster/models.py @@ -51,6 +51,7 @@ class Entry(Base): ) name: Mapped[str] = mapped_column(String(128), nullable=False) amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False) + notes: Mapped[str | None] = mapped_column(String(1024), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) @@ -117,6 +118,7 @@ class MonthEntry(Base): 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( Numeric(10, 2), nullable=True diff --git a/src/quartermaster/month_service.py b/src/quartermaster/month_service.py index 92e7b9f..8928aad 100644 --- a/src/quartermaster/month_service.py +++ b/src/quartermaster/month_service.py @@ -112,6 +112,7 @@ def create_month(db: Session, year_month: str) -> Month: name=e.name, planned=e.amount, applied=Decimal("0.00"), + notes=e.notes, origin_name=e.name, origin_planned=e.amount, source_entry_id=e.id, @@ -203,12 +204,20 @@ def section_view(month: Month, section: Section, label: str) -> MonthSectionView ) +def _clean_notes(raw: str | None) -> str | None: + if raw is None: + return None + stripped = raw.strip() + return stripped if stripped else None + + def add_month_entry( db: Session, month: Month, section: Section, name: str, planned: Decimal, + notes: str | None = None, ) -> MonthEntry: entry = MonthEntry( month_id=month.id, @@ -216,6 +225,7 @@ def add_month_entry( name=name.strip(), planned=planned, applied=Decimal("0.00"), + notes=_clean_notes(notes), origin_name=None, origin_planned=None, source_entry_id=None, @@ -243,6 +253,9 @@ def delete_month_entry(db: Session, month: Month, entry_id: int) -> Section | No return section +_NOTES_SENTINEL = object() + + def update_month_entry( db: Session, month: Month, @@ -251,6 +264,7 @@ 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) if entry is None: @@ -261,6 +275,8 @@ def update_month_entry( 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() db.refresh(entry) return entry diff --git a/src/quartermaster/routes.py b/src/quartermaster/routes.py index 926eb60..b9283c4 100644 --- a/src/quartermaster/routes.py +++ b/src/quartermaster/routes.py @@ -107,13 +107,14 @@ def create_entry( request: Request, name: str = Form(...), amount: str = Form(...), + notes: str | None = Form(None), db: Session = Depends(get_session), ) -> HTMLResponse: clean_name = name.strip() if not clean_name: raise HTTPException(status_code=400, detail="name is required") parsed = _parse_amount(amount) - service.add_entry(db, section, clean_name, parsed) + service.add_entry(db, section, clean_name, parsed, notes=notes) response = _render_section(request, db, section) extras: list[HTMLResponse] = [ _render_zero(request, db), @@ -143,6 +144,19 @@ def remove_entry( return _append_oob(response, *extras) +@router.post("/entries/{entry_id}/notes", response_class=HTMLResponse) +def update_entry_notes( + entry_id: int, + request: Request, + notes: str | None = Form(None), + db: Session = Depends(get_session), +) -> HTMLResponse: + updated = service.set_entry_notes(db, entry_id, notes) + if updated is None: + raise HTTPException(status_code=404, detail="entry not found") + return _render_section(request, db, updated.section) + + @router.post("/debt-target", response_class=HTMLResponse) def update_debt_target( request: Request, diff --git a/src/quartermaster/routes_month.py b/src/quartermaster/routes_month.py index 8faa748..b6ee5e2 100644 --- a/src/quartermaster/routes_month.py +++ b/src/quartermaster/routes_month.py @@ -164,6 +164,7 @@ def add_month_entry( request: Request, name: str = Form(...), planned: str = Form(...), + notes: str | None = Form(None), db: Session = Depends(get_session), ) -> HTMLResponse: month = _require_month(db, year_month) @@ -171,7 +172,7 @@ def add_month_entry( 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, month, section, clean_name, _parse_amount(planned), notes=notes ) db.refresh(month) return _append_oob( @@ -210,6 +211,7 @@ def update_month_entry( 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: month = _require_month(db, year_month) @@ -220,14 +222,14 @@ def update_month_entry( 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, + kwargs: dict = dict( name=clean_name, planned=parsed_planned, applied=parsed_applied, ) + if notes is not None: + kwargs["notes"] = notes + updated = month_service.update_month_entry(db, month, entry_id, **kwargs) if updated is None: raise HTTPException(status_code=404, detail="entry not found") db.refresh(month) diff --git a/src/quartermaster/service.py b/src/quartermaster/service.py index 7ed9fb7..5a9cc95 100644 --- a/src/quartermaster/service.py +++ b/src/quartermaster/service.py @@ -87,14 +87,44 @@ def budget_zero(db: Session) -> Decimal: return (total_income - total_non_income).quantize(Decimal("0.01")) -def add_entry(db: Session, section: Section, name: str, amount: Decimal) -> Entry: - entry = Entry(section=section, name=name.strip(), amount=amount) +def add_entry( + db: Session, + section: Section, + name: str, + amount: Decimal, + notes: str | None = None, +) -> Entry: + entry = Entry( + section=section, + name=name.strip(), + amount=amount, + notes=_clean_notes(notes), + ) db.add(entry) db.commit() db.refresh(entry) return entry +def _clean_notes(raw: str | None) -> str | None: + if raw is None: + return None + stripped = raw.strip() + return stripped if stripped else None + + +def set_entry_notes( + db: Session, entry_id: int, notes: str | None +) -> Entry | None: + entry = db.get(Entry, entry_id) + if entry is None: + return None + entry.notes = _clean_notes(notes) + db.commit() + db.refresh(entry) + return entry + + def delete_entry(db: Session, entry_id: int) -> Entry | None: entry = db.get(Entry, entry_id) if entry is None: diff --git a/src/quartermaster/static/app.css b/src/quartermaster/static/app.css index 8a9fb97..5106d1e 100644 --- a/src/quartermaster/static/app.css +++ b/src/quartermaster/static/app.css @@ -111,6 +111,37 @@ tr.add-row td { .muted { color: var(--muted); font-style: italic; } +tr.entry-notes-row td { + padding: 0 0.5rem 0.35rem; + border-bottom: 1px solid var(--rule); +} + +.notes-input { + width: 100%; + padding: 0.2rem 0.4rem; + font-size: 0.85rem; + color: var(--muted); + background: transparent; + border: none; + border-bottom: 1px dashed transparent; +} + +.notes-input:hover, +.notes-input:focus { + border-bottom-color: var(--rule); + outline: none; +} + +.notes-input:not(:placeholder-shown) { + color: var(--ink); + font-style: italic; +} + +.add-form .add-notes { + grid-column: 1 / -1; + font-size: 0.85rem; +} + button.delete { background: transparent; border: none; diff --git a/src/quartermaster/templates/partials/month_section.html b/src/quartermaster/templates/partials/month_section.html index 922316d..88b6d6f 100644 --- a/src/quartermaster/templates/partials/month_section.html +++ b/src/quartermaster/templates/partials/month_section.html @@ -68,6 +68,22 @@ >× +