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

-
-
Total documents{{ total_documents }}
-
Active documents{{ active_documents }}
-
Reviewed documents{{ reviewed_documents }}
-
Pending OCR/review{{ pending_review_documents }}
-
Extracted field rows{{ extracted_documents }}
-
Trashed documents{{ trashed_documents }}
+
+
+
Total documents
+
{{ total_documents }}
+
+
+
Active documents
+
{{ active_documents }}
+
+
+
Reviewed documents
+
{{ reviewed_documents }}
+
+
+
Pending OCR/review
+
{{ pending_review_documents }}
+
+
+
Extracted field rows
+
{{ extracted_documents }}
+
+
+
Trashed documents
+
{{ trashed_documents }}
+

Line item overview

-
-
Total line items{{ total_line_items }}
-
Cocktail items{{ cocktail_count }}
-
Rated cocktails{{ rated_cocktails }}
-
Pending cocktail ratings{{ pending_cocktail_reviews }}
-
Cocktails marked N/A{{ na_cocktails }}
-
Average cocktail rating{{ avg_cocktail_rating or "—" }}
+
+
+
Total line items
+
{{ total_line_items }}
+
+
+
Cocktail items
+
{{ cocktail_count }}
+
+
+
Rated cocktails
+
{{ rated_cocktails }}
+
+
+
Pending cocktail ratings
+
{{ pending_cocktail_reviews }}
+
+
+
Cocktails marked N/A
+
{{ na_cocktails }}
+
+
+
Average cocktail rating
+
{{ avg_cocktail_rating or "—" }}
+
@@ -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.

-
- Open ingest - Open review queue - Open trash +
+ + +
+ +
+

All Documents

+

Showing all active documents. Use the quick search below or switch to Advanced Search.

+ +
+ +
+
+ +
+
+ +
+ + Clear +
+
+
+ +
+

Advanced Search

+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + Clear +
+
-

All documents

+

Documents

+ {% if documents %}
@@ -34,8 +88,8 @@ - - + + @@ -44,7 +98,11 @@ - + @@ -53,7 +111,7 @@
Document TypeReview statusCurrent pathReview StatusCurrent Path Updated
{{ doc.document_id }} {{ doc.document_type }}{{ doc.review_status }} + + {{ doc.review_status }} + + {{ doc.current_path }} {{ doc.updated_at }}
{% 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

-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
+
+ + +
-
- - Clear - Summary view -
-
-
+
+

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 %} +
+ +
+
+ +
- - - - - - - - +
+ + Clear + Detailed view +
+
-
-
- - +
+

Summary Results

+ + {% if summary_rows %} +
+ + + + + + + + + + + + + + {% for row in summary_rows %} + + + + + + + + + + {% endfor %} + +
ItemCountAvg PriceMin PriceMax PriceRated CountAvg Rating
{{ row.item }}{{ row.count }}{{ row.avg_price }}{{ row.min_price }}{{ row.max_price }}{{ row.rated_count }}{{ row.avg_rating }}
+
+ {% else %} +

No summary rows found for the current search.

+ {% endif %} +
+
+ +
+

Advanced Search

+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + Clear +
+
+ +
+

Results

+ + {% if not has_advanced_query %} +

Enter one or more filters and run a search to view detailed line-item results.

+ {% elif 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 %} +
-
- - -
-
-
- - Open document +
+ + + + + + + + + +
+
+ + +
+
+ + +
+
+ +
+ + Open document +
+
- + {% endfor %} + {% else %} +

No line items found for the current filters.

+ {% endif %}
- {% endfor %} - {% else %} -

No line items found for the current filters.

- {% 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()); + }); + }); })(); 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 @@ + + + + + Quality Review Queue + + + +
+ {% 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 %} +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + Open document +
+
+
+ {% endfor %} + {% else %} +

Nothing is waiting in the quality review queue.

+ {% endif %} +
+
+
+ + + + diff --git a/app/templates/partials/sidebar.html b/app/templates/partials/sidebar.html index 4552ffb..60af95e 100644 --- a/app/templates/partials/sidebar.html +++ b/app/templates/partials/sidebar.html @@ -8,11 +8,11 @@ diff --git a/app/templates/queue/index.html b/app/templates/queue/index.html index e034765..f4f923e 100644 --- a/app/templates/queue/index.html +++ b/app/templates/queue/index.html @@ -17,19 +17,7 @@
-
-
- {% if next_ocr %} - Next needing OCR review - {% endif %} - {% if next_fields %} - Next needing field extraction - {% endif %} - {% if next_quality %} - Next needing quality review - {% endif %} -
-
+