/* ============================================================
ui.jsx — shared primitives & connective components
============================================================ */
/* ---- Tally posting badge — distinct, honest states ---------------------- */
// Driven by the accounting card's `acct` ({posted, simulated, state, master_id}).
// "simulated" = the test connector only; real-but-unconfirmed posts show their
// actual state (Posting…/Failed) — never a misleading "Simulated".
function PostBadge({ acct }) {
if (!acct || !acct.posted) return Preview;
if (acct.simulated) return Simulated (test);
const st = acct.state, id = acct.master_id;
if (st === "ALREADY_IN_TALLY") return Already in Tally{id ? " · ID " + id : ""};
if (st === "VERIFIED") return Posted · ID {id || "—"};
if (st === "FLAGGED") return Failed;
return Posting…; // SENT/IMPORTED/POSTED/COMPOSED
}
/* ---- icons (inline, no deps) -------------------------------------------- */
const Ic = {
chevron: (p) => ,
folder: (p) => ,
check: (p) => ,
x: (p) => ,
refresh: (p) => ,
search: (p) => ,
bolt: (p) => ,
arrow: (p) => ,
shield: (p) => ,
mail: (p) => ,
doc: (p) => ,
building: (p) => ,
clock: (p) => ,
tally: (p) => ,
layers: (p) => ,
inbox: (p) => ,
download: (p) => ,
};
/* ---- customer-facing status vocabulary ----------------------------------
Brand Ops & Finance never see Lekha's internal gate or the Tally queue —
statuses read as a fully automated flow. Nakad/Lekha keep the real STATUS. */
const CUSTOMER_STATUS = {
AVAILABLE: { label: "Available", tone: "gray" },
PENDING_ADMIN: { label: "Processing", tone: "gray" },
FETCHED: { label: "Processing", tone: "gray" },
CREATED: { label: "Processing", tone: "blue" },
PENDING_LEKHA_REVIEW: { label: "Processing", tone: "blue" },
PENDING_REVIEW: { label: "Awaiting Approval", tone: "amber" },
APPROVED: { label: "Approved", tone: "teal" },
POSTING: { label: "Approved", tone: "teal" },
POSTED: { label: "Posted to Tally", tone: "teal" },
CONFIRMED: { label: "Posted to Tally", tone: "green" },
REJECTED: { label: "Rejected", tone: "red" },
};
/* ---- StatusBadge (customer mode hides internal stages) ------------------ */
/* Ops-view status: the whole pipeline collapsed to 3 states (Brand Ops + Nakad
console). Everything between generate and the final Tally round-trip reads
"Processing" — the detailed statuses still drive the real pipeline underneath. */
function opsBadge(status) {
if (status === "verified_in_tally" || status === "closed")
return { label: "Verified in Tally", tone: "green" };
if (status === "received")
return { label: "Generate credit note", tone: "gray" };
return { label: "Processing", tone: "blue" };
}
function StatusBadge({ status, customer, ops }) {
const s = ops ? opsBadge(status)
: (customer && CUSTOMER_STATUS[status]) || STATUS[status];
if (!s) return null;
const spin = s.spin && (ops || !customer);
return (
{s.label}
);
}
/* ---- time-range filter (7d / 30d / all) --------------------------------- */
function TimeFilter({ value, onChange }) {
return (
);
}
function withinRange(iso, range) {
if (range === "all" || !iso) return true;
const days = range === "7d" ? 7 : 30;
return (Date.now() - new Date(iso).getTime()) <= days * 86400000;
}
/* ---- Pipeline tracker ----------------------------------------------------
Visual node grouping. The canonical 7-step model (STEPS / STATUS.step) is
unchanged; here steps 2 (CN Created) + 3 (Portal Entry) are merged into one
wide "Lekha AI is creating credit note …" box that carries the brand+amount.
`lo`/`hi` are the inclusive canonical-step range a node represents. */
const PIPE_NODES = [
{ label: "Fetch", lo: 1, hi: 1 },
{ wide: true, lo: 2, hi: 3 }, // CN Created + Portal Entry
{ label: "Notify Finance", lo: 4, hi: 4 },
{ label: "Finance Review", lo: 5, hi: 5, failAt: 5 },
{ label: "Post to Tally", lo: 6, hi: 6 },
{ label: "Confirm", lo: 7, hi: 7 },
];
function Pipeline({ status, compact, doc, biz }) {
const s = STATUS[status] || {};
const cur = s.step || 1;
const failed = status === "cancelled";
const busy = status === "posting";
return (
{PIPE_NODES.map((node, i) => {
const isFail = failed && node.failAt === 5;
const done = failed
? node.hi < 5 // everything before Finance Review stays done
: (cur > node.hi || (status === "verified_in_tally" && cur >= node.lo));
const active = !failed && !done && cur >= node.lo && cur <= node.hi;
let cls = "node" + (node.wide ? " wide" : "");
if (isFail) cls += " fail";
else if (done) cls += " done";
else if (active) cls += " active";
// wide-box label: kept SHORT (brand + amount are shown on the voucher itself) so
// nodes stay equal width and the connecting bar never breaks.
const wideLbl = done ? "Credit note created" : "Creating credit note…";
const label = node.wide ? wideLbl : node.label;
const glyph = isFail ? : done ? : node.wide ? "✦" : node.lo;
return (
{glyph}
{!compact && {label}}
);
})}
);
}
/* ---- line-item table (SKU mapping) -------------------------------------- */
function LineItems({ doc }) {
// `tallyItem` = the CONFIRMED Tally stock-item name (the name that posts);
// the platform's sku/description stays visible only as the document source.
return (
{/* right column leads straight with the ledger entry — the CN number, GSTIN,
debit-note ref and original bill now live in the header/party block, and
taxable/tax are already in the items table on the left (no duplication). */}
{legs.length > 0 && (
);
}
/* ---- business avatar ----------------------------------------------------- */
function BizAvatar({ biz, size = 30 }) {
return (
{biz.short}
);
}
/* ---- product header + tabs ---------------------------------------------- */
function AppHeader({ sub, who, right, store }) {
// the account menu (was in the removed top proto-bar) now lives here on the right
const acct = store && store.user;
return (