/* ============================================================= CoreIQ Office — Stock screens Overview · Manage · New stock line ============================================================= */ const { useState: stkUseState, useMemo: stkUseMemo } = React; // ----------------------------------------------------------------- // STOCK OVERVIEW // ----------------------------------------------------------------- const stkMoney = (cents) => "$" + (Number(cents || 0) / 100).toLocaleString("en-AU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const stkNum = (n) => Number(n || 0).toLocaleString("en-AU"); // ----------------------------------------------------------------- // STOCK OVERVIEW — LIVE · this pharmacy // The org's own inventory, from Aurora: catalogue counts (/overview) + the // stocked lines, real category facets and totals (/stock). Categories are the // pharmacy's real 79 (Z Departments/SubDepartments); products Z never // categorised fall under "Uncategorised" — honest, not invented. // ----------------------------------------------------------------- function StockOverview() { const { scope, stores, setScreen, setScreenState } = window.useOffice(); const openProduct = (pid) => { setScreenState((st) => ({ ...st, activeProductId: pid, productFrom: "stockOverview" })); setScreen("productDetail"); }; const [ov, setOv] = stkUseState(null); const [stock, setStock] = stkUseState(null); const [loading, setLoading] = stkUseState(true); const [err, setErr] = stkUseState(null); const [q, setQ] = stkUseState(""); const [cat, setCat] = stkUseState("all"); // "all" | "__uncat__" | categoryId const [sort, setSort] = stkUseState({ key: "valueCents", dir: "desc" }); React.useEffect(() => { let alive = true; setLoading(true); setErr(null); const siteId = scope === "all" ? undefined : scope; Promise.all([window.OfficeAPI.getOverview(siteId), window.OfficeAPI.getStockView(siteId)]) .then(([o, s]) => { if (alive) { setOv(o); setStock(s); setLoading(false); } }) .catch((e) => { if (alive) { setErr(String((e && e.message) || e)); setLoading(false); } }); return () => { alive = false; }; }, [scope]); const items = (stock && stock.items) || []; const cats = (stock && stock.categories) || []; const totals = (stock && stock.totals) || { stockedSkus: 0, unitsOnShelf: 0, stockValueCents: 0 }; const realCats = cats.filter((c) => c.id).sort((a, b) => b.stockedCount - a.stockedCount); const uncat = cats.find((c) => !c.id); const filtered = stkUseMemo(() => { let rows = items; if (cat === "__uncat__") rows = rows.filter((r) => !r.categoryId); else if (cat !== "all") rows = rows.filter((r) => r.categoryId === cat); if (q.trim()) { const needle = q.trim().toLowerCase(); rows = rows.filter((r) => (r.name + " " + r.sku + " " + (r.categoryName || "")).toLowerCase().includes(needle)); } const dir = sort.dir === "asc" ? 1 : -1; return [...rows].sort((a, b) => { const ka = a[sort.key], kb = b[sort.key]; if (typeof ka === "number") return (ka - kb) * dir; return String(ka || "").localeCompare(String(kb || "")) * dir; }); }, [items, cat, q, sort]); function sortBy(key) { setSort((s) => (s.key === key ? { key, dir: s.dir === "asc" ? "desc" : "asc" } : { key, dir: "asc" })); } const COLS = [ { k: "name", label: "Product", num: false }, { k: "sku", label: "SKU", num: false }, { k: "categoryName", label: "Category", num: false }, { k: "schedule", label: "Sched", num: false }, { k: "quantityOnHand", label: "On hand", num: true }, { k: "costPriceCents", label: "Cost", num: true }, { k: "sellPriceCents", label: "Sell", num: true }, { k: "valueCents", label: "Value", num: true }, ]; const scopeName = scope === "all" ? "all stores" : (stores.find((s) => s.id === scope)?.name || "—"); return (
Inventory · {scopeName} · live from Aurora

Stock overview

{err && (
Couldn't load stock from the API: {err}
)} {/* Catalogue (from /overview) + stock (from /stock) KPIs, together */}
0" }}/>
{/* Real category rail */}
CATEGORIES {realCats.length} real
setCat("all")} strong/> {realCats.map((c) => setCat(c.id)}/>)} {uncat && setCat("__uncat__")} muted/>}
{/* Real stocked-lines table */}
setQ(e.target.value)} style={{height:34,fontSize:'var(--fs-13)'}}/>
{loading ? "loading…" : stkNum(filtered.length) + " of " + stkNum(items.length) + " lines"}
{COLS.map((c) => (
sortBy(c.k)} style={c.num ? {justifyContent:'flex-end'} : null}> {c.label} {sort.key === c.k && }
))} {filtered.slice(0, 500).map((r) => (
openProduct(r.productId)}>
{r.name}
{r.sku}
{r.categoryName || }
{r.schedule ? : ""}
{r.quantityOnHand}
{r.costPriceCents == null ? "—" : stkMoney(r.costPriceCents)}
{stkMoney(r.sellPriceCents)}
{stkMoney(r.valueCents)}
))}
{filtered.length > 500 &&
Showing the top 500 of {stkNum(filtered.length)} by value — refine with search or a category.
} {!loading && filtered.length === 0 &&
No stocked lines match.
}
); } function CatRow({ active, name, count, onClick, strong, muted }) { return (
{name} {Number(count || 0).toLocaleString("en-AU")}
); } // Mutates OD.CATEGORIES + rebuilds lookup maps so the new node is // visible everywhere (rail, tree view, new-stock-line dropdowns). const LEVEL_LABEL = { dept: "Department", cat: "Category", sub: "Subcategory" }; function slugify(name) { return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "").slice(0, 32); } function uniqId(prefix, name) { let base = slugify(name) || prefix; let id = base; let n = 2; const taken = new Set([ ...OD.CATEGORIES.map(d => d.id), ...Object.keys(OD.CAT_BY_ID), ...Object.keys(OD.SUB_BY_ID), ]); while (taken.has(id)) { id = base + "-" + n; n++; } return id; } function addCategoryNode({ level, parentId, name, desc }) { name = (name || "").trim(); if (!name) return null; if (level === "dept") { const id = uniqId("dept", name); const node = { id, name, desc: (desc || "").trim() || "Custom department", cats: [] }; OD.CATEGORIES.push(node); OD.DEPT_BY_ID[id] = node; return node; } if (level === "cat") { const dept = OD.DEPT_BY_ID[parentId]; if (!dept) return null; const id = uniqId("cat", name); const node = { id, name, subs: [] }; dept.cats.push(node); OD.CAT_BY_ID[id] = { ...node, deptId: dept.id }; return node; } if (level === "sub") { const cat = OD.CAT_BY_ID[parentId]; if (!cat) return null; const dept = OD.DEPT_BY_ID[cat.deptId]; const liveCat = dept?.cats.find(c => c.id === cat.id); if (!liveCat) return null; const id = uniqId("sub", name); const node = { id, name }; liveCat.subs.push(node); OD.SUB_BY_ID[id] = { ...node, catId: liveCat.id, deptId: dept.id }; return node; } return null; } // ----------------------------------------------------------------- // NEW CATEGORY MODAL — used by the rail's + buttons. Lets the user // pick what level (Dept / Cat / Sub) and the parent, then name the // new node. Defaults to the level/parent the user clicked from. // ----------------------------------------------------------------- function NewCategoryModal({ initial, onClose, onCreate }) { const [level, setLevel] = stkUseState(initial.level); const [parentId, setParentId] = stkUseState(initial.parentId || ""); const [name, setName] = stkUseState(""); const [desc, setDesc] = stkUseState(""); // When level changes, reset parent appropriately function pickLevel(l) { setLevel(l); if (l === "dept") setParentId(""); if (l === "cat") setParentId(OD.CATEGORIES[0]?.id || ""); if (l === "sub") setParentId(OD.CATEGORIES[0]?.cats[0]?.id || ""); } // For sub-level, the parent select needs to show categories grouped // by department so users can disambiguate (e.g. "Allergy" exists in // both Dispensary and OTC potentially). const catOptions = stkUseMemo(() => { const out = []; OD.CATEGORIES.forEach(d => d.cats.forEach(c => out.push({ id:c.id, label:`${d.name} › ${c.name}`, deptName:d.name, name:c.name }))); return out; }, [initial]); const canCreate = !!name.trim() && (level === "dept" || !!parentId); // Helper text under the form preview the structural change function structurePreview() { if (!name.trim()) return Type a name to preview the new node; if (level === "dept") return <>{name} · top-level department; if (level === "cat") { const d = OD.DEPT_BY_ID[parentId]; return <>{d?.name} › {name}; } if (level === "sub") { const c = OD.CAT_BY_ID[parentId]; const d = c ? OD.DEPT_BY_ID[c.deptId] : null; return <>{d?.name} › {c?.name} › {name}; } } return (
e.stopPropagation()} style={{width: 500}}>
Categories
New {LEVEL_LABEL[level].toLowerCase()}
{/* Level picker */}
{[ { id:"dept", title:"Department", hint:"Top-level (e.g. NDSS)" }, { id:"cat", title:"Category", hint:"Inside a department" }, { id:"sub", title:"Subcategory", hint:"Inside a category" }, ].map(opt => ( ))}
{/* Parent picker — only for cat/sub */} {level === "cat" && (
)} {level === "sub" && (
)} {/* Name */}
setName(e.target.value)} placeholder={ level === "dept" ? "e.g. Veterinary" : level === "cat" ? "e.g. Eye care" : "e.g. Wound dressings" }/>
{/* Description — only for departments */} {level === "dept" && (
setDesc(e.target.value)} placeholder="One-line label shown in the tree"/>
)} {/* Preview */}
Will appear as
{structurePreview()}
); } // ----------------------------------------------------------------- // CATEGORY RAIL — left-side hierarchical filter tree // Department › Category › Subcategory · click any node to scope the // product list. Counts are SKUs visible in current scope, value is // rolled up from on-hand × cost. // ----------------------------------------------------------------- function CategoryRail({ rows, treeSel, setTreeSel, onAdd, catTick }) { // Default-open the department that contains the current selection const initOpen = stkUseMemo(() => { const open = {}; OD.CATEGORIES.forEach(d => { open[d.id] = false; }); if (treeSel.kind === "dept") open[treeSel.id] = true; if (treeSel.kind === "cat") open[OD.CAT_BY_ID[treeSel.id]?.deptId] = true; if (treeSel.kind === "sub") open[OD.SUB_BY_ID[treeSel.id]?.deptId] = true; return open; }, []); const [openDept, setOpenDept] = stkUseState(initOpen); const [openCat, setOpenCat] = stkUseState(() => { const o = {}; if (treeSel.kind === "cat") o[treeSel.id] = true; if (treeSel.kind === "sub") o[OD.SUB_BY_ID[treeSel.id]?.catId] = true; return o; }); // Pre-compute stats const stats = stkUseMemo(() => { const byDept = {}, byCat = {}, bySub = {}; rows.forEach(r => { const d = byDept[r.dept] = byDept[r.dept] || { skus:0, units:0, value:0, low:0 }; const c = byCat[r.cat] = byCat[r.cat] || { skus:0, units:0, value:0, low:0 }; const s = bySub[r.sub] = bySub[r.sub] || { skus:0, units:0, value:0, low:0 }; [d,c,s].forEach(o => { o.skus++; o.units += r.onHand; o.value += r.stockValue; if (r.onHand <= r.min && r.min > 0) o.low++; }); }); return { byDept, byCat, bySub, total: rows.length }; }, [rows]); const isSel = (kind, id) => treeSel.kind === kind && (kind === "all" || treeSel.id === id); return ( ); } // ----------------------------------------------------------------- // TREE BREADCRUMB — shows current selection above the table, with clear // ----------------------------------------------------------------- function TreeBreadcrumb({ treeSel, setTreeSel }) { const parts = []; if (treeSel.kind === "dept") { parts.push({ label: OD.DEPT_BY_ID[treeSel.id]?.name }); } else if (treeSel.kind === "cat") { const c = OD.CAT_BY_ID[treeSel.id]; parts.push({ label: OD.DEPT_BY_ID[c?.deptId]?.name, onClick: () => setTreeSel({kind:"dept", id:c.deptId}) }); parts.push({ label: c?.name }); } else if (treeSel.kind === "sub") { const s = OD.SUB_BY_ID[treeSel.id]; parts.push({ label: OD.DEPT_BY_ID[s?.deptId]?.name, onClick: () => setTreeSel({kind:"dept", id:s.deptId}) }); parts.push({ label: OD.CAT_BY_ID[s?.catId]?.name, onClick: () => setTreeSel({kind:"cat", id:s.catId}) }); parts.push({ label: s?.name }); } return (
{parts.map((p, i) => ( {p.onClick ? : {p.label}} ))}
); } // ----------------------------------------------------------------- // STOCK TREE VIEW — hierarchical 3-level rollup: Department → Category // → Subcategory → products. Collapsible at every level. Totals at each // header row. This is the "By category" view, properly hierarchical. // ----------------------------------------------------------------- function StockTreeView({ rows, openProduct, treeSel, setTreeSel }) { // Build nested structure from flat rows const tree = stkUseMemo(() => { const t = {}; rows.forEach(r => { const d = t[r.dept] = t[r.dept] || { rows:[], cats:{} }; d.rows.push(r); const c = d.cats[r.cat] = d.cats[r.cat] || { rows:[], subs:{} }; c.rows.push(r); const s = c.subs[r.sub] = c.subs[r.sub] || { rows:[] }; s.rows.push(r); }); return t; }, [rows]); // Default-open behaviour: // - if a tree filter is active, expand the matching path; collapse others // - if no filter, open departments at top level only const [openDept, setOpenDept] = stkUseState(() => { const o = {}; OD.CATEGORIES.forEach(d => { o[d.id] = true; }); if (treeSel.kind === "dept") { OD.CATEGORIES.forEach(d => o[d.id] = d.id === treeSel.id); } return o; }); const [openCat, setOpenCat] = stkUseState(() => { const o = {}; if (treeSel.kind === "cat") o[treeSel.id] = true; if (treeSel.kind === "sub") o[OD.SUB_BY_ID[treeSel.id]?.catId] = true; return o; }); const [openSub, setOpenSub] = stkUseState(() => { const o = {}; if (treeSel.kind === "sub") o[treeSel.id] = true; return o; }); function totals(arr) { return arr.reduce((a, r) => ({ units: a.units + r.onHand, value: a.value + r.stockValue, low: a.low + (r.onHand <= r.min && r.min > 0 ? 1 : 0), out: a.out + (r.onHand === 0 ? 1 : 0), }), { units:0, value:0, low:0, out:0 }); } if (rows.length === 0) { return (
No SKUs in this branch
Try clearing filters or selecting a different category.
); } return (
{OD.CATEGORIES.map(d => { const dnode = tree[d.id]; if (!dnode) return null; const t = totals(dnode.rows); const expanded = openDept[d.id]; return (
setOpenDept(o => ({...o, [d.id]: !o[d.id]}))} >
{d.name}
{d.desc}
0 ? "warn" : null}/>
{expanded && Object.keys(dnode.cats).map(catId => { const cobj = OD.CAT_BY_ID[catId]; const cnode = dnode.cats[catId]; const ct = totals(cnode.rows); const copen = openCat[catId] !== false; // default-open inside expanded dept return (
setOpenCat(o => ({...o, [catId]: copen ? false : true}))} >
{cobj?.name}
0 ? "warn" : null}/>
{copen && Object.keys(cnode.subs).map(subId => { const sobj = OD.SUB_BY_ID[subId]; const snode = cnode.subs[subId]; const st = totals(snode.rows); const sopen = openSub[subId] !== false; return (
setOpenSub(o => ({...o, [subId]: sopen ? false : true}))} >
{sobj?.name}
{snode.rows.length} {snode.rows.length === 1 ? "SKU" : "SKUs"} 0 ? "warn" : null}/>
{sopen && (
{snode.rows.map(r => { const lowStock = r.onHand <= r.min && r.min > 0; const outOfStock = r.onHand === 0; return (
openProduct(r.productId)}>
{r.name} · {r.product.strength}
{r.sku}
{r.schedule !== "—" && }
{r.onHand}
{r.min}
{r.expiry}
{oMoney(r.price)}
{oMoney(r.stockValue)}
); })}
)}
); })}
); })}
); })}
); } function TreeStat({ label, value, strong, tone }) { return (
{label}
{value}
); } // ----------------------------------------------------------------- // MANAGE STOCK — bulk edit pricing, reorder rules // ----------------------------------------------------------------- // ----------------------------------------------------------------- // MANAGE STOCK — LIVE · this pharmacy (read + WRITE) // Real products from /products (search-driven over the org's 98k catalogue). // Inline-edit cost / price / reorder min / max; Save PATCHes each changed // product to Aurora — which the POS terminals then sync down. No mock. // ----------------------------------------------------------------- function ManageStock() { const { setScreen, pushToast, setScreenState } = window.useOffice(); const openProduct = (pid) => { setScreenState((st) => ({ ...st, activeProductId: pid, productFrom: "manageStock" })); setScreen("productDetail"); }; const [products, setProducts] = stkUseState([]); const [loading, setLoading] = stkUseState(true); const [err, setErr] = stkUseState(null); const [q, setQ] = stkUseState(""); const [selected, setSelected] = stkUseState(new Set()); const [dirty, setDirty] = stkUseState({}); // { productId: { cost?, price?, min?, max? } } const [saving, setSaving] = stkUseState(false); const [catMap, setCatMap] = stkUseState({}); const [supMap, setSupMap] = stkUseState({}); // Reference maps (categories + suppliers) for name display — loaded once. React.useEffect(() => { let alive = true; Promise.all([ window.OfficeAPI.listCategories().catch(() => []), window.OfficeAPI.listSuppliers().catch(() => []), ]).then(([cats, sups]) => { if (!alive) return; const cm = {}; (cats || []).forEach((c) => { cm[c.id] = c.name; }); const sm = {}; (sups || []).forEach((s) => { sm[s.id] = s.name; }); setCatMap(cm); setSupMap(sm); }); return () => { alive = false; }; }, []); function loadProducts() { const query = { limit: 200 }; if (q.trim()) query.search = q.trim(); return window.OfficeAPI.listProducts(query); } // Product list — server-side search, debounced. React.useEffect(() => { let alive = true; setLoading(true); setErr(null); const t = setTimeout(() => { loadProducts() .then((ps) => { if (alive) { setProducts(ps || []); setLoading(false); } }) .catch((e) => { if (alive) { setErr(String((e && e.message) || e)); setLoading(false); } }); }, q ? 300 : 0); return () => { alive = false; clearTimeout(t); }; }, [q]); function toggle(id) { setSelected((s) => { const ns = new Set(s); ns.has(id) ? ns.delete(id) : ns.add(id); return ns; }); } function toggleAll() { setSelected((s) => (s.size === products.length ? new Set() : new Set(products.map((p) => p.id)))); } function baseField(p, field) { if (field === "cost") return p.costPriceCents == null ? 0 : p.costPriceCents / 100; if (field === "price") return (p.sellPriceCents || 0) / 100; if (field === "min") return p.reorderMin ?? 0; if (field === "max") return p.reorderMax ?? 0; return 0; } function setField(id, field, value) { setDirty((d) => ({ ...d, [id]: { ...(d[id] || {}), [field]: value } })); } function getField(p, field) { const v = dirty[p.id]?.[field]; return v !== undefined ? v : baseField(p, field); } function isDirty(id, field) { return dirty[id] && Object.prototype.hasOwnProperty.call(dirty[id], field); } const dirtyCount = Object.values(dirty).reduce((s, o) => s + Object.keys(o).length, 0); async function save() { setSaving(true); const ids = Object.keys(dirty); let ok = 0, failed = 0; for (const id of ids) { const d = dirty[id], patch = {}; if ("cost" in d) patch.costPriceCents = Math.max(0, Math.round(Number(d.cost) * 100)); if ("price" in d) patch.sellPriceCents = Math.max(0, Math.round(Number(d.price) * 100)); if ("min" in d) patch.reorderMin = Math.max(0, parseInt(d.min, 10) || 0); if ("max" in d) patch.reorderMax = Math.max(0, parseInt(d.max, 10) || 0); try { await window.OfficeAPI.updateProduct(id, patch); ok++; } catch (e) { failed++; } } setSaving(false); setDirty({}); setSelected(new Set()); pushToast({ kind: failed ? "hold" : "paid", icon: failed ? "alert" : "check-circle", title: failed ? `${ok} saved, ${failed} failed` : `${ok} product${ok > 1 ? "s" : ""} updated`, meta: "Written to Aurora → syncs to the POS", }); loadProducts().then((ps) => setProducts(ps || [])).catch(() => {}); } function bulkRepriceUp() { setDirty((d) => { const next = { ...d }; [...selected].forEach((id) => { const p = products.find((x) => x.id === id); if (!p) return; const base = d[id]?.price !== undefined ? Number(d[id].price) : (p.sellPriceCents || 0) / 100; next[id] = { ...(next[id] || {}), price: Number((base * 1.05).toFixed(2)) }; }); return next; }); if (selected.size) pushToast({ kind: "hold", icon: "edit", title: "Bulk reprice prepared", meta: `${selected.size} items +5% — review & save` }); } const money2 = (n) => "$" + Number(n || 0).toFixed(2); const dispName = (p) => (p.name && p.name !== "-" ? p.name : (p.sku || "(unnamed)")); return (
Inventory · this pharmacy · live from Aurora

Manage stock

{dirtyCount > 0 && }
{err && (
Couldn't load products: {err}
)} {selected.size > 0 && (
{selected.size} {selected.size === 1 ? "product" : "products"} selected
)}
setQ(e.target.value)} style={{height:34, fontSize:'var(--fs-13)'}}/>
{loading ? "loading…" : `${products.length} shown${q ? " for “" + q + "”" : " · search to find any of 98,666"} · click a cell to edit`}
0} onChange={toggleAll}/>
Product
Category
Cost
Price
Margin %
Min
Max
Supplier
{products.map((p) => { const cost = parseFloat(getField(p, "cost")) || 0; const price = parseFloat(getField(p, "price")) || 0; const margin = price > 0 ? Math.round(((price - cost) / price) * 100) : 0; return (
toggle(p.id)} onClick={(e) => e.stopPropagation()}/>
openProduct(p.id)}> {dispName(p)} {p.schedule ? : null}
{p.sku}
{catMap[p.categoryId] || }
setField(p.id, "cost", parseFloat(e.target.value.replace(/[^\d.]/g, "")) || 0)}/>
setField(p.id, "price", parseFloat(e.target.value.replace(/[^\d.]/g, "")) || 0)}/>
60 ? 'var(--paid-text)' : 'inherit', fontWeight:500}}>{margin}%
setField(p.id, "min", parseInt(e.target.value, 10) || 0)}/>
setField(p.id, "max", parseInt(e.target.value, 10) || 0)}/>
{supMap[p.primarySupplierId] || ""}
); })}
{!loading && products.length === 0 &&
No products match “{q}”.
}
); } window.OFFICE_SCREENS = Object.assign(window.OFFICE_SCREENS || {}, { stockOverview: StockOverview, manageStock: ManageStock, });