/* ============================================================ finance.jsx — Finance team user (primary persona) Focused, low-cognitive-load review — approve / reject. ============================================================ */ // Tax type derived from the parsed tax label (no longer hardcoded intra-state). function gstType(doc) { const tl = doc.taxLabel || ""; if (/CGST/i.test(tl)) return "Intra-state (CGST + SGST)" + (tl ? ` · ${tl}` : ""); if (/IGST/i.test(tl)) return "Inter-state (IGST)" + (tl ? ` · ${tl}` : ""); return tl || "—"; } /* ---- at-a-glance pipeline stats for the Lekha tab ---- */ function LekhaStats({ docs }) { // Drive-ingested docs only (fetched === "From Drive") — not seeded/API demo docs. const drive = docs.filter((d) => d.fetched === "From Drive"); const total = drive.length; // "written" = the credit note made it into Tally const written = drive.filter((d) => ["verified_in_tally", "posted"].includes(d.status)).length; // "in progress" = in the pipeline, not yet written and not rejected const inProgress = drive.filter((d) => ["draft", "pending_approval", "approved", "posting"].includes(d.status)).length; const tiles = [ { label: "Total documents", val: total, foot: "Ingested from Drive", ic: , bg: "var(--blue-bg)", fg: "var(--blue-fg)" }, { label: "Written to Tally", val: written, foot: "Posted as credit notes", ic: , bg: "var(--green-bg)", fg: "var(--green-fg)" }, { label: "In progress", val: inProgress, foot: "Working through the pipeline", ic: , bg: "var(--amber-bg)", fg: "var(--amber-fg)" }, ]; return (
{tiles.map((t, i) => (
{t.label} {t.ic}
{t.val}
{t.foot}
))}
); } function FinanceApp({ store }) { const { docs, bizById, financeApprove, financeReject } = store; const [tab, setTab] = React.useState("review"); // lekha-nakad: no Drive entry; review is the start const [openId, setOpenId] = React.useState(null); // top-bar AccountMenu jumps to a tab via a global event (it doesn't own tab state) React.useEffect(() => { const nav = (e) => { setTab(e.detail); setOpenId(null); }; window.addEventListener("lekha-nav", nav); return () => window.removeEventListener("lekha-nav", nav); }, []); const [range, setRange] = React.useState("30d"); const inRange = (d) => withinRange(d.createdAt, range); const reviewQ = docs.filter((d) => ["pending_approval"].includes(d.status) && inRange(d)); const reviewed = docs.filter((d) => ["approved", "irn_pending", "irn_generated", "irn_failed", "posting", "posted", "post_failed", "verified_in_tally", "cancelled"].includes(d.status) && inRange(d)); const open = openId && docs.find((d) => d.id === openId); const who = store.user || {}; const myBridge = (store.bridges || []).find((b) => b.business_id === (who.business_id)) || {}; const [excCount, setExcCount] = React.useState(0); React.useEffect(() => { store.financeExceptions && store.financeExceptions().then((d) => setExcCount(d.total)).catch(() => {}); }, [docs.length]); const availableDN = docs.filter((d) => d.status === "received").length; // uploaded, ready to generate const tabs = [ { id: "debitnotes", label: "Debit notes", count: availableDN || null }, { id: "review", label: "To review", count: reviewQ.length }, { id: "exceptions", label: "Exceptions", count: excCount || null }, { id: "done", label: "Reviewed", count: reviewed.length }, { id: "drive", label: "Drive" }, { id: "team", label: "Team" }, { id: "docs", label: "Docs" }, { id: "notif", label: "Settings" }, ]; return (
{ setTab(t); setOpenId(null); }} />
{open ? ( setOpenId(null)} /> ) : (
{tab === "debitnotes" && } {tab === "review" && } {tab === "exceptions" && } {tab === "done" && } {tab === "drive" && } {tab === "team" && } {tab === "docs" && } {tab === "notif" && }
)}
); } /* 3-hour TAT after 'Generate Credit Note': the ETA the credit note will be ready, anchored to when it was generated (cnCreatedAt). Formatted like "3:46pm". */ function etaBy(cnCreatedAt) { if (!cnCreatedAt) return null; const t = new Date(cnCreatedAt); if (isNaN(t.getTime())) return null; t.setHours(t.getHours() + 3); let h = t.getHours(); const m = t.getMinutes(); const ap = h >= 12 ? "pm" : "am"; h = h % 12 || 12; return `${h}:${String(m).padStart(2, "0")}${ap}`; } /* Finance can also run Step 1 — upload a debit-note PDF and generate its credit note (same pipeline as Brand Ops: OCR → Lekha admin → finance review → Tally). Mirrors the Brand Ops "Debit notes" workbench, reusing the shared store methods. */ function FinanceDebitNotes({ store, who }) { const { docs, bizById, uploadDebitNote, generateCreditNote } = store; const [busyId, setBusyId] = React.useState(null); const [uploading, setUploading] = React.useState(false); const [err, setErr] = React.useState(""); const [range, setRange] = React.useState("all"); const [filter, setFilter] = React.useState("all"); const fileRef = React.useRef(null); // Brand Ops resolves the brand as `businesses[0] || bizById(...)` — businesses[0] is the // scoped biz that carries the finance `email`. bizById NEVER returns falsy (it falls back // to {email:""}), so it must be the SECOND operand, else the empty-email fallback wins and // greys the upload button even though the business HAS an approver. (Verified: FMC 29 email // = accountsecom@fixmycurls.in, returned by /api/businesses.) const brand = (store.businesses || [])[0] || bizById(who.business_id || ""); const noApprover = brand && !brand.email; let rows = docs.filter((d) => withinRange(d.createdAt, range)); if (filter === "available") rows = rows.filter((d) => d.status === "received"); if (filter === "done") rows = rows.filter((d) => d.status !== "received"); rows = rows.slice().sort((a, b) => (a.status === "received" ? 0 : 1) - (b.status === "received" ? 0 : 1) || (b.createdAt || "").localeCompare(a.createdAt || "")); const availableCount = docs.filter((d) => d.status === "received").length; const doUpload = async (e) => { const file = e.target.files && e.target.files[0]; if (e.target) e.target.value = ""; if (!file) return; setErr(""); setUploading(true); const res = await uploadDebitNote(file); setUploading(false); if (res && !res.ok) setErr(/already been uploaded/i.test(res.error || "") ? "This exact PDF has already been uploaded." : res.error === "no_finance_approver" ? "No authorised finance email is configured for your business yet." : "Couldn't upload that PDF — please try again."); }; const generate = async (id) => { setErr(""); setBusyId(id); const res = await generateCreditNote(id); setBusyId(null); if (res && !res.ok) setErr("Couldn't generate the credit note just now — please try again."); }; return ( <>
Debit notes
Upload a debit-note PDF, then generate its credit note — it runs the same pipeline: OCR → Lekha review → your approval → Tally.
{err ? (
{err}
) : (
{availableCount} debit note{availableCount === 1 ? "" : "s"} ready to generate. Generating moves a note into the pipeline until it returns to your review queue.
)}
{rows.map((d) => { const available = d.status === "received"; return ( {/* 3 customer states only: Generate → Processing → Verified in Tally */} ); })} {!rows.length && }
Debit noteInvoiceDateAmountStatus / Action
{d.dnNo} {d.invoice || "—"} {d.docDate || d.fetched} {fmtINR(d.amount)} {available ? ( ) : d.status === "verified_in_tally" ? ( Verified in Tally ) : (
Processing {etaBy(d.cnCreatedAt || d.createdAt) && ( Credit note will be with you by {etaBy(d.cnCreatedAt || d.createdAt)} )}
)}
No debit notes yet — upload one to begin.
); } function FinanceExceptions({ store }) { const [items, setItems] = React.useState(null); const load = React.useCallback(() => { store.financeExceptions().then((d) => setItems(d.items)).catch(() => setItems([])); }, []); React.useEffect(() => { load(); }, [load]); if (!items) return
Loading…
; return ( <>
Your exceptions
Credit notes Lekha AI couldn't fix from your first feedback. Give it more detail — you get 2 tries, then it goes to the Lekha team.
These are only yours — the ones you rejected (or Tally rejected after you approved) that need one more round of your guidance. A successful rework comes back to To review; after 2 tries it moves to the Lekha team, who send it back for your approval.
{!items.length && } />}
{items.map((e) => )}
); } function FinanceExcCard({ exc, store, onDone }) { const [fb, setFb] = React.useState(""); const [busy, setBusy] = React.useState(false); const triesLeft = Math.max(0, 2 - (exc.financeTry || 0)); const c = exc.case || {}; return (
{exc.kind === "TALLY_REJECTED" ? "Tally rejected" : exc.kind === "FLAG_VERIFY_MISMATCH" ? "Not found in Tally" : "Rework needed"} {c.cnNo || exc.lekhaId || ""}{c.party ? " · " + c.party : ""} {triesLeft} of 2 {triesLeft === 1 ? "try" : "tries"} left
{exc.reason}
{exc.explanation &&
Lekha AI: {exc.explanation}
}
Give Lekha AI more detail