// ── Receipts list ───────────────────────────────────────────────────────────── const _CHAIN_COLORS_FALLBACK = { bg: "#9ca3af", text: "#fff" }; let _chainColorsCache = null; function useChainColors() { const [colors, setColors] = React.useState(_chainColorsCache); React.useEffect(() => { if (_chainColorsCache) return; fetch("/api/chains") .then((r) => r.json()) .then(({ chains }) => { const map = {}; chains.forEach(({ name, bg, text }) => { map[name] = { bg, text }; }); _chainColorsCache = map; setColors(map); }) .catch(() => {}); }, []); return colors; } function ChainBadge({ chain, colors }) { const color = (colors && colors[chain]) || _CHAIN_COLORS_FALLBACK; return ( {chain} ); } // phase: null | 'uploading' | 'pending' | 'processing' | 'done' | 'error' const JOB_LABELS = { uploading: "Caricamento…", pending: "In coda…", processing: "OCR in corso…", done: "Fatto!", }; function UploadPdf({ onUploaded }) { const [dragging, setDragging] = React.useState(false); const [phase, setPhase] = React.useState(null); const [error, setError] = React.useState(null); const [receiptId, setReceiptId] = React.useState(null); const inputRef = React.useRef(); const pollRef = React.useRef(null); function stopPolling() { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } } async function pollJob(filename) { stopPolling(); pollRef.current = setInterval(async () => { try { const r = await fetch(`/api/jobs/${filename}`); if (!r.ok) { stopPolling(); setPhase("error"); setError(`Operazione non trovata`); return; } const job = await r.json(); setPhase(job.status); if (job.status === "done") { stopPolling(); setReceiptId(job.id); onUploaded && onUploaded(job.id); } else if (job.status === "error") { stopPolling(); setError(job.error || "OCR fallito"); } } catch (e) { stopPolling(); setPhase("error"); setError(String(e)); } }, 1500); } async function upload(file) { if (!file || !file.name.toLowerCase().endsWith(".pdf")) { setPhase("error"); setError("Sono accettati solo file PDF."); return; } setPhase("uploading"); setError(null); const fd = new FormData(); fd.append("file", file); try { const r = await fetch("/api/upload-pdf", { method: "POST", body: fd }); const json = await r.json(); if (!r.ok) { setPhase("error"); setError(json.detail || `Error ${r.status}`); return; } setPhase("pending"); pollJob(json.filename); } catch (e) { setPhase("error"); setError(String(e)); } } function reset() { stopPolling(); setPhase(null); setError(null); setReceiptId(null); } function onDrop(e) { e.preventDefault(); setDragging(false); upload(e.dataTransfer.files[0]); } const busy = phase === "uploading" || phase === "pending" || phase === "processing"; const isDone = phase === "done"; return (
{ e.preventDefault(); if (!busy) setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={(e) => { if (!busy) onDrop(e); }} onClick={() => { if (isDone) { if (receiptId) window.location.hash = `#/receipts/${receiptId}`; } else if (!busy) { reset(); inputRef.current.click(); } }} className={`border-2 border-dashed rounded-xl px-4 py-5 text-center transition-colors select-none ${ busy ? "cursor-wait border-gray-200 bg-gray-50" : isDone ? "cursor-pointer border-green-300 bg-green-50" : dragging ? "cursor-pointer border-[#2d7a3a] bg-green-50" : "cursor-pointer border-gray-300 hover:border-gray-400 bg-white" }`} > { upload(e.target.files[0]); e.target.value = ""; }} /> {busy && (
{["uploading", "pending", "processing"].map((s) => ( {JOB_LABELS[s]} {s !== "processing" && ( )} ))}
)} {isDone && (

Scontrino elaborato —{" "} {receiptId && ( vedi scontrino )} {receiptId && " · "} { e.stopPropagation(); reset(); }} > carica un altro

)} {phase === "error" &&

{error}

} {!phase && (

Trascina un PDF qui, o{" "} seleziona

)}
); } function _getChainFromHash() { const hash = window.location.hash; const q = hash.indexOf("?"); if (q === -1) return "all"; return new URLSearchParams(hash.slice(q + 1)).get("chain") || "all"; } function _setChainInHash(chain) { const base = "#/receipts"; const next = chain === "all" ? base : `${base}?chain=${encodeURIComponent(chain)}`; if (window.location.hash !== next) history.replaceState(null, "", next); } const PAGE_SIZE = 20; function _receiptYearMonth(r) { if (r.receipt_at) return r.receipt_at.slice(0, 7); if (r.date && r.date.includes("/")) { const [, m, y] = r.date.split("/"); return `${y}-${m.padStart(2, "0")}`; } return null; } function _monthLabel(ym) { const [y, m] = ym.split("-"); const d = new Date(parseInt(y, 10), parseInt(m, 10) - 1); return `${d.toLocaleString("it-IT", { month: "long" })} ${y}`; } function ReceiptsList() { const [data, setData] = React.useState(null); const [error, setError] = React.useState(null); const [chain, setChain] = React.useState(_getChainFromHash); const [page, setPage] = React.useState(1); const [refreshKey, setRefreshKey] = React.useState(0); const colors = useChainColors(); React.useEffect(() => { const h = () => { const next = _getChainFromHash(); setChain((prev) => { if (prev !== next) setPage(1); return next; }); }; window.addEventListener("hashchange", h); return () => window.removeEventListener("hashchange", h); }, []); function handleChainChange(c) { _setChainInHash(c); setChain(c); setPage(1); } React.useEffect(() => { setData(null); const params = new URLSearchParams({ page, limit: PAGE_SIZE }); if (chain !== "all") params.set("chain", chain); fetch(`/api/receipts?${params}`) .then((r) => (r.ok ? r.json() : Promise.reject(r.status))) .then(setData) .catch((e) => setError(String(e))); }, [refreshKey, chain, page]); if (error) return

Errore: {error}

; if (!data) return (

Caricamento scontrini…

); const chains = ["all", ...data.chains]; const totalPages = Math.ceil(data.total / PAGE_SIZE); let lastYM = null; return (

Scontrini{" "} ({data.total})

{/* Chain filter pills */}
{chains.map((c) => { const color = c === "all" ? null : (colors && colors[c]) || _CHAIN_COLORS_FALLBACK; const active = chain === c; return ( ); })}
{data.receipts.map((r) => { const ym = _receiptYearMonth(r); const showSep = ym && ym !== lastYM; lastYM = ym; return ( {showSep && (
{_monthLabel(ym)}
)}
{ window.location.hash = `#/receipts/${r.id}`; }} >
{r.store_name}
{r.store_address && (
{r.store_address}
)}
{r.date} {r.time}
€{fmt(r.total_eur)}
{r.item_count} articoli
{r.score_avg != null && ( {r.score_avg} )} {r.chain && r.chain !== "unknown" && ( )} {r.source === "ocr" && ( PDF )} {r.source === "manual" && ( Manuale )}
); })} {data.receipts.length === 0 && (

Nessuno scontrino{chain !== "all" ? " per questa catena" : ""}.

)}
{totalPages > 1 && (
{page} / {totalPages}
)}
); }