refactor: use shared sidebar partial across templates
This commit is contained in:
parent
d14ee39cc8
commit
4f10978989
|
|
@ -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",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
Loading…
Reference in New Issue