/* =============================================================
CoreIQ Office — Product Detail screen
Opens when a row is clicked from Stock overview. Shows the full
picture for one SKU across all stores: stock + locations,
batches/expiries, pricing, demand, movements, supplier, flags.
============================================================= */
const { useState: pdUseState, useMemo: pdUseMemo } = React;
// Small synthetic but deterministic data helpers so each product
// has a plausible 30-day sales curve and a movement history without
// us inflating office-data.js.
function pdSeed(p) {
const s = (p.id || "").split("").reduce((a, c) => a + c.charCodeAt(0), 0);
return s * 7 + 11;
}
function pdDemand30(p) {
const seed = pdSeed(p);
return Array.from({ length: 30 }, (_, i) => {
const base = 2 + ((seed * 3 + i * 13) % 8);
const wave = Math.round(Math.sin((i / 30) * Math.PI * 2 + seed) * 2);
return Math.max(0, base + wave);
});
}
// -----------------------------------------------------------------
// PRODUCT DETAIL — LIVE · this pharmacy (read + WRITE), Z-parity tabs
// Full product master from /products/:id/detail: Details (identity, class,
// stock rules, behaviour + clinical flags), Pricing (cost/avg/prev/markup/GP/
// sell/RRP), Barcodes, Stock-by-site, and History (movements). Editable fields
// PATCH to Aurora → sync to the POS.
// -----------------------------------------------------------------
const pdMoney = (c) => "$" + (Number(c || 0) / 100).toLocaleString("en-AU", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const pdPct = (bp) => (bp == null ? "—" : (bp / 100).toFixed(2) + "%");
function ProductDetail() {
const { screenState, setScreen } = window.useOffice();
const id = screenState.activeProductId;
const backTo = screenState.productFrom || "stockOverview";
const [detail, setDetail] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [err, setErr] = React.useState(null);
const [edits, setEdits] = React.useState({});
const [saving, setSaving] = React.useState(false);
const [tab, setTab] = React.useState("details");
React.useEffect(() => {
if (!id) return undefined;
let alive = true;
setLoading(true); setErr(null); setEdits({}); setTab("details");
window.OfficeAPI.getProductDetail(id)
.then((d) => { if (alive) { setDetail(d); setLoading(false); } })
.catch((e) => { if (alive) { setErr(String((e && e.message) || e)); setLoading(false); } });
return () => { alive = false; };
}, [id]);
const back = setScreen(backTo)}>‹ Back ;
if (!id) return
;
if (loading) return ;
if (err || !detail) return Couldn't load product: {err || "not found"}
;
const p = detail.product;
const E = (k, base) => (edits[k] !== undefined ? edits[k] : base);
const setE = (k, v) => setEdits((e) => ({ ...e, [k]: v }));
const dirty = Object.keys(edits).length;
const cost = Number(E("cost", p.costPriceCents == null ? 0 : p.costPriceCents / 100));
const sell = Number(E("sell", (p.sellPriceCents || 0) / 100));
const rrp = E("rrp", p.comparePriceCents == null ? "" : (p.comparePriceCents / 100).toFixed(2));
const margin = sell > 0 ? Math.round(((sell - cost) / sell) * 100) : 0;
const onHandTotal = detail.stock.reduce((s, x) => s + x.quantityOnHand, 0);
async function save() {
setSaving(true);
const patch = {};
const numEdit = (k, conv) => { if (edits[k] !== undefined) patch[k.replace(/^x_/, "")] = conv(edits[k]); };
if (edits.cost !== undefined) patch.costPriceCents = Math.max(0, Math.round(Number(edits.cost) * 100));
if (edits.sell !== undefined) patch.sellPriceCents = Math.max(0, Math.round(Number(edits.sell) * 100));
if (edits.rrp !== undefined) patch.comparePriceCents = edits.rrp === "" ? null : Math.max(0, Math.round(Number(edits.rrp) * 100));
if (edits.fullName !== undefined) patch.fullName = edits.fullName || null;
if (edits.cartonSize !== undefined) patch.cartonSize = Math.max(0, parseInt(edits.cartonSize, 10) || 0);
if (edits.minOrderQty !== undefined) patch.minOrderQty = Math.max(0, parseInt(edits.minOrderQty, 10) || 0);
if (edits.reorderMin !== undefined) patch.reorderMin = Math.max(0, parseInt(edits.reorderMin, 10) || 0);
if (edits.reorderMax !== undefined) patch.reorderMax = Math.max(0, parseInt(edits.reorderMax, 10) || 0);
if (edits.tillMessage !== undefined) patch.tillMessage = edits.tillMessage || null;
["ethical","discountable","authRequired","autoOrders","orderOnlyBelowMin","descOnDisplay","storedInRobot","markedOnline","soldByWeight"].forEach((f) => { if (edits[f] !== undefined) patch[f] = !!edits[f]; });
try { await window.OfficeAPI.updateProduct(id, patch); } catch (e) { /* surfaced by reload diff */ }
setSaving(false);
setEdits({});
const d = await window.OfficeAPI.getProductDetail(id).catch(() => null);
if (d) setDetail(d);
}
const reloadDetail = async () => {
const d = await window.OfficeAPI.getProductDetail(id).catch(() => null);
if (d) setDetail(d);
};
const TABS = [
{ k: "details", label: "Details" },
{ k: "pricing", label: "Pricing" },
{ k: "priceHistory", label: "Price history", n: (detail.priceHistory || []).length },
{ k: "pdes", label: "Suppliers (PDEs)", n: (detail.pdes || []).length },
{ k: "barcodes", label: "Barcodes", n: detail.barcodes.length },
{ k: "stock", label: "Stock", n: detail.stock.length },
{ k: "history", label: "History" },
];
return (
{back} · live from Aurora · {detail.supplierName || "no supplier"}{p.schedule ? " · " + p.schedule : ""}
{p.name && p.name !== "-" ? p.name : p.sku}
{dirty > 0 && setEdits({})}>Discard }
{saving ? : } {saving ? "Saving…" : (dirty ? `Save ${dirty} change${dirty > 1 ? "s" : ""}` : "Save")}
= 30 ? "up" : "down", text: "markup " + pdPct(p.markupBp) }}/>
1 ? "s" : "") : "not stocked" }}/>
{/* Tab bar — DS tabset */}
{TABS.map((t) => (
setTab(t.k)}>
{t.label}{t.n != null ? {t.n} : null}
))}
{tab === "details" && (
Basic details
setE("fullName", v)} wide/>
setE("schedule", v)} options={["", "S2", "S3", "S4", "S8"]}/>
Stock rules
setE("cartonSize", v)}/>
setE("minOrderQty", v)}/>
setE("reorderMin", v)}/>
setE("reorderMax", v)}/>
Behaviour edit + Save → Aurora → POS
setE("autoOrders", b)}/>
setE("orderOnlyBelowMin", b)}/>
setE("ethical", b)}/>
setE("discountable", b)}/>
setE("authRequired", b)}/>
setE("soldByWeight", b)}/>
setE("descOnDisplay", b)}/>
setE("storedInRobot", b)}/>
setE("markedOnline", b)}/>
Clinical
)}
{tab === "pricing" && (
Cost
setE("cost", parseFloat(v.replace(/[^\d.]/g, "")) || 0)}/>
Sell
setE("sell", parseFloat(v.replace(/[^\d.]/g, "")) || 0)}/>
setE("rrp", v.replace(/[^\d.]/g, ""))}/>
)}
{tab === "priceHistory" &&
}
{tab === "pdes" && (
Supplier PDEs {(detail.pdes || []).length} · the same product across wholesalers
{(detail.pdes || []).length === 0 &&
No supplier PDEs on file.
}
{(detail.pdes || []).length > 0 && (
PDE #
Supplier
Supplier description
Trade $
Pref
{detail.pdes.map((pde, i) => (
{pde.pde}
{pde.supplierName || "—"}
{pde.supplierDescription || "—"}
{pde.tradePriceExGstCents == null ? "—" : pdMoney(pde.tradePriceExGstCents)}
{pde.isPreferred ? ★ : ""}
))}
)}
)}
{tab === "barcodes" && (
Barcodes {detail.barcodes.length}
{detail.barcodes.length === 0 &&
No barcodes on file.
}
{detail.barcodes.map((b, i) => (
{b.value}
{b.barcodeType || "—"}
{b.isPrimary &&
PRIMARY }
))}
)}
{tab === "stock" && (
Stock on hand by site
{detail.stock.length === 0 &&
Not stocked at any site.
}
{detail.stock.map((s) => (
{s.siteId}
{s.quantityOnHand}
))}
)}
{tab === "history" && (<>
Stock movements {(detail.movements || []).length} most recent · old → new SOH
{(detail.movements || []).length === 0 &&
No recorded movements.
}
{(detail.movements || []).length > 0 && (
Date
Type
Qty
SOH
By / note
{detail.movements.map((m, i) => {
const d = String(m.occurredAt || "").replace("T", " ").slice(0, 16);
const up = m.quantityDelta >= 0;
return (
{d}
{String(m.movementType || "").replace(/_/g, " ")}
{up ? "+" : ""}{m.quantityDelta}
{m.quantityBefore}→{m.quantityAfter}
{m.notes || "—"}
);
})}
)}
>)}
);
}
function PdField({ label, value, mono }) {
return (
);
}
function PdEdit({ label, value, onChange, dirty, prefix, wide }) {
return (
);
}
function PdNum({ label, value, onChange, dirty }) {
return onChange(parseInt(v.replace(/[^\d]/g, ""), 10) || 0)}/>;
}
function PdSelect({ label, value, onChange, dirty, options }) {
return (
{label}
onChange(e.target.value)}
style={{width:130, height:32, fontSize:'var(--fs-13)', padding:'0 8px',
borderColor: dirty ? 'var(--brand)' : undefined, background: dirty ? 'var(--brand-bg)' : undefined}}>
{options.map((o) => {o === "" ? "—" : o} )}
);
}
function PdCheck({ label, v, onToggle, dirty, readOnly }) {
return (
{ e.preventDefault(); onToggle(!v); }}>
{label}
{readOnly && read-only }
{dirty && edited }
);
}
function PdChip({ children, tone, compact }) {
const cls = "pd-chip" + (tone ? ` pd-chip--${tone}` : "") + (compact ? " pd-chip--compact" : "");
return {children} ;
}
function PdRow({ label, value, strong, mono, tone }) {
return (
{label}
{value}
);
}
function PdStockBar({ onHand, min, max }) {
const ceiling = Math.max(max, onHand, min * 2, 1);
const pct = Math.min(100, (onHand / ceiling) * 100);
const minPct = ceiling ? (min / ceiling) * 100 : 0;
const low = min > 0 && onHand <= min;
return (
{min || 0}
min · max
{max || "—"}
);
}
function PdSpark({ data }) {
const max = Math.max(...data, 1);
const W = 100, H = 60;
const step = W / (data.length - 1);
const points = data.map((v, i) => [i * step, H - (v / max) * H]);
const path = points.map((pt, i) => (i === 0 ? "M" : "L") + pt[0].toFixed(1) + " " + pt[1].toFixed(1)).join(" ");
const area = path + ` L ${W} ${H} L 0 ${H} Z`;
return (
{data.map((v, i) => (
))}
);
}
// A change-journal field label + colour.
const PD_FIELD = { cost: "Cost", sell: "Sell price", compare: "RRP" };
// Recent price/cost changes journalled in the Office (the forward moat signal).
function PdPriceChanges({ changes }) {
if (!changes || changes.length === 0) return null;
return (
Recent price changes {changes.length} · journalled for analytics
When
Field
From
To
Change
Reason
{changes.map((c, i) => {
const up = (c.deltaCents || 0) > 0;
return (
{String(c.date || "").replace("T", " ").slice(0, 16)}
{PD_FIELD[c.field] || c.field}
{c.oldCents == null ? "—" : pdMoney(c.oldCents)}
{c.newCents == null ? "—" : pdMoney(c.newCents)}
{c.deltaCents == null ? "—" : (up ? "+" : "") + pdMoney(c.deltaCents)}
{c.reason || c.source || "—"}
);
})}
);
}
// Cost & sell price over time, from the legacy z_archive.stock_price_history.
// Latest competitor observation per competitor + our current sell, with the gap.
// A script line's competitor price is the patient co-pay (not a like-for-like
// retail price), so the gap is labelled a monitor rather than a recommendation.
function PdCompetitorPrices({ competitors, product, onCaptured }) {
const [adding, setAdding] = React.useState(false);
const rows = competitors || [];
const ourSell = product ? product.sellPriceCents : null;
const isRx = !!(product && (product.ethical || product.schedule || product.isScheduleProduct));
return (
Competitor prices
{rows.length ? rows.length + " competitor" + (rows.length > 1 ? "s" : "") + " · latest observed" : "no observations yet"}{isRx ? " · script (co-pay basis — monitor)" : ""}
{window.CompetitorCaptureForm &&
setAdding((v) => !v)}> Add }
{adding && window.CompetitorCaptureForm && (
{ setAdding(false); onCaptured && onCaptured(); }} onCancel={() => setAdding(false)}/>
)}
{rows.length === 0 && !adding && (
No competitor price observed for this line yet.
)}
{rows.length > 0 && (
Competitor
Their price
Our sell
Gap
{rows.map((c, i) => {
const theirs = c.priceCents;
const gapPct = (theirs != null && theirs > 0 && ourSell != null) ? ((ourSell - theirs) / theirs) * 100 : null;
return (
{c.url ?
{c.competitor} :
{c.competitor} }
{String(c.observedAt || '').slice(0,10)}
{theirs == null ? '—' : pdMoney(theirs)}
{ourSell == null ? '—' : pdMoney(ourSell)}
0.05 ? 'var(--void-text)' : gapPct < -0.05 ? 'var(--paid)' : 'var(--text-subtle)'}}>
{theirs === 0 ? 'n/a' : gapPct == null ? '—' : (gapPct > 0 ? '+' : '') + gapPct.toFixed(1) + '%'}
);
})}
)}
);
}
// Wholesaler shortage history for this product — only shown when present (the OOS
// events resolve a product on ~5% of lines, so most products have none).
function PdShortages({ shortages }) {
const rows = shortages || [];
if (rows.length === 0) return null;
return (
Wholesaler shortages
{rows.length} time{rows.length > 1 ? "s" : ""} a wholesaler couldn't fully supply this line
Date
Supplier
Short
Status
{rows.map((e, i) => (
{String(e.orderedAt || "").slice(0, 10)}
{e.supplierName || "—"}
{Number((e.qtyBackordered ?? 0) > 0 ? e.qtyBackordered : (e.qtyOutOfStock ?? 0)).toLocaleString('en-AU')}
{e.status === 'backordered' ? 'Backordered' : e.status === 'out_of_stock' ? 'Out of stock' : (e.status || 'Other')}
))}
);
}
function PdPriceHistory({ points, changes, competitors, product, onCaptured }) {
if (!points || points.length === 0) {
return (
<>
Price history
No long-run cost/price history on file for this line.
>
);
}
const chron = points.slice().reverse(); // oldest → newest for the chart
const all = chron.flatMap((p) => [p.sellPriceCents, p.costExCents]).filter((v) => v != null);
const mn = Math.min(...all), mx = Math.max(...all), span = Math.max(1, mx - mn);
const W = 720, H = 190, padL = 6, padR = 6, padT = 10, padB = 16;
const iw = W - padL - padR, ih = H - padT - padB;
const x = (i) => padL + (chron.length === 1 ? iw / 2 : (i / (chron.length - 1)) * iw);
const y = (c) => padT + ih - ((c - mn) / span) * ih;
const path = (key) => chron.map((p, i) => (p[key] == null ? null : [x(i), y(p[key])])).filter(Boolean)
.map((pt, i) => (i === 0 ? "M" : "L") + pt[0].toFixed(1) + " " + pt[1].toFixed(1)).join(" ");
const ym = (s) => String(s || "").slice(0, 7);
return (
<>
Cost & price over time
{points.length} change points · {ym(chron[0].date)} → {ym(chron[chron.length - 1].date)}
Sell
Cost ex-GST
{pdMoney(mx)}
{pdMoney(mn)}
Date
Cost ex
Avg cost
Sell
SOH
{points.slice(0, 120).map((p, i) => (
{String(p.date || "").slice(0, 10)}
{p.costExCents == null ? "—" : pdMoney(p.costExCents)}
{p.avgCostExCents == null ? "—" : pdMoney(p.avgCostExCents)}
{p.sellPriceCents == null ? "—" : pdMoney(p.sellPriceCents)}
{p.shopSoh == null ? "—" : p.shopSoh}
))}
{points.length > 120 &&
Showing the 120 most recent of {points.length} change points.
}
>
);
}
function PdAdjustModal({ product, stores, onClose, onSave }) {
const [storeId, setStoreId] = pdUseState(stores[0]?.store.id || "");
const [qty, setQty] = pdUseState(0);
const [reason, setReason] = pdUseState("count");
const [note, setNote] = pdUseState("");
const REASONS = [
{ id: "count", label: "Cycle count correction" },
{ id: "damaged", label: "Damaged / wastage" },
{ id: "expired", label: "Expired" },
{ id: "return", label: "Patient return" },
{ id: "found", label: "Found / mis-shelved" },
];
return (
e.stopPropagation()}>
Store
setStoreId(e.target.value)}>
{stores.map(r => {r.store.name} · on hand {r.stock?.onHand || 0} )}
Reason
setReason(e.target.value)}>
{REASONS.map(r => {r.label} )}
Note (optional)
setNote(e.target.value)} placeholder="Visible in audit log"/>
Cancel
onSave(qty)} disabled={qty === 0}>
Post adjustment
);
}
// -----------------------------------------------------------------
// Pure helpers
// -----------------------------------------------------------------
function parseExpiry(str) {
if (!str || str === "—") return null;
const [m, y] = str.split("/").map(Number);
if (!m || !y) return null;
return new Date(y, m - 1, 28);
}
function mvIcon(kind) {
return { sale:"cart", receive:"truck", transfer:"transfer", adjust:"edit", stocktake:"clipboard" }[kind] || "info";
}
function mvLabel(kind) {
return { sale:"Sale", receive:"Receipt", transfer:"Transfer", adjust:"Adjustment", stocktake:"Stocktake" }[kind] || kind;
}
// -----------------------------------------------------------------
// Register
// -----------------------------------------------------------------
window.OFFICE_SCREENS = Object.assign(window.OFFICE_SCREENS || {}, {
productDetail: ProductDetail,
});