From aa7f8d6a54a00566ef8695babc565c9937480ec2 Mon Sep 17 00:00:00 2001
From: McElwain
Date: Tue, 7 Apr 2026 12:32:49 -0500
Subject: [PATCH] feat: unify dashboard, documents, queue, and line item
navigation UI
---
app/routes/documents.py | 137 +++++++++-
app/routes/line_items.py | 376 +++++++++++++++-------------
app/templates/dashboard.html | 149 ++++++++---
app/templates/documents/list.html | 118 +++++++--
app/templates/line_items/list.html | 276 +++++++++++++-------
app/templates/line_items/queue.html | 101 ++++++++
app/templates/partials/sidebar.html | 12 +-
app/templates/queue/index.html | 14 +-
8 files changed, 847 insertions(+), 336 deletions(-)
create mode 100644 app/templates/line_items/queue.html
diff --git a/app/routes/documents.py b/app/routes/documents.py
index a8710f0..69a7093 100644
--- a/app/routes/documents.py
+++ b/app/routes/documents.py
@@ -3,7 +3,7 @@ from datetime import datetime
from decimal import Decimal, InvalidOperation
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.templating import Jinja2Templates
from sqlalchemy.orm import Session, selectinload
@@ -71,7 +71,6 @@ def _to_decimal(value: str) -> Decimal | None:
return None
-
def _get_all_presets(db: Session) -> list[DocumentPreset]:
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]
-
def _extracted_field_form_values(document: Document, request: Request) -> dict:
current = get_current_extracted_fields(document)
auto = request.query_params.get("autofill_extracted")
@@ -152,6 +150,7 @@ def _extracted_field_form_values(document: Document, request: Request) -> dict:
return values
+
def _additional_field_form_values(document: Document, preset: DocumentPreset | None = None) -> dict:
current = _get_current_additional_fields(document)
if current is None:
@@ -265,6 +264,7 @@ def _get_queue_navigation(db: Session, document: Document) -> dict:
.order_by(Document.created_at.asc())
.all()
)
+
doc_ids = [d.document_id for d in active_docs]
prev_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)
-def list_documents(request: Request, db: Session = Depends(get_db)):
- documents = (
+def list_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)
+ .options(
+ selectinload(Document.extracted_fields),
+ selectinload(Document.additional_fields),
+ )
.filter(Document.is_trashed.is_(False))
.order_by(Document.created_at.desc())
.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(
request=request,
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)
def rerun_ocr(document_id: str, db: Session = Depends(get_db)):
document = db.query(Document).filter(Document.document_id == document_id).first()
-
if document is None:
return RedirectResponse(url="/documents/", status_code=303)
diff --git a/app/routes/line_items.py b/app/routes/line_items.py
index 4096c83..2b34b4d 100644
--- a/app/routes/line_items.py
+++ b/app/routes/line_items.py
@@ -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)
def save_line_item_review(
line_item_id: int,
@@ -165,13 +297,88 @@ def save_line_item_review(
return RedirectResponse(url="/queue/?tab=quality", status_code=303)
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"&rating_min={rating_min}&rating_max={rating_max}"
)
return RedirectResponse(url=redirect_url, status_code=303)
+@router.get("/", response_class=HTMLResponse)
+def list_line_items(
+ request: Request,
+ q: str = Query("", description="Item description contains"),
+ merchant: str = Query("", description="Merchant contains"),
+ category: str = Query("", description="Category equals"),
+ date_from: str = Query("", description="YYYY-MM-DD"),
+ date_to: str = Query("", description="YYYY-MM-DD"),
+ rating_min: str = Query("", description="Minimum rating"),
+ rating_max: str = Query("", description="Maximum rating"),
+ 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)
def quality_queue(
request: Request,
@@ -214,170 +421,3 @@ def quality_queue(
"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",
- },
- )
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html
index 6101cb3..3874cf4 100644
--- a/app/templates/dashboard.html
+++ b/app/templates/dashboard.html
@@ -4,6 +4,45 @@
Dashboard
+
@@ -18,36 +57,71 @@
-
Document overview
-
Line item overview
-
@@ -60,7 +134,7 @@
| Document |
Type |
- Review status |
+ Review Status |
Updated |
@@ -69,7 +143,11 @@
| {{ doc.document_id }} |
{{ doc.document_type }} |
- {{ doc.review_status }} |
+
+
+ {{ doc.review_status }}
+
+ |
{{ doc.updated_at }} |
{% endfor %}
@@ -77,7 +155,7 @@
{% else %}
-
No documents found.
+
No recent documents found.
{% endif %}
@@ -94,7 +172,6 @@
Category |
Total |
Rating |
-
Status |
Document |
@@ -106,8 +183,15 @@
{{ row.description }} |
{{ row.category }} |
{{ row.line_total }} |
-
{{ row.quality_rating }} |
-
{{ row.quality_status }} |
+
+ {% if row.quality_rating %}
+ {{ row.quality_rating }}
+ {% elif row.quality_status == "na" %}
+ N/A
+ {% else %}
+ —
+ {% endif %}
+ |
{{ row.document_id }} |
{% endfor %}
@@ -115,7 +199,7 @@
{% else %}
- No line items found.
+ No recent line items found.
{% endif %}
@@ -125,16 +209,17 @@
(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();
+ 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");
+ }
+ });
+ }
})();
diff --git a/app/templates/documents/list.html b/app/templates/documents/list.html
index 4ea6e72..1240b6f 100644
--- a/app/templates/documents/list.html
+++ b/app/templates/documents/list.html
@@ -13,20 +13,74 @@
Documents
-
Active documents available for review and processing.
+
Browse all documents or refine with advanced search.
-
-
All documents
+
Documents
+
{% if documents %}
@@ -34,8 +88,8 @@
| Document |
Type |
- Review status |
- Current path |
+ Review Status |
+ Current Path |
Updated |
@@ -44,7 +98,11 @@
| {{ doc.document_id }} |
{{ doc.document_type }} |
- {{ doc.review_status }} |
+
+
+ {{ doc.review_status }}
+
+ |
{{ doc.current_path }} |
{{ doc.updated_at }} |
@@ -53,7 +111,7 @@
{% else %}
-
No documents found.
+
No documents matched the current search.
{% endif %}
@@ -63,15 +121,39 @@
(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();
+ if (appShell && menuToggle) {
+ menuToggle.addEventListener("click", function () {
appShell.classList.toggle("nav-open");
- }
+ });
+ menuToggle.addEventListener("keydown", function (e) {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ appShell.classList.toggle("nav-open");
+ }
+ });
+ }
+
+ const tabButtons = document.querySelectorAll("[data-tab]");
+ const tabPanels = document.querySelectorAll("[data-panel]");
+
+ function activateTab(target) {
+ tabButtons.forEach(function (b) {
+ b.classList.toggle("active", b.getAttribute("data-tab") === target);
+ });
+ tabPanels.forEach(function (p) {
+ p.classList.toggle("active", p.getAttribute("data-panel") === target);
+ });
+ }
+
+ tabButtons.forEach(function (btn) {
+ btn.addEventListener("click", function () {
+ const target = btn.getAttribute("data-tab");
+ activateTab(target);
+
+ const url = new URL(window.location.href);
+ url.searchParams.set("tab", target);
+ window.history.replaceState({}, "", url.toString());
+ });
});
})();
diff --git a/app/templates/line_items/list.html b/app/templates/line_items/list.html
index 1c0c761..f3c0b68 100644
--- a/app/templates/line_items/list.html
+++ b/app/templates/line_items/list.html
@@ -13,112 +13,181 @@
Line Items
-
Search extracted purchase lines across documents
+
Analyze extracted purchase lines, prices, and ratings
+
+
Line Item Summary
-
-
Results
-
- {% if rows %}
- {% for row in rows %}
-
-
-
-
{{ row.transaction_date }} · {{ row.merchant }}
-
{{ row.description }}
-
{{ row.raw_description }}
-
-
- {% if row.category %}
-
{{ row.category }}
- {% endif %}
- {% if row.quantity %}
-
Qty {{ row.quantity }}
- {% endif %}
- {% if row.line_total %}
-
${{ row.line_total }}
- {% endif %}
- {% if row.confidence %}
-
Conf {{ row.confidence }}
- {% endif %}
- {% if row.quality_rating %}
-
Rating {{ row.quality_rating }}
- {% endif %}
+
-
@@ -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());
+ });
+ });
})();
+
+ {% include "partials/sidebar.html" %}
+
+
+
+
+
Quality Review Queue
+
Cocktails waiting for a rating or an N/A mark
+
+
+
+
+ {% if next_row %}
+
+ {% else %}
+
No cocktail line items are waiting for quality review.
+ {% endif %}
+
+
+
+
Pending quality reviews
+
+ {% if rows %}
+ {% for row in rows %}
+
+
+
+
{{ row.transaction_date }} · {{ row.merchant }}
+
{{ row.description }}
+
{{ row.raw_description }}
+
+
+ {{ row.category }}
+ {% if row.quantity %}
+ Qty {{ row.quantity }}
+ {% endif %}
+ {% if row.line_total %}
+ ${{ row.line_total }}
+ {% endif %}
+
+
+
+
+
+ {% endfor %}
+ {% else %}
+
Nothing is waiting in the quality review queue.
+ {% endif %}
+
+
+
+
+
+
diff --git a/app/templates/line_items/queue.html b/app/templates/line_items/queue.html
new file mode 100644
index 0000000..2b22a18
--- /dev/null
+++ b/app/templates/line_items/queue.html
@@ -0,0 +1,101 @@
+
+
+