feat: add document presets for additional fields defaults

This commit is contained in:
Sean McElwain 2026-04-07 07:27:16 -05:00
parent fcd70ec256
commit 9db8aadfdf
7 changed files with 647 additions and 95 deletions

View File

@ -15,6 +15,7 @@ from app.routes.health import router as health_router
from app.routes.ingest import router as ingest_router
from app.routes.line_items import router as line_items_router
from app.routes.queue import router as queue_router
from app.routes.presets import router as presets_router
from app.routes.trash import router as trash_router
app = FastAPI(title="document-processor")
@ -26,6 +27,7 @@ app.include_router(documents_router)
app.include_router(ingest_router)
app.include_router(line_items_router)
app.include_router(queue_router)
app.include_router(presets_router)
app.include_router(trash_router)
templates = Jinja2Templates(directory="app/templates")

View File

@ -4,6 +4,8 @@ from app.models.text_version import TextVersion
from app.models.extracted_field import ExtractedField
from app.models.layer1_candidate import Layer1Candidate
from app.models.receipt_line_item import ReceiptLineItem
from app.models.document_additional_field import DocumentAdditionalField
from app.models.document_preset import DocumentPreset
__all__ = [
"Document",
@ -12,4 +14,6 @@ __all__ = [
"ExtractedField",
"Layer1Candidate",
"ReceiptLineItem",
"DocumentAdditionalField",
"DocumentPreset",
]

View File

@ -0,0 +1,35 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class DocumentPreset(Base):
__tablename__ = "document_presets"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
name: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
owner_person: Mapped[str | None] = mapped_column(Text, nullable=True)
paid_by_person: Mapped[str | None] = mapped_column(Text, nullable=True)
occasion_note: Mapped[str | None] = mapped_column(Text, nullable=True)
is_shared_expense: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
covered_people: Mapped[list | None] = mapped_column(JSONB, nullable=True)
attendees: Mapped[list | None] = mapped_column(JSONB, nullable=True)
reimbursement_expected_from: Mapped[list | None] = mapped_column(JSONB, nullable=True)
reimbursement_paid_by: Mapped[str | None] = mapped_column(Text, nullable=True)
reimbursement_paid_to: Mapped[str | None] = mapped_column(Text, nullable=True)
reimbursement_note: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=datetime.utcnow,
onupdate=datetime.utcnow,
nullable=False,
)

View File

@ -1,6 +1,7 @@
from copy import deepcopy
from datetime import datetime
from decimal import Decimal, InvalidOperation
from pathlib import Path
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
@ -19,6 +20,8 @@ from app.logic.extraction import (
)
from app.logic.ingest import compute_quality_score, rerun_ocr_for_document
from app.models.document import Document
from app.models.document_additional_field import DocumentAdditionalField
from app.models.document_preset import DocumentPreset
from app.models.text_version import TextVersion
router = APIRouter(prefix="/documents", tags=["documents"])
@ -26,7 +29,6 @@ router = APIRouter(prefix="/documents", tags=["documents"])
BASE_DIR = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
QUALITY_FLAG_OPTIONS = [
"bad_embedded_text",
"ocr_garbled",
@ -49,11 +51,140 @@ QUALITY_FLAG_OPTIONS = [
]
def _document_url(document_id: str, **params) -> str:
clean_params = {k: v for k, v in params.items() if v not in (None, "", False)}
if not clean_params:
return f"/documents/{document_id}"
return f"/documents/{document_id}?{urlencode(clean_params)}"
def _parse_people_list(value: str) -> list[str]:
return [part.strip() for part in value.split(",") if part.strip()]
def _format_people_list(value: list | None) -> str:
if not value:
return ""
return ", ".join(str(x).strip() for x in value if str(x).strip())
def _to_decimal(value: str) -> Decimal | None:
cleaned = (value or "").strip()
if not cleaned:
return None
try:
return Decimal(cleaned)
except (InvalidOperation, TypeError):
return None
def _get_all_presets(db: Session) -> list[DocumentPreset]:
return db.query(DocumentPreset).order_by(DocumentPreset.name.asc()).all()
def _get_preset_by_id(db: Session, preset_id: int | None) -> DocumentPreset | None:
if not preset_id:
return None
return db.query(DocumentPreset).filter(DocumentPreset.id == preset_id).first()
def _merge_additional_form_with_preset(values: dict, preset: DocumentPreset | None) -> dict:
if preset is None:
return values
return {
"owner_person": preset.owner_person if preset.owner_person is not None else values.get("owner_person", ""),
"paid_by_person": preset.paid_by_person if preset.paid_by_person is not None else values.get("paid_by_person", ""),
"covered_people": _format_people_list(preset.covered_people) if preset.covered_people is not None else values.get("covered_people", ""),
"attendees": _format_people_list(preset.attendees) if preset.attendees is not None else values.get("attendees", ""),
"occasion_note": preset.occasion_note if preset.occasion_note is not None else values.get("occasion_note", ""),
"is_shared_expense": bool(preset.is_shared_expense),
"reimbursement_expected_from": _format_people_list(preset.reimbursement_expected_from) if preset.reimbursement_expected_from is not None else values.get("reimbursement_expected_from", ""),
"reimbursement_paid_by": preset.reimbursement_paid_by if preset.reimbursement_paid_by is not None else values.get("reimbursement_paid_by", ""),
"reimbursement_paid_to": preset.reimbursement_paid_to if preset.reimbursement_paid_to is not None else values.get("reimbursement_paid_to", ""),
"reimbursement_paid_amount": values.get("reimbursement_paid_amount", ""),
"reimbursement_paid_date": values.get("reimbursement_paid_date", ""),
"reimbursement_note": preset.reimbursement_note if preset.reimbursement_note is not None else values.get("reimbursement_note", ""),
}
def _get_current_additional_fields(document: Document) -> DocumentAdditionalField | None:
rows = list(getattr(document, "additional_fields", []) or [])
if not rows:
return None
return sorted(rows, key=lambda x: x.updated_at or x.created_at, reverse=True)[0]
def _extracted_field_form_values(document: Document, request: Request) -> dict:
current = get_current_extracted_fields(document)
auto = request.query_params.get("autofill_extracted")
if auto == "1":
values = auto_extract_from_document(None, document)
elif current is not None:
values = {
"merchant_raw": current.merchant_raw or "",
"merchant_normalized": current.merchant_normalized or "",
"transaction_date": current.transaction_date.isoformat() if current.transaction_date else "",
"transaction_time": current.transaction_time or "",
"subtotal": str(current.subtotal) if current.subtotal is not None else "",
"tax": str(current.tax) if current.tax is not None else "",
"total": str(current.total) if current.total is not None else "",
"currency": current.currency or "",
"payment_method": current.payment_method or "",
"receipt_number": current.receipt_number or "",
"location": current.location or "",
"counterparty": current.counterparty or "",
"extra_json": "{}" if current.extra_json is None else __import__("json").dumps(current.extra_json, indent=2, sort_keys=True),
}
else:
values = {
"merchant_raw": "",
"merchant_normalized": "",
"transaction_date": "",
"transaction_time": "",
"subtotal": "",
"tax": "",
"total": "",
"currency": "",
"payment_method": "",
"receipt_number": "",
"location": "",
"counterparty": "",
"extra_json": "{}",
}
return values
def _additional_field_form_values(document: Document, preset: DocumentPreset | None = None) -> dict:
current = _get_current_additional_fields(document)
if current is None:
values = {
"owner_person": "",
"paid_by_person": "",
"covered_people": "",
"attendees": "",
"occasion_note": "",
"is_shared_expense": False,
"reimbursement_expected_from": "",
"reimbursement_paid_by": "",
"reimbursement_paid_to": "",
"reimbursement_paid_amount": "",
"reimbursement_paid_date": "",
"reimbursement_note": "",
}
return _merge_additional_form_with_preset(values, preset)
values = {
"owner_person": current.owner_person or "",
"paid_by_person": current.paid_by_person or "",
"covered_people": _format_people_list(current.covered_people),
"attendees": _format_people_list(current.attendees),
"occasion_note": current.occasion_note or "",
"is_shared_expense": bool(current.is_shared_expense),
"reimbursement_expected_from": _format_people_list(current.reimbursement_expected_from),
"reimbursement_paid_by": current.reimbursement_paid_by or "",
"reimbursement_paid_to": current.reimbursement_paid_to or "",
"reimbursement_paid_amount": str(current.reimbursement_paid_amount) if current.reimbursement_paid_amount is not None else "",
"reimbursement_paid_date": current.reimbursement_paid_date.isoformat() if current.reimbursement_paid_date else "",
"reimbursement_note": current.reimbursement_note or "",
}
return _merge_additional_form_with_preset(values, preset)
def _get_current_text_versions(document: Document) -> tuple[TextVersion | None, TextVersion | None]:
@ -185,51 +316,14 @@ def _get_queue_navigation(db: Session, document: Document) -> dict:
}
def _extracted_field_form_values(document: Document, request: Request) -> dict:
current = get_current_extracted_fields(document)
auto = request.query_params.get("autofill_extracted")
if auto == "1":
values = auto_extract_from_document(None, document)
elif current is not None:
values = {
"merchant_raw": current.merchant_raw or "",
"merchant_normalized": current.merchant_normalized or "",
"transaction_date": current.transaction_date.isoformat() if current.transaction_date else "",
"transaction_time": current.transaction_time or "",
"subtotal": str(current.subtotal) if current.subtotal is not None else "",
"tax": str(current.tax) if current.tax is not None else "",
"total": str(current.total) if current.total is not None else "",
"currency": current.currency or "",
"payment_method": current.payment_method or "",
"receipt_number": current.receipt_number or "",
"location": current.location or "",
"counterparty": current.counterparty or "",
"extra_json": "{}" if current.extra_json is None else __import__("json").dumps(current.extra_json, indent=2, sort_keys=True),
}
else:
values = {
"merchant_raw": "",
"merchant_normalized": "",
"transaction_date": "",
"transaction_time": "",
"subtotal": "",
"tax": "",
"total": "",
"currency": "",
"payment_method": "",
"receipt_number": "",
"location": "",
"counterparty": "",
"extra_json": "{}",
}
return values
@router.get("/", response_class=HTMLResponse)
def list_documents(request: Request, db: Session = Depends(get_db)):
documents = db.query(Document).filter(Document.is_trashed.is_(False)).order_by(Document.created_at.desc()).all()
documents = (
db.query(Document)
.filter(Document.is_trashed.is_(False))
.order_by(Document.created_at.desc())
.all()
)
return templates.TemplateResponse(
request=request,
name="documents/list.html",
@ -247,37 +341,23 @@ def rerun_ocr(document_id: str, db: Session = Depends(get_db)):
try:
rerun_ocr_for_document(db, document)
except Exception:
return RedirectResponse(
url=_document_url(document.document_id, error="rerun_ocr_failed", tab="ocr-review"),
status_code=303,
)
return RedirectResponse(url=f"/documents/{document.document_id}?error=rerun_ocr_failed", status_code=303)
return RedirectResponse(
url=_document_url(document.document_id, editor_source="raw", tab="ocr-review"),
status_code=303,
)
return RedirectResponse(url=f"/documents/{document.document_id}?editor_source=raw&tab=ocr-review", status_code=303)
@router.post("/{document_id}/save-ocr-corrected-pdf", response_class=RedirectResponse)
def save_ocr_corrected_pdf(document_id: str, db: Session = Depends(get_db)):
document = (
db.query(Document)
.options(selectinload(Document.text_versions))
.filter(Document.document_id == document_id)
.first()
)
document = db.query(Document).options(selectinload(Document.text_versions)).filter(Document.document_id == document_id).first()
if document is None:
return RedirectResponse(url="/documents/", status_code=303)
try:
create_ocr_corrected_pdf_version(db, document)
except Exception:
return RedirectResponse(
url=_document_url(document.document_id, error="save_ocr_corrected_failed", tab="ocr-review"),
status_code=303,
)
return RedirectResponse(url=f"/documents/{document.document_id}?error=save_ocr_corrected_failed", status_code=303)
return RedirectResponse(url=_document_url(document.document_id, tab="ocr-review"), status_code=303)
return RedirectResponse(url=f"/documents/{document.document_id}?tab=ocr-review", status_code=303)
@router.post("/{document_id}/move-to-trash", response_class=RedirectResponse)
@ -286,7 +366,6 @@ def move_to_trash(document_id: str, db: Session = Depends(get_db)):
if document is None:
return RedirectResponse(url="/documents/", status_code=303)
from datetime import datetime
document.is_trashed = True
document.trashed_at = datetime.utcnow()
db.commit()
@ -303,12 +382,9 @@ def save_field_enriched_pdf(document_id: str, db: Session = Depends(get_db)):
try:
create_field_enriched_pdf_version(db, document)
except Exception:
return RedirectResponse(
url=_document_url(document.document_id, error="save_field_enriched_failed", tab="extracted-fields"),
status_code=303,
)
return RedirectResponse(url=f"/documents/{document.document_id}?error=save_field_enriched_failed", status_code=303)
return RedirectResponse(url=_document_url(document.document_id, tab="extracted-fields"), status_code=303)
return RedirectResponse(url=f"/documents/{document.document_id}?tab=extracted-fields", status_code=303)
@router.post("/{document_id}/review-text", response_class=RedirectResponse)
@ -335,19 +411,11 @@ def save_reviewed_text(
if expected_line_count and actual_line_count != expected_line_count:
return RedirectResponse(
url=_document_url(
document.document_id,
error="line_count_mismatch",
expected=expected_line_count,
actual=actual_line_count,
tab="ocr-review",
),
url=f"/documents/{document.document_id}?error=line_count_mismatch&expected={expected_line_count}&actual={actual_line_count}&tab=ocr-review",
status_code=303,
)
existing_reviewed = [
tv for tv in document.text_versions if tv.version_type == "reviewed" and tv.is_current
]
existing_reviewed = [tv for tv in document.text_versions if tv.version_type == "reviewed" and tv.is_current]
for tv in existing_reviewed:
tv.is_current = False
@ -374,13 +442,9 @@ def save_reviewed_text(
raw_ocr.quality_note = quality_note or None
document.review_status = "reviewed"
db.commit()
return RedirectResponse(
url=_document_url(document.document_id, editor_source="reviewed", tab="ocr-review"),
status_code=303,
)
return RedirectResponse(url=f"/documents/{document.document_id}?editor_source=reviewed&tab=ocr-review", status_code=303)
@router.post("/{document_id}/save-extracted-fields", response_class=RedirectResponse)
@ -428,10 +492,55 @@ def save_extracted_fields_route(
extra_json=extra_json,
)
return RedirectResponse(
url=_document_url(document.document_id, autofill_extracted=0, tab="extracted-fields"),
status_code=303,
return RedirectResponse(url=f"/documents/{document.document_id}?autofill_extracted=0&tab=extracted-fields", status_code=303)
@router.post("/{document_id}/save-additional-fields", response_class=RedirectResponse)
def save_additional_fields_route(
document_id: str,
owner_person: str = Form(""),
paid_by_person: str = Form(""),
covered_people: str = Form(""),
attendees: str = Form(""),
occasion_note: str = Form(""),
is_shared_expense: str | None = Form(None),
reimbursement_expected_from: str = Form(""),
reimbursement_paid_by: str = Form(""),
reimbursement_paid_to: str = Form(""),
reimbursement_paid_amount: str = Form(""),
reimbursement_paid_date: str = Form(""),
reimbursement_note: str = Form(""),
db: Session = Depends(get_db),
):
document = (
db.query(Document)
.options(selectinload(Document.additional_fields))
.filter(Document.document_id == document_id)
.first()
)
if document is None:
return RedirectResponse(url="/documents/", status_code=303)
current = _get_current_additional_fields(document)
if current is None:
current = DocumentAdditionalField(document_id=document.id)
db.add(current)
current.owner_person = owner_person.strip() or None
current.paid_by_person = paid_by_person.strip() or None
current.covered_people = _parse_people_list(covered_people)
current.attendees = _parse_people_list(attendees)
current.occasion_note = occasion_note.strip() or None
current.is_shared_expense = bool(is_shared_expense)
current.reimbursement_expected_from = _parse_people_list(reimbursement_expected_from)
current.reimbursement_paid_by = reimbursement_paid_by.strip() or None
current.reimbursement_paid_to = reimbursement_paid_to.strip() or None
current.reimbursement_paid_amount = _to_decimal(reimbursement_paid_amount)
current.reimbursement_paid_date = datetime.strptime(reimbursement_paid_date, "%Y-%m-%d").date() if reimbursement_paid_date else None
current.reimbursement_note = reimbursement_note.strip() or None
db.commit()
return RedirectResponse(url=f"/documents/{document.document_id}?tab=additional-fields", status_code=303)
@router.get("/{document_id}", response_class=HTMLResponse)
@ -443,6 +552,7 @@ def document_detail(document_id: str, request: Request, queue: str | None = None
selectinload(Document.text_versions),
selectinload(Document.extracted_fields),
selectinload(Document.layer1_candidates),
selectinload(Document.additional_fields),
)
.filter(Document.document_id == document_id)
.first()
@ -454,7 +564,6 @@ def document_detail(document_id: str, request: Request, queue: str | None = None
raw_ocr, reviewed_ocr = _get_current_text_versions(document)
editor_source = request.query_params.get("editor_source", "reviewed")
active_tab = request.query_params.get("tab", "ocr-review")
review_text_value = _build_review_text_value(raw_ocr, reviewed_ocr, editor_source)
expected_line_count = _line_count_from_layout(raw_ocr.layout_json if raw_ocr else None)
@ -476,10 +585,25 @@ def document_detail(document_id: str, request: Request, queue: str | None = None
error_expected = request.query_params.get("expected")
error_actual = request.query_params.get("actual")
preset_id_raw = request.query_params.get("preset_id")
try:
preset_id = int(preset_id_raw) if preset_id_raw else None
except ValueError:
preset_id = None
selected_preset = _get_preset_by_id(db, preset_id)
all_presets = _get_all_presets(db)
extracted_form = _extracted_field_form_values(document, request)
additional_form = _additional_field_form_values(document, selected_preset)
current_extracted = get_current_extracted_fields(document)
current_additional = _get_current_additional_fields(document)
queue_nav = _get_queue_navigation(db, document)
active_tab = request.query_params.get("tab", "ocr-review")
if active_tab not in {"ocr-review", "extracted-fields", "additional-fields", "versions", "raw-ocr"}:
active_tab = "ocr-review"
return templates.TemplateResponse(
request=request,
name="documents/detail.html",
@ -495,7 +619,6 @@ def document_detail(document_id: str, request: Request, queue: str | None = None
"review_text_value": review_text_value,
"file_url": file_url,
"app_url": app_url,
"active_tab": active_tab,
"quality_flag_options": QUALITY_FLAG_OPTIONS,
"current_quality_flags": raw_ocr.quality_flags if raw_ocr and raw_ocr.quality_flags else [],
"current_quality_note": raw_ocr.quality_note if raw_ocr and raw_ocr.quality_note else "",
@ -507,6 +630,11 @@ def document_detail(document_id: str, request: Request, queue: str | None = None
"error_actual": error_actual,
"extracted_form": extracted_form,
"current_extracted": current_extracted,
"additional_form": additional_form,
"current_additional": current_additional,
"presets": all_presets,
"selected_preset_id": preset_id,
"active_tab": active_tab,
"active_page": "documents",
},
)

153
app/routes/presets.py Normal file
View File

@ -0,0 +1,153 @@
from pathlib import Path
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.db.deps import get_db
from app.models.document_preset import DocumentPreset
router = APIRouter(prefix="/presets", tags=["presets"])
BASE_DIR = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _parse_people_list(value: str) -> list[str]:
return [part.strip() for part in value.split(",") if part.strip()]
def _format_people_list(value: list | None) -> str:
if not value:
return ""
return ", ".join(str(x).strip() for x in value if str(x).strip())
def _preset_form_values(preset: DocumentPreset | None = None) -> dict:
if preset is None:
return {
"name": "",
"owner_person": "",
"paid_by_person": "",
"covered_people": "",
"attendees": "",
"occasion_note": "",
"is_shared_expense": False,
"reimbursement_expected_from": "",
"reimbursement_paid_by": "",
"reimbursement_paid_to": "",
"reimbursement_note": "",
}
return {
"name": preset.name or "",
"owner_person": preset.owner_person or "",
"paid_by_person": preset.paid_by_person or "",
"covered_people": _format_people_list(preset.covered_people),
"attendees": _format_people_list(preset.attendees),
"occasion_note": preset.occasion_note or "",
"is_shared_expense": bool(preset.is_shared_expense),
"reimbursement_expected_from": _format_people_list(preset.reimbursement_expected_from),
"reimbursement_paid_by": preset.reimbursement_paid_by or "",
"reimbursement_paid_to": preset.reimbursement_paid_to or "",
"reimbursement_note": preset.reimbursement_note or "",
}
@router.get("/", response_class=HTMLResponse)
def list_presets(request: Request, edit_id: int | None = None, db: Session = Depends(get_db)):
presets = db.query(DocumentPreset).order_by(DocumentPreset.name.asc()).all()
editing = None
if edit_id is not None:
editing = db.query(DocumentPreset).filter(DocumentPreset.id == edit_id).first()
return templates.TemplateResponse(
request=request,
name="presets/index.html",
context={
"request": request,
"presets": presets,
"editing": editing,
"form_values": _preset_form_values(editing),
"active_page": "presets",
},
)
@router.post("/create", response_class=RedirectResponse)
def create_preset(
name: str = Form(...),
owner_person: str = Form(""),
paid_by_person: str = Form(""),
covered_people: str = Form(""),
attendees: str = Form(""),
occasion_note: str = Form(""),
is_shared_expense: str | None = Form(None),
reimbursement_expected_from: str = Form(""),
reimbursement_paid_by: str = Form(""),
reimbursement_paid_to: str = Form(""),
reimbursement_note: str = Form(""),
db: Session = Depends(get_db),
):
preset = DocumentPreset(
name=name.strip(),
owner_person=owner_person.strip() or None,
paid_by_person=paid_by_person.strip() or None,
covered_people=_parse_people_list(covered_people),
attendees=_parse_people_list(attendees),
occasion_note=occasion_note.strip() or None,
is_shared_expense=bool(is_shared_expense),
reimbursement_expected_from=_parse_people_list(reimbursement_expected_from),
reimbursement_paid_by=reimbursement_paid_by.strip() or None,
reimbursement_paid_to=reimbursement_paid_to.strip() or None,
reimbursement_note=reimbursement_note.strip() or None,
)
db.add(preset)
db.commit()
return RedirectResponse(url="/presets/", status_code=303)
@router.post("/{preset_id}/update", response_class=RedirectResponse)
def update_preset(
preset_id: int,
name: str = Form(...),
owner_person: str = Form(""),
paid_by_person: str = Form(""),
covered_people: str = Form(""),
attendees: str = Form(""),
occasion_note: str = Form(""),
is_shared_expense: str | None = Form(None),
reimbursement_expected_from: str = Form(""),
reimbursement_paid_by: str = Form(""),
reimbursement_paid_to: str = Form(""),
reimbursement_note: str = Form(""),
db: Session = Depends(get_db),
):
preset = db.query(DocumentPreset).filter(DocumentPreset.id == preset_id).first()
if preset is None:
return RedirectResponse(url="/presets/", status_code=303)
preset.name = name.strip()
preset.owner_person = owner_person.strip() or None
preset.paid_by_person = paid_by_person.strip() or None
preset.covered_people = _parse_people_list(covered_people)
preset.attendees = _parse_people_list(attendees)
preset.occasion_note = occasion_note.strip() or None
preset.is_shared_expense = bool(is_shared_expense)
preset.reimbursement_expected_from = _parse_people_list(reimbursement_expected_from)
preset.reimbursement_paid_by = reimbursement_paid_by.strip() or None
preset.reimbursement_paid_to = reimbursement_paid_to.strip() or None
preset.reimbursement_note = reimbursement_note.strip() or None
db.commit()
return RedirectResponse(url="/presets/", status_code=303)
@router.post("/{preset_id}/delete", response_class=RedirectResponse)
def delete_preset(preset_id: int, db: Session = Depends(get_db)):
preset = db.query(DocumentPreset).filter(DocumentPreset.id == preset_id).first()
if preset is not None:
db.delete(preset)
db.commit()
return RedirectResponse(url="/presets/", status_code=303)

View File

@ -94,6 +94,7 @@
<div class="right-pane-tabs">
<button class="tab-button{% if active_tab == 'ocr-review' %} active{% endif %}" type="button" data-tab="ocr-review">OCR Review</button>
<button class="tab-button{% if active_tab == 'extracted-fields' %} active{% endif %}" type="button" data-tab="extracted-fields">Extracted Fields</button>
<button class="tab-button{% if active_tab == 'additional-fields' %} active{% endif %}" type="button" data-tab="additional-fields">Additional Fields</button>
<button class="tab-button{% if active_tab == 'versions' %} active{% endif %}" type="button" data-tab="versions">Versions</button>
<button class="tab-button{% if active_tab == 'raw-ocr' %} active{% endif %}" type="button" data-tab="raw-ocr">Raw OCR</button>
</div>
@ -187,6 +188,92 @@
</form>
</div>
<div class="tab-panel{% if active_tab == 'additional-fields' %} active{% endif %}" data-panel="additional-fields">
<h2 class="card-title">Additional fields</h2>
{% if current_additional %}
<p>Current additional fields last updated at {{ current_additional.updated_at }}</p>
{% else %}
<p class="empty-state">No additional fields saved yet.</p>
{% endif %}
{% if presets %}
<form method="get" action="/documents/{{ document.document_id }}" style="margin-bottom: 1rem;">
<input type="hidden" name="tab" value="additional-fields">
<div class="button-row">
<select name="preset_id">
<option value="">Select preset</option>
{% for preset in presets %}
<option value="{{ preset.id }}" {% if selected_preset_id == preset.id %}selected{% endif %}>{{ preset.name }}</option>
{% endfor %}
</select>
<button type="submit">Apply preset</button>
<a class="button-link" href="/presets/">Manage presets</a>
</div>
</form>
{% else %}
<div class="button-row" style="margin-bottom: 1rem;">
<a class="button-link" href="/presets/">Create first preset</a>
</div>
{% endif %}
<form method="post" action="/documents/{{ document.document_id }}/save-additional-fields">
<div class="form-grid">
<div class="form-field">
<label>Owner person</label>
<input type="text" name="owner_person" value="{{ additional_form.owner_person }}">
</div>
<div class="form-field">
<label>Paid by person</label>
<input type="text" name="paid_by_person" value="{{ additional_form.paid_by_person }}">
</div>
<div class="form-field full">
<label>Covered people</label>
<input type="text" name="covered_people" value="{{ additional_form.covered_people }}" placeholder="Full Name, Full Name">
</div>
<div class="form-field full">
<label>Attendees</label>
<input type="text" name="attendees" value="{{ additional_form.attendees }}" placeholder="Full Name, Full Name">
</div>
<div class="form-field full">
<label>Occasion note</label>
<input type="text" name="occasion_note" value="{{ additional_form.occasion_note }}" placeholder="Dinner with Camie and friends">
</div>
<div class="form-field">
<label><input type="checkbox" name="is_shared_expense" value="1" {% if additional_form.is_shared_expense %}checked{% endif %}> Shared expense</label>
</div>
<div class="form-field full">
<label>Reimbursement expected from</label>
<input type="text" name="reimbursement_expected_from" value="{{ additional_form.reimbursement_expected_from }}" placeholder="Full Name, Full Name">
</div>
<div class="form-field">
<label>Reimbursement paid by</label>
<input type="text" name="reimbursement_paid_by" value="{{ additional_form.reimbursement_paid_by }}">
</div>
<div class="form-field">
<label>Reimbursement paid to</label>
<input type="text" name="reimbursement_paid_to" value="{{ additional_form.reimbursement_paid_to }}">
</div>
<div class="form-field">
<label>Reimbursement paid amount</label>
<input type="text" name="reimbursement_paid_amount" value="{{ additional_form.reimbursement_paid_amount }}">
</div>
<div class="form-field">
<label>Reimbursement paid date</label>
<input type="date" name="reimbursement_paid_date" value="{{ additional_form.reimbursement_paid_date }}">
</div>
<div class="form-field full">
<label>Reimbursement note</label>
<textarea name="reimbursement_note" rows="4">{{ additional_form.reimbursement_note }}</textarea>
</div>
</div>
<div class="button-row" style="margin-top: 1rem;">
<button class="primary" type="submit">Save additional fields</button>
</div>
</form>
</div>
<div class="tab-panel{% if active_tab == 'versions' %} active{% endif %}" data-panel="versions">
<h2 class="card-title">Document versions</h2>
{% if document.versions %}

View File

@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Presets</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="app-shell" id="app-shell">
{% include "partials/sidebar.html" %}
<main class="main">
<div class="topbar">
<div>
<h1 class="page-title">Presets</h1>
<p class="page-subtitle">Create reusable defaults for additional fields.</p>
</div>
</div>
<div class="card">
<h2 class="card-title">{% if editing %}Edit preset{% else %}Create preset{% endif %}</h2>
<form method="post" action="{% if editing %}/presets/{{ editing.id }}/update{% else %}/presets/create{% endif %}">
<div class="form-grid">
<div class="form-field">
<label>Preset name</label>
<input type="text" name="name" value="{{ form_values.name }}" required>
</div>
<div class="form-field">
<label>Owner person</label>
<input type="text" name="owner_person" value="{{ form_values.owner_person }}">
</div>
<div class="form-field">
<label>Paid by person</label>
<input type="text" name="paid_by_person" value="{{ form_values.paid_by_person }}">
</div>
<div class="form-field full">
<label>Covered people</label>
<input type="text" name="covered_people" value="{{ form_values.covered_people }}" placeholder="Full Name, Full Name">
</div>
<div class="form-field full">
<label>Attendees</label>
<input type="text" name="attendees" value="{{ form_values.attendees }}" placeholder="Full Name, Full Name">
</div>
<div class="form-field full">
<label>Occasion note</label>
<input type="text" name="occasion_note" value="{{ form_values.occasion_note }}">
</div>
<div class="form-field">
<label><input type="checkbox" name="is_shared_expense" value="1" {% if form_values.is_shared_expense %}checked{% endif %}> Shared expense</label>
</div>
<div class="form-field full">
<label>Reimbursement expected from</label>
<input type="text" name="reimbursement_expected_from" value="{{ form_values.reimbursement_expected_from }}" placeholder="Full Name, Full Name">
</div>
<div class="form-field">
<label>Reimbursement paid by</label>
<input type="text" name="reimbursement_paid_by" value="{{ form_values.reimbursement_paid_by }}">
</div>
<div class="form-field">
<label>Reimbursement paid to</label>
<input type="text" name="reimbursement_paid_to" value="{{ form_values.reimbursement_paid_to }}">
</div>
<div class="form-field full">
<label>Reimbursement note</label>
<textarea name="reimbursement_note" rows="3">{{ form_values.reimbursement_note }}</textarea>
</div>
</div>
<div class="button-row" style="margin-top: 1rem;">
<button class="primary" type="submit">{% if editing %}Save preset{% else %}Create preset{% endif %}</button>
{% if editing %}
<a class="button-link" href="/presets/">Cancel</a>
{% endif %}
</div>
</form>
</div>
<div class="card">
<h2 class="card-title">Existing presets</h2>
{% if presets %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Owner</th>
<th>Paid by</th>
<th>Covered people</th>
<th>Attendees</th>
<th>Shared</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for preset in presets %}
<tr>
<td>{{ preset.name }}</td>
<td>{{ preset.owner_person or "" }}</td>
<td>{{ preset.paid_by_person or "" }}</td>
<td>{{ preset.covered_people or [] }}</td>
<td>{{ preset.attendees or [] }}</td>
<td>{{ "Yes" if preset.is_shared_expense else "No" }}</td>
<td>
<div class="button-row">
<a class="button-link" href="/presets/?edit_id={{ preset.id }}">Edit</a>
<form method="post" action="/presets/{{ preset.id }}/delete">
<button class="danger" type="submit">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">No presets yet.</p>
{% endif %}
</div>
</main>
</div>
<script>
(function () {
const appShell = document.getElementById("app-shell");
const menuToggle = document.getElementById("menu-toggle");
if (appShell && menuToggle) {
menuToggle.addEventListener("click", function () {
appShell.classList.toggle("nav-open");
});
menuToggle.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
appShell.classList.toggle("nav-open");
}
});
}
})();
</script>
</body>
</html>