feat: unify dashboard, documents, queue, and line item navigation UI

This commit is contained in:
Sean McElwain 2026-04-07 12:32:49 -05:00
parent d3562ef0d4
commit aa7f8d6a54
8 changed files with 847 additions and 336 deletions

View File

@ -3,7 +3,7 @@ from datetime import datetime
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, Form, Request from fastapi import APIRouter, Depends, Form, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
@ -71,7 +71,6 @@ def _to_decimal(value: str) -> Decimal | None:
return None return None
def _get_all_presets(db: Session) -> list[DocumentPreset]: def _get_all_presets(db: Session) -> list[DocumentPreset]:
return db.query(DocumentPreset).order_by(DocumentPreset.name.asc()).all() return db.query(DocumentPreset).order_by(DocumentPreset.name.asc()).all()
@ -110,7 +109,6 @@ def _get_current_additional_fields(document: Document) -> DocumentAdditionalFiel
return sorted(rows, key=lambda x: x.updated_at or x.created_at, reverse=True)[0] return sorted(rows, key=lambda x: x.updated_at or x.created_at, reverse=True)[0]
def _extracted_field_form_values(document: Document, request: Request) -> dict: def _extracted_field_form_values(document: Document, request: Request) -> dict:
current = get_current_extracted_fields(document) current = get_current_extracted_fields(document)
auto = request.query_params.get("autofill_extracted") auto = request.query_params.get("autofill_extracted")
@ -152,6 +150,7 @@ def _extracted_field_form_values(document: Document, request: Request) -> dict:
return values return values
def _additional_field_form_values(document: Document, preset: DocumentPreset | None = None) -> dict: def _additional_field_form_values(document: Document, preset: DocumentPreset | None = None) -> dict:
current = _get_current_additional_fields(document) current = _get_current_additional_fields(document)
if current is None: if current is None:
@ -265,6 +264,7 @@ def _get_queue_navigation(db: Session, document: Document) -> dict:
.order_by(Document.created_at.asc()) .order_by(Document.created_at.asc())
.all() .all()
) )
doc_ids = [d.document_id for d in active_docs] doc_ids = [d.document_id for d in active_docs]
prev_doc = None prev_doc = None
next_doc = None next_doc = None
@ -319,25 +319,148 @@ def _get_queue_navigation(db: Session, document: Document) -> dict:
} }
def _document_matches_filters(
doc: Document,
q: str,
document_type: str,
review_status: str,
merchant: str,
owner_primary: str,
) -> bool:
q_norm = q.strip().lower()
type_norm = document_type.strip().lower()
review_norm = review_status.strip().lower()
merchant_norm = merchant.strip().lower()
owner_norm = owner_primary.strip().lower()
if q_norm:
haystacks = [
doc.document_id or "",
doc.document_type or "",
doc.original_filename or "",
doc.canonical_filename or "",
doc.current_path or "",
doc.source_path or "",
]
current_extracted = get_current_extracted_fields(doc)
current_additional = _get_current_additional_fields(doc)
if current_extracted is not None:
haystacks.extend([
current_extracted.merchant_raw or "",
current_extracted.merchant_normalized or "",
current_extracted.location or "",
current_extracted.counterparty or "",
current_extracted.receipt_number or "",
])
if current_additional is not None:
haystacks.extend([
current_additional.owner_primary or "",
current_additional.owner_secondary or "",
current_additional.paid_by_person or "",
current_additional.occasion_note or "",
])
if not any(q_norm in h.lower() for h in haystacks):
return False
if type_norm and type_norm != (doc.document_type or "").lower():
return False
if review_norm and review_norm != (doc.review_status or "").lower():
return False
if merchant_norm:
current_extracted = get_current_extracted_fields(doc)
merchant_values = []
if current_extracted is not None:
merchant_values = [
current_extracted.merchant_raw or "",
current_extracted.merchant_normalized or "",
]
if not any(merchant_norm in m.lower() for m in merchant_values):
return False
if owner_norm:
current_additional = _get_current_additional_fields(doc)
owner_values = []
if current_additional is not None:
owner_values = [
current_additional.owner_primary or "",
current_additional.owner_secondary or "",
]
if not any(owner_norm in o.lower() for o in owner_values):
return False
return True
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
def list_documents(request: Request, db: Session = Depends(get_db)): def list_documents(
documents = ( request: Request,
q: str = Query("", description="Search"),
document_type: str = Query("", description="Document type"),
review_status: str = Query("", description="Review status"),
merchant: str = Query("", description="Merchant contains"),
owner_primary: str = Query("", description="Owner contains"),
tab: str = Query("all-documents"),
db: Session = Depends(get_db),
):
documents_all = (
db.query(Document) db.query(Document)
.options(
selectinload(Document.extracted_fields),
selectinload(Document.additional_fields),
)
.filter(Document.is_trashed.is_(False)) .filter(Document.is_trashed.is_(False))
.order_by(Document.created_at.desc()) .order_by(Document.created_at.desc())
.all() .all()
) )
has_search_query = any([
q.strip(),
document_type.strip(),
review_status.strip(),
merchant.strip(),
owner_primary.strip(),
])
filtered_documents = documents_all
if has_search_query:
filtered_documents = []
for doc in documents_all:
if _document_matches_filters(
doc=doc,
q=q,
document_type=document_type,
review_status=review_status,
merchant=merchant,
owner_primary=owner_primary,
):
filtered_documents.append(doc)
if tab not in {"all-documents", "advanced-search"}:
tab = "all-documents"
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="documents/list.html", name="documents/list.html",
context={"request": request, "documents": documents, "active_page": "documents"}, context={
"request": request,
"documents": filtered_documents,
"q": q,
"document_type": document_type,
"review_status": review_status,
"merchant": merchant,
"owner_primary": owner_primary,
"has_search_query": has_search_query,
"active_tab": tab,
"active_page": "documents",
},
) )
@router.post("/{document_id}/rerun-ocr", response_class=RedirectResponse) @router.post("/{document_id}/rerun-ocr", response_class=RedirectResponse)
def rerun_ocr(document_id: str, db: Session = Depends(get_db)): def rerun_ocr(document_id: str, db: Session = Depends(get_db)):
document = db.query(Document).filter(Document.document_id == document_id).first() document = db.query(Document).filter(Document.document_id == document_id).first()
if document is None: if document is None:
return RedirectResponse(url="/documents/", status_code=303) return RedirectResponse(url="/documents/", status_code=303)

View File

@ -109,6 +109,138 @@ def _build_row(item: ReceiptLineItem) -> dict | None:
} }
def _load_all_items(db: Session) -> list[ReceiptLineItem]:
return (
db.query(ReceiptLineItem)
.options(
selectinload(ReceiptLineItem.document).selectinload(Document.extracted_fields)
)
.order_by(ReceiptLineItem.id.desc())
.all()
)
def _build_filtered_rows(
items: list[ReceiptLineItem],
q: str,
merchant: str,
category: str,
date_from: str,
date_to: str,
rating_min: str,
rating_max: str,
) -> list[dict]:
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:
row = _build_row(item)
if row is None:
continue
quality_rating_dec = _to_decimal(row["quality_rating"])
if q_norm and q_norm not in row["description"].lower():
continue
if merchant_norm and merchant_norm not in row["merchant"].lower():
continue
if category_norm and category_norm != row["category"].lower():
continue
if date_from and (not row["transaction_date"] or row["transaction_date"] < date_from):
continue
if date_to and (not row["transaction_date"] or row["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(row)
rows.sort(
key=lambda row: (
row["transaction_date"] or "",
row["merchant"] or "",
row["description"] or "",
),
reverse=True,
)
return rows
def _build_summary_rows(items: list[ReceiptLineItem], q: str) -> list[dict]:
q_norm = q.strip().lower()
grouped: dict[str, dict] = {}
for item in items:
row = _build_row(item)
if row is None:
continue
item_name = row["description"]
if q_norm and q_norm not in item_name.lower():
continue
line_total_dec = _to_decimal(row["line_total"])
rating_dec = _to_decimal(row["quality_rating"])
bucket = grouped.setdefault(
item_name,
{
"item": item_name,
"count": 0,
"prices": [],
"rated_count": 0,
"rating_sum": Decimal("0"),
},
)
bucket["count"] += 1
if line_total_dec is not None:
bucket["prices"].append(line_total_dec)
if rating_dec is not None:
bucket["rated_count"] += 1
bucket["rating_sum"] += rating_dec
rows = []
for bucket in grouped.values():
prices = bucket["prices"]
avg_price = ""
min_price = ""
max_price = ""
if prices:
avg_price = str((sum(prices) / len(prices)).quantize(Decimal("0.01")))
min_price = str(min(prices).quantize(Decimal("0.01")))
max_price = str(max(prices).quantize(Decimal("0.01")))
avg_rating = ""
if bucket["rated_count"] > 0:
avg_rating = str((bucket["rating_sum"] / bucket["rated_count"]).quantize(Decimal("0.01")))
rows.append(
{
"item": bucket["item"],
"count": bucket["count"],
"avg_price": avg_price,
"min_price": min_price,
"max_price": max_price,
"rated_count": bucket["rated_count"],
"avg_rating": avg_rating,
}
)
rows.sort(key=lambda x: (x["count"], x["item"]), reverse=True)
return rows
@router.post("/{line_item_id}/review", response_class=RedirectResponse) @router.post("/{line_item_id}/review", response_class=RedirectResponse)
def save_line_item_review( def save_line_item_review(
line_item_id: int, line_item_id: int,
@ -165,13 +297,88 @@ def save_line_item_review(
return RedirectResponse(url="/queue/?tab=quality", status_code=303) return RedirectResponse(url="/queue/?tab=quality", status_code=303)
redirect_url = ( redirect_url = (
f"/line-items/?q={q}&merchant={merchant}&category={category}" f"/line-items/?tab=advanced-search"
f"&q={q}&merchant={merchant}&category={category}"
f"&date_from={date_from}&date_to={date_to}" f"&date_from={date_from}&date_to={date_to}"
f"&rating_min={rating_min}&rating_max={rating_max}" f"&rating_min={rating_min}&rating_max={rating_max}"
) )
return RedirectResponse(url=redirect_url, status_code=303) 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"),
tab: str = Query("summary"),
db: Session = Depends(get_db),
):
items = _load_all_items(db)
has_advanced_query = any([
q.strip(),
merchant.strip(),
category.strip(),
date_from.strip(),
date_to.strip(),
rating_min.strip(),
rating_max.strip(),
])
detail_rows = []
if has_advanced_query:
detail_rows = _build_filtered_rows(
items=items,
q=q,
merchant=merchant,
category=category,
date_from=date_from,
date_to=date_to,
rating_min=rating_min,
rating_max=rating_max,
)
summary_rows = _build_summary_rows(items=items, q=q)
if tab not in {"summary", "advanced-search"}:
tab = "summary"
if tab == "summary" and any([merchant.strip(), category.strip(), date_from.strip(), date_to.strip(), rating_min.strip(), rating_max.strip()]):
tab = "advanced-search"
return templates.TemplateResponse(
request=request,
name="line_items/list.html",
context={
"request": request,
"rows": detail_rows,
"summary_rows": summary_rows,
"q": q,
"merchant": merchant,
"category": category,
"date_from": date_from,
"date_to": date_to,
"rating_min": rating_min,
"rating_max": rating_max,
"active_tab": tab,
"has_advanced_query": has_advanced_query,
"active_page": "line_items",
},
)
@router.get("/summary", response_class=RedirectResponse)
def summarize_line_items_redirect(
q: str = Query("", description="Item contains"),
):
return RedirectResponse(url=f"/line-items/?tab=summary&q={q}", status_code=303)
@router.get("/queue", response_class=HTMLResponse) @router.get("/queue", response_class=HTMLResponse)
def quality_queue( def quality_queue(
request: Request, request: Request,
@ -214,170 +421,3 @@ def quality_queue(
"active_page": "line_items", "active_page": "line_items",
}, },
) )
@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:
row = _build_row(item)
if row is None:
continue
quality_rating_dec = _to_decimal(row["quality_rating"])
if q_norm and q_norm not in row["description"].lower():
continue
if merchant_norm and merchant_norm not in row["merchant"].lower():
continue
if category_norm and category_norm not in row["category"].lower():
continue
if date_from and (not row["transaction_date"] or row["transaction_date"] < date_from):
continue
if date_to and (not row["transaction_date"] or row["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(row)
rows.sort(
key=lambda row: (
row["transaction_date"] or "",
row["merchant"] or "",
row["description"] or "",
),
reverse=True,
)
return templates.TemplateResponse(
request=request,
name="line_items/list.html",
context={
"request": request,
"rows": rows,
"q": q,
"merchant": merchant,
"category": category,
"date_from": date_from,
"date_to": date_to,
"rating_min": rating_min,
"rating_max": rating_max,
"active_page": "line_items",
},
)
@router.get("/summary", response_class=HTMLResponse)
def summarize_line_items(
request: Request,
q: str = Query("", description="Item contains"),
db: Session = Depends(get_db),
):
query = (
db.query(
ReceiptLineItem.normalized_description.label("item"),
func.count().label("count"),
func.avg(ReceiptLineItem.line_total).label("avg_price"),
func.min(ReceiptLineItem.line_total).label("min_price"),
func.max(ReceiptLineItem.line_total).label("max_price"),
)
)
if q:
query = query.filter(
ReceiptLineItem.normalized_description.ilike(f"%{q}%")
)
query = query.group_by(ReceiptLineItem.normalized_description)
results = query.all()
rating_query = db.query(
ReceiptLineItem.normalized_description,
ReceiptLineItem.extra_json,
)
if q:
rating_query = rating_query.filter(
ReceiptLineItem.normalized_description.ilike(f"%{q}%")
)
rating_rows = rating_query.all()
rating_map: dict[str, dict[str, Decimal | int]] = {}
for item_name, extra_json in rating_rows:
key = item_name or ""
rating_info = rating_map.setdefault(
key,
{"rated_count": 0, "rating_sum": Decimal("0")}
)
extra = extra_json or {}
rating_dec = _to_decimal(extra.get("quality_rating"))
if rating_dec is not None:
rating_info["rated_count"] += 1
rating_info["rating_sum"] += rating_dec
rows = []
for r in results:
item_name = r.item or ""
rating_info = rating_map.get(item_name, {"rated_count": 0, "rating_sum": Decimal("0")})
rated_count = int(rating_info["rated_count"])
rating_sum = rating_info["rating_sum"]
avg_rating = ""
if rated_count > 0:
avg_rating = str((rating_sum / rated_count).quantize(Decimal("0.01")))
rows.append(
{
"item": item_name,
"count": r.count,
"avg_price": str(round(r.avg_price, 2)) if r.avg_price is not None else "",
"min_price": str(r.min_price) if r.min_price is not None else "",
"max_price": str(r.max_price) if r.max_price is not None else "",
"rated_count": rated_count,
"avg_rating": avg_rating,
}
)
rows.sort(key=lambda x: (x["count"], x["item"]), reverse=True)
return templates.TemplateResponse(
request=request,
name="line_items/summary.html",
context={
"request": request,
"rows": rows,
"q": q,
"active_page": "line_item_summary",
},
)

View File

@ -4,6 +4,45 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Dashboard</title> <title>Dashboard</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
<style>
.stats-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.85rem;
}
.stat-card {
border: 1px solid #d7dce5;
border-radius: 14px;
padding: 0.85rem 1rem;
background: #f8fafc;
}
.stat-label {
font-size: 0.78rem;
color: #6b7280;
margin-bottom: 0.35rem;
}
.stat-value {
font-size: 1.35rem;
font-weight: 700;
color: #111827;
line-height: 1.1;
}
.compact-button-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
@media (max-width: 900px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>
</head> </head>
<body> <body>
<div class="app-shell" id="app-shell"> <div class="app-shell" id="app-shell">
@ -18,36 +57,71 @@
</div> </div>
<div class="card"> <div class="card">
<div class="button-row"> <div class="compact-button-row">
<a class="button-link primary" href="/documents/">Open documents</a> <a class="button-link primary" href="/documents/">Open documents</a>
<a class="button-link" href="/queue/">Open queue</a> <a class="button-link" href="/queue/">Open queue</a>
<a class="button-link" href="/line-items/">Open line items</a> <a class="button-link" href="/line-items/">Open line items</a>
<a class="button-link" href="/line-items/summary">Open summary</a>
<a class="button-link" href="/ingest/">Open ingest</a> <a class="button-link" href="/ingest/">Open ingest</a>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h2 class="card-title">Document overview</h2> <h2 class="card-title">Document overview</h2>
<div class="meta-grid"> <div class="stats-grid">
<div class="meta-item"><span class="meta-label">Total documents</span>{{ total_documents }}</div> <div class="stat-card">
<div class="meta-item"><span class="meta-label">Active documents</span>{{ active_documents }}</div> <div class="stat-label">Total documents</div>
<div class="meta-item"><span class="meta-label">Reviewed documents</span>{{ reviewed_documents }}</div> <div class="stat-value">{{ total_documents }}</div>
<div class="meta-item"><span class="meta-label">Pending OCR/review</span>{{ pending_review_documents }}</div> </div>
<div class="meta-item"><span class="meta-label">Extracted field rows</span>{{ extracted_documents }}</div> <div class="stat-card">
<div class="meta-item"><span class="meta-label">Trashed documents</span>{{ trashed_documents }}</div> <div class="stat-label">Active documents</div>
<div class="stat-value">{{ active_documents }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Reviewed documents</div>
<div class="stat-value">{{ reviewed_documents }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Pending OCR/review</div>
<div class="stat-value">{{ pending_review_documents }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Extracted field rows</div>
<div class="stat-value">{{ extracted_documents }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Trashed documents</div>
<div class="stat-value">{{ trashed_documents }}</div>
</div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h2 class="card-title">Line item overview</h2> <h2 class="card-title">Line item overview</h2>
<div class="meta-grid"> <div class="stats-grid">
<div class="meta-item"><span class="meta-label">Total line items</span>{{ total_line_items }}</div> <div class="stat-card">
<div class="meta-item"><span class="meta-label">Cocktail items</span>{{ cocktail_count }}</div> <div class="stat-label">Total line items</div>
<div class="meta-item"><span class="meta-label">Rated cocktails</span>{{ rated_cocktails }}</div> <div class="stat-value">{{ total_line_items }}</div>
<div class="meta-item"><span class="meta-label">Pending cocktail ratings</span>{{ pending_cocktail_reviews }}</div> </div>
<div class="meta-item"><span class="meta-label">Cocktails marked N/A</span>{{ na_cocktails }}</div> <div class="stat-card">
<div class="meta-item"><span class="meta-label">Average cocktail rating</span>{{ avg_cocktail_rating or "—" }}</div> <div class="stat-label">Cocktail items</div>
<div class="stat-value">{{ cocktail_count }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Rated cocktails</div>
<div class="stat-value">{{ rated_cocktails }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Pending cocktail ratings</div>
<div class="stat-value">{{ pending_cocktail_reviews }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Cocktails marked N/A</div>
<div class="stat-value">{{ na_cocktails }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Average cocktail rating</div>
<div class="stat-value">{{ avg_cocktail_rating or "—" }}</div>
</div>
</div> </div>
</div> </div>
@ -60,7 +134,7 @@
<tr> <tr>
<th>Document</th> <th>Document</th>
<th>Type</th> <th>Type</th>
<th>Review status</th> <th>Review Status</th>
<th>Updated</th> <th>Updated</th>
</tr> </tr>
</thead> </thead>
@ -69,7 +143,11 @@
<tr> <tr>
<td><a href="/documents/{{ doc.document_id }}">{{ doc.document_id }}</a></td> <td><a href="/documents/{{ doc.document_id }}">{{ doc.document_id }}</a></td>
<td>{{ doc.document_type }}</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>
<span class="badge {% if doc.review_status == 'reviewed' %}reviewed{% else %}pending{% endif %}">
{{ doc.review_status }}
</span>
</td>
<td>{{ doc.updated_at }}</td> <td>{{ doc.updated_at }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -77,7 +155,7 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p class="empty-state">No documents found.</p> <p class="empty-state">No recent documents found.</p>
{% endif %} {% endif %}
</div> </div>
@ -94,7 +172,6 @@
<th>Category</th> <th>Category</th>
<th>Total</th> <th>Total</th>
<th>Rating</th> <th>Rating</th>
<th>Status</th>
<th>Document</th> <th>Document</th>
</tr> </tr>
</thead> </thead>
@ -106,8 +183,15 @@
<td>{{ row.description }}</td> <td>{{ row.description }}</td>
<td>{{ row.category }}</td> <td>{{ row.category }}</td>
<td>{{ row.line_total }}</td> <td>{{ row.line_total }}</td>
<td>{{ row.quality_rating }}</td> <td>
<td>{{ row.quality_status }}</td> {% if row.quality_rating %}
{{ row.quality_rating }}
{% elif row.quality_status == "na" %}
N/A
{% else %}
{% endif %}
</td>
<td><a href="/documents/{{ row.document_id }}?tab=extracted-fields">{{ row.document_id }}</a></td> <td><a href="/documents/{{ row.document_id }}?tab=extracted-fields">{{ row.document_id }}</a></td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -115,7 +199,7 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p class="empty-state">No line items found.</p> <p class="empty-state">No recent line items found.</p>
{% endif %} {% endif %}
</div> </div>
</main> </main>
@ -125,7 +209,7 @@
(function () { (function () {
const appShell = document.getElementById("app-shell"); const appShell = document.getElementById("app-shell");
const menuToggle = document.getElementById("menu-toggle"); const menuToggle = document.getElementById("menu-toggle");
if (!appShell || !menuToggle) return; if (appShell && menuToggle) {
menuToggle.addEventListener("click", function () { menuToggle.addEventListener("click", function () {
appShell.classList.toggle("nav-open"); appShell.classList.toggle("nav-open");
}); });
@ -135,6 +219,7 @@
appShell.classList.toggle("nav-open"); appShell.classList.toggle("nav-open");
} }
}); });
}
})(); })();
</script> </script>
</body> </body>

View File

@ -13,20 +13,74 @@
<div class="topbar"> <div class="topbar">
<div> <div>
<h1 class="page-title">Documents</h1> <h1 class="page-title">Documents</h1>
<p class="page-subtitle">Active documents available for review and processing.</p> <p class="page-subtitle">Browse all documents or refine with advanced search.</p>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="button-row"> <div class="right-pane-tabs">
<a class="button-link primary" href="/ingest/">Open ingest</a> <button class="tab-button{% if active_tab == 'all-documents' %} active{% endif %}" type="button" data-tab="all-documents">All Documents</button>
<a class="button-link" href="/queue/">Open review queue</a> <button class="tab-button{% if active_tab == 'advanced-search' %} active{% endif %}" type="button" data-tab="advanced-search">Advanced Search</button>
<a class="button-link" href="/trash/">Open trash</a> </div>
<div class="tab-panel{% if active_tab == 'all-documents' %} active{% endif %}" data-panel="all-documents">
<h2 class="card-title">All Documents</h2>
<p class="page-subtitle">Showing all active documents. Use the quick search below or switch to Advanced Search.</p>
<form method="get" action="/documents/">
<input type="hidden" name="tab" value="all-documents">
<div class="form-grid">
<div class="form-field">
<input id="q" type="text" name="q" value="{{ q }}" placeholder="merchant, filename, document id, owner, note">
</div>
</div>
<div class="button-row" style="margin-top: 1rem;">
<button class="primary" type="submit">Search</button>
<a class="button-link" href="/documents/?tab=all-documents">Clear</a>
</div>
</form>
</div>
<div class="tab-panel{% if active_tab == 'advanced-search' %} active{% endif %}" data-panel="advanced-search">
<h2 class="card-title">Advanced Search</h2>
<form method="get" action="/documents/">
<input type="hidden" name="tab" value="advanced-search">
<div class="form-grid">
<div class="form-field">
<label for="q_advanced">Search</label>
<input id="q_advanced" type="text" name="q" value="{{ q }}" placeholder="merchant, filename, document id, owner, note">
</div>
<div class="form-field">
<label for="document_type">Document type</label>
<input id="document_type" type="text" name="document_type" value="{{ document_type }}" placeholder="receipt">
</div>
<div class="form-field">
<label for="review_status">Review status</label>
<input id="review_status" type="text" name="review_status" value="{{ review_status }}" placeholder="reviewed">
</div>
<div class="form-field">
<label for="merchant">Merchant contains</label>
<input id="merchant" type="text" name="merchant" value="{{ merchant }}" placeholder="Bar Margaret">
</div>
<div class="form-field">
<label for="owner_primary">Owner contains</label>
<input id="owner_primary" type="text" name="owner_primary" value="{{ owner_primary }}" placeholder="Sean McElwain">
</div>
</div>
<div class="button-row" style="margin-top: 1rem;">
<button class="primary" type="submit">Search</button>
<a class="button-link" href="/documents/?tab=advanced-search">Clear</a>
</div>
</form>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h2 class="card-title">All documents</h2> <h2 class="card-title">Documents</h2>
{% if documents %} {% if documents %}
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
@ -34,8 +88,8 @@
<tr> <tr>
<th>Document</th> <th>Document</th>
<th>Type</th> <th>Type</th>
<th>Review status</th> <th>Review Status</th>
<th>Current path</th> <th>Current Path</th>
<th>Updated</th> <th>Updated</th>
</tr> </tr>
</thead> </thead>
@ -44,7 +98,11 @@
<tr> <tr>
<td><a href="/documents/{{ doc.document_id }}">{{ doc.document_id }}</a></td> <td><a href="/documents/{{ doc.document_id }}">{{ doc.document_id }}</a></td>
<td>{{ doc.document_type }}</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>
<span class="badge {% if doc.review_status == 'reviewed' %}reviewed{% else %}pending{% endif %}">
{{ doc.review_status }}
</span>
</td>
<td>{{ doc.current_path }}</td> <td>{{ doc.current_path }}</td>
<td>{{ doc.updated_at }}</td> <td>{{ doc.updated_at }}</td>
</tr> </tr>
@ -53,7 +111,7 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p class="empty-state">No documents found.</p> <p class="empty-state">No documents matched the current search.</p>
{% endif %} {% endif %}
</div> </div>
</main> </main>
@ -63,7 +121,7 @@
(function () { (function () {
const appShell = document.getElementById("app-shell"); const appShell = document.getElementById("app-shell");
const menuToggle = document.getElementById("menu-toggle"); const menuToggle = document.getElementById("menu-toggle");
if (!appShell || !menuToggle) return; if (appShell && menuToggle) {
menuToggle.addEventListener("click", function () { menuToggle.addEventListener("click", function () {
appShell.classList.toggle("nav-open"); appShell.classList.toggle("nav-open");
}); });
@ -73,6 +131,30 @@
appShell.classList.toggle("nav-open"); 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());
});
});
})(); })();
</script> </script>
</body> </body>

View File

@ -13,12 +13,78 @@
<div class="topbar"> <div class="topbar">
<div> <div>
<h1 class="page-title">Line Items</h1> <h1 class="page-title">Line Items</h1>
<p class="page-subtitle">Search extracted purchase lines across documents</p> <p class="page-subtitle">Analyze extracted purchase lines, prices, and ratings</p>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="right-pane-tabs">
<button class="tab-button{% if active_tab == 'summary' %} active{% endif %}" type="button" data-tab="summary">Summary</button>
<button class="tab-button{% if active_tab == 'advanced-search' %} active{% endif %}" type="button" data-tab="advanced-search">Advanced Search</button>
</div>
<div class="tab-panel{% if active_tab == 'summary' %} active{% endif %}" data-panel="summary">
<h2 class="card-title">Line Item Summary</h2>
<form method="get" action="/line-items/"> <form method="get" action="/line-items/">
<input type="hidden" name="tab" value="summary">
<div class="form-grid">
<div class="form-field">
<label for="summary_q">Item contains</label>
<input id="summary_q" type="text" 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/?tab=summary">Clear</a>
<a class="button-link" href="/line-items/?tab=advanced-search&q={{ q }}">Detailed view</a>
</div>
</form>
<div style="margin-top: 1.25rem;">
<h2 class="card-title">Summary Results</h2>
{% if summary_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 summary_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>
</div>
<div class="tab-panel{% if active_tab == 'advanced-search' %} active{% endif %}" data-panel="advanced-search">
<h2 class="card-title">Advanced Search</h2>
<form method="get" action="/line-items/">
<input type="hidden" name="tab" value="advanced-search">
<div class="form-grid"> <div class="form-grid">
<div class="form-field"> <div class="form-field">
<label for="q">Item contains</label> <label for="q">Item contains</label>
@ -52,16 +118,16 @@
<div class="button-row" style="margin-top: 1rem;"> <div class="button-row" style="margin-top: 1rem;">
<button class="primary" type="submit">Search</button> <button class="primary" type="submit">Search</button>
<a class="button-link" href="/line-items/">Clear</a> <a class="button-link" href="/line-items/?tab=advanced-search">Clear</a>
<a class="button-link" href="/line-items/summary?q={{ q }}">Summary view</a>
</div> </div>
</form> </form>
</div>
<div class="card"> <div style="margin-top: 1.25rem;">
<h2 class="card-title">Results</h2> <h2 class="card-title">Results</h2>
{% if rows %} {% if not has_advanced_query %}
<p class="empty-state">Enter one or more filters and run a search to view detailed line-item results.</p>
{% elif rows %}
{% for row in rows %} {% for row in rows %}
<div class="card" style="margin-bottom: 1rem;"> <div class="card" style="margin-bottom: 1rem;">
<div class="topbar" style="margin-bottom: 0.75rem;"> <div class="topbar" style="margin-bottom: 0.75rem;">
@ -97,6 +163,7 @@
<input type="hidden" name="date_to" value="{{ date_to }}"> <input type="hidden" name="date_to" value="{{ date_to }}">
<input type="hidden" name="rating_min" value="{{ rating_min }}"> <input type="hidden" name="rating_min" value="{{ rating_min }}">
<input type="hidden" name="rating_max" value="{{ rating_max }}"> <input type="hidden" name="rating_max" value="{{ rating_max }}">
<input type="hidden" name="return_to" value="list">
<div class="form-grid"> <div class="form-grid">
<div class="form-field"> <div class="form-field">
@ -120,6 +187,8 @@
<p class="empty-state">No line items found for the current filters.</p> <p class="empty-state">No line items found for the current filters.</p>
{% endif %} {% endif %}
</div> </div>
</div>
</div>
</main> </main>
</div> </div>
@ -138,6 +207,29 @@
} }
}); });
} }
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());
});
});
})(); })();
</script> </script>
</body> </body>

View File

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Quality Review Queue</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">Quality Review Queue</h1>
<p class="page-subtitle">Cocktails waiting for a rating or an N/A mark</p>
</div>
</div>
<div class="card">
{% if next_row %}
<div class="button-row">
<a class="button-link primary" href="#item-{{ next_row.line_item_id }}">Jump to next item</a>
<a class="button-link" href="/line-items/?category=cocktail">Open all cocktails</a>
</div>
{% else %}
<p class="empty-state">No cocktail line items are waiting for quality review.</p>
{% endif %}
</div>
<div class="card">
<h2 class="card-title">Pending quality reviews</h2>
{% if rows %}
{% for row in rows %}
<div class="card" id="item-{{ row.line_item_id }}" 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">
<span class="badge">{{ row.category }}</span>
{% if row.quantity %}
<span class="badge">Qty {{ row.quantity }}</span>
{% endif %}
{% if row.line_total %}
<span class="badge">${{ row.line_total }}</span>
{% endif %}
</div>
</div>
<form method="post" action="/line-items/{{ row.line_item_id }}/review">
<input type="hidden" name="return_to" value="quality_queue">
<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">
</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, sweetness, strength, food notes...">{{ row.quality_note }}</textarea>
</div>
</div>
<div class="button-row" style="margin-top: 1rem;">
<button class="primary" type="submit" name="quality_status" value="rated">Save rating</button>
<button type="submit" name="quality_status" value="na">Mark N/A</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">Nothing is waiting in the quality review queue.</p>
{% endif %}
</div>
</main>
</div>
<script>
(function () {
const appShell = document.getElementById("app-shell");
const menuToggle = document.getElementById("menu-toggle");
if (appShell && menuToggle) {
menuToggle.addEventListener("click", function () {
appShell.classList.toggle("nav-open");
});
menuToggle.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
appShell.classList.toggle("nav-open");
}
});
}
})();
</script>
</body>
</html>

View File

@ -8,9 +8,9 @@
<div class="sidebar-section-title">Workspace</div> <div class="sidebar-section-title">Workspace</div>
<nav class="nav-list"> <nav class="nav-list">
<a class="nav-link {% if active_page == 'dashboard' %}active{% endif %}" href="/" title="Dashboard"><span class="nav-link-short">H</span><span class="nav-link-text">Dashboard</span></a>
<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 == '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_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 == '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 == '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> <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>

View File

@ -17,19 +17,7 @@
</div> </div>
</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 %}
{% if next_quality %}
<a class="button-link" href="/queue/?tab=quality#quality-{{ next_quality.line_item_id }}">Next needing quality review</a>
{% endif %}
</div>
</div>
<div class="card"> <div class="card">
<div class="right-pane-tabs"> <div class="right-pane-tabs">