/* =============================================================
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 */}
{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 &&
}
);
}
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}}>
{/* 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]}))}
>
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 (
);
}
// -----------------------------------------------------------------
// 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
)}
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 &&
}
);
}
window.OFFICE_SCREENS = Object.assign(window.OFFICE_SCREENS || {}, {
stockOverview: StockOverview,
manageStock: ManageStock,
});