refactor: use shared sidebar partial across templates

This commit is contained in:
Sean McElwain 2026-04-06 16:31:40 -05:00
parent d14ee39cc8
commit 4f10978989
20 changed files with 1627 additions and 82 deletions

View File

@ -233,7 +233,7 @@ def list_documents(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="documents/list.html", name="documents/list.html",
context={"request": request, "documents": documents}, context={"request": request, "documents": documents, "active_page": "documents"},
) )
@ -507,5 +507,6 @@ def document_detail(document_id: str, request: Request, queue: str | None = None
"error_actual": error_actual, "error_actual": error_actual,
"extracted_form": extracted_form, "extracted_form": extracted_form,
"current_extracted": current_extracted, "current_extracted": current_extracted,
"active_page": "documents",
}, },
) )

View File

@ -23,6 +23,7 @@ def ingest_home(request: Request):
context={ context={
"request": request, "request": request,
"inbox_root": INBOX_ROOT, "inbox_root": INBOX_ROOT,
"active_page": "ingest",
}, },
) )

298
app/routes/line_items.py Normal file
View File

@ -0,0 +1,298 @@
from pathlib import Path
from decimal import Decimal, InvalidOperation
from fastapi import APIRouter, Depends, Form, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import func
from sqlalchemy.orm import Session, selectinload
from app.db.deps import get_db
from app.logic.extraction import get_current_extracted_fields
from app.models.document import Document
from app.models.receipt_line_item import ReceiptLineItem
router = APIRouter(prefix="/line-items", tags=["line-items"])
BASE_DIR = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _decimal_to_str(value: Decimal | None) -> str:
if value is None:
return ""
return str(value)
def _to_decimal(value: str | None) -> Decimal | None:
if value is None:
return None
cleaned = str(value).strip()
if not cleaned:
return None
try:
return Decimal(cleaned)
except (InvalidOperation, TypeError):
return None
def _line_item_quality_rating(item: ReceiptLineItem) -> str:
extra = item.extra_json or {}
value = extra.get("quality_rating")
return "" if value is None else str(value)
def _line_item_quality_note(item: ReceiptLineItem) -> str:
extra = item.extra_json or {}
value = extra.get("quality_note")
return "" if value is None else str(value)
@router.post("/{line_item_id}/review", response_class=RedirectResponse)
def save_line_item_review(
line_item_id: int,
q: str = Form(""),
merchant: str = Form(""),
category: str = Form(""),
date_from: str = Form(""),
date_to: str = Form(""),
rating_min: str = Form(""),
rating_max: str = Form(""),
quality_rating: str = Form(""),
quality_note: str = Form(""),
db: Session = Depends(get_db),
):
item = db.query(ReceiptLineItem).filter(ReceiptLineItem.id == line_item_id).first()
if item is None:
return RedirectResponse(url="/line-items/", status_code=303)
extra = dict(item.extra_json or {})
rating_clean = quality_rating.strip()
note_clean = quality_note.strip()
if rating_clean:
extra["quality_rating"] = rating_clean
else:
extra.pop("quality_rating", None)
if note_clean:
extra["quality_note"] = note_clean
else:
extra.pop("quality_note", None)
item.extra_json = extra
db.commit()
redirect_url = (
f"/line-items/?q={q}&merchant={merchant}&category={category}"
f"&date_from={date_from}&date_to={date_to}"
f"&rating_min={rating_min}&rating_max={rating_max}"
)
return RedirectResponse(url=redirect_url, status_code=303)
@router.get("/", response_class=HTMLResponse)
def list_line_items(
request: Request,
q: str = Query("", description="Item description contains"),
merchant: str = Query("", description="Merchant contains"),
category: str = Query("", description="Category equals"),
date_from: str = Query("", description="YYYY-MM-DD"),
date_to: str = Query("", description="YYYY-MM-DD"),
rating_min: str = Query("", description="Minimum rating"),
rating_max: str = Query("", description="Maximum rating"),
db: Session = Depends(get_db),
):
items = (
db.query(ReceiptLineItem)
.options(
selectinload(ReceiptLineItem.document).selectinload(Document.extracted_fields)
)
.order_by(ReceiptLineItem.id.desc())
.all()
)
q_norm = q.strip().lower()
merchant_norm = merchant.strip().lower()
category_norm = category.strip().lower()
rating_min_dec = _to_decimal(rating_min)
rating_max_dec = _to_decimal(rating_max)
rows: list[dict] = []
for item in items:
document = item.document
if document is None:
continue
extracted = get_current_extracted_fields(document)
merchant_value = ""
transaction_date = ""
if extracted is not None:
merchant_value = (
extracted.merchant_normalized
or extracted.merchant_raw
or ""
)
if extracted.transaction_date:
transaction_date = extracted.transaction_date.isoformat()
if not transaction_date and document.created_at:
transaction_date = document.created_at.date().isoformat()
description_value = (
item.normalized_description
or item.raw_description
or ""
)
category_value = item.item_category or ""
quality_rating_value = _line_item_quality_rating(item)
quality_note_value = _line_item_quality_note(item)
quality_rating_dec = _to_decimal(quality_rating_value)
if q_norm and q_norm not in description_value.lower():
continue
if merchant_norm and merchant_norm not in merchant_value.lower():
continue
if category_norm and category_norm not in category_value.lower():
continue
if date_from and (not transaction_date or transaction_date < date_from):
continue
if date_to and (not transaction_date or transaction_date > date_to):
continue
if rating_min_dec is not None:
if quality_rating_dec is None or quality_rating_dec < rating_min_dec:
continue
if rating_max_dec is not None:
if quality_rating_dec is None or quality_rating_dec > rating_max_dec:
continue
rows.append(
{
"line_item_id": item.id,
"document_id": document.document_id,
"transaction_date": transaction_date,
"merchant": merchant_value,
"description": description_value,
"raw_description": item.raw_description or "",
"quantity": _decimal_to_str(item.quantity),
"line_total": _decimal_to_str(item.line_total),
"category": category_value,
"confidence": _decimal_to_str(item.confidence),
"quality_rating": quality_rating_value,
"quality_note": quality_note_value,
}
)
rows.sort(
key=lambda row: (
row["transaction_date"] or "",
row["merchant"] or "",
row["description"] or "",
),
reverse=True,
)
return templates.TemplateResponse(
request=request,
name="line_items/list.html",
context={
"request": request,
"rows": rows,
"q": q,
"merchant": merchant,
"category": category,
"date_from": date_from,
"date_to": date_to,
"rating_min": rating_min,
"rating_max": rating_max,
"active_page": "line_items",
},
)
@router.get("/summary", response_class=HTMLResponse)
def summarize_line_items(
request: Request,
q: str = Query("", description="Item contains"),
db: Session = Depends(get_db),
):
query = (
db.query(
ReceiptLineItem.normalized_description.label("item"),
func.count().label("count"),
func.avg(ReceiptLineItem.line_total).label("avg_price"),
func.min(ReceiptLineItem.line_total).label("min_price"),
func.max(ReceiptLineItem.line_total).label("max_price"),
)
)
if q:
query = query.filter(
ReceiptLineItem.normalized_description.ilike(f"%{q}%")
)
query = query.group_by(ReceiptLineItem.normalized_description)
results = query.all()
rating_query = db.query(
ReceiptLineItem.normalized_description,
ReceiptLineItem.extra_json,
)
if q:
rating_query = rating_query.filter(
ReceiptLineItem.normalized_description.ilike(f"%{q}%")
)
rating_rows = rating_query.all()
rating_map: dict[str, dict[str, Decimal | int]] = {}
for item_name, extra_json in rating_rows:
key = item_name or ""
rating_info = rating_map.setdefault(
key,
{"rated_count": 0, "rating_sum": Decimal("0")}
)
extra = extra_json or {}
rating_dec = _to_decimal(extra.get("quality_rating"))
if rating_dec is not None:
rating_info["rated_count"] += 1
rating_info["rating_sum"] += rating_dec
rows = []
for r in results:
item_name = r.item or ""
rating_info = rating_map.get(item_name, {"rated_count": 0, "rating_sum": Decimal("0")})
rated_count = int(rating_info["rated_count"])
rating_sum = rating_info["rating_sum"]
avg_rating = ""
if rated_count > 0:
avg_rating = str((rating_sum / rated_count).quantize(Decimal("0.01")))
rows.append(
{
"item": item_name,
"count": r.count,
"avg_price": str(round(r.avg_price, 2)) if r.avg_price is not None else "",
"min_price": str(r.min_price) if r.min_price is not None else "",
"max_price": str(r.max_price) if r.max_price is not None else "",
"rated_count": rated_count,
"avg_rating": avg_rating,
}
)
rows.sort(key=lambda x: (x["count"], x["item"]), reverse=True)
return templates.TemplateResponse(
request=request,
name="line_items/summary.html",
context={
"request": request,
"rows": rows,
"q": q,
"active_page": "line_item_summary",
},
)

View File

@ -54,5 +54,6 @@ def review_queue(request: Request, db: Session = Depends(get_db)):
"recently_updated": recently_updated, "recently_updated": recently_updated,
"next_ocr": next_ocr, "next_ocr": next_ocr,
"next_fields": next_fields, "next_fields": next_fields,
"active_page": "queue",
}, },
) )

View File

@ -30,7 +30,7 @@ def trash_index(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="trash/index.html", name="trash/index.html",
context={"request": request, "documents": documents}, context={"request": request, "documents": documents, "active_page": "trash"},
) )

View File

@ -7,22 +7,7 @@
</head> </head>
<body> <body>
<div class="app-shell" id="app-shell"> <div class="app-shell" id="app-shell">
<aside class="sidebar"> {% include "partials/sidebar.html" %}
<div class="sidebar-top">
<div class="sidebar-toggle" id="menu-toggle" aria-label="Toggle navigation" role="button" tabindex="0">
<span></span><span></span><span></span>
</div>
<div class="brand">Document Processor</div>
</div>
<div class="sidebar-section-title">Workspace</div>
<nav class="nav-list">
<a class="nav-link active" href="/documents/" title="Documents"><span class="nav-link-short">D</span><span class="nav-link-text">Documents</span></a>
<a class="nav-link" href="/queue/" title="Review Queue"><span class="nav-link-short">Q</span><span class="nav-link-text">Review Queue</span></a>
<a class="nav-link" href="/trash/" title="Trash"><span class="nav-link-short">T</span><span class="nav-link-text">Trash</span></a>
<a class="nav-link" href="/ingest/" title="Ingest"><span class="nav-link-short">I</span><span class="nav-link-text">Ingest</span></a>
</nav>
</aside>
<main class="main"> <main class="main">
{% if error == "line_count_mismatch" %} {% if error == "line_count_mismatch" %}

View File

@ -0,0 +1,362 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ document.document_id }}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="app-shell" id="app-shell">
<aside class="sidebar">
<div class="sidebar-top">
<div class="sidebar-toggle" id="menu-toggle" aria-label="Toggle navigation" role="button" tabindex="0">
<span></span><span></span><span></span>
</div>
<div class="brand">Document Processor</div>
</div>
<div class="sidebar-section-title">Workspace</div>
<nav class="nav-list">
<a class="nav-link active" href="/documents/" title="Documents"><span class="nav-link-short">D</span><span class="nav-link-text">Documents</span></a>
<a class="nav-link" href="/line-items/" title="Line Items"><span class="nav-link-short">L</span><span class="nav-link-text">Line Items</span></a>
<a class="nav-link" href="/queue/" title="Review Queue"><span class="nav-link-short">Q</span><span class="nav-link-text">Review Queue</span></a>
<a class="nav-link" href="/trash/" title="Trash"><span class="nav-link-short">T</span><span class="nav-link-text">Trash</span></a>
<a class="nav-link" href="/ingest/" title="Ingest"><span class="nav-link-short">I</span><span class="nav-link-text">Ingest</span></a>
</nav>
</aside>
<main class="main">
{% if error == "line_count_mismatch" %}
<div class="error-box">
Could not save reviewed OCR because line count did not match OCR layout.
Expected {{ error_expected }}, got {{ error_actual }}.
</div>
{% elif error == "save_ocr_corrected_failed" %}
<div class="error-box">
Could not save OCR-corrected PDF. Check that reviewed OCR line count matches raw OCR line count.
</div>
{% elif error == "rerun_ocr_failed" %}
<div class="error-box">OCR rerun failed.</div>
{% elif error == "save_field_enriched_failed" %}
<div class="error-box">Could not save field-enriched PDF.</div>
{% endif %}
<div class="detail-sticky-header">
<div class="topbar">
<div>
<h1 class="page-title">{{ document.document_id }}</h1>
<p class="page-subtitle">{{ document.original_filename or document.canonical_filename or document.document_type }}</p>
</div>
<div class="badges">
<span class="badge {% if document.review_status == 'reviewed' %}reviewed{% else %}pending{% endif %}">{{ document.review_status }}</span>
<span class="badge">{{ document.document_type }}</span>
<span class="badge">{{ document.mime_type }}</span>
</div>
</div>
<div class="card" style="margin-bottom: 0;">
<div class="button-row">
<form method="post" action="/documents/{{ document.document_id }}/rerun-ocr">
<button type="submit">Re-run OCR</button>
</form>
<form method="post" action="/documents/{{ document.document_id }}/save-ocr-corrected-pdf">
<button class="primary" type="submit">Save OCR-corrected PDF</button>
</form>
<form method="post" action="/documents/{{ document.document_id }}/save-field-enriched-pdf">
<button type="submit">Save field-enriched PDF</button>
</form>
<form method="post" action="/documents/{{ document.document_id }}/move-to-trash">
<button class="danger" type="submit">Move to trash</button>
</form>
</div>
<div class="queue-nav-row">
{% if prev_doc %}
<a class="button-link" href="/documents/{{ prev_doc.document_id }}">← Previous</a>
{% endif %}
{% if next_doc %}
<a class="button-link" href="/documents/{{ next_doc.document_id }}">Next →</a>
{% endif %}
{% if next_ocr_doc %}
<a class="button-link" href="/documents/{{ next_ocr_doc.document_id }}">Next OCR review</a>
{% endif %}
{% if next_fields_doc %}
<a class="button-link" href="/documents/{{ next_fields_doc.document_id }}">Next field extraction</a>
{% endif %}
</div>
</div>
</div>
<div class="workspace-grid">
<section>
<div class="card preview-card">
<h2 class="card-title">Document preview</h2>
{% if file_url %}
{% if document.mime_type == "application/pdf" %}
<iframe class="preview-frame" src="{{ file_url }}"></iframe>
{% elif document.mime_type in ["image/jpeg", "image/png"] %}
<img class="preview-image" src="{{ file_url }}" alt="Document image">
{% else %}
<p><a href="{{ file_url }}" target="_blank">Open file</a></p>
{% endif %}
{% else %}
<p class="empty-state">No preview available.</p>
{% endif %}
</div>
</section>
<section>
<div class="card">
<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 == '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>
<div class="tab-panel{% if active_tab == 'ocr-review' %} active{% endif %}" data-panel="ocr-review">
<h2 class="card-title">Reviewed OCR</h2>
{% if reviewed_ocr %}
<p>Current reviewed version saved at {{ reviewed_ocr.created_at }} — v{{ reviewed_ocr.version_number }}</p>
{% else %}
<p class="empty-state">No reviewed OCR saved yet.</p>
{% endif %}
<p>
Expected OCR lines: <span id="expected-lines">{{ expected_line_count }}</span><br>
Current editor lines: <span id="actual-lines">{{ actual_line_count }}</span><br>
<span id="line-warning" class="line-warning" {% if expected_line_count == actual_line_count %}style="display:none;"{% endif %}>
Line count mismatch may affect corrected PDF layout.
</span>
</p>
<form method="post" action="/documents/{{ document.document_id }}/review-text">
<div class="form-field full">
<label for="reviewed_text">Edit reviewed OCR text (one line per OCR line)</label>
<div class="editor-wrap">
<pre class="line-numbers" id="line-numbers">{% for n in line_numbers %}{{ n }}
{% endfor %}</pre>
<textarea id="reviewed_text" name="reviewed_text" rows="34" spellcheck="false">{{ review_text_value }}</textarea>
</div>
</div>
<div class="form-field full">
<label>Quality flags</label>
<div>
{% for flag in quality_flag_options %}
<label style="display:block; margin-bottom: 0.25rem;">
<input type="checkbox" name="quality_flags" value="{{ flag }}" {% if flag in current_quality_flags %}checked{% endif %}>
{{ flag }}
</label>
{% endfor %}
</div>
</div>
<div class="form-field full">
<label for="quality_note">Quality note</label>
<textarea id="quality_note" name="quality_note" rows="4">{{ current_quality_note }}</textarea>
</div>
<div class="button-row">
<button class="primary" type="submit" id="save-reviewed-btn">Save reviewed OCR</button>
</div>
</form>
</div>
<div class="tab-panel{% if active_tab == 'extracted-fields' %} active{% endif %}" data-panel="extracted-fields">
<h2 class="card-title">Extracted fields</h2>
{% if current_extracted %}
<p>Current extracted fields last updated at {{ current_extracted.updated_at }}</p>
{% else %}
<p class="empty-state">No extracted fields saved yet.</p>
{% endif %}
<form method="get" action="/documents/{{ document.document_id }}">
<input type="hidden" name="autofill_extracted" value="1">
<input type="hidden" name="tab" value="extracted-fields">
<div class="button-row">
<button type="submit">Auto-extract fields</button>
</div>
</form>
<form method="post" action="/documents/{{ document.document_id }}/save-extracted-fields" style="margin-top: 1rem;">
<div class="form-grid">
<div class="form-field"><label>Merchant raw</label><input type="text" name="merchant_raw" value="{{ extracted_form.merchant_raw }}"></div>
<div class="form-field"><label>Merchant normalized</label><input type="text" name="merchant_normalized" value="{{ extracted_form.merchant_normalized }}"></div>
<div class="form-field"><label>Transaction date</label><input type="date" name="transaction_date" value="{{ extracted_form.transaction_date }}"></div>
<div class="form-field"><label>Transaction time</label><input type="text" name="transaction_time" value="{{ extracted_form.transaction_time }}"></div>
<div class="form-field"><label>Subtotal</label><input type="text" name="subtotal" value="{{ extracted_form.subtotal }}"></div>
<div class="form-field"><label>Tax</label><input type="text" name="tax" value="{{ extracted_form.tax }}"></div>
<div class="form-field"><label>Total</label><input type="text" name="total" value="{{ extracted_form.total }}"></div>
<div class="form-field"><label>Currency</label><input type="text" name="currency" value="{{ extracted_form.currency }}"></div>
<div class="form-field"><label>Payment method</label><input type="text" name="payment_method" value="{{ extracted_form.payment_method }}"></div>
<div class="form-field"><label>Reference number</label><input type="text" name="receipt_number" value="{{ extracted_form.receipt_number }}"></div>
<div class="form-field full"><label>Location</label><input type="text" name="location" value="{{ extracted_form.location }}"></div>
<div class="form-field full"><label>Counterparty</label><input type="text" name="counterparty" value="{{ extracted_form.counterparty }}"></div>
<div class="form-field full"><label>Extra JSON</label><textarea name="extra_json" rows="8">{{ extracted_form.extra_json }}</textarea></div>
</div>
<div class="button-row" style="margin-top: 1rem;">
<button class="primary" type="submit">Save extracted 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 %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Version</th>
<th>Type</th>
<th>Path</th>
<th>Created</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{% for version in document.versions %}
<tr>
<td>v{{ version.version_number }}</td>
<td>{{ version.version_type }}</td>
<td>{{ version.file_path }}</td>
<td>{{ version.created_at }}</td>
<td>{{ version.notes or "" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">No versions found.</p>
{% endif %}
</div>
<div class="tab-panel{% if active_tab == 'raw-ocr' %} active{% endif %}" data-panel="raw-ocr">
<h2 class="card-title">Raw OCR</h2>
{% if raw_ocr %}
<div class="meta-grid">
<div class="meta-item"><span class="meta-label">Text version</span>v{{ raw_ocr.version_number }}</div>
<div class="meta-item"><span class="meta-label">OCR engine</span>{{ raw_ocr.ocr_engine or "unknown" }}</div>
<div class="meta-item"><span class="meta-label">Engine version</span>{{ raw_ocr.ocr_engine_version or "unknown" }}</div>
<div class="meta-item"><span class="meta-label">Rerun source</span>{{ raw_ocr.rerun_source or "unknown" }}</div>
<div class="meta-item"><span class="meta-label">Quality score</span>{{ raw_ocr.quality_score if raw_ocr.quality_score is not none else "not scored yet" }}</div>
<div class="meta-item"><span class="meta-label">Quality note</span>{{ raw_ocr.quality_note or "" }}</div>
</div>
<p><strong>Quality flags:</strong> {{ raw_ocr.quality_flags if raw_ocr and raw_ocr.quality_flags else [] }}</p>
<pre class="codeblock">{{ raw_ocr.text_content }}</pre>
{% else %}
<p class="empty-state">No raw OCR text found.</p>
{% endif %}
</div>
</div>
</section>
</div>
<div class="card">
<h2 class="card-title">Metadata</h2>
<div class="meta-grid">
<div class="meta-item"><span class="meta-label">Type</span>{{ document.document_type }}</div>
<div class="meta-item"><span class="meta-label">Review status</span>{{ document.review_status }}</div>
<div class="meta-item"><span class="meta-label">Source path</span>{{ document.source_path }}</div>
<div class="meta-item"><span class="meta-label">Current path</span>{{ document.current_path }}</div>
<div class="meta-item"><span class="meta-label">Original filename</span>{{ document.original_filename }}</div>
<div class="meta-item"><span class="meta-label">Canonical filename</span>{{ document.canonical_filename }}</div>
<div class="meta-item"><span class="meta-label">MIME type</span>{{ document.mime_type }}</div>
<div class="meta-item"><span class="meta-label">File size</span>{{ document.file_size }}</div>
<div class="meta-item"><span class="meta-label">Page count</span>{{ document.page_count }}</div>
<div class="meta-item"><span class="meta-label">Share path</span>{{ document.share_path or "" }}</div>
<div class="meta-item"><span class="meta-label">Created at</span>{{ document.created_at }}</div>
<div class="meta-item"><span class="meta-label">Updated at</span>{{ document.updated_at }}</div>
</div>
</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");
}
});
}
const tabButtons = document.querySelectorAll("[data-tab]");
const tabPanels = document.querySelectorAll("[data-panel]");
function activateTab(target) {
tabButtons.forEach(function (b) {
b.classList.toggle("active", b.getAttribute("data-tab") === target);
});
tabPanels.forEach(function (p) {
p.classList.toggle("active", p.getAttribute("data-panel") === target);
});
}
tabButtons.forEach(function (btn) {
btn.addEventListener("click", function () {
const target = btn.getAttribute("data-tab");
activateTab(target);
const url = new URL(window.location.href);
url.searchParams.set("tab", target);
window.history.replaceState({}, "", url.toString());
});
});
const textarea = document.getElementById("reviewed_text");
const expectedLinesEl = document.getElementById("expected-lines");
const actualLinesEl = document.getElementById("actual-lines");
const warningEl = document.getElementById("line-warning");
const saveBtn = document.getElementById("save-reviewed-btn");
const lineNumbersEl = document.getElementById("line-numbers");
if (textarea && expectedLinesEl && actualLinesEl && warningEl && saveBtn && lineNumbersEl) {
const expectedLines = parseInt(expectedLinesEl.textContent || "0", 10);
function countLines(text) {
if (text.length === 0) return 0;
return text.split('\n').length;
}
function rebuildLineNumbers(lineCount) {
let nums = "";
for (let i = 1; i <= lineCount; i++) nums += i + "\n";
lineNumbersEl.textContent = nums;
}
function syncScroll() {
lineNumbersEl.scrollTop = textarea.scrollTop;
}
function updateEditorState() {
const actual = countLines(textarea.value);
actualLinesEl.textContent = actual.toString();
rebuildLineNumbers(Math.max(actual, expectedLines));
const mismatch = expectedLines > 0 && actual !== expectedLines;
warningEl.style.display = mismatch ? "inline" : "none";
saveBtn.disabled = mismatch;
syncScroll();
}
textarea.addEventListener("input", updateEditorState);
textarea.addEventListener("scroll", syncScroll);
updateEditorState();
syncScroll();
}
})();
</script>
</body>
</html>

View File

@ -7,22 +7,7 @@
</head> </head>
<body> <body>
<div class="app-shell" id="app-shell"> <div class="app-shell" id="app-shell">
<aside class="sidebar"> {% include "partials/sidebar.html" %}
<div class="sidebar-top">
<div class="sidebar-toggle" id="menu-toggle" aria-label="Toggle navigation" role="button" tabindex="0">
<span></span><span></span><span></span>
</div>
<div class="brand">Document Processor</div>
</div>
<div class="sidebar-section-title">Workspace</div>
<nav class="nav-list">
<a class="nav-link active" href="/documents/" title="Documents"><span class="nav-link-short">D</span><span class="nav-link-text">Documents</span></a>
<a class="nav-link" href="/queue/" title="Review Queue"><span class="nav-link-short">Q</span><span class="nav-link-text">Review Queue</span></a>
<a class="nav-link" href="/trash/" title="Trash"><span class="nav-link-short">T</span><span class="nav-link-text">Trash</span></a>
<a class="nav-link" href="/ingest/" title="Ingest"><span class="nav-link-short">I</span><span class="nav-link-text">Ingest</span></a>
</nav>
</aside>
<main class="main"> <main class="main">
<div class="topbar"> <div class="topbar">

View File

@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Documents</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="app-shell" id="app-shell">
<aside class="sidebar">
<div class="sidebar-top">
<div class="sidebar-toggle" id="menu-toggle" aria-label="Toggle navigation" role="button" tabindex="0">
<span></span><span></span><span></span>
</div>
<div class="brand">Document Processor</div>
</div>
<div class="sidebar-section-title">Workspace</div>
<nav class="nav-list">
<a class="nav-link active" href="/documents/" title="Documents"><span class="nav-link-short">D</span><span class="nav-link-text">Documents</span></a>
<a class="nav-link" href="/line-items/" title="Line Items"><span class="nav-link-short">L</span><span class="nav-link-text">Line Items</span></a>
<a class="nav-link" href="/queue/" title="Review Queue"><span class="nav-link-short">Q</span><span class="nav-link-text">Review Queue</span></a>
<a class="nav-link" href="/trash/" title="Trash"><span class="nav-link-short">T</span><span class="nav-link-text">Trash</span></a>
<a class="nav-link" href="/ingest/" title="Ingest"><span class="nav-link-short">I</span><span class="nav-link-text">Ingest</span></a>
</nav>
</aside>
<main class="main">
<div class="topbar">
<div>
<h1 class="page-title">Documents</h1>
<p class="page-subtitle">Active documents available for review and processing.</p>
</div>
</div>
<div class="card">
<div class="button-row">
<a class="button-link primary" href="/ingest/">Open ingest</a>
<a class="button-link" href="/queue/">Open review queue</a>
<a class="button-link" href="/trash/">Open trash</a>
</div>
</div>
<div class="card">
<h2 class="card-title">All documents</h2>
{% if documents %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Document</th>
<th>Type</th>
<th>Review status</th>
<th>Current path</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
{% for doc in documents %}
<tr>
<td><a href="/documents/{{ doc.document_id }}">{{ doc.document_id }}</a></td>
<td>{{ doc.document_type }}</td>
<td><span class="badge {% if doc.review_status == 'reviewed' %}reviewed{% else %}pending{% endif %}">{{ doc.review_status }}</span></td>
<td>{{ doc.current_path }}</td>
<td>{{ doc.updated_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">No documents found.</p>
{% endif %}
</div>
</main>
</div>
<script>
(function () {
const appShell = document.getElementById("app-shell");
const menuToggle = document.getElementById("menu-toggle");
if (!appShell || !menuToggle) return;
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>

View File

@ -7,22 +7,7 @@
</head> </head>
<body> <body>
<div class="app-shell" id="app-shell"> <div class="app-shell" id="app-shell">
<aside class="sidebar"> {% include "partials/sidebar.html" %}
<div class="sidebar-top">
<div class="sidebar-toggle" id="menu-toggle" aria-label="Toggle navigation" role="button" tabindex="0">
<span></span><span></span><span></span>
</div>
<div class="brand">Document Processor</div>
</div>
<div class="sidebar-section-title">Workspace</div>
<nav class="nav-list">
<a class="nav-link" href="/documents/" title="Documents"><span class="nav-link-short">D</span><span class="nav-link-text">Documents</span></a>
<a class="nav-link" href="/queue/" title="Review Queue"><span class="nav-link-short">Q</span><span class="nav-link-text">Review Queue</span></a>
<a class="nav-link" href="/trash/" title="Trash"><span class="nav-link-short">T</span><span class="nav-link-text">Trash</span></a>
<a class="nav-link active" href="/ingest/" title="Ingest"><span class="nav-link-short">I</span><span class="nav-link-text">Ingest</span></a>
</nav>
</aside>
<main class="main"> <main class="main">
<div class="topbar"> <div class="topbar">

View File

@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Ingest</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="app-shell" id="app-shell">
<aside class="sidebar">
<div class="sidebar-top">
<div class="sidebar-toggle" id="menu-toggle" aria-label="Toggle navigation" role="button" tabindex="0">
<span></span><span></span><span></span>
</div>
<div class="brand">Document Processor</div>
</div>
<div class="sidebar-section-title">Workspace</div>
<nav class="nav-list">
<a class="nav-link" href="/documents/" title="Documents"><span class="nav-link-short">D</span><span class="nav-link-text">Documents</span></a>
<a class="nav-link" href="/line-items/" title="Line Items"><span class="nav-link-short">L</span><span class="nav-link-text">Line Items</span></a>
<a class="nav-link" href="/queue/" title="Review Queue"><span class="nav-link-short">Q</span><span class="nav-link-text">Review Queue</span></a>
<a class="nav-link" href="/trash/" title="Trash"><span class="nav-link-short">T</span><span class="nav-link-text">Trash</span></a>
<a class="nav-link active" href="/ingest/" title="Ingest"><span class="nav-link-short">I</span><span class="nav-link-text">Ingest</span></a>
</nav>
</aside>
<main class="main">
<div class="topbar">
<div>
<h1 class="page-title">Ingest</h1>
<p class="page-subtitle">Upload files or ingest from server-side paths.</p>
</div>
</div>
<div class="card">
<h2 class="card-title">Upload files</h2>
<form method="post" action="/ingest/upload-files" enctype="multipart/form-data">
<div class="form-field full">
<label>Select file(s)</label>
<input type="file" name="uploaded_files" multiple required>
</div>
<div class="button-row" style="margin-top: 1rem;">
<button class="primary" type="submit">Upload and ingest</button>
</div>
</form>
</div>
<div class="card">
<h2 class="card-title">Server-side ingest</h2>
<form method="post" action="/ingest/server-file" style="margin-bottom: 1.25rem;">
<div class="form-field full">
<label>Ingest one server file</label>
<input type="text" name="file_path" placeholder="/mnt/storage/.../file.pdf" required>
</div>
<div class="button-row" style="margin-top: 1rem;">
<button type="submit">Ingest file</button>
</div>
</form>
<form method="post" action="/ingest/server-directory" style="margin-bottom: 1.25rem;">
<div class="form-field full">
<label>Ingest one server directory</label>
<input type="text" name="directory_path" placeholder="/mnt/storage/.../incoming" required>
</div>
<div class="form-field">
<label><input type="checkbox" name="recursive" value="1"> Recursive</label>
</div>
<div class="button-row" style="margin-top: 1rem;">
<button type="submit">Ingest directory</button>
</div>
</form>
<form method="post" action="/ingest/inbox">
<div class="form-field full">
<label>Inbox root</label>
<input type="text" value="{{ inbox_root }}" readonly>
</div>
<div class="button-row" style="margin-top: 1rem;">
<button type="submit">Ingest inbox</button>
</div>
</form>
</div>
</main>
</div>
<script>
(function () {
const appShell = document.getElementById("app-shell");
const menuToggle = document.getElementById("menu-toggle");
if (!appShell || !menuToggle) return;
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>

View File

@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Line Items</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">Line Items</h1>
<p class="page-subtitle">Search extracted purchase lines across documents</p>
</div>
</div>
<div class="card">
<form method="get" action="/line-items/">
<div class="form-grid">
<div class="form-field">
<label for="q">Item contains</label>
<input id="q" type="text" name="q" value="{{ q }}" placeholder="margarita">
</div>
<div class="form-field">
<label for="merchant">Merchant contains</label>
<input id="merchant" type="text" name="merchant" value="{{ merchant }}" placeholder="El Canelo">
</div>
<div class="form-field">
<label for="category">Category</label>
<input id="category" type="text" name="category" value="{{ category }}" placeholder="cocktail">
</div>
<div class="form-field">
<label for="date_from">Date from</label>
<input id="date_from" type="date" name="date_from" value="{{ date_from }}">
</div>
<div class="form-field">
<label for="date_to">Date to</label>
<input id="date_to" type="date" name="date_to" value="{{ date_to }}">
</div>
<div class="form-field">
<label for="rating_min">Min rating</label>
<input id="rating_min" type="text" name="rating_min" value="{{ rating_min }}" placeholder="7">
</div>
<div class="form-field">
<label for="rating_max">Max rating</label>
<input id="rating_max" type="text" name="rating_max" value="{{ rating_max }}" placeholder="10">
</div>
</div>
<div class="button-row" style="margin-top: 1rem;">
<button class="primary" type="submit">Search</button>
<a class="button-link" href="/line-items/">Clear</a>
<a class="button-link" href="/line-items/summary?q={{ q }}">Summary view</a>
</div>
</form>
</div>
<div class="card">
<h2 class="card-title">Results</h2>
{% if rows %}
{% for row in rows %}
<div class="card" style="margin-bottom: 1rem;">
<div class="topbar" style="margin-bottom: 0.75rem;">
<div>
<div class="page-subtitle">{{ row.transaction_date }} · {{ row.merchant }}</div>
<h3 class="card-title" style="margin: 0.2rem 0 0 0;">{{ row.description }}</h3>
<div class="page-subtitle">{{ row.raw_description }}</div>
</div>
<div class="badges">
{% if row.category %}
<span class="badge">{{ row.category }}</span>
{% endif %}
{% if row.quantity %}
<span class="badge">Qty {{ row.quantity }}</span>
{% endif %}
{% if row.line_total %}
<span class="badge">${{ row.line_total }}</span>
{% endif %}
{% if row.confidence %}
<span class="badge">Conf {{ row.confidence }}</span>
{% endif %}
{% if row.quality_rating %}
<span class="badge reviewed">Rating {{ row.quality_rating }}</span>
{% endif %}
</div>
</div>
<form method="post" action="/line-items/{{ row.line_item_id }}/review">
<input type="hidden" name="q" value="{{ q }}">
<input type="hidden" name="merchant" value="{{ merchant }}">
<input type="hidden" name="category" value="{{ category }}">
<input type="hidden" name="date_from" value="{{ date_from }}">
<input type="hidden" name="date_to" value="{{ date_to }}">
<input type="hidden" name="rating_min" value="{{ rating_min }}">
<input type="hidden" name="rating_max" value="{{ rating_max }}">
<div class="form-grid">
<div class="form-field">
<label for="quality_rating_{{ row.line_item_id }}">Quality rating</label>
<input id="quality_rating_{{ row.line_item_id }}" type="text" name="quality_rating" value="{{ row.quality_rating }}" placeholder="e.g. 8.5 or 4/5">
</div>
<div class="form-field full">
<label for="quality_note_{{ row.line_item_id }}">Quality note</label>
<textarea id="quality_note_{{ row.line_item_id }}" name="quality_note" rows="3" placeholder="Taste, portion, texture, service notes...">{{ row.quality_note }}</textarea>
</div>
</div>
<div class="button-row" style="margin-top: 1rem;">
<button class="primary" type="submit">Save rating/note</button>
<a class="button-link" href="/documents/{{ row.document_id }}?tab=extracted-fields">Open document</a>
</div>
</form>
</div>
{% endfor %}
{% else %}
<p class="empty-state">No line items found for the current filters.</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>

View File

@ -0,0 +1,161 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Line Items</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="app-shell" id="app-shell">
<aside class="sidebar">
<div class="sidebar-top">
<div class="sidebar-toggle" id="menu-toggle" aria-label="Toggle navigation" role="button" tabindex="0">
<span></span><span></span><span></span>
</div>
<div class="brand">Document Processor</div>
</div>
<div class="sidebar-section-title">Workspace</div>
<nav class="nav-list">
<a class="nav-link" href="/documents/" title="Documents"><span class="nav-link-short">D</span><span class="nav-link-text">Documents</span></a>
<a class="nav-link active" href="/line-items/" title="Line Items"><span class="nav-link-short">L</span><span class="nav-link-text">Line Items</span></a>
<a class="nav-link" href="/line-items/summary" title="Line Item Summary"><span class="nav-link-short">S</span><span class="nav-link-text">Line Item Summary</span></a>
<a class="nav-link" href="/queue/" title="Review Queue"><span class="nav-link-short">Q</span><span class="nav-link-text">Review Queue</span></a>
<a class="nav-link" href="/trash/" title="Trash"><span class="nav-link-short">T</span><span class="nav-link-text">Trash</span></a>
<a class="nav-link" href="/ingest/" title="Ingest"><span class="nav-link-short">I</span><span class="nav-link-text">Ingest</span></a>
</nav>
</aside>
<main class="main">
<div class="topbar">
<div>
<h1 class="page-title">Line Items</h1>
<p class="page-subtitle">Search extracted purchase lines across documents</p>
</div>
</div>
<div class="card">
<form method="get" action="/line-items/">
<div class="form-grid">
<div class="form-field">
<label for="q">Item contains</label>
<input id="q" type="text" name="q" value="{{ q }}" placeholder="margarita">
</div>
<div class="form-field">
<label for="merchant">Merchant contains</label>
<input id="merchant" type="text" name="merchant" value="{{ merchant }}" placeholder="El Canelo">
</div>
<div class="form-field">
<label for="category">Category</label>
<input id="category" type="text" name="category" value="{{ category }}" placeholder="cocktail">
</div>
<div class="form-field">
<label for="date_from">Date from</label>
<input id="date_from" type="date" name="date_from" value="{{ date_from }}">
</div>
<div class="form-field">
<label for="date_to">Date to</label>
<input id="date_to" type="date" name="date_to" value="{{ date_to }}">
</div>
<div class="form-field">
<label for="rating_min">Min rating</label>
<input id="rating_min" type="text" name="rating_min" value="{{ rating_min }}" placeholder="7">
</div>
<div class="form-field">
<label for="rating_max">Max rating</label>
<input id="rating_max" type="text" name="rating_max" value="{{ rating_max }}" placeholder="10">
</div>
</div>
<div class="button-row" style="margin-top: 1rem;">
<button class="primary" type="submit">Search</button>
<a class="button-link" href="/line-items/">Clear</a>
<a class="button-link" href="/line-items/summary?q={{ q }}">Summary view</a>
</div>
</form>
</div>
<div class="card">
<h2 class="card-title">Results</h2>
{% if rows %}
{% for row in rows %}
<div class="card" style="margin-bottom: 1rem;">
<div class="topbar" style="margin-bottom: 0.75rem;">
<div>
<div class="page-subtitle">{{ row.transaction_date }} · {{ row.merchant }}</div>
<h3 class="card-title" style="margin: 0.2rem 0 0 0;">{{ row.description }}</h3>
<div class="page-subtitle">{{ row.raw_description }}</div>
</div>
<div class="badges">
{% if row.category %}
<span class="badge">{{ row.category }}</span>
{% endif %}
{% if row.quantity %}
<span class="badge">Qty {{ row.quantity }}</span>
{% endif %}
{% if row.line_total %}
<span class="badge">${{ row.line_total }}</span>
{% endif %}
{% if row.confidence %}
<span class="badge">Conf {{ row.confidence }}</span>
{% endif %}
{% if row.quality_rating %}
<span class="badge reviewed">Rating {{ row.quality_rating }}</span>
{% endif %}
</div>
</div>
<form method="post" action="/line-items/{{ row.line_item_id }}/review">
<input type="hidden" name="q" value="{{ q }}">
<input type="hidden" name="merchant" value="{{ merchant }}">
<input type="hidden" name="category" value="{{ category }}">
<input type="hidden" name="date_from" value="{{ date_from }}">
<input type="hidden" name="date_to" value="{{ date_to }}">
<input type="hidden" name="rating_min" value="{{ rating_min }}">
<input type="hidden" name="rating_max" value="{{ rating_max }}">
<div class="form-grid">
<div class="form-field">
<label for="quality_rating_{{ row.line_item_id }}">Quality rating</label>
<input id="quality_rating_{{ row.line_item_id }}" type="text" name="quality_rating" value="{{ row.quality_rating }}" placeholder="e.g. 8.5 or 4/5">
</div>
<div class="form-field full">
<label for="quality_note_{{ row.line_item_id }}">Quality note</label>
<textarea id="quality_note_{{ row.line_item_id }}" name="quality_note" rows="3" placeholder="Taste, portion, texture, service notes...">{{ row.quality_note }}</textarea>
</div>
</div>
<div class="button-row" style="margin-top: 1rem;">
<button class="primary" type="submit">Save rating/note</button>
<a class="button-link" href="/documents/{{ row.document_id }}?tab=extracted-fields">Open document</a>
</div>
</form>
</div>
{% endfor %}
{% else %}
<p class="empty-state">No line items found for the current filters.</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>

View File

@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Line Item Summary</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">Line Item Summary</h1>
<p class="page-subtitle">Aggregate prices and ratings across extracted line items</p>
</div>
</div>
<div class="card">
<form method="get" action="/line-items/summary">
<div class="form-grid">
<div class="form-field">
<label for="q">Item contains</label>
<input id="q" name="q" value="{{ q }}" placeholder="margarita">
</div>
</div>
<div class="button-row" style="margin-top: 1rem;">
<button class="primary" type="submit">Search</button>
<a class="button-link" href="/line-items/summary">Clear</a>
<a class="button-link" href="/line-items/?q={{ q }}">Detailed view</a>
</div>
</form>
</div>
<div class="card">
<h2 class="card-title">Summary Results</h2>
{% if rows %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Item</th>
<th>Count</th>
<th>Avg Price</th>
<th>Min Price</th>
<th>Max Price</th>
<th>Rated Count</th>
<th>Avg Rating</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td>{{ row.item }}</td>
<td>{{ row.count }}</td>
<td>{{ row.avg_price }}</td>
<td>{{ row.min_price }}</td>
<td>{{ row.max_price }}</td>
<td>{{ row.rated_count }}</td>
<td>{{ row.avg_rating }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">No summary rows found for the current search.</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>

View File

@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Line Item Summary</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="app-shell" id="app-shell">
<aside class="sidebar">
<div class="sidebar-top">
<div class="sidebar-toggle" id="menu-toggle" aria-label="Toggle navigation" role="button" tabindex="0">
<span></span><span></span><span></span>
</div>
<div class="brand">Document Processor</div>
</div>
<div class="sidebar-section-title">Workspace</div>
<nav class="nav-list">
<a class="nav-link" href="/documents/" title="Documents"><span class="nav-link-short">D</span><span class="nav-link-text">Documents</span></a>
<a class="nav-link" href="/line-items/" title="Line Items"><span class="nav-link-short">L</span><span class="nav-link-text">Line Items</span></a>
<a class="nav-link active" href="/line-items/summary" title="Line Item Summary"><span class="nav-link-short">S</span><span class="nav-link-text">Line Item Summary</span></a>
<a class="nav-link" href="/queue/" title="Review Queue"><span class="nav-link-short">Q</span><span class="nav-link-text">Review Queue</span></a>
<a class="nav-link" href="/trash/" title="Trash"><span class="nav-link-short">T</span><span class="nav-link-text">Trash</span></a>
<a class="nav-link" href="/ingest/" title="Ingest"><span class="nav-link-short">I</span><span class="nav-link-text">Ingest</span></a>
</nav>
</aside>
<main class="main">
<div class="topbar">
<div>
<h1 class="page-title">Line Item Summary</h1>
<p class="page-subtitle">Aggregate prices and ratings across extracted line items</p>
</div>
</div>
<div class="card">
<form method="get" action="/line-items/summary">
<div class="form-grid">
<div class="form-field">
<label for="q">Item contains</label>
<input id="q" name="q" value="{{ q }}" placeholder="margarita">
</div>
</div>
<div class="button-row" style="margin-top: 1rem;">
<button class="primary" type="submit">Search</button>
<a class="button-link" href="/line-items/summary">Clear</a>
<a class="button-link" href="/line-items/?q={{ q }}">Detailed view</a>
</div>
</form>
</div>
<div class="card">
<h2 class="card-title">Summary Results</h2>
{% if rows %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Item</th>
<th>Count</th>
<th>Avg Price</th>
<th>Min Price</th>
<th>Max Price</th>
<th>Rated Count</th>
<th>Avg Rating</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td>{{ row.item }}</td>
<td>{{ row.count }}</td>
<td>{{ row.avg_price }}</td>
<td>{{ row.min_price }}</td>
<td>{{ row.max_price }}</td>
<td>{{ row.rated_count }}</td>
<td>{{ row.avg_rating }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">No summary rows found for the current search.</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>

View File

@ -0,0 +1,18 @@
<aside class="sidebar">
<div class="sidebar-top">
<div class="sidebar-toggle" id="menu-toggle" aria-label="Toggle navigation" role="button" tabindex="0">
<span></span><span></span><span></span>
</div>
<div class="brand">Document Processor</div>
</div>
<div class="sidebar-section-title">Workspace</div>
<nav class="nav-list">
<a class="nav-link{% if active_page == 'documents' %} active{% endif %}" href="/documents/" title="Documents"><span class="nav-link-short">D</span><span class="nav-link-text">Documents</span></a>
<a class="nav-link{% if active_page == 'line_items' %} active{% endif %}" href="/line-items/" title="Line Items"><span class="nav-link-short">L</span><span class="nav-link-text">Line Items</span></a>
<a class="nav-link{% if active_page == 'line_item_summary' %} active{% endif %}" href="/line-items/summary" title="Line Item Summary"><span class="nav-link-short">S</span><span class="nav-link-text">Line Item Summary</span></a>
<a class="nav-link{% if active_page == 'queue' %} active{% endif %}" href="/queue/" title="Review Queue"><span class="nav-link-short">Q</span><span class="nav-link-text">Review Queue</span></a>
<a class="nav-link{% if active_page == 'trash' %} active{% endif %}" href="/trash/" title="Trash"><span class="nav-link-short">T</span><span class="nav-link-text">Trash</span></a>
<a class="nav-link{% if active_page == 'ingest' %} active{% endif %}" href="/ingest/" title="Ingest"><span class="nav-link-short">I</span><span class="nav-link-text">Ingest</span></a>
</nav>
</aside>

View File

@ -7,22 +7,7 @@
</head> </head>
<body> <body>
<div class="app-shell" id="app-shell"> <div class="app-shell" id="app-shell">
<aside class="sidebar"> {% include "partials/sidebar.html" %}
<div class="sidebar-top">
<div class="sidebar-toggle" id="menu-toggle" aria-label="Toggle navigation" role="button" tabindex="0">
<span></span><span></span><span></span>
</div>
<div class="brand">Document Processor</div>
</div>
<div class="sidebar-section-title">Workspace</div>
<nav class="nav-list">
<a class="nav-link" href="/documents/" title="Documents"><span class="nav-link-short">D</span><span class="nav-link-text">Documents</span></a>
<a class="nav-link active" href="/queue/" title="Review Queue"><span class="nav-link-short">Q</span><span class="nav-link-text">Review Queue</span></a>
<a class="nav-link" href="/trash/" title="Trash"><span class="nav-link-short">T</span><span class="nav-link-text">Trash</span></a>
<a class="nav-link" href="/ingest/" title="Ingest"><span class="nav-link-short">I</span><span class="nav-link-text">Ingest</span></a>
</nav>
</aside>
<main class="main"> <main class="main">
<div class="topbar"> <div class="topbar">

View File

@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Review Queue</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="app-shell" id="app-shell">
<aside class="sidebar">
<div class="sidebar-top">
<div class="sidebar-toggle" id="menu-toggle" aria-label="Toggle navigation" role="button" tabindex="0">
<span></span><span></span><span></span>
</div>
<div class="brand">Document Processor</div>
</div>
<div class="sidebar-section-title">Workspace</div>
<nav class="nav-list">
<a class="nav-link" href="/documents/" title="Documents"><span class="nav-link-short">D</span><span class="nav-link-text">Documents</span></a>
<a class="nav-link" href="/line-items/" title="Line Items"><span class="nav-link-short">L</span><span class="nav-link-text">Line Items</span></a>
<a class="nav-link active" href="/queue/" title="Review Queue"><span class="nav-link-short">Q</span><span class="nav-link-text">Review Queue</span></a>
<a class="nav-link" href="/trash/" title="Trash"><span class="nav-link-short">T</span><span class="nav-link-text">Trash</span></a>
<a class="nav-link" href="/ingest/" title="Ingest"><span class="nav-link-short">I</span><span class="nav-link-text">Ingest</span></a>
</nav>
</aside>
<main class="main">
<div class="topbar">
<div>
<h1 class="page-title">Review Queue</h1>
<p class="page-subtitle">Work through OCR review and field extraction in order.</p>
</div>
</div>
<div class="card">
<div class="button-row">
{% if next_ocr %}
<a class="button-link primary" href="/documents/{{ next_ocr.document_id }}?queue=ocr">Next needing OCR review</a>
{% endif %}
{% if next_fields %}
<a class="button-link" href="/documents/{{ next_fields.document_id }}?queue=fields">Next needing field extraction</a>
{% endif %}
</div>
</div>
<div class="card">
<h2 class="card-title">Needs OCR review ({{ needs_ocr_review|length }})</h2>
{% if needs_ocr_review %}
<div class="table-wrap">
<table>
<thead><tr><th>Document</th><th>Type</th><th>Review status</th><th>Updated</th></tr></thead>
<tbody>
{% for doc in needs_ocr_review %}
<tr>
<td><a href="/documents/{{ doc.document_id }}?queue=ocr">{{ doc.document_id }}</a></td>
<td>{{ doc.document_type }}</td>
<td><span class="badge pending">{{ doc.review_status }}</span></td>
<td>{{ doc.updated_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">No documents currently need OCR review.</p>
{% endif %}
</div>
<div class="card">
<h2 class="card-title">Needs field extraction ({{ needs_field_extraction|length }})</h2>
{% if needs_field_extraction %}
<div class="table-wrap">
<table>
<thead><tr><th>Document</th><th>Type</th><th>Review status</th><th>Updated</th></tr></thead>
<tbody>
{% for doc in needs_field_extraction %}
<tr>
<td><a href="/documents/{{ doc.document_id }}?queue=fields">{{ doc.document_id }}</a></td>
<td>{{ doc.document_type }}</td>
<td><span class="badge reviewed">{{ doc.review_status }}</span></td>
<td>{{ doc.updated_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">No reviewed documents are waiting on field extraction.</p>
{% endif %}
</div>
<div class="card">
<h2 class="card-title">Recently updated</h2>
{% if recently_updated %}
<div class="table-wrap">
<table>
<thead><tr><th>Document</th><th>Type</th><th>Review status</th><th>Current path</th><th>Updated</th></tr></thead>
<tbody>
{% for doc in recently_updated %}
<tr>
<td><a href="/documents/{{ doc.document_id }}?queue=recent">{{ doc.document_id }}</a></td>
<td>{{ doc.document_type }}</td>
<td><span class="badge {% if doc.review_status == 'reviewed' %}reviewed{% else %}pending{% endif %}">{{ doc.review_status }}</span></td>
<td>{{ doc.current_path }}</td>
<td>{{ doc.updated_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</main>
</div>
<script>
(function () {
const appShell = document.getElementById("app-shell");
const menuToggle = document.getElementById("menu-toggle");
if (!appShell || !menuToggle) return;
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>

View File

@ -7,22 +7,7 @@
</head> </head>
<body> <body>
<div class="app-shell" id="app-shell"> <div class="app-shell" id="app-shell">
<aside class="sidebar"> {% include "partials/sidebar.html" %}
<div class="sidebar-top">
<div class="sidebar-toggle" id="menu-toggle" aria-label="Toggle navigation" role="button" tabindex="0">
<span></span><span></span><span></span>
</div>
<div class="brand">Document Processor</div>
</div>
<div class="sidebar-section-title">Workspace</div>
<nav class="nav-list">
<a class="nav-link" href="/documents/" title="Documents"><span class="nav-link-short">D</span><span class="nav-link-text">Documents</span></a>
<a class="nav-link" href="/queue/" title="Review Queue"><span class="nav-link-short">Q</span><span class="nav-link-text">Review Queue</span></a>
<a class="nav-link active" href="/trash/" title="Trash"><span class="nav-link-short">T</span><span class="nav-link-text">Trash</span></a>
<a class="nav-link" href="/ingest/" title="Ingest"><span class="nav-link-short">I</span><span class="nav-link-text">Ingest</span></a>
</nav>
</aside>
<main class="main"> <main class="main">
<div class="topbar"> <div class="topbar">

View File

@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Trash</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="app-shell" id="app-shell">
<aside class="sidebar">
<div class="sidebar-top">
<div class="sidebar-toggle" id="menu-toggle" aria-label="Toggle navigation" role="button" tabindex="0">
<span></span><span></span><span></span>
</div>
<div class="brand">Document Processor</div>
</div>
<div class="sidebar-section-title">Workspace</div>
<nav class="nav-list">
<a class="nav-link" href="/documents/" title="Documents"><span class="nav-link-short">D</span><span class="nav-link-text">Documents</span></a>
<a class="nav-link" href="/line-items/" title="Line Items"><span class="nav-link-short">L</span><span class="nav-link-text">Line Items</span></a>
<a class="nav-link" href="/queue/" title="Review Queue"><span class="nav-link-short">Q</span><span class="nav-link-text">Review Queue</span></a>
<a class="nav-link active" href="/trash/" title="Trash"><span class="nav-link-short">T</span><span class="nav-link-text">Trash</span></a>
<a class="nav-link" href="/ingest/" title="Ingest"><span class="nav-link-short">I</span><span class="nav-link-text">Ingest</span></a>
</nav>
</aside>
<main class="main">
<div class="topbar">
<div>
<h1 class="page-title">Trash</h1>
<p class="page-subtitle">Soft-deleted documents can be restored or removed permanently.</p>
</div>
</div>
<div class="card">
{% if documents %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Document</th>
<th>Type</th>
<th>Review status</th>
<th>Trashed at</th>
<th>Current path</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for doc in documents %}
<tr>
<td><a href="/documents/{{ doc.document_id }}">{{ doc.document_id }}</a></td>
<td>{{ doc.document_type }}</td>
<td><span class="badge trashed">{{ doc.review_status }}</span></td>
<td>{{ doc.trashed_at }}</td>
<td>{{ doc.current_path }}</td>
<td>
<div class="button-row">
<form method="post" action="/trash/{{ doc.document_id }}/restore">
<button type="submit">Restore</button>
</form>
<form method="post" action="/trash/{{ doc.document_id }}/delete">
<button class="danger" type="submit">Delete permanently</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">Trash is empty.</p>
{% endif %}
</div>
</main>
</div>
<script>
(function () {
const appShell = document.getElementById("app-shell");
const menuToggle = document.getElementById("menu-toggle");
if (!appShell || !menuToggle) return;
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>