);
}
// Human labels for Lekha's categories. `unclassified` ≠ `unrecognised`:
// unrecognised = Gemini decided it's not a finance doc; unclassified = Gemini
// didn't run (no key / failure) — surfaced so you can see the intelligence is off.
const CAT_LABEL = {
debit_note: "Debit note", credit_note: "Credit note", invoice: "Invoice",
sales_register: "Sales register", bank_statement: "Bank statement",
purchase_invoice: "Purchase invoice", unrecognised: "Unrecognised",
unclassified: "Couldn’t read — retry",
};
// Files are grouped by category; debit notes lead (they're the actionable ones).
const GROUP_ORDER = ["debit_note", "credit_note", "invoice", "purchase_invoice",
"sales_register", "bank_statement", "unrecognised", "unclassified",
"__reading", "__other"];
const GROUP_TITLE = {
debit_note: "Debit notes", credit_note: "Credit notes", invoice: "Invoices",
purchase_invoice: "Purchase invoices", sales_register: "Sales registers",
bank_statement: "Bank statements", unrecognised: "Unrecognised",
unclassified: "Couldn’t read (retry)", __reading: "Reading…",
__other: "Other files (view only)",
};
// shared rounded-pill style for the row status badges
const pill = (bg, fg, bd) => ({
display: "inline-flex", alignItems: "center", gap: 5, background: bg, color: fg,
border: "1px solid " + bd, borderRadius: 999, padding: "3px 11px",
fontSize: 11.5, fontWeight: 700, whiteSpace: "nowrap",
});
async function classifyToLekha(blob, f, force) {
const fd = new FormData();
fd.append("file", new File([blob], f.name, { type: "application/pdf" }));
fd.append("file_id", f.id); fd.append("file_name", f.name);
fd.append("modified_time", f.modifiedTime || "");
if (force) fd.append("force", "true");
const r = await fetch("/api/drive/classify", { method: "POST",
headers: { Authorization: "Bearer " + (localStorage.getItem("lekha_token") || "") }, body: fd });
if (!r.ok) throw new Error("classify failed");
return await r.json(); // {category, ingestible, reason, retryable}
}
// --- main: connect state + file list + classify + select & import ----------
function DriveDocs({ who }) {
const [clientId, setClientId] = React.useState("");
const [status, setStatus] = React.useState(null);
const [files, setFiles] = React.useState(null);
const [cats, setCats] = React.useState({}); // fileId -> {category, ingestible, reason}
const [reading, setReading] = React.useState({}); // fileId -> true while classifying
const [sel, setSel] = React.useState({});
const [browsing, setBrowsing] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const [msg, setMsg] = React.useState("");
const [err, setErr] = React.useState("");
const [openGroups, setOpenGroups] = React.useState({ debit_note: true, invoice: true }); // ingestible groups open by default
const [needsAuth, setNeedsAuth] = React.useState(false); // token gone after reload → one-click reconnect
const refreshStatus = () => api.get("/api/drive/status").then((s) => {
setStatus(s); setCats((c) => ({ ...(s.classifications || {}), ...c })); return s;
}).catch(() => {});
React.useEffect(() => {
api.get("/api/auth/config").then((c) => setClientId(c.google_client_id || "")).catch(() => {});
refreshStatus();
}, []);
// Lekha's intelligence: download each not-yet-classified PDF and classify it
// (sequential — gentle on the API). Cached server-side, so this is one-time.
const classifyOne = async (token, f, force) => {
setReading((r) => ({ ...r, [f.id]: true }));
try {
const blob = await driveDownload(token, f.id);
const v = await classifyToLekha(blob, f, force);
setCats((c) => ({ ...c, [f.id]: v }));
} catch (_) {
setCats((c) => ({ ...c, [f.id]: { category: "unclassified", ingestible: false, reason: "could not read", retryable: true } }));
} finally {
setReading((r) => { const n = { ...r }; delete n[f.id]; return n; });
}
};
// Read-once: classify ONLY files we haven't seen (new id) or that changed
// (new modifiedTime). Everything already classified is served from cache.
const needsRead = (f, known) => {
if (!isPdf(f)) return false;
const k = known[f.id];
return !k || (k.modified_time || "") !== (f.modifiedTime || "");
};
const loadFiles = async (st, interactive) => {
if (!st || !st.connected || !clientId) return;
setBusy(true); setErr("");
let tok;
try {
tok = await getDriveToken(clientId, interactive);
} catch (_) {
setBusy(false); setNeedsAuth(true); // token needs a user click
return;
}
setNeedsAuth(false);
try {
const list = await listFiles(tok, st.folder_id);
setFiles(list);
const known = st.classifications || {};
for (const f of list) { // only the new / changed ones
if (needsRead(f, known)) await classifyOne(tok, f);
}
} catch (e) { setErr(e.message || "Could not read folder"); }
finally { setBusy(false); }
};
const retryOne = async (f) => { // force re-read one file
if (!clientId) return;
try { await classifyOne(await getDriveToken(clientId, true), f, true); }
catch (e) { setErr(e.message || "Retry failed"); }
};
// On load, try a SILENT token; if it needs interaction we show a button.
React.useEffect(() => { if (status && status.connected && clientId) loadFiles(status, false); }, [status && status.folder_id, clientId]);
const pickFolder = async (folder) => {
setBrowsing(false); setBusy(true); setErr(""); setMsg("");
try {
const st = await api.req("PUT", "/api/drive/folder",
{ folder_id: folder.id, folder_name: folder.name });
setFiles(null); setSel({});
await refreshStatus();
} catch (e) { setErr(e.message || "Could not save folder"); }
finally { setBusy(false); }
};
const isIngestible = (f) => cats[f.id] && cats[f.id].ingestible; // debit notes + sales invoices
const importSelected = async () => {
const chosen = (files || []).filter((f) => sel[f.id] && isIngestible(f));
if (!chosen.length) return;
setBusy(true); setErr(""); setMsg("");
let ok = 0;
try {
const tok = await getDriveToken(clientId, true);
for (const f of chosen) {
const blob = await driveDownload(tok, f.id);
await ingestToLekha(blob, f.id, f.name);
ok++;
}
setMsg(`Imported ${ok} document${ok === 1 ? "" : "s"} — now with Lekha for review. If any issues or discrepancies, Lekha will reach out here.`);
setSel({});
await refreshStatus();
} catch (e) { setErr(`Imported ${ok}. ${e.message || "Import stopped."}`); }
finally { setBusy(false); }
};
// --- not connected: single center-aligned CTA ---
if (status && !status.connected) {
return (
<>
{browsing && setBrowsing(false)} />}
Connect your Drive
Pick the Google Drive folder that holds your documents. Lekha will list
them here so you can send your debit notes & sales invoices through for review.