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(
|
||||
request=request,
|
||||
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,
|
||||
"extracted_form": extracted_form,
|
||||
"current_extracted": current_extracted,
|
||||
"active_page": "documents",
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ def ingest_home(request: Request):
|
|||
context={
|
||||
"request": request,
|
||||
"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,
|
||||
"next_ocr": next_ocr,
|
||||
"next_fields": next_fields,
|
||||
"active_page": "queue",
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ def trash_index(request: Request, db: Session = Depends(get_db)):
|
|||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="trash/index.html",
|
||||
context={"request": request, "documents": documents},
|
||||
context={"request": request, "documents": documents, "active_page": "trash"},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,22 +7,7 @@
|
|||
</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="/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>
|
||||
{% include "partials/sidebar.html" %}
|
||||
|
||||
<main class="main">
|
||||
{% 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>
|
||||
<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="/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>
|
||||
{% include "partials/sidebar.html" %}
|
||||
|
||||
<main class="main">
|
||||
<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>
|
||||
<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="/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>
|
||||
{% include "partials/sidebar.html" %}
|
||||
|
||||
<main class="main">
|
||||
<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>
|
||||
<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="/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>
|
||||
{% include "partials/sidebar.html" %}
|
||||
|
||||
<main class="main">
|
||||
<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>
|
||||
<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="/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>
|
||||
{% include "partials/sidebar.html" %}
|
||||
|
||||
<main class="main">
|
||||
<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