feat: finalized Phase 3.5/4.1 workflow and added queue-trash plus UX-1 shell

This commit is contained in:
Sean McElwain 2026-04-05 12:05:19 -05:00
parent bb22e2585a
commit 431372438e
11 changed files with 1874 additions and 475 deletions

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import hashlib
import os
import shutil
import subprocess
import tempfile
@ -28,6 +29,45 @@ def sha256_for_file(path: Path) -> str:
return hasher.hexdigest()
def compress_pdf_with_ghostscript(path: Path) -> bool:
compressed_path = path.with_suffix(".compressed.pdf")
try:
subprocess.run(
[
"gs",
"-sDEVICE=pdfwrite",
"-dCompatibilityLevel=1.4",
"-dPDFSETTINGS=/ebook",
"-dNOPAUSE",
"-dQUIET",
"-dBATCH",
f"-sOutputFile={compressed_path}",
str(path),
],
check=True,
capture_output=True,
text=True,
)
if not compressed_path.exists() or compressed_path.stat().st_size == 0:
return False
original_size = path.stat().st_size if path.exists() else 0
compressed_size = compressed_path.stat().st_size
# Only replace if compression actually helped.
if original_size > 0 and compressed_size < original_size:
os.replace(compressed_path, path)
else:
compressed_path.unlink(missing_ok=True)
return True
except Exception:
compressed_path.unlink(missing_ok=True)
return False
def get_next_document_version_number(db: Session, document_id: int) -> int:
max_version = (
db.query(func.max(DocumentVersion.version_number))
@ -188,6 +228,8 @@ def create_ocr_corrected_pdf_version(db: Session, document: Document) -> Documen
c.save()
shutil.copy2(overlay_pdf_path, out_path)
compress_pdf_with_ghostscript(out_path)
file_hash = sha256_for_file(out_path)
version = DocumentVersion(

View File

@ -8,6 +8,7 @@ from app.routes.queue import router as queue_router
from app.routes.trash import router as trash_router
app = FastAPI(title="document-processor")
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.mount("/files", StaticFiles(directory="/mnt/storage/document-processor"), name="files")

View File

@ -25,6 +25,50 @@ from app.models.text_version import TextVersion
router = APIRouter(prefix="/documents", tags=["documents"])
BASE_DIR = Path(__file__).resolve().parent.parent
def _build_queue_navigation(db: Session, document: Document, queue: str | None) -> dict:
if not queue:
return {"queue": None, "prev_doc": None, "next_doc": None}
base = db.query(Document).filter(Document.is_trashed.is_(False))
if queue == "ocr":
docs = (
base.filter(Document.review_status != "reviewed")
.order_by(Document.created_at.asc())
.all()
)
elif queue == "fields":
docs = (
base.filter(Document.review_status == "reviewed")
.all()
)
filtered = []
for d in docs:
has_fields = bool(getattr(d, "extracted_fields", None))
if not has_fields:
filtered.append(d)
docs = sorted(filtered, key=lambda d: d.updated_at or d.created_at)
elif queue == "recent":
docs = (
base.order_by(Document.updated_at.desc())
.all()
)
else:
return {"queue": None, "prev_doc": None, "next_doc": None}
ids = [d.document_id for d in docs]
if document.document_id not in ids:
return {"queue": queue, "prev_doc": None, "next_doc": None}
idx = ids.index(document.document_id)
prev_doc = docs[idx - 1] if idx > 0 else None
next_doc = docs[idx + 1] if idx < len(docs) - 1 else None
return {"queue": queue, "prev_doc": prev_doc, "next_doc": next_doc}
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
QUALITY_FLAG_OPTIONS = [
@ -117,6 +161,68 @@ def _apply_reviewed_lines_to_layout(base_layout: dict | None, reviewed_text: str
return new_layout
def _get_queue_navigation(db: Session, document: Document) -> dict:
active_docs = (
db.query(Document)
.filter(Document.is_trashed.is_(False))
.order_by(Document.created_at.asc())
.all()
)
doc_ids = [d.document_id for d in active_docs]
prev_doc = None
next_doc = None
if document.document_id in doc_ids:
idx = doc_ids.index(document.document_id)
if idx > 0:
prev_doc = active_docs[idx - 1]
if idx < len(active_docs) - 1:
next_doc = active_docs[idx + 1]
needs_ocr = (
db.query(Document)
.filter(Document.is_trashed.is_(False))
.filter(Document.review_status != "reviewed")
.order_by(Document.created_at.asc())
.all()
)
reviewed_no_fields = []
for d in (
db.query(Document)
.options(selectinload(Document.extracted_fields))
.filter(Document.is_trashed.is_(False))
.filter(Document.review_status == "reviewed")
.order_by(Document.updated_at.asc())
.all()
):
if not d.extracted_fields:
reviewed_no_fields.append(d)
next_ocr = None
next_fields = None
if needs_ocr:
for d in needs_ocr:
if d.document_id != document.document_id:
next_ocr = d
break
if reviewed_no_fields:
for d in reviewed_no_fields:
if d.document_id != document.document_id:
next_fields = d
break
return {
"prev_doc": prev_doc,
"next_doc": next_doc,
"next_ocr_doc": next_ocr,
"next_fields_doc": next_fields,
}
def _extracted_field_form_values(document: Document, request: Request) -> dict:
current = get_current_extracted_fields(document)
auto = request.query_params.get("autofill_extracted")
@ -339,7 +445,7 @@ def save_extracted_fields_route(
@router.get("/{document_id}", response_class=HTMLResponse)
def document_detail(document_id: str, request: Request, db: Session = Depends(get_db)):
def document_detail(document_id: str, request: Request, queue: str | None = None, db: Session = Depends(get_db)):
document = (
db.query(Document)
.options(
@ -381,6 +487,8 @@ def document_detail(document_id: str, request: Request, db: Session = Depends(ge
extracted_form = _extracted_field_form_values(document, request)
current_extracted = get_current_extracted_fields(document)
queue_nav = _get_queue_navigation(db, document)
return templates.TemplateResponse(
request=request,
@ -388,6 +496,10 @@ def document_detail(document_id: str, request: Request, db: Session = Depends(ge
context={
"request": request,
"document": document,
"prev_doc": queue_nav.get("prev_doc"),
"next_doc": queue_nav.get("next_doc"),
"next_ocr_doc": queue_nav.get("next_ocr") or queue_nav.get("next_ocr_doc"),
"next_fields_doc": queue_nav.get("next_fields") or queue_nav.get("next_fields_doc"),
"raw_ocr": raw_ocr,
"reviewed_ocr": reviewed_ocr,
"review_text_value": review_text_value,

510
app/static/app.css Normal file
View File

@ -0,0 +1,510 @@
:root {
--bg: #f3f5f9;
--panel: #ffffff;
--panel-muted: #f8fafc;
--border: #d8dee8;
--text: #1f2937;
--text-muted: #6b7280;
--accent: #2563eb;
--accent-soft: #dbeafe;
--success: #166534;
--success-soft: #dcfce7;
--warn: #92400e;
--warn-soft: #fef3c7;
--danger: #991b1b;
--danger-soft: #fee2e2;
--shadow: 0 1px 2px rgba(0,0,0,0.06), 0 8px 20px rgba(0,0,0,0.04);
--radius: 12px;
--sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--rail-closed: 52px;
--rail-open: 230px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
min-height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
}
body {
min-height: 100vh;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.app-shell {
min-height: 100vh;
}
.sidebar {
position: fixed;
inset: 0 auto 0 0;
width: var(--rail-closed);
background: #08142f;
color: #e5e7eb;
overflow-x: hidden;
overflow-y: auto;
padding: 0.75rem 0.45rem 1rem 0.45rem;
z-index: 100;
transition: width 0.18s ease;
}
.app-shell.nav-open .sidebar {
width: var(--rail-open);
}
.main {
margin-left: var(--rail-closed);
min-height: 100vh;
padding: 1.25rem;
transition: margin-left 0.18s ease;
}
.app-shell.nav-open .main {
margin-left: var(--rail-open);
}
.sidebar-top {
display: flex;
align-items: center;
gap: 0.7rem;
min-height: 36px;
margin-bottom: 1rem;
}
.sidebar-toggle {
width: 24px;
height: 18px;
display: flex;
flex-direction: column;
justify-content: space-between;
cursor: pointer;
flex: 0 0 24px;
margin-left: 2px;
}
.sidebar-toggle span {
display: block;
width: 100%;
height: 2px;
background: #94a3b8;
border-radius: 999px;
}
.sidebar-toggle:hover span {
background: #e2e8f0;
}
.brand {
font-weight: 700;
font-size: 1rem;
white-space: nowrap;
display: none;
}
.sidebar-section-title {
color: #94a3b8;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 1rem 0 0.5rem 0;
white-space: nowrap;
display: none;
}
.nav-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.nav-link {
display: flex;
align-items: center;
gap: 0.65rem;
color: #e5e7eb;
border-radius: 10px;
padding: 0.72rem 0.6rem;
white-space: nowrap;
overflow: hidden;
}
.nav-link:hover {
background: rgba(255,255,255,0.08);
text-decoration: none;
}
.nav-link.active {
background: rgba(59,130,246,0.28);
color: #fff;
}
.nav-link-short {
display: inline-flex;
min-width: 1rem;
justify-content: center;
font-weight: 600;
}
.nav-link-text {
display: none;
}
.app-shell.nav-open .brand,
.app-shell.nav-open .sidebar-section-title,
.app-shell.nav-open .nav-link-text {
display: initial;
}
.app-shell:not(.nav-open) .nav-link {
justify-content: center;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.page-title {
margin: 0;
font-size: 1.7rem;
}
.page-subtitle {
margin: 0.25rem 0 0 0;
color: var(--text-muted);
font-size: 0.95rem;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1rem;
margin-bottom: 1rem;
}
.card-title {
margin: 0 0 0.75rem 0;
font-size: 1.05rem;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
align-items: center;
}
button,
.button-link {
appearance: none;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
border-radius: 10px;
padding: 0.65rem 0.95rem;
cursor: pointer;
font: inherit;
}
button:hover,
.button-link:hover {
background: #f3f4f6;
text-decoration: none;
}
button.primary,
.button-link.primary {
background: var(--accent);
color: white;
border-color: var(--accent);
}
button.danger {
background: var(--danger-soft);
color: var(--danger);
border-color: #fecaca;
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.28rem 0.7rem;
font-size: 0.82rem;
border: 1px solid var(--border);
background: var(--panel-muted);
color: var(--text);
}
.badge.reviewed {
background: var(--success-soft);
color: var(--success);
border-color: #bbf7d0;
}
.badge.pending {
background: var(--warn-soft);
color: var(--warn);
border-color: #fde68a;
}
.badge.trashed {
background: var(--danger-soft);
color: var(--danger);
border-color: #fecaca;
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
}
th, td {
text-align: left;
vertical-align: top;
border-bottom: 1px solid var(--border);
padding: 0.7rem 0.6rem;
}
th {
color: var(--text-muted);
font-weight: 600;
font-size: 0.83rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.grid-2 {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 0.65rem 1rem;
}
.meta-item {
background: var(--panel-muted);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.65rem 0.8rem;
}
.meta-label {
display: block;
color: var(--text-muted);
font-size: 0.78rem;
margin-bottom: 0.25rem;
}
.preview-frame {
width: 100%;
height: 950px;
border: 1px solid var(--border);
border-radius: 10px;
background: white;
}
.preview-image {
width: 100%;
max-height: 950px;
object-fit: contain;
border: 1px solid var(--border);
border-radius: 10px;
background: white;
}
.sticky-actions {
position: sticky;
top: 0;
z-index: 20;
background: rgba(243,245,249,0.94);
padding: 0.25rem 0 0.75rem 0;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.85rem 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.form-field.full {
grid-column: 1 / -1;
}
label {
font-size: 0.86rem;
color: var(--text-muted);
}
input[type="text"],
input[type="date"],
textarea {
width: 100%;
border: 1px solid var(--border);
background: white;
border-radius: 10px;
padding: 0.7rem 0.75rem;
font: inherit;
color: var(--text);
}
textarea {
font-family: var(--mono);
line-height: 1.45;
}
.editor-wrap {
display: grid;
grid-template-columns: 52px 1fr;
gap: 0.5rem;
align-items: start;
}
.line-numbers {
font-family: var(--mono);
white-space: pre;
text-align: right;
color: var(--text-muted);
user-select: none;
padding-top: 0.75rem;
line-height: 1.45;
}
pre.codeblock {
white-space: pre-wrap;
word-break: break-word;
font-family: var(--mono);
background: #f8fafc;
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.8rem;
max-height: 18rem;
overflow: auto;
}
.error-box {
background: var(--danger-soft);
color: var(--danger);
border: 1px solid #fecaca;
border-radius: 10px;
padding: 0.9rem 1rem;
margin-bottom: 1rem;
}
.empty-state {
color: var(--text-muted);
padding: 0.4rem 0;
}
.mobile-header,
.sidebar-overlay {
display: none !important;
}
@media (max-width: 1100px) {
.grid-2,
.form-grid,
.meta-grid {
grid-template-columns: 1fr;
}
.preview-frame {
height: 720px;
}
}
.doc-header-sticky {
position: sticky;
top: 0;
z-index: 30;
background: rgba(243,245,249,0.96);
backdrop-filter: blur(8px);
padding-bottom: 0.75rem;
margin-bottom: 1rem;
}
.tabbar {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.tab-button {
appearance: none;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
border-radius: 999px;
padding: 0.5rem 0.85rem;
cursor: pointer;
font: inherit;
}
.tab-button.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
.queue-nav-row {
display: flex;
gap: 0.65rem;
flex-wrap: wrap;
align-items: center;
margin-top: 0.75rem;
}
.queue-label {
color: var(--text-muted);
font-size: 0.9rem;
}

View File

@ -3,207 +3,390 @@
<head>
<meta charset="UTF-8">
<title>{{ document.document_id }}</title>
<link rel="stylesheet" href="/static/app.css">
<style>
body { font-family: sans-serif; }
textarea { font-family: monospace; }
.editor-wrap {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.line-numbers {
font-family: monospace;
white-space: pre;
text-align: right;
color: #666;
user-select: none;
padding-top: 2px;
min-width: 3rem;
}
.line-warning {
color: #8a5a00;
font-weight: 600;
}
.error-box {
background: #ffe8e8;
color: #8b0000;
padding: 0.75rem;
border: 1px solid #cc9999;
.detail-sticky-header {
position: sticky;
top: 0;
z-index: 30;
background: rgba(243,245,249,0.96);
backdrop-filter: blur(8px);
padding-bottom: 0.75rem;
margin-bottom: 1rem;
}
.queue-nav-row {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin-top: 0.75rem;
}
.workspace-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(380px, 0.9fr);
gap: 1rem;
align-items: start;
margin-bottom: 1rem;
}
.right-pane-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border);
padding-bottom: 0.75rem;
}
.tab-button {
appearance: none;
border: 1px solid var(--border);
background: var(--panel-muted);
color: var(--text);
border-radius: 999px;
padding: 0.45rem 0.85rem;
cursor: pointer;
font: inherit;
}
.tab-button.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
.preview-card {
position: sticky;
top: 7.5rem;
}
@media (max-width: 1100px) {
.workspace-grid {
grid-template-columns: 1fr;
}
.preview-card {
position: static;
}
}
</style>
</head>
<body>
<p><a href="/documents/">Back to documents</a></p>
<h1>{{ document.document_id }}</h1>
{% 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 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>
{% 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 %}
<p>
<a href="/queue/">Open review queue</a> |
<a href="/trash/">Open trash</a>
</p>
<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>
<form method="post" action="/documents/{{ document.document_id }}/move-to-trash" style="margin-bottom: 1rem;">
<button type="submit">Move to trash</button>
</form>
<h2>Document metadata</h2>
<ul>
<li>Type: {{ document.document_type }}</li>
<li>Source path: {{ document.source_path }}</li>
<li>Current path: {{ document.current_path }}</li>
<li>Share path: {{ document.share_path or "" }}</li>
<li>App URL: <a href="{{ app_url }}">{{ app_url }}</a></li>
<li>Original filename: {{ document.original_filename }}</li>
<li>Canonical filename: {{ document.canonical_filename }}</li>
<li>MIME type: {{ document.mime_type }}</li>
<li>File size: {{ document.file_size }}</li>
<li>Page count: {{ document.page_count }}</li>
<li>Storage status: {{ document.storage_status }}</li>
<li>Review status: {{ document.review_status }}</li>
<li>Created at: {{ document.created_at }}</li>
<li>Updated at: {{ document.updated_at }}</li>
</ul>
<h2>Saved PDF scaffolds</h2>
<form method="post" action="/documents/{{ document.document_id }}/save-ocr-corrected-pdf" style="display:inline;">
<button type="submit">Save OCR-corrected PDF</button>
</form>
<form method="post" action="/documents/{{ document.document_id }}/save-field-enriched-pdf" style="display:inline; margin-left: 1rem;">
<button type="submit">Save field-enriched PDF</button>
</form>
<h2>Document preview</h2>
{% if file_url %}
{% if document.mime_type == "application/pdf" %}
<iframe src="{{ file_url }}" width="900" height="700"></iframe>
{% elif document.mime_type in ["image/jpeg", "image/png"] %}
<img src="{{ file_url }}" alt="Document image" style="max-width: 900px; max-height: 700px;">
{% else %}
<p><a href="{{ file_url }}" target="_blank">Open file</a></p>
<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 %}
{% else %}
<p>No preview available.</p>
{% endif %}
<h2>Document versions</h2>
{% if document.versions %}
<ul>
{% for version in document.versions %}
<li>
v{{ version.version_number }} —
{{ version.version_type }} —
{{ version.file_path }} —
{{ version.created_at }}
{% if version.notes %}<br><em>{{ version.notes }}</em>{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p>No versions found.</p>
{% 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>
<h2>Raw OCR</h2>
<form method="post" action="/documents/{{ document.document_id }}/rerun-ocr">
<button type="submit">Re-run OCR</button>
</form>
<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>
{% if raw_ocr %}
<p>
<strong>Text version:</strong> v{{ raw_ocr.version_number }}<br>
<strong>OCR engine:</strong> {{ raw_ocr.ocr_engine or "unknown" }}<br>
<strong>OCR engine version:</strong> {{ raw_ocr.ocr_engine_version or "unknown" }}<br>
<strong>Rerun source:</strong> {{ raw_ocr.rerun_source or "unknown" }}<br>
<strong>Quality score:</strong> {{ raw_ocr.quality_score if raw_ocr.quality_score is not none else "not scored yet" }}<br>
<strong>Quality flags:</strong> {{ raw_ocr.quality_flags if raw_ocr.quality_flags else [] }}<br>
<strong>Quality note:</strong> {{ raw_ocr.quality_note or "" }}
</p>
<pre>{{ raw_ocr.text_content }}</pre>
{% else %}
<p>No raw OCR text found.</p>
{% endif %}
<h2>Reviewed OCR</h2>
{% if reviewed_ocr %}
<p>
Current reviewed version saved at {{ reviewed_ocr.created_at }} —
v{{ reviewed_ocr.version_number }}
</p>
{% else %}
<p>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>
<label for="reviewed_text">Edit reviewed OCR text (one line per OCR line):</label>
<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="editor-wrap">
<div class="line-numbers" id="line-numbers">{% for n in line_numbers %}{{ n }}
<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 active" type="button" data-tab="ocr-review">OCR Review</button>
<button class="tab-button" type="button" data-tab="extracted-fields">Extracted Fields</button>
<button class="tab-button" type="button" data-tab="versions">Versions</button>
<button class="tab-button" type="button" data-tab="raw-ocr">Raw OCR</button>
</div>
<div class="tab-panel active" 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">
<div class="line-numbers" id="line-numbers">{% for n in line_numbers %}{{ n }}
{% endfor %}</div>
<textarea id="reviewed_text" name="reviewed_text" rows="{{ [actual_line_count + 2, 20]|max }}" cols="100">{{ review_text_value }}</textarea>
<textarea id="reviewed_text" name="reviewed_text" rows="34">{{ 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" 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">
<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>Receipt 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" 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" 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>
<h3>Quality flags</h3>
<div>
{% for flag in quality_flag_options %}
<label style="display:block;">
<input
type="checkbox"
name="quality_flags"
value="{{ flag }}"
{% if flag in current_quality_flags %}checked{% endif %}
>
{{ flag }}
</label>
{% endfor %}
<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>
<h3>Quality note</h3>
<div>
<textarea id="quality_note" name="quality_note" rows="4" cols="100">{{ current_quality_note }}</textarea>
</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");
}
});
}
<div style="margin-top: 1rem;">
<button type="submit" id="save-reviewed-btn">Save reviewed OCR</button>
</div>
</form>
const tabButtons = document.querySelectorAll("[data-tab]");
const tabPanels = document.querySelectorAll("[data-panel]");
tabButtons.forEach(function (btn) {
btn.addEventListener("click", function () {
const target = btn.getAttribute("data-tab");
tabButtons.forEach(function (b) { b.classList.remove("active"); });
tabPanels.forEach(function (p) { p.classList.remove("active"); });
btn.classList.add("active");
const panel = document.querySelector('[data-panel="' + target + '"]');
if (panel) panel.classList.add("active");
});
});
<script>
const textarea = document.getElementById("reviewed_text");
const expectedLines = parseInt(document.getElementById("expected-lines").textContent || "0", 10);
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");
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;
@ -212,9 +395,7 @@
function rebuildLineNumbers(lineCount) {
let nums = "";
for (let i = 1; i <= lineCount; i++) {
nums += i + "\n";
}
for (let i = 1; i <= lineCount; i++) nums += i + "\n";
lineNumbersEl.textContent = nums;
}
@ -222,7 +403,6 @@
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;
@ -230,78 +410,8 @@
textarea.addEventListener("input", updateEditorState);
updateEditorState();
</script>
<h2>Extracted fields</h2>
{% if current_extracted %}
<p>Current extracted fields last updated at {{ current_extracted.updated_at }}</p>
{% else %}
<p>No extracted fields saved yet.</p>
{% endif %}
<form method="get" action="/documents/{{ document.document_id }}">
<input type="hidden" name="autofill_extracted" value="1">
<button type="submit">Auto-extract fields</button>
</form>
<form method="post" action="/documents/{{ document.document_id }}/save-extracted-fields" style="margin-top: 1rem;">
<div>
<label>Merchant raw:</label><br>
<input type="text" name="merchant_raw" value="{{ extracted_form.merchant_raw }}" size="80">
</div>
<div>
<label>Merchant normalized:</label><br>
<input type="text" name="merchant_normalized" value="{{ extracted_form.merchant_normalized }}" size="80">
</div>
<div>
<label>Transaction date:</label><br>
<input type="date" name="transaction_date" value="{{ extracted_form.transaction_date }}">
</div>
<div>
<label>Transaction time:</label><br>
<input type="text" name="transaction_time" value="{{ extracted_form.transaction_time }}" size="20">
</div>
<div>
<label>Subtotal:</label><br>
<input type="text" name="subtotal" value="{{ extracted_form.subtotal }}" size="20">
</div>
<div>
<label>Tax:</label><br>
<input type="text" name="tax" value="{{ extracted_form.tax }}" size="20">
</div>
<div>
<label>Total:</label><br>
<input type="text" name="total" value="{{ extracted_form.total }}" size="20">
</div>
<div>
<label>Currency:</label><br>
<input type="text" name="currency" value="{{ extracted_form.currency }}" size="10">
</div>
<div>
<label>Payment method:</label><br>
<input type="text" name="payment_method" value="{{ extracted_form.payment_method }}" size="40">
</div>
<div>
<label>Receipt number:</label><br>
<input type="text" name="receipt_number" value="{{ extracted_form.receipt_number }}" size="40">
</div>
<div>
<label>Location:</label><br>
<input type="text" name="location" value="{{ extracted_form.location }}" size="80">
</div>
<div>
<label>Counterparty:</label><br>
<input type="text" name="counterparty" value="{{ extracted_form.counterparty }}" size="80">
</div>
<div>
<label>Extra JSON:</label><br>
<textarea name="extra_json" rows="8" cols="100">{{ extracted_form.extra_json }}</textarea>
</div>
<div style="margin-top: 1rem;">
<button type="submit">Save extracted fields</button>
</div>
</form>
}
})();
</script>
</body>
</html>

View File

@ -0,0 +1,383 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ document.document_id }}</title>
<style>
body { font-family: sans-serif; margin: 1rem; }
textarea { font-family: monospace; }
input[type="text"], input[type="date"] { padding: 0.25rem; }
.editor-wrap {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.line-numbers {
font-family: monospace;
white-space: pre;
text-align: right;
color: #666;
user-select: none;
padding-top: 2px;
min-width: 3rem;
}
.line-warning {
color: #8a5a00;
font-weight: 600;
}
.error-box {
background: #ffe8e8;
color: #8b0000;
padding: 0.75rem;
border: 1px solid #cc9999;
margin-bottom: 1rem;
}
.split-layout {
display: flex;
align-items: flex-start;
gap: 1.5rem;
}
.preview-pane {
flex: 1 1 52%;
min-width: 420px;
}
.editor-pane {
flex: 1 1 48%;
min-width: 420px;
}
.preview-box {
border: 1px solid #ccc;
padding: 0.5rem;
background: #fafafa;
}
.preview-box iframe,
.preview-box img {
width: 100%;
height: 900px;
border: none;
background: white;
}
.section-box {
margin-bottom: 1.5rem;
}
table { border-collapse: collapse; width: 100%; margin-bottom: 1rem; }
th, td { border: 1px solid #ccc; padding: 0.4rem; text-align: left; vertical-align: top; }
th { background: #f3f3f3; }
@media (max-width: 1200px) {
.split-layout {
flex-direction: column;
}
.preview-pane,
.editor-pane {
min-width: 0;
width: 100%;
}
.preview-box iframe,
.preview-box img {
height: 700px;
}
}
</style>
</head>
<body>
<p>
<a href="/documents/">Back to documents</a> |
<a href="/queue/">Open review queue</a> |
<a href="/trash/">Open trash</a>
</p>
<h1>{{ document.document_id }}</h1>
{% 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 %}
<form method="post" action="/documents/{{ document.document_id }}/move-to-trash" style="margin-bottom: 1rem;">
<button type="submit">Move to trash</button>
</form>
<div class="section-box">
<h2>Document metadata</h2>
<ul>
<li>Type: {{ document.document_type }}</li>
<li>Source path: {{ document.source_path }}</li>
<li>Current path: {{ document.current_path }}</li>
<li>Share path: {{ document.share_path or "" }}</li>
<li>App URL: <a href="{{ app_url }}">{{ app_url }}</a></li>
<li>Original filename: {{ document.original_filename }}</li>
<li>Canonical filename: {{ document.canonical_filename }}</li>
<li>MIME type: {{ document.mime_type }}</li>
<li>File size: {{ document.file_size }}</li>
<li>Page count: {{ document.page_count }}</li>
<li>Storage status: {{ document.storage_status }}</li>
<li>Review status: {{ document.review_status }}</li>
<li>Created at: {{ document.created_at }}</li>
<li>Updated at: {{ document.updated_at }}</li>
</ul>
</div>
<div class="section-box">
<h2>Saved PDF outputs</h2>
<form method="post" action="/documents/{{ document.document_id }}/save-ocr-corrected-pdf" style="display:inline;">
<button type="submit">Save OCR-corrected PDF</button>
</form>
<form method="post" action="/documents/{{ document.document_id }}/save-field-enriched-pdf" style="display:inline; margin-left: 1rem;">
<button type="submit">Save field-enriched PDF</button>
</form>
</div>
<div class="split-layout">
<div class="preview-pane">
<div class="section-box">
<h2>Document preview</h2>
<div class="preview-box">
{% if file_url %}
{% if document.mime_type == "application/pdf" %}
<iframe src="{{ file_url }}"></iframe>
{% elif document.mime_type in ["image/jpeg", "image/png"] %}
<img src="{{ file_url }}" alt="Document image">
{% else %}
<p><a href="{{ file_url }}" target="_blank">Open file</a></p>
{% endif %}
{% else %}
<p>No preview available.</p>
{% endif %}
</div>
</div>
<div class="section-box">
<h2>Document versions</h2>
{% if document.versions %}
<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>
{% else %}
<p>No versions found.</p>
{% endif %}
</div>
</div>
<div class="editor-pane">
<div class="section-box">
<h2>Raw OCR</h2>
<form method="post" action="/documents/{{ document.document_id }}/rerun-ocr">
<button type="submit">Re-run OCR</button>
</form>
{% if raw_ocr %}
<p>
<strong>Text version:</strong> v{{ raw_ocr.version_number }}<br>
<strong>OCR engine:</strong> {{ raw_ocr.ocr_engine or "unknown" }}<br>
<strong>OCR engine version:</strong> {{ raw_ocr.ocr_engine_version or "unknown" }}<br>
<strong>Rerun source:</strong> {{ raw_ocr.rerun_source or "unknown" }}<br>
<strong>Quality score:</strong> {{ raw_ocr.quality_score if raw_ocr.quality_score is not none else "not scored yet" }}<br>
<strong>Quality flags:</strong> {{ raw_ocr.quality_flags if raw_ocr.quality_flags else [] }}<br>
<strong>Quality note:</strong> {{ raw_ocr.quality_note or "" }}
</p>
{% else %}
<p>No raw OCR text found.</p>
{% endif %}
</div>
<div class="section-box">
<h2>Reviewed OCR</h2>
{% if reviewed_ocr %}
<p>
Current reviewed version saved at {{ reviewed_ocr.created_at }} —
v{{ reviewed_ocr.version_number }}
</p>
{% else %}
<p>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>
<label for="reviewed_text">Edit reviewed OCR text (one line per OCR line):</label>
</div>
<div class="editor-wrap">
<div class="line-numbers" id="line-numbers">{% for n in line_numbers %}{{ n }}
{% endfor %}</div>
<textarea id="reviewed_text" name="reviewed_text" rows="{{ [actual_line_count + 2, 20]|max }}" cols="90">{{ review_text_value }}</textarea>
</div>
<h3>Quality flags</h3>
<div>
{% for flag in quality_flag_options %}
<label style="display:block;">
<input
type="checkbox"
name="quality_flags"
value="{{ flag }}"
{% if flag in current_quality_flags %}checked{% endif %}
>
{{ flag }}
</label>
{% endfor %}
</div>
<h3>Quality note</h3>
<div>
<textarea id="quality_note" name="quality_note" rows="4" cols="90">{{ current_quality_note }}</textarea>
</div>
<div style="margin-top: 1rem;">
<button type="submit" id="save-reviewed-btn">Save reviewed OCR</button>
</div>
</form>
</div>
<div class="section-box">
<h2>Extracted fields</h2>
{% if current_extracted %}
<p>Current extracted fields last updated at {{ current_extracted.updated_at }}</p>
{% else %}
<p>No extracted fields saved yet.</p>
{% endif %}
<form method="get" action="/documents/{{ document.document_id }}">
<input type="hidden" name="autofill_extracted" value="1">
<button type="submit">Auto-extract fields</button>
</form>
<form method="post" action="/documents/{{ document.document_id }}/save-extracted-fields" style="margin-top: 1rem;">
<div>
<label>Merchant raw:</label><br>
<input type="text" name="merchant_raw" value="{{ extracted_form.merchant_raw }}" size="80">
</div>
<div>
<label>Merchant normalized:</label><br>
<input type="text" name="merchant_normalized" value="{{ extracted_form.merchant_normalized }}" size="80">
</div>
<div>
<label>Transaction date:</label><br>
<input type="date" name="transaction_date" value="{{ extracted_form.transaction_date }}">
</div>
<div>
<label>Transaction time:</label><br>
<input type="text" name="transaction_time" value="{{ extracted_form.transaction_time }}" size="20">
</div>
<div>
<label>Subtotal:</label><br>
<input type="text" name="subtotal" value="{{ extracted_form.subtotal }}" size="20">
</div>
<div>
<label>Tax:</label><br>
<input type="text" name="tax" value="{{ extracted_form.tax }}" size="20">
</div>
<div>
<label>Total:</label><br>
<input type="text" name="total" value="{{ extracted_form.total }}" size="20">
</div>
<div>
<label>Currency:</label><br>
<input type="text" name="currency" value="{{ extracted_form.currency }}" size="10">
</div>
<div>
<label>Payment method:</label><br>
<input type="text" name="payment_method" value="{{ extracted_form.payment_method }}" size="40">
</div>
<div>
<label>Receipt number:</label><br>
<input type="text" name="receipt_number" value="{{ extracted_form.receipt_number }}" size="40">
</div>
<div>
<label>Location:</label><br>
<input type="text" name="location" value="{{ extracted_form.location }}" size="80">
</div>
<div>
<label>Counterparty:</label><br>
<input type="text" name="counterparty" value="{{ extracted_form.counterparty }}" size="80">
</div>
<div>
<label>Extra JSON:</label><br>
<textarea name="extra_json" rows="8" cols="90">{{ extracted_form.extra_json }}</textarea>
</div>
<div style="margin-top: 1rem;">
<button type="submit">Save extracted fields</button>
</div>
</form>
</div>
</div>
</div>
<script>
const textarea = document.getElementById("reviewed_text");
const expectedLines = parseInt(document.getElementById("expected-lines").textContent || "0", 10);
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");
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 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;
}
textarea.addEventListener("input", updateEditorState);
updateEditorState();
</script>
</body>
</html>

View File

@ -3,27 +3,92 @@
<head>
<meta charset="UTF-8">
<title>Documents</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<p><a href="/trash/">Open trash</a></p>
<p><a href="/queue/">Open review queue</a></p>
<h1>Documents</h1>
<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>
<p><a href="/documents/test-ingest">Create test ingest</a></p>
<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>
{% if documents %}
<ul>
{% for doc in documents %}
<li>
<a href="/documents/{{ doc.document_id }}">{{ doc.document_id }}</a>
— {{ doc.document_type or "unknown" }}
— {{ doc.review_status }}
— {{ doc.created_at }}
</li>
{% endfor %}
</ul>
{% else %}
<p>No documents yet.</p>
{% endif %}
<main class="main">
<div class="topbar">
<div>
<h1 class="page-title">Documents</h1>
<p class="page-subtitle">Active documents available for review and processing.</p>
</div>
</div>
<div class="card">
<div class="button-row">
<a class="button-link primary" href="/ingest/">Open ingest</a>
<a class="button-link" href="/queue/">Open review queue</a>
<a class="button-link" href="/trash/">Open trash</a>
</div>
</div>
<div class="card">
<h2 class="card-title">All documents</h2>
{% if documents %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Document</th>
<th>Type</th>
<th>Review status</th>
<th>Current path</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
{% for doc in documents %}
<tr>
<td><a href="/documents/{{ doc.document_id }}">{{ doc.document_id }}</a></td>
<td>{{ doc.document_type }}</td>
<td><span class="badge {% if doc.review_status == 'reviewed' %}reviewed{% else %}pending{% endif %}">{{ doc.review_status }}</span></td>
<td>{{ doc.current_path }}</td>
<td>{{ doc.updated_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">No documents found.</p>
{% endif %}
</div>
</main>
</div>
<script>
(function () {
const appShell = document.getElementById("app-shell");
const menuToggle = document.getElementById("menu-toggle");
if (!appShell || !menuToggle) return;
menuToggle.addEventListener("click", function () {
appShell.classList.toggle("nav-open");
});
menuToggle.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
appShell.classList.toggle("nav-open");
}
});
})();
</script>
</body>
</html>

View File

@ -3,51 +3,102 @@
<head>
<meta charset="UTF-8">
<title>Ingest</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<h1>Ingest</h1>
<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>
<p><a href="/documents/">View documents</a></p>
<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>
<h2>Inbox ingest</h2>
<p>Configured inbox: {{ inbox_root }}</p>
<form method="post" action="/ingest/inbox">
<button type="submit">Run inbox ingest</button>
</form>
<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>
<hr>
<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>
<h2>Server-side ingest</h2>
<div class="card">
<h2 class="card-title">Server-side ingest</h2>
<h3>Ingest one server file</h3>
<form method="post" action="/ingest/server-file">
<label for="file_path">Server file path:</label><br>
<input id="file_path" name="file_path" type="text" size="120" required>
<br><br>
<button type="submit">Ingest server file</button>
</form>
<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>
<h3>Ingest one server directory</h3>
<form method="post" action="/ingest/server-directory">
<label for="directory_path">Server directory path:</label><br>
<input id="directory_path" name="directory_path" type="text" size="120" required>
<br><br>
<label>
<input type="checkbox" name="recursive" checked>
Recursive
</label>
<br><br>
<button type="submit">Ingest server directory</button>
</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>
<hr>
<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>
<h2>Upload ingest</h2>
<form method="post" action="/ingest/upload-files" enctype="multipart/form-data">
<label for="uploaded_files">Upload one or more files:</label><br>
<input id="uploaded_files" type="file" name="uploaded_files" multiple required>
<br><br>
<button type="submit">Upload and ingest files</button>
</form>
<script>
(function () {
const appShell = document.getElementById("app-shell");
const menuToggle = document.getElementById("menu-toggle");
if (!appShell || !menuToggle) return;
menuToggle.addEventListener("click", function () {
appShell.classList.toggle("nav-open");
});
menuToggle.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
appShell.classList.toggle("nav-open");
}
});
})();
</script>
</body>
</html>

View File

@ -3,37 +3,96 @@
<head>
<meta charset="UTF-8">
<title>Ingest Result</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<h1>Ingest Result</h1>
<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>
<p>{{ message }}</p>
<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>
<p>
<a href="/ingest/">Back to ingest</a> |
<a href="/documents/">View documents</a>
</p>
<main class="main">
<div class="topbar">
<div>
<h1 class="page-title">Ingest Result</h1>
<p class="page-subtitle">{{ message }}</p>
</div>
</div>
{% if errors %}
<h2>Errors</h2>
<ul>
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
<div class="card">
<div class="button-row">
<a class="button-link" href="/ingest/">Back to ingest</a>
<a class="button-link" href="/documents/">View documents</a>
</div>
</div>
{% if documents %}
<h2>Documents</h2>
<ul>
{% for doc in documents %}
<li>
<a href="/documents/{{ doc.document_id }}">{{ doc.document_id }}</a>
— {{ doc.original_filename }}
— {{ doc.current_path }}
</li>
{% endfor %}
</ul>
{% endif %}
{% if documents %}
<div class="card">
<h2 class="card-title">Documents</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Document</th>
<th>Filename</th>
<th>Current path</th>
</tr>
</thead>
<tbody>
{% for doc in documents %}
<tr>
<td><a href="/documents/{{ doc.document_id }}">{{ doc.document_id }}</a></td>
<td>{{ doc.original_filename or doc.canonical_filename }}</td>
<td>{{ doc.current_path }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if errors %}
<div class="card">
<h2 class="card-title">Errors</h2>
<ul>
{% for err in errors %}
<li>{{ err }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</main>
</div>
<script>
(function () {
const appShell = document.getElementById("app-shell");
const menuToggle = document.getElementById("menu-toggle");
if (!appShell || !menuToggle) return;
menuToggle.addEventListener("click", function () {
appShell.classList.toggle("nav-open");
});
menuToggle.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
appShell.classList.toggle("nav-open");
}
});
})();
</script>
</body>
</html>

View File

@ -3,106 +3,131 @@
<head>
<meta charset="UTF-8">
<title>Review Queue</title>
<style>
body { font-family: sans-serif; }
table { border-collapse: collapse; width: 100%; margin-bottom: 2rem; }
th, td { border: 1px solid #ccc; padding: 0.5rem; text-align: left; vertical-align: top; }
th { background: #f3f3f3; }
.actions { margin-bottom: 1.5rem; }
</style>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<p><a href="/trash/">Open trash</a></p>
<p><a href="/documents/">Back to documents</a></p>
<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>
<h1>Review Queue</h1>
<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>
<div class="actions">
{% if next_ocr %}
<a href="/documents/{{ next_ocr.document_id }}">Next needing OCR review</a>
{% endif %}
{% if next_ocr and next_fields %} | {% endif %}
{% if next_fields %}
<a href="/documents/{{ next_fields.document_id }}">Next needing field extraction</a>
{% endif %}
</div>
<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>
<h2>Needs OCR review ({{ needs_ocr_review|length }})</h2>
{% if needs_ocr_review %}
<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 }}">{{ doc.document_id }}</a></td>
<td>{{ doc.document_type }}</td>
<td>{{ doc.review_status }}</td>
<td>{{ doc.updated_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No documents currently need OCR review.</p>
{% endif %}
<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>
<h2>Needs field extraction ({{ needs_field_extraction|length }})</h2>
{% if needs_field_extraction %}
<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 }}">{{ doc.document_id }}</a></td>
<td>{{ doc.document_type }}</td>
<td>{{ doc.review_status }}</td>
<td>{{ doc.updated_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No reviewed documents are waiting on field extraction.</p>
{% endif %}
<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>
<h2>Recently updated</h2>
{% if recently_updated %}
<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 }}">{{ doc.document_id }}</a></td>
<td>{{ doc.document_type }}</td>
<td>{{ doc.review_status }}</td>
<td>{{ doc.current_path }}</td>
<td>{{ doc.updated_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<div class="card">
<h2 class="card-title">Needs field extraction ({{ needs_field_extraction|length }})</h2>
{% if needs_field_extraction %}
<div class="table-wrap">
<table>
<thead><tr><th>Document</th><th>Type</th><th>Review status</th><th>Updated</th></tr></thead>
<tbody>
{% for doc in needs_field_extraction %}
<tr>
<td><a href="/documents/{{ doc.document_id }}?queue=fields">{{ doc.document_id }}</a></td>
<td>{{ doc.document_type }}</td>
<td><span class="badge reviewed">{{ doc.review_status }}</span></td>
<td>{{ doc.updated_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">No reviewed documents are waiting on field extraction.</p>
{% endif %}
</div>
<div class="card">
<h2 class="card-title">Recently updated</h2>
{% if recently_updated %}
<div class="table-wrap">
<table>
<thead><tr><th>Document</th><th>Type</th><th>Review status</th><th>Current path</th><th>Updated</th></tr></thead>
<tbody>
{% for doc in recently_updated %}
<tr>
<td><a href="/documents/{{ doc.document_id }}?queue=recent">{{ doc.document_id }}</a></td>
<td>{{ doc.document_type }}</td>
<td><span class="badge {% if doc.review_status == 'reviewed' %}reviewed{% else %}pending{% endif %}">{{ doc.review_status }}</span></td>
<td>{{ doc.current_path }}</td>
<td>{{ doc.updated_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</main>
</div>
<script>
(function () {
const appShell = document.getElementById("app-shell");
const menuToggle = document.getElementById("menu-toggle");
if (!appShell || !menuToggle) return;
menuToggle.addEventListener("click", function () {
appShell.classList.toggle("nav-open");
});
menuToggle.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
appShell.classList.toggle("nav-open");
}
});
})();
</script>
</body>
</html>

View File

@ -3,53 +3,94 @@
<head>
<meta charset="UTF-8">
<title>Trash</title>
<style>
body { font-family: sans-serif; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ccc; padding: 0.5rem; text-align: left; vertical-align: top; }
th { background: #f3f3f3; }
form { display: inline; }
</style>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<p><a href="/documents/">Back to documents</a> | <a href="/queue/">Open review queue</a></p>
<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>
<h1>Trash</h1>
<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>
{% if documents %}
<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>{{ doc.review_status }}</td>
<td>{{ doc.trashed_at }}</td>
<td>{{ doc.current_path }}</td>
<td>
<form method="post" action="/trash/{{ doc.document_id }}/restore">
<button type="submit">Restore</button>
</form>
<form method="post" action="/trash/{{ doc.document_id }}/delete" style="margin-left: 0.5rem;">
<button type="submit">Delete permanently</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>Trash is empty.</p>
{% endif %}
<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>