// ── 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}
)}
);
}