/* ============================================================= 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 = ; if (!id) return
No product selected.
; if (loading) return
{back}

Loading…

; 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 && }
= 30 ? "up" : "down", text: "markup " + pdPct(p.markupBp) }}/> 1 ? "s" : "") : "not stocked" }}/>
{/* Tab bar — DS tabset */}
{TABS.map((t) => ( ))}
{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 (
{label}
{value || "—"}
); } function PdEdit({ label, value, onChange, dirty, prefix, wide }) { return (
{label}
{prefix && {prefix}} onChange(e.target.value)} style={{width: wide ? 320 : 130, height:32, fontSize:'var(--fs-13)', padding:'0 10px', borderColor: dirty ? 'var(--brand)' : undefined, background: dirty ? 'var(--brand-bg)' : undefined}}/>
); } function PdNum({ label, value, onChange, dirty }) { return onChange(parseInt(v.replace(/[^\d]/g, ""), 10) || 0)}/>; } function PdSelect({ label, value, onChange, dirty, options }) { return (
{label}
); } function PdCheck({ label, v, onToggle, dirty, readOnly }) { return ( ); } 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 || 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 && }
{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()}>
Adjust stock
{product.name} · {product.strength}
setQty(parseInt(e.target.value) || 0)} style={{textAlign:'center', fontSize:'var(--fs-18)', fontWeight:600}}/>
Use negative numbers to remove stock. {reason === "damaged" || reason === "expired" ? "Will write off at cost." : ""}
setNote(e.target.value)} placeholder="Visible in audit log"/>
); } // ----------------------------------------------------------------- // 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, });