No cocktail line items are waiting for quality review.
+ {% endif %} +Pending quality reviews
+ + {% if rows %} + {% for row in rows %} +Nothing is waiting in the quality review queue.
+ {% endif %} +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 @@
No documents found.
+No recent documents found.
{% endif %}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 @@| Document | Type | -Review status | -Current path | +Review Status | +Current Path | Updated |
|---|---|---|---|---|---|---|
| {{ doc.document_id }} | {{ doc.document_type }} | -{{ doc.review_status }} | ++ + {{ doc.review_status }} + + | {{ doc.current_path }} | {{ doc.updated_at }} |
No documents found.
+No documents matched the current search.
{% endif %}No cocktail line items are waiting for quality review.
+ {% endif %} +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 @@ + + +
+ +
+ + +
+