Verify the scanned details against the source PDF, edit if wrong, then approve to let Lekha generate the credit note.
Only debit notes from companies with the verify gate on wait here. Open one to check its scanned fields against the source document, correct anything wrong, then approve.
{(() => {
// group the queue by company
const groups = {};
for (const d of docs) (groups[d.biz] ||= []).push(d);
const order = Object.keys(groups).sort((a, b) =>
(bizById(a).name || "").localeCompare(bizById(b).name || ""));
return order.map((bid) => {
const biz = bizById(bid);
return (
{biz.name}{groups[bid].length}
{groups[bid].map((d) => (
onOpen(d.id)}>
{d.dnNo}
Against {d.invoice} · {d.lines.length} line items · fetched {d.fetched}
Confidence
{fmtINR(d.amount)}
))}
);
});
})()}
>
);
}
/* ---- G1: Lekha review of composed drafts (flow inversion 2026-07-05) ------
The draft was composed on ACTUALS (real ledgers from the company's own
chart) before anything became brand-visible. Open shows the same accounting
impact the finance screen renders; approve releases it to brand finance. */
function DraftRow({ d, biz, approve, reject, onOpen }) {
const [rejecting, setRejecting] = React.useState(false);
const [reason, setReason] = React.useState("");
const [working, setWorking] = React.useState(false);
return (
onOpen(d.id)}>
{d.cnNo}from {d.dnNo}
{biz.name} · against {d.invoice} · {d.lines.length} line items
{fmtINR(d.amount)}
{rejecting && (
Reason for rejection
)}
);
}
function AdminDrafts({ docs, bizById, approve, reject, onOpen }) {
if (!docs.length) return } />;
return (
<>
Draft Review
Each draft was composed against the company's real Tally ledgers. Open to inspect the exact double entry, then approve to release it to brand finance.
{docs.map((d) => (
))}
>
);
}
/* ---- admin verify gate: SPLIT SCREEN — source PDF | editable OCR fields -- */
// PDF source: a faithful facsimile of the fetched debit note (placeholder
// sample CHC98139). When the real Step-1 fetch + per-doc PDF storage lands,
// point GATE_PDF at the document's own PDF URL.
const GATE_PDF = "/static/sample-debit-note.html";
// human-readable labels for the parser's consistency flags
const FLAG_LABEL = {
"missing:note_no": "Debit note number missing",
"missing:date": "Date missing",
"missing:gstin": "GSTIN missing",
"missing:invoice": "Invoice reference missing",
"missing:total": "Total missing",
"missing:lines": "No line items found",
"subtotal_mismatch": "Line items don't sum to the printed subtotal",
"total_mismatch": "Subtotal + tax ≠ the printed total",
};
function flagLabel(f) {
if (FLAG_LABEL[f]) return FLAG_LABEL[f];
const m = f.match(/^line:(\d+):(.+)$/);
if (m) {
const k = { missing_sku: "SKU missing", amount_mismatch: "Amount ≠ qty × rate" }[m[2]] || m[2];
return `Line ${m[1]}: ${k}`;
}
return f;
}
function AdminGateDrawer({ doc, biz, store, onClose }) {
const { adminEdit, adminApprove, adminReject } = store;
const [invoice, setInvoice] = React.useState(doc.invoice);
const [lines, setLines] = React.useState(() => doc.lines.map((l) => ({ ...l, item: l.tallyItem || "" })));
const [detail, setDetail] = React.useState(null);
const [skuOpts, setSkuOpts] = React.useState(null);
React.useEffect(() => {
api.get(`/api/credit-notes/${doc.id}`).then(setDetail).catch(() => {});
api.get(`/api/credit-notes/${doc.id}/sku-options`).then(setSkuOpts).catch(() => {});
}, [doc.id, doc.status]);
// Pre-select the Tally item ONLY when the match is safe (already confirmed, or a
// unique deterministic hit — incl. a unique PO MRP). Ambiguous / verbose lines are
// left blank so the operator must choose from the candidate shortlist — never a guess.
React.useEffect(() => {
if (!skuOpts) return;
setLines((ls) => ls.map((l) => {
if (l.item) return l;
const key = (l.desc || l.sku || "").trim();
const s = (skuOpts.lines && skuOpts.lines[key]) || {};
const safe = s.confidence === "confirmed" || s.confidence === "high";
return { ...l, item: safe && s.stock_item ? s.stock_item : "" };
}));
}, [skuOpts]);
React.useEffect(() => {
const h = (e) => e.key === "Escape" && onClose();
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
const setLine = (i, key, value) => {
const strKey = key === "sku" || key === "desc" || key === "unit" || key === "item";
setLines((ls) => ls.map((l, j) => (j === i ? { ...l, [key]: strKey ? value : Number(value) } : l)));
};
// Totals are document-level on these PDFs (no per-line GST) — show what was
// printed/parsed, not a per-line recompute.
const lineAmt = (l) => (l.amount != null ? l.amount : l.qty * l.rate);
const save = () => adminEdit(doc.id, {
fields: { invoice },
lines: lines.map((l) => ({ sku: l.sku, desc: l.desc, unit: l.unit, qty: l.qty, rate: l.rate, gst: l.gst, amount: lineAmt(l), item: l.item })),
});
return (
{doc.dnNo}
{biz.name} · verify the scanned fields against the source document
{/* LEFT — the fetched debit-note PDF, to verify against */}
{/* RIGHT — the OCR-extracted fields, editable */}
These fields were {doc.source === "ocr" ? "OCR-read from a scanned PDF" : "extracted from the PDF"} on the left. Check them, correct anything wrong, then approve to let Lekha generate the credit note.
{doc.flags && doc.flags.length > 0 && (
{doc.flags.length} thing{doc.flags.length > 1 ? "s" : ""} to verify against the source
Open a section to manage it. Verify gate is per company; notifications are master controls.
} iconBg="var(--amber-bg)" iconFg="var(--amber-fg)"
title="Verify gate — per company"
sub={`On for ${gateOn} of ${businesses.length} · controls which brands' debit notes you review before a credit note is generated`}
defaultOpen
>
Turn the gate on for a new company so each fetched debit note waits in the Approval Queue for you to verify before Lekha generates the credit note. Switch it off once their debit notes are reliably clean — those flow straight through.{pending > 0 ? ` ${pending} waiting at the gate now.` : ""}
{businesses.map((b) => (
{b.name}
{b.manual_approval ? "Gate on — debit notes wait for your review" : "Gate off — debit notes flow straight to credit-note creation"}
{b.manual_approval ? "On" : "Off"}
))}
} iconBg="var(--blue-bg)" iconFg="var(--blue-fg)"
title="Finance approval — per company"
sub={`Required for ${finReq} of ${businesses.length} · controls whether a brand's finance team must approve each credit note before it posts to Tally`}
>
Keep this on so each credit note waits for the brand's finance team to approve (by email or portal) before Lekha posts it to Tally. Switch it off to auto-approve credit notes the moment they're created and queue them straight for posting — the approval email becomes informational only. Posting itself is unchanged: an entry still only writes once the Lekha app is online and Tally is open.
{businesses.map((b) => (
{b.name}
{b.finance_approval_required ? "Approval required — credit notes wait for finance" : "Approval off — credit notes auto-approve and queue for Tally"}
} iconBg="var(--blue-bg)" iconFg="var(--blue-fg)"
title="Notifications — master control"
sub="Decide which emails each persona is allowed to receive; they opt in or out within what you allow"
>
} iconBg="var(--teal-bg)" iconFg="var(--teal-fg)"
title="Recipient email IDs — per company"
sub="The actual inboxes Lekha sends to for each business — finance (approval/review) and brand ops (status). Add as many as you like."
>
>
);
}
/* ---- recipient email control (Lekha admin) ----------------------------- */
const RCHANNELS = [
{ key: "finance", label: "Finance — approval & review emails" },
{ key: "brand_ops", label: "Brand ops — generation & status updates" },
];
function AdminRecipients({ businesses, store }) {
return (
<>
Add the email IDs Lekha communicates with for each company. Type an address and press Enter (or Add) to register it; add several per channel. These are the recipients used for live sends.
{businesses.map((b) => )}
{!businesses.length &&
No businesses yet.
}
>
);
}
function RecipientCard({ biz, store }) {
const [data, setData] = React.useState(null);
React.useEffect(() => {
let live = true;
store.listRecipients(biz.id).then((d) => live && setData(d)).catch(() => live && setData({ finance: [], brand_ops: [] }));
return () => { live = false; };
}, [biz.id]);
return (
);
}
/* ---- accounting exceptions (engine flagged — human-in-loop) ------------- */
/* ---- Feedback Loops (FB1–FB5) — live loop health from the tables ---------- */
function AdminFeedbackLoops({ store, onGoExceptions }) {
const [data, setData] = React.useState(null);
const [err, setErr] = React.useState(false);
const load = React.useCallback(() => {
setErr(false);
store.loopsSummary().then(setData).catch(() => setErr(true));
}, [store]);
React.useEffect(() => { load(); }, [load]);
if (err) return } />;
if (!data) return
Loading…
;
const t = data.totals || {};
const when = (iso) => { if (!iso) return "—"; try { return new Date(iso).toLocaleString(); } catch (e) { return iso; } };
const Metric = ({ label, value, strong }) => (
{value}
{label}
);
return (
<>
Feedback Loops
The 5 feedback gates (FB1–FB5) that feed the Lekha AI agent — aggregated live from the tables (exceptions, learned rules, doc status). This is the source of truth, not a snapshot.
{/* What the agent consumes as prompt context on a failed case. Grounded in
ACO agent guidance: the agent's memory must be curated + human-readable
(Anthropic, Writing effective tools) and learning is human-confirmed
before load (OpenAI, agent = model + instructions + tools + guardrails). */}
Agent memory — what Lekha AI reads on a failed case
These human-confirmed rules are injected verbatim into agent.cn-write's prompt (as “Learned rules for this company”) whenever it is handed feedback for a failed case. Nothing here was auto-learned — each was confirmed by a human before the agent could load it. This is the functional input the agent reasons from.
)}
{/* The resolution ledger — every closed case and the input that closed it. */}
Resolution ledger — how each exception was resolved
Every resolved case: what failed, and the exact input that closed it — a deterministic option, a human's free-text note, or the agent's tool trace. This is the history the learned rules above are distilled from.
{!(data.resolutions || []).length
? } />
: (
Gate
Case
Failure
Resolved by
Input that resolved it
Rule?
When
{data.resolutions.map((r) => (
{r.gate}
{r.cnNo || r.dnNo || ("#" + r.id)}
{r.party &&
{r.party}
}
{r.why || r.kind}
{r.by}
{r.note &&
{r.note}
}
{!r.note && r.option &&
option: {r.option}
}
{r.tools && r.tools.length > 0 &&
tools: {r.tools.join(" → ")}
}
{r.becameRule ? saved : —}
{when(r.at)}
))}
)}
>
);
}
function AdminExceptions({ store }) {
const [items, setItems] = React.useState(null);
const [filter, setFilter] = React.useState("all"); // all | OPEN | resolved
const load = React.useCallback(() => {
store.listExceptions(filter)
.then((d) => setItems(d.items)).catch(() => setItems([]));
}, [filter]);
React.useEffect(() => { load(); }, [load]);
if (!items) return
Open items need a human; resolved ones STAY here as an audit record of who fixed what. No entry is ever faked.
{FILTERS.map(([f, label]) => (
))}
Each item is a document the engine could not compose or post cleanly (no reference invoice, unbalanced entry, missing ledger, unmapped SKU, Tally offline/rejection). Open ones hold in Processing — give feedback and the agent recomposes. Resolved ones remain for audit, tagged with who resolved them (finance / Lekha).
{!items.length && } />}
{sorted.map((e) => )}
>
);
}
function ExceptionCard({ exc, store, onResolved }) {
const [note, setNote] = React.useState("");
const [feedback, setFeedback] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [busyAI, setBusyAI] = React.useState(false);
const [aiResult, setAiResult] = React.useState(null);
const open = (exc.status || "").toUpperCase() === "OPEN";
const resolve = async (option) => {
setBusy(true);
const r = await store.resolveException(exc.id, option, note);
setBusy(false);
if (r) onResolved();
};
const giveFeedback = async () => {
if (!feedback.trim()) return;
setBusyAI(true);
const r = await store.sendAgentFeedback(exc.id, feedback);
setBusyAI(false);
setAiResult(r);
setFeedback("");
// reload whether it fully resolved OR advanced to a new blocker, so the card
// reflects the CURRENT state (a partial fix moves the exception to what's wrong now)
if (r && (r.resolved || r.advanced_to)) onResolved();
};
const optLabel = (o) => (typeof o === "string" ? o : (o.label || o.id || "Resolve"));
const optValue = (o) => (typeof o === "string" ? o : (o.id || o.label || "resolve"));
const triedTools = aiResult && aiResult.trace ? aiResult.trace.map((t) => t.tool).filter(Boolean).join(" → ") : "";
const c = exc.case || {};
const money = (v) => (v == null ? null : (store.fmtINR ? store.fmtINR(v) : ("₹" + v)));
// label → value; `ph` renders when the value is empty but its absence is signal
// (missing invoice / nature are themselves the reason a case failed).
const Fact = ({ label, value, ph }) => (
(value != null && value !== "") || ph ? (
{label}:{" "}
{(value != null && value !== "") ? value : ph}
) : null
);
const hasCase = exc.case && Object.keys(exc.case).length > 0;
return (
Give Lekha AI free-text guidance above, or use a deterministic action here (post as New Ref, supply the invoice, hold). Resolving re-runs the compose.
)}
);
}
/* ---- Tally (desktop app)s (Step 6) — read-only monitor ---------------------- */
function relTime(iso) {
if (!iso) return "never";
const s = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000));
if (s < 60) return s + "s ago";
if (s < 3600) return Math.round(s / 60) + "m ago";
return Math.round(s / 3600) + "h ago";
}
function AdminTally({ store }) {
const { bridges } = store;
const totalQueued = bridges.reduce((n, b) => n + (b.queued || 0), 0);
return (
<>
Tally Status
Live connectivity of each finance team's Lekha desktop app — detected automatically.
Connectivity is detected from each desktop app's heartbeat — there's no manual switch here. If the app is offline, that business's approved credit notes wait as Approved (queued); when it comes back online they're written to Tally and move to Confirmed.{totalQueued > 0 ? ` ${totalQueued} credit note${totalQueued > 1 ? "s" : ""} queued right now.` : ""}
The GUID-keyed replica of each client's Tally — masters & vouchers, kept fresh by the background sync. Review the auto-derived ledger binding and confirm it here.
Reading a client's Tally is automatic when their Lekha app is online — there's no client-facing button. This console shows what we've mirrored and lets ops confirm the role→ledger map (by stable GUID) before any credit note is posted.
{/* REMASTER: sync state is per object_type (one company per tenant),
not per company. Render each object_type's freshness row. */}
{(!state.objects || !state.objects.length) &&
{sug.ledger_count || 0} ledgers · {(sug.party || {}).count || 0} debtor parties. Roles derive from each ledger's group + GST metadata; the party resolves per credit note by GSTIN. Voucher type: {cb.voucher_type_name || "—"}.
Missing required roles: {(sug.missing_required || []).join(", ")} — posting blocks until these are bound.
}
);
}
// The engine mapping IS the binding view now — the old name-keyword "suggest &
// confirm" screen (deprecated Layer A) is gone; the engine derives the binding
// from the live replica, so there's nothing to hand-confirm.
function ReplicaBinding({ bid, onConfirmed }) {
return ;
}
/* ---- master notification matrix (Lekha admin) --------------------------- */
function AdminNotifications() {
const [cells, setCells] = React.useState(null);
React.useEffect(() => { api.get("/api/notifications/master").then((d) => setCells(d.cells)); }, []);
const toggle = (c, allowed) =>
api.put("/api/notifications/master", { role: c.role, notif_type: c.notif_type, allowed }).then((d) => setCells(d.cells));
if (!cells) return
Loading…
;
// group cells by email type for a tidy matrix
const byType = {};
cells.forEach((c) => { (byType[c.notif_type] = byType[c.notif_type] || { label: c.type_label, rows: [] }).rows.push(c); });
return (
<>
Turning a cell off here disables that email for everyone in that persona and locks their own toggle. An email sends only when it's allowed here and the user has opted in.
{Object.keys(byType).map((t) => (
{byType[t].label}
{byType[t].rows.map((c) => (
{c.role_label}
{c.allowed ? "Allowed to receive this email" : "Blocked — persona cannot receive or enable this"}