/* =============================================================
CoreIQ Office — Promotions (LIVE · this pharmacy)
Author here; the register executes. Publishing writes to the two tables
the till already reads — a promo price per product (pricing_profiles) or
one multibuy rule plus a pricing group (promotion_rules) — and PowerSync
carries them to the registers within seconds. A draft changes nothing.
Ported from the design's Promotions shell + builder + detail, restricted
to the five mechanics the till can execute today. Threshold, second-unit,
gift-with-purchase and member price arrive with the till release.
Registers window.OFFICE_SCREENS.promotions / .promotionNew / .promotionDetail
============================================================= */
const { useState: pmState, useEffect: pmEffect, useMemo: pmMemo } = React;
const pmM = (c) => (c == null ? "—" : "$" + (Number(c) / 100).toLocaleString("en-AU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }));
const pmSigned = (c) => (c == null ? "—" : (c > 0 ? "+" : c < 0 ? "−" : "") + pmM(Math.abs(c)));
const pmPct = (v) => (v == null ? "—" : Number(v).toFixed(1) + "%");
const pmDmy = (iso) => (iso ? iso.slice(8, 10) + "/" + iso.slice(5, 7) + "/" + iso.slice(0, 4) : "—");
const pmAddDays = (iso, d) => new Date(Date.parse(iso + "T00:00:00Z") + d * 86400000).toISOString().slice(0, 10);
const pmDays = (a, b) => Math.round((Date.parse(b) - Date.parse(a)) / 86400000);
const PM_STATE = {
draft: { l: "Draft", bg: "var(--bg-subtle)", fg: "var(--text-subtle)", bd: "var(--line)" },
scheduled: { l: "Scheduled", bg: "var(--navy-100)", fg: "var(--navy-800)", bd: "var(--navy-200)" },
live: { l: "Live", bg: "var(--paid-bg)", fg: "var(--paid-text)", bd: "var(--paid)" },
paused: { l: "Paused", bg: "var(--hold-bg)", fg: "var(--hold-text)", bd: "var(--hold)" },
ended: { l: "Ended", bg: "var(--bg-subtle)", fg: "var(--text-subtle)", bd: "var(--line)" },
cancelled: { l: "Cancelled", bg: "var(--bg-subtle)", fg: "var(--text-subtle)", bd: "var(--line)" },
};
const PM_ACTION = {
publish: { l: "Publish", primary: true, confirm: "Publish this promotion? It reaches the registers within seconds." },
pause: { l: "Pause", confirm: "Pause it? The registers stop applying it until you resume." },
resume: { l: "Resume" },
endEarly: { l: "End early", danger: true, confirm: "End it now? Prices revert on the registers and it cannot be resumed." },
extend: { l: "Extend" },
cancel: { l: "Cancel", danger: true, confirm: "Cancel it? It will not start." },
startNow: { l: "Start now", confirm: "Start today instead of the scheduled date?" },
rerun: { l: "Run it again" },
edit: { l: "Edit" },
discard: { l: "Discard", danger: true, confirm: "Discard this draft?" },
};
const PM_API_ACTION = { publish: "publish", pause: "pause", resume: "resume", endEarly: "end", cancel: "cancel", startNow: "start-now", rerun: "rerun" };
function PmStateTag({ state }) {
const s = PM_STATE[state] || PM_STATE.draft;
return {s.l} ;
}
function PmErr({ text }) {
return text ?
{text}
: null;
}
// Runs a lifecycle action with a confirm where the design asks for one.
async function pmRunAction(id, key, extra, pushToast, onDone) {
const a = PM_ACTION[key];
if (a && a.confirm && !window.confirm(a.confirm)) return;
try {
const r = await window.OfficeAPI.promotionAction(id, PM_API_ACTION[key] || key, extra);
pushToast && pushToast({ kind: "ok", text: (a ? a.l : key) + " — done." });
onDone && onDone(r);
} catch (e) {
pushToast && pushToast({ kind: "error", text: e && e.message ? e.message : "That didn't work." });
}
}
// =================================================================
// LIST + CALENDAR
// =================================================================
function PmCalendar({ promotions, today }) {
const start = pmAddDays(today, -7);
const DAYS = 42;
const rows = promotions.filter((p) => p.state !== "draft" && p.state !== "cancelled" && pmDays(start, p.endsOn) >= 0 && pmDays(start, p.startsOn) < DAYS);
const weeks = Array.from({ length: 6 }, (_, i) => pmAddDays(start, i * 7));
return (
Six weeks out
Where two bars overlap the register gives the customer whichever is better — that is the point of this view.
{weeks.map((w) =>
{pmDmy(w).slice(0, 5)}
)}
{rows.length === 0 &&
Nothing scheduled or live in this window.
}
{rows.map((p) => {
const s = Math.max(0, pmDays(start, p.startsOn)), e = Math.min(DAYS, pmDays(start, p.endsOn) + 1);
const st = PM_STATE[p.state];
return (
{p.name} {p.say} · {p.scopeLabel}
{p.where === "basket" ? "register rule" : "shelf price"}
);
})}
);
}
function Promotions() {
const { setScreen, setScreenState, pushToast } = window.useOffice();
const Kpi = window.KpiCard;
const [data, setData] = pmState(null);
const [err, setErr] = pmState(null);
const [loading, setLoading] = pmState(true);
const [tab, setTab] = pmState("active");
const [reloadKey, setReloadKey] = pmState(0);
pmEffect(() => {
let alive = true; setLoading(true); setErr(null);
window.OfficeAPI.promotions().then((d) => { if (alive) setData(d); }).catch((e) => { if (alive) setErr(e && e.message ? e.message : "Couldn't load promotions."); }).finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [reloadKey]);
const all = (data && data.promotions) || [];
const counts = (data && data.counts) || {};
const TABS = [
{ k: "active", label: "Live & scheduled", n: (counts.live || 0) + (counts.scheduled || 0) + (counts.paused || 0) },
{ k: "draft", label: "Drafts", n: counts.draft || 0 },
{ k: "past", label: "Ended", n: (counts.ended || 0) + (counts.cancelled || 0) },
{ k: "all", label: "All", n: all.length },
];
const rows = all.filter((p) => tab === "all" || (tab === "active" ? ["live", "scheduled", "paused"].includes(p.state) : tab === "draft" ? p.state === "draft" : ["ended", "cancelled"].includes(p.state)));
const open = (p) => { setScreenState((st) => ({ ...st, promotionId: p.id })); setScreen("promotionDetail"); };
const create = () => { setScreenState((st) => ({ ...st, promotionEditId: null, promotionRerunOf: null })); setScreen("promotionNew"); };
const live = all.filter((p) => p.state === "live");
const endingSoon = live.filter((p) => data && pmDays(data.today, p.endsOn) <= 7).length;
return (
{data && Kpi && (
p.where === "basket").length + " are register rules, not shelf prices" }} />
p.funded && ["live", "scheduled", "paused"].includes(p.state)).length)} delta={{ text: "co-op claims to raise" }} />
)}
{data &&
}
{TABS.map((t) => setTab(t.k)}>{t.label}{t.n ? {t.n} : null} )}
setReloadKey((k) => k + 1)}> Refresh
{!loading && rows.length === 0 && (
{all.length === 0 ? "No promotions yet. The first one you publish reaches the registers within seconds." : "Nothing in this tab."}
)}
{rows.length > 0 && (
Promotion
Offer
Scope
Dates
Lines
State
{rows.map((p) => (
open(p)}>
{p.name}
{p.mechanicLabel}{p.where === "basket" ? " · register rule" : " · shelf price"}{p.funded ? " · funded by " + p.funded.supplier : ""}
{p.say}
{p.scopeLabel}
{pmDmy(p.startsOn)} → {pmDmy(p.endsOn)}
{p.lineCount}
))}
)}
);
}
// =================================================================
// BUILDER — the design's New promotion, on real data.
// Every section, its copy and its order come from the design prototype
// (office-screens-promotions-4.jsx). Numbers on the right come from
// /promotions/preview, which runs the design's planning maths server-side
// against the stock ledger, eight weeks of sales and observed competitor
// prices. Nothing reaches a register until you publish.
// =================================================================
const pmSay = (id, p) => {
const M = (c) => pmM(c);
switch (id) {
case "pct": return (p.pct || 0) + "% off";
case "amount": return M(p.offCents || 0) + " off";
case "fixed": return M(p.priceCents || 0);
case "member": return M(p.priceCents || 0) + " members";
case "multibuy": return (p.qty || 0) + " for " + M(p.bundleCents || 0);
case "mixmatch": return "any " + (p.qty || 0) + " for " + M(p.bundleCents || 0);
case "threshold": return M(p.offCents || 0) + " off " + M(p.spendCents || 0);
case "bundle": return "buy one, second line " + (p.bPct || 0) + "% off";
case "secondhalf": return "2nd unit " + (p.pct || 50) + "% off";
case "gwp": return ("free " + (p.gift || "")).trim();
default: return "";
}
};
const pmShort = (name) => String(name || "").split(" ").slice(0, 3).join(" ");
// One ticket — real artwork at real size. Ported from the design's shelf-ticket screen.
function PgTicket({ name, sku, beforeCents, priceCents, say, startsOn, endsOn, theme, themes, isPrice, member, clearance }) {
const t = (themes && themes[theme]) || (themes && themes.red) || { flag: "#d81f2a", fg: "#fff", ink: "#b3141d", tint: "#fdecec" };
const showPrice = isPrice && priceCents != null;
const save = showPrice ? beforeCents - priceCents : null;
const dollars = showPrice ? Math.floor(priceCents / 100) : "";
const cents = showPrice ? String(priceCents % 100).padStart(2, "0") : "";
return (
{clearance ? "CLEARANCE" : member ? "MEMBER PRICE" : !showPrice ? "OFFER" : "SPECIAL"}
{save != null && save > 0 && SAVE ${(save / 100).toFixed(2)} }
{name}
{showPrice ? (
$ {dollars} {cents}
) : (
{String(say || "").toUpperCase()}
)}
{showPrice ? "Usually " + pmM(beforeCents) : pmM(beforeCents) + " each"}
{pmDmy(startsOn)} – {pmDmy(endsOn)}
{member ? "Loyalty card must be presented. " : ""}{clearance ? "Short-dated stock. " : ""}While stocks last.{sku}
);
}
function PromotionNew() {
const { screenState, setScreen, setScreenState, pushToast, stores: orgStores } = window.useOffice();
const editId = screenState.promotionEditId || null;
const rerunOf = screenState.promotionRerunOf || null;
const OIcon = window.OIcon;
const [meta, setMeta] = pmState(null);
const [opts, setOpts] = pmState(null);
const [today, setToday] = pmState(null);
const [name, setName] = pmState("");
const [mech, setMech] = pmState("pct");
const [p, setP] = pmState({ pct: 20, offCents: 500, priceCents: 999, qty: 3, bundleCents: 3000, spendCents: 5000, bPct: 25, b: "", gift: "" });
const [level, setLevel] = pmState("category");
const [key, setKey] = pmState("");
const [stores, setStores] = pmState([]);
const [from, setFrom] = pmState("");
const [to, setTo] = pmState("");
const [channels, setChannels] = pmState(["esl", "ticket"]);
const [funded, setFunded] = pmState(false);
const [supplier, setSupplier] = pmState("");
const [perCents, setPerCents] = pmState(150);
const [prompt, setPrompt] = pmState("");
const [reason, setReason] = pmState("");
const [theme, setTheme] = pmState("red");
const [excl, setExcl] = pmState([]);
const [preview, setPreview] = pmState(null);
const [previewErr, setPreviewErr] = pmState(null);
const [busy, setBusy] = pmState(false);
const [loaded, setLoaded] = pmState(!editId && !rerunOf);
pmEffect(() => {
let alive = true;
window.OfficeAPI.promotionsMeta().then((m) => { if (alive) setMeta(m); }).catch(() => {});
window.OfficeAPI.promotionScopeOptions().then((o) => { if (alive) setOpts(o); }).catch((e) => { if (alive) setPreviewErr(e && e.message ? e.message : "Couldn't load the scope options."); });
window.OfficeAPI.promotions().then((d) => { if (!alive) return; setToday(d.today); if (!editId && !rerunOf) { setFrom(d.today); setTo(pmAddDays(d.today, 13)); } }).catch(() => {});
const src = editId || rerunOf;
if (src) window.OfficeAPI.promotion(src).then((d) => {
if (!alive) return;
setName(d.name); setMech(d.mechanic); setP((s) => ({ ...s, ...d.params })); setLevel(d.scopeKind === "all" ? "group" : d.scopeKind); setKey(d.scopeRef || "");
setChannels((d.channels || []).filter((c) => c !== "pos")); setExcl(d.exclusions || []); setPrompt(d.prompt || ""); setReason(d.why || ""); setTheme(d.ticketTheme || "red");
if (d.funded) { setFunded(true); setSupplier(d.funded.supplier); setPerCents(d.funded.perCents); }
if (editId) { setFrom(d.startsOn); setTo(d.endsOn); }
else { const days = pmDays(d.startsOn, d.endsOn); window.OfficeAPI.promotions().then((x) => { if (alive) { setFrom(x.today); setTo(pmAddDays(x.today, days)); } }).catch(() => {}); }
setLoaded(true);
}).catch((e) => setPreviewErr(e.message));
return () => { alive = false; };
}, [editId, rerunOf]);
// one store today; the chip row is the design's, the list is real
pmEffect(() => { if (orgStores && orgStores.length && stores.length === 0) setStores([orgStores[0].id]); }, [orgStores]);
const M = (meta && meta.mechanics) || [];
const m = M.find((x) => x.id === mech) || { id: mech, where: "price", label: mech, d: "" };
const isPrice = m.where === "price";
const params = mech === "pct" ? { pct: Number(p.pct) } : mech === "amount" ? { offCents: Number(p.offCents) } : (mech === "fixed" || mech === "member") ? { priceCents: Number(p.priceCents) }
: (mech === "multibuy" || mech === "mixmatch") ? { qty: Number(p.qty), bundleCents: Number(p.bundleCents) } : mech === "threshold" ? { spendCents: Number(p.spendCents), offCents: Number(p.offCents) }
: mech === "secondhalf" ? { pct: Number(p.pct) || 50 } : mech === "bundle" ? { bPct: Number(p.bPct), b: p.b } : { gift: p.gift, qty: Number(p.qty) };
const scopeKind = level === "group" ? "all" : level;
const input = { name: name || "Untitled promotion", mechanic: mech, params, scopeKind, scopeRef: scopeKind === "all" ? null : key || null, exclusions: excl, startsOn: from, endsOn: to,
why: reason || null, prompt: channels.includes("prompt") ? prompt || null : null, channels: ["pos", ...channels], ticketTheme: theme,
funded: funded && supplier ? { supplier, perCents: Number(perCents) || 0 } : null, siteId: stores[0] || null };
const scoped = scopeKind === "all" || !!key;
const ready0 = !!from && !!to && scoped && loaded;
pmEffect(() => {
if (!ready0) { setPreview(null); return; }
let alive = true;
const t = setTimeout(() => {
window.OfficeAPI.previewPromotion(input).then((pv) => { if (alive) { setPreview(pv); setPreviewErr(null); } }).catch((e) => { if (alive) { setPreview(null); setPreviewErr(e && e.message ? e.message : "Couldn't preview."); } });
}, 250);
return () => { alive = false; clearTimeout(t); };
}, [JSON.stringify(input), ready0]);
const lines = (preview && preview.lines) || [];
const affected = lines.filter((l) => !l.held);
const held = lines.filter((l) => l.held);
const sum = preview && preview.summary;
const breaches = affected.filter((l) => l.under);
const overlaps = affected.filter((l) => l.existing && l.existing.length);
const stock = affected.filter((l) => l.stock);
const shortL = stock.filter((l) => l.stock.short);
const heavy = stock.filter((l) => l.stock.heavy);
const comp = affected.filter((l) => l.landing);
const overshot = comp.filter((l) => l.landing.overshootCents > 5).sort((a, b) => b.landing.overshootCents - a.landing.overshootCents);
const stillLosing = comp.filter((l) => l.promoCents != null && l.landing.cheapest && l.promoCents > l.landing.cheapest.priceCents).sort((a, b) => (b.promoCents - b.landing.cheapest.priceCents) - (a.promoCents - a.landing.cheapest.priceCents));
const roll = (sum && sum.roll) || { wins: 0, losses: 0, noData: 0, over: 0, total: 0 };
const durDays = sum ? sum.days : 0;
const storeName = (sum && sum.storeName) || (orgStores && orgStores[0] && orgStores[0].name) || "this store";
const ready = name.trim().length > 2 && stores.length > 0 && affected.length > 0 && !(preview && preview.problem) && (!breaches.length || reason.trim().length > 3) && m.available !== false;
const save = async (asDraft) => {
setBusy(true);
try {
const body = { ...input, name: name.trim() };
const d = editId ? await window.OfficeAPI.updatePromotion(editId, body) : await window.OfficeAPI.createPromotion(body);
if (!asDraft) {
if (!window.confirm("Publish \"" + name.trim() + "\"? " + (isPrice ? "It pushes " + affected.length + " price" + (affected.length === 1 ? "" : "s") + " to the registers within seconds." : "The rule reaches every till within seconds."))) { setBusy(false); setScreenState((st) => ({ ...st, promotionId: d.id, promotionEditId: null, promotionRerunOf: null })); setScreen("promotionDetail"); return; }
await window.OfficeAPI.promotionAction(d.id, "publish");
}
pushToast({ kind: asDraft ? "hold" : "ok", text: asDraft ? "Saved as draft · " + name.trim() + " — not visible at any register until you publish it." : (editId ? "Changes saved · " : "Promotion created · ") + name.trim() + " · " + affected.length + " line" + (affected.length === 1 ? "" : "s") + " · " + (isPrice ? "prices reach the registers within seconds" : "rule cached on every till") });
setScreenState((st) => ({ ...st, promotionId: d.id, promotionEditId: null, promotionRerunOf: null })); setScreen(asDraft ? "promotionDetail" : "promotions");
} catch (e) { pushToast({ kind: "error", text: e && e.message ? e.message : "Couldn't save." }); }
finally { setBusy(false); }
};
const Chip = ({ on, onClick, children, title }) => {children} ;
const setKeyFor = (l) => { setLevel(l); setKey(""); };
const cats = (opts && opts.categories) || [], brands = (opts && opts.brands) || [], prods = (opts && opts.products) || [];
const setsAll = (meta && meta.exclusionSets) || [];
const chans = ((meta && meta.channels) || []).filter((c) => !c.always);
const themes = (meta && meta.ticketThemes) || {};
const heldCountFor = (id) => held.filter((h) => h.held.set === id).length;
const cents = (v, set, step) => set(Math.round(Number(e.target.value || 0) * 100))} />;
const set = (k, v) => setP((s) => ({ ...s, [k]: v }));
return (
setScreen("promotions")}> Promotions
{editId ? "Edit promotion" : rerunOf ? "Run it again" : "New promotion"}
{editId ? "Changes take effect at the next register sync. Shelf tickets will need reprinting if the price moves."
: rerunOf ? "Copied from the earlier run. Give it new dates — everything else carried over."
: "The panel on the right updates as you build. Nothing reaches a register until you publish."}
setScreen("promotions")}>Cancel
save(true)}>Save as draft
save(false)}> {editId ? "Save changes" : "Publish"}
{/* ---------------- form ---------------- */}
Name it
setName(e.target.value)} />
This is what prints on the ticket and shows on the receipt, so write it the way a customer would read it.
Mechanic
Price mechanics resolve to a number the register receives. Register rules are evaluated at the till against the whole basket.
{M.map((x) => (
setMech(x.id)}>
{x.label}
{x.where === "price" ? "price" : "rule"}
))}
{mech === "pct" && Percent off set("pct", +e.target.value)} /> }
{mech === "amount" && Dollars off {cents(p.offCents, (v) => set("offCents", v))} }
{(mech === "fixed" || mech === "member") && {mech === "member" ? "Member price" : "Promo price"} {cents(p.priceCents, (v) => set("priceCents", v))} }
{(mech === "multibuy" || mech === "mixmatch") && <>Quantity set("qty", +e.target.value)} />For {cents(p.bundleCents, (v) => set("bundleCents", v))} >}
{mech === "threshold" && <>Spend at least {cents(p.spendCents, (v) => set("spendCents", v), "1")}Take off {cents(p.offCents, (v) => set("offCents", v), "1")} >}
{mech === "secondhalf" && Second unit off set("pct", +e.target.value)} /> }
{mech === "bundle" && <>Second line set("b", e.target.value)} />Discount % set("bPct", +e.target.value)} /> >}
{mech === "gwp" && <>Gift set("gift", e.target.value)} />Buy quantity set("qty", +e.target.value)} /> >}
Reads as {pmSay(mech, params)}
What it applies to
Same hierarchy as pricing rules. A category promotion covers every line in it, now and any added later.
{[["group", "Everything"], ["category", "Category"], ["brand", "Brand"], ["sku", "Single line"]].filter(([l]) => mech !== "multibuy" || l === "sku").map(([l, lab]) => setKeyFor(l)}>{lab} )}
{level === "category" &&
setKey(e.target.value)}>Choose a category… {cats.map((c) => {c.path} )} }
{level === "brand" &&
setKey(e.target.value)}>Choose a brand… {brands.map((b) => {b} )} }
{level === "sku" &&
setKey(e.target.value)}>Choose a line… {prods.map((x) => {x.name}{x.sku ? " · " + x.sku : ""} · {pmM(x.sellPriceCents)} )} }
{level === "group" &&
Every front-of-shop line. Prescriptions, S8 and consignment stock are excluded automatically.
}
Exclusions
Applied before anything else. The locked sets are not ours to discount, so they are held out of every promotion whatever its scope.
{setsAll.map((s) => {
const on = s.mandatory || excl.includes(s.id);
const caught = heldCountFor(s.id);
return (
{ if (!s.mandatory) setExcl((v) => v.includes(s.id) ? v.filter((x) => x !== s.id) : [...v, s.id]); }}>
{s.label}{s.mandatory && always }
{s.why}
{caught > 0 &&
{caught} in scope }
);
})}
{held.length > 0 &&
Holding back {held.length} line{held.length === 1 ? "" : "s"}: {held.slice(0, 6).map((h) => pmShort(h.name)).join(", ")}{held.length > 6 ? ", …" : ""}.
}
Where it shows
{}}>Register & receipt
{chans.map((c) => setChannels((s) => s.includes(c.id) ? s.filter((x) => x !== c.id) : [...s, c.id])}>{c.label} )}
{channels.includes("prompt") && <>
What the till says to staff setPrompt(e.target.value)} />>}
Supplier funding
setFunded(!funded)}>
A supplier funds part of this Changes the true margin and creates a claim against their statement.
{funded &&
Supplier setSupplier(e.target.value)} />They fund per unit {cents(perCents, setPerCents)}
}
{/* ---------------- consequences ---------------- */}
What this will do
Lines affected {affected.length}
Stores {stores.length || "—"}
Avg margin after {sum && sum.avgMarginAfterPct != null ? Math.round(sum.avgMarginAfterPct) + "%" : isPrice ? "—" : "at till"}
Given up per unit {isPrice ? (sum && sum.givenUpPerUnitCents != null ? pmM(sum.givenUpPerUnitCents) : "—") : "varies"}
{ready0 && preview && affected.length === 0 && (
{held.length > 0
? "Every line in this scope is held back by an exclusion. " + (held.some((h) => setsAll.find((s) => s.id === h.held.set && s.mandatory)) ? "At least one is a mandatory set, so this promotion cannot run as scoped." : "Turn one off above, or change the scope.")
: (preview.problem || "Nothing matches that scope yet.")}
)}
{!ready0 &&
Choose the scope and dates to see what this will do.
}
{stock.length > 0 && (
Will the stock last {durDays} days?
{shortL.length === 0 && heavy.length === 0 &&
Every line covers the run at the expected lift, with something left over. Nothing to do.
}
{shortL.length > 0 && sum.stock.worst && (
{shortL.length === 1 ? "1 line runs out" : shortL.length + " lines run out"} before the end. {" "}
{pmShort(sum.stock.worst.name)} has {sum.stock.worst.onHand} on hand at {storeName} and would go on day {sum.stock.worst.runsOut} of {durDays}.
{sum.stock.worst.canReorder ? " Lead time is " + sum.stock.worst.leadDays + " days, so a reorder placed now still lands in time." : " Lead time is " + sum.stock.worst.leadDays + " days, which is too long to restock mid-promotion — either shorten the run or order before it starts."}
)}
{heavy.length > 0 &&
{heavy.length} line{heavy.length === 1 ? " is" : "s are"} over-covered. You will finish with promotion-priced stock on the shelf that then reverts to full price. Consider a shorter run or a deeper discount.
}
{stock.slice(0, 6).map((l) => (
{l.name}
{l.stock.onHand} on hand · {Math.round(l.stock.weeklyUnits * 10) / 10}/wk normally · +{Math.round(l.stock.lift * 100)}% expected
{l.stock.short ? "day " + l.stock.runsOut : l.stock.leftOver + " left"} {l.stock.short ? "runs out" : "at the end"}
))}
{stock.length > 6 &&
and {stock.length - 6} more
}
Lift is a planning estimate from discount depth, whether the line is a KVI, and whether a ticket goes up. It is not a forecast — treat it as the order of magnitude, not the number.
)}
{comp.length > 0 && (
Where this lands you
{!isPrice ? (
A {m.label.toLowerCase()} has no single unit price, so there is nothing to compare against a shelf price. The observed set below is context for the lines it covers, not a verdict.
) : (
<>
0 ? " is-all" : roll.wins === 0 ? " is-none" : "")}>{roll.wins} of {roll.total - roll.noData} cheapest
{roll.losses === 0 && roll.wins > 0 && "This depth makes you the cheapest observed price on every line it covers."}
{roll.losses > 0 && roll.wins > 0 && roll.losses + " line" + (roll.losses === 1 ? " is" : "s are") + " still beaten by someone even at this depth. Worth knowing before you print tickets calling it a special."}
{roll.wins === 0 && roll.losses > 0 && "This depth does not make you cheapest on any line. The promotion costs margin without winning the comparison."}
{roll.noData > 0 && " " + roll.noData + " line" + (roll.noData === 1 ? " has" : "s have") + " no usable observation."}
{overshot.length > 0 &&
Deeper than you need on {overshot.length === 1 ? "1 line" : overshot.length + " lines"}. {" "}{pmShort(overshot[0].name)} goes to {pmM(overshot[0].promoCents)}, but {pmM(overshot[0].landing.enoughCents)} would still have undercut {overshot[0].landing.cheapest.retailer} at {pmM(overshot[0].landing.cheapest.priceCents)} — {pmM(overshot[0].landing.overshootCents)} a unit given away for nothing.
}
{stillLosing.length > 0 &&
Still beaten on {stillLosing.length === 1 ? "1 line" : stillLosing.length + " lines"}. {" "}{pmShort(stillLosing[0].name)} lands at {pmM(stillLosing[0].promoCents)} against {stillLosing[0].landing.cheapest.retailer} at {pmM(stillLosing[0].landing.cheapest.priceCents)}.
}
>
)}
{comp.slice(0, 5).map((l) => (
{l.name}
{l.landing.chips.slice(0, 4).map((c) => {c.retailer} {pmM(c.priceCents)}{c.onSale && sale } )}
{l.landing.chips.length === 0 && not observed }
{l.promoCents != null ? pmM(l.promoCents) : "at till"} {l.promoCents == null ? "no verdict" : l.landing.position || "—"}
))}
{comp.length > 5 &&
and {comp.length - 5} more
}
{comp.some((l) => l.landing.anyStale) ? "Greyed prices are older than three days and should not be trusted. " : ""}
{comp.some((l) => l.landing.chips.some((c) => c.onSale)) ? <>Prices marked sale look like the competitor's own promotional price, not their shelf price — matching a catalogue special with a permanent promotion is how margin leaks. > : null}
Nothing here sets your depth; that is your call.
)}
{breaches.length > 0 &&
{breaches.length} line{breaches.length === 1 ? " goes" : "s go"} below cost. Permitted with a reason, which is recorded against the promotion.
}
{breaches.length > 0 &&
{channels.includes("ticket") && affected.length > 0 && (
Ticket preview
One per line, printed at 70 × 50 mm.
{Object.entries(themes).map(([k, t]) => setTheme(k)}>{t.l} )}
See the full sheet
)}
);
}
// =================================================================
// DETAIL — what the register holds, and the lifecycle
// =================================================================
function PromotionDetail() {
const { screenState, setScreen, setScreenState, pushToast } = window.useOffice();
const id = screenState.promotionId;
const [d, setD] = pmState(null);
const [err, setErr] = pmState(null);
const [extendTo, setExtendTo] = pmState("");
const [reloadKey, setReloadKey] = pmState(0);
pmEffect(() => {
if (!id) { setErr("No promotion selected."); return; }
let alive = true;
window.OfficeAPI.promotion(id).then((x) => { if (alive) { setD(x); setExtendTo(x.endsOn); } }).catch((e) => { if (alive) setErr(e && e.message ? e.message : "Couldn't load it."); });
return () => { alive = false; };
}, [id, reloadKey]);
const act = (key) => {
if (key === "edit") { setScreenState((st) => ({ ...st, promotionEditId: id, promotionRerunOf: null })); setScreen("promotionNew"); return; }
if (key === "discard") { if (!window.confirm(PM_ACTION.discard.confirm)) return; window.OfficeAPI.discardPromotion(id).then(() => { pushToast({ kind: "ok", text: "Draft discarded." }); setScreen("promotions"); }).catch((e) => pushToast({ kind: "error", text: e.message })); return; }
if (key === "extend") { if (!extendTo || extendTo <= d.endsOn) { pushToast({ kind: "error", text: "Pick a later end date first." }); return; } pmRunAction(id, "extend", { endsOn: extendTo }, pushToast, () => setReloadKey((k) => k + 1)); return; }
if (key === "rerun") { setScreenState((st) => ({ ...st, promotionEditId: null, promotionRerunOf: id })); setScreen("promotionNew"); return; }
pmRunAction(id, key, undefined, pushToast, () => setReloadKey((k) => k + 1));
};
return (
{d && (
{d.lines.length} line{d.lines.length === 1 ? "" : "s"}
{d.state === "draft" ? "Publishing writes these to the registers." : "What was written when it was published."}
{d.lines.length === 0 &&
Nothing written yet — a draft changes nothing until it is published.
}
{d.lines.length > 0 &&
Line
Shelf
Promo
Move
{d.lines.map((l) =>
{l.name} {l.sku || ""}
{pmM(l.beforeCents)}
{l.promoCents == null ? (d.where === "basket" ? "rule" : "—") : pmM(l.promoCents)}
{l.promoCents == null ? "" : pmSigned(l.promoCents - l.beforeCents)}
)}
}
What the register holds
Read back from the tables the tills sync
{[
d.where === "price" ? ["Lines carrying a promo price", String(d.tillHolds.profilesWithPromoPrice), "pricing_profiles.promo_price_cents"] : ["Lines in the pricing group", String(d.tillHolds.profilesInGroup), d.tillHolds.pricingGroup != null ? "group " + d.tillHolds.pricingGroup : "no group yet"],
d.where === "basket" ? ["Register rule", d.tillHolds.rule ? (d.tillHolds.rule.isActive ? "active" : "inactive") : "not written", d.tillHolds.rule ? d.tillHolds.rule.quantityA + " for " + pmM(d.tillHolds.rule.bundlePriceCents) : ""] : null,
d.tillHolds.rule ? ["Rule window", (d.tillHolds.rule.startDate || "").slice(0, 16), "→ " + (d.tillHolds.rule.endDate || "").slice(0, 16)] : null,
["Published", d.publishedAt ? d.publishedAt.slice(0, 16).replace("T", " ") : "not yet", ""],
].filter(Boolean).map(([k, v, s]) =>
)}
{(d.why || d.funded) &&
{d.why &&
{d.why}
}{d.funded &&
Funded by {d.funded.supplier} at {pmM(d.funded.perCents)} per unit{d.funded.ref ? " · ref " + d.funded.ref : ""}.
}
}
)}
);
}
window.OFFICE_SCREENS = Object.assign(window.OFFICE_SCREENS || {}, { promotions: Promotions, promotionNew: PromotionNew, promotionDetail: PromotionDetail });