/* =============================================================
CoreIQ Office — Price Position (LIVE · this pharmacy)
Where our shelf price sits against the latest OBSERVED competitor price.
Source: public.competitor_prices (migrated CWH observations + Office captures),
compared within-tenant only (market research, not cross-pharmacy coordination).
Honest by construction: the "reduce to sell faster" recommendation runs on
FRONT-OF-SHOP RETAIL lines only. Prescription/ethical lines are shown as a
monitor — for a script the competitor's captured price is the patient co-pay,
not a like-for-like retail price, so the gap there is informational only.
============================================================= */
const { useState: ppState, useEffect: ppEffect, useRef: ppRef } = React;
const ppM = (c) => "$" + (Number(c || 0) / 100).toLocaleString("en-AU", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const ppPct = (v) => (v > 0 ? "+" : "") + Number(v).toFixed(1) + "%";
// A small typeahead that resolves one of OUR products to feed a capture.
function ProductPicker({ value, onPick }) {
const [q, setQ] = ppState("");
const [matches, setMatches] = ppState([]);
const [open, setOpen] = ppState(false);
const t = ppRef(null);
ppEffect(() => () => { if (t.current) clearTimeout(t.current); }, []); // clear pending debounce on unmount
const search = (text) => {
setQ(text); onPick(null);
if (t.current) clearTimeout(t.current);
if (!text.trim()) { setMatches([]); setOpen(false); return; }
t.current = setTimeout(() => {
window.OfficeAPI.listProducts({ search: text.trim(), limit: 8 })
.then((ps) => { setMatches(ps || []); setOpen(true); })
.catch(() => { setMatches([]); setOpen(false); });
}, 220);
};
if (value) {
return (
{value.name}
{ onPick(null); setQ(""); }}>change
);
}
return (
search(e.target.value)} onFocus={() => matches.length && setOpen(true)}/>
{open && matches.length > 0 && (
{matches.map((p) => (
{ onPick(p); setOpen(false); }}>
{p.name}
{p.sku} · our {ppM(p.sellPriceCents)}
))}
)}
);
}
// Capture a competitor price. `fixedProduct` pins the product (product-page use);
// otherwise the form shows a product picker (price-position screen use).
function CompetitorCaptureForm({ fixedProduct, onSaved, onCancel }) {
const [product, setProduct] = ppState(fixedProduct || null);
const [competitor, setCompetitor] = ppState("Chemist Warehouse");
const [price, setPrice] = ppState("");
const [url, setUrl] = ppState("");
const [busy, setBusy] = ppState(false);
const [msg, setMsg] = ppState(null);
const submit = async () => {
setMsg(null);
const pid = product ? product.id : null;
if (!pid) { setMsg({ ok: false, t: "Pick a product first." }); return; }
if (!competitor.trim()) { setMsg({ ok: false, t: "Competitor name is required." }); return; }
const dollars = parseFloat(price);
if (!(dollars >= 0)) { setMsg({ ok: false, t: "Enter a valid price." }); return; }
setBusy(true);
try {
await window.OfficeAPI.recordCompetitorPrice({
productId: pid,
competitor: competitor.trim(),
priceCents: Math.round(dollars * 100),
priceBasis: "manual",
url: url.trim() || null,
});
setMsg({ ok: true, t: "Saved." });
setPrice(""); setUrl("");
if (!fixedProduct) setProduct(null);
if (onSaved) onSaved();
} catch (e) {
setMsg({ ok: false, t: String((e && e.message) || e) });
} finally { setBusy(false); }
};
return (
Add a competitor price
{onCancel && close }
);
}
// A single price-position row's gap chip (above = dearer than market = red-ish).
function GapChip({ deltaPct, isRx }) {
if (isRx) return {ppPct(deltaPct)} · monitor ;
const above = deltaPct > 0.05;
const below = deltaPct < -0.05;
const c = above ? "var(--void-text)" : below ? "var(--paid)" : "var(--text-subtle)";
const bg = above ? "var(--void-bg)" : below ? "var(--paid-bg)" : "var(--bg-subtle)";
return {ppPct(deltaPct)} ;
}
function PricePosition() {
const { setScreen, setScreenState } = window.useOffice();
const [scope, setScope] = ppState("retail");
const [position, setPosition] = ppState("all");
const [search, setSearch] = ppState("");
const [data, setData] = ppState(null);
const [err, setErr] = ppState(null);
const [loading, setLoading] = ppState(true);
const [showAdd, setShowAdd] = ppState(false);
const [reloadKey, setReloadKey] = ppState(0);
ppEffect(() => {
let alive = true;
setLoading(true);
const q = { scope, position, limit: 200 };
if (search.trim()) q.search = search.trim();
window.OfficeAPI.pricePosition(q)
.then((d) => { if (alive) { setData(d); setErr(null); setLoading(false); } })
.catch((e) => { if (alive) { setErr(String((e && e.message) || e)); setLoading(false); } });
return () => { alive = false; };
}, [scope, position, search, reloadKey]);
const openProduct = (pid) => { setScreenState((st) => ({ ...st, activeProductId: pid, productFrom: "pricePosition" })); setScreen("productDetail"); };
const s = data && data.summary;
const rows = (data && data.rows) || [];
const Kpi = window.KpiCard;
const SCOPES = [
{ k: "retail", label: "Retail (front of shop)" },
{ k: "rx", label: "Prescription" },
{ k: "all", label: "All" },
];
const POS = [
{ k: "all", label: "All" },
{ k: "above", label: "Above market" },
{ k: "below", label: "Below market" },
];
return (
{showAdd &&
setReloadKey((k) => k + 1)} onCancel={() => setShowAdd(false)}/>
}
{/* KPIs — counts are honest: above/below are over the retail subset only */}
{s && Kpi && (
0 ? "down" : undefined }}/>
)}
{/* controls */}
{SCOPES.map((t) => (
setScope(t.k)}>{t.label}
))}
{POS.map((t) => (
setPosition(t.k)}>{t.label}
))}
setSearch(e.target.value)}/>
{err &&
{err}
}
{scope === "rx" && (
Prescription lines — the competitor's captured price is the patient co-pay, not a like-for-like retail price. Shown for monitoring; the gap is informational.
)}
{!loading && !err && rows.length === 0 && (
{scope === "retail" ? "No front-of-shop retail comparisons yet" : "No competitor observations match"}
{scope === "retail"
? "Your observed competitor prices so far are prescription items — switch to Prescription to monitor those. Capture a retail competitor price above, and new comparisons also arrive from the national feed as it grows."
: "Try a different scope or clear the filter."}
)}
{rows.length > 0 && (
{position === "above" ? "Priced above market" : position === "below" ? "Priced below market" : "Our price vs latest competitor"}
{rows.length} line{rows.length === 1 ? "" : "s"} · sorted by gap
Product
Competitor
Our price
Theirs
Gap
{rows.map((r, i) => (
r.productId && openProduct(r.productId)}>
{r.productName || r.productId}
{r.isRx && SCRIPT }
{ppM(r.ourSellCents)}
{ppM(r.competitorPriceCents)}
))}
)}
);
}
window.CompetitorCaptureForm = CompetitorCaptureForm;
window.OFFICE_SCREENS = Object.assign(window.OFFICE_SCREENS || {}, { pricePosition: PricePosition });