/* ============================================================= CoreIQ Office — Pack levels (build-1 web leg) 1. 'Pack review' screen: the pack-fact-unconfirmed worklist + the uom_review_queue subtab (sweep items needing a human). 2. window.PackLevelsCard — the product page's Pack levels card (levels, SELL/ORDER badges, add-outer editor, conversion entry). 3. window.PackConversionWizard — the 4-step 'Sell as singles…' modal. 4. window.PackBarcodesPanel — level-grouped Barcodes tab with CRUD. Vocabulary (spec ruling): 'Each', 'Outer of N', 'Carton of N'; never the bare word 'pack' as a level name; SELL = 'Sold at till as', ORDER = 'Ordered from supplier as'; always show the equivalence line. No-fake-data: every number here traces to the pack API, the product detail payload, or the operator's own input. ============================================================= */ const { useState: pkUseState, useEffect: pkUseEffect, useMemo: pkUseMemo } = React; const pkMoney = (c) => "$" + (Number(c || 0) / 100).toLocaleString("en-AU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); // barcode_type grammar '[;units=N]' (spec §Algorithms C). Unknown → units 1. function pkParseBarcodeType(bt) { const s = String(bt || "").trim(); if (!s) return { symbology: "ean13", units: 1 }; const parts = s.split(";"); let units = 1; for (let i = 1; i < parts.length; i++) { const m = /^units=(\d+)$/.exec(parts[i].trim()); if (m) units = Math.max(1, parseInt(m[1], 10) || 1); } return { symbology: (parts[0] || "ean13").trim().toLowerCase(), units }; } const PK_SYM_LABEL = { ean13: "EAN-13", ean8: "EAN-8", upc: "UPC", itf14: "ITF-14", code128: "Code 128", internal: "Internal" }; const pkSymLabel = (s) => PK_SYM_LABEL[s] || String(s || "").toUpperCase(); // Factor "in words" for the consent dialog (spec: consent shows the factor spelt out). function pkWords(n) { const ones = ["zero","one","two","three","four","five","six","seven","eight","nine","ten","eleven","twelve","thirteen","fourteen","fifteen","sixteen","seventeen","eighteen","nineteen"]; const tens = ["","","twenty","thirty","forty","fifty","sixty","seventy","eighty","ninety"]; n = Math.trunc(Number(n)); if (!isFinite(n) || n < 0 || n > 999) return String(n); if (n < 20) return ones[n]; if (n < 100) return tens[Math.floor(n / 10)] + (n % 10 ? "-" + ones[n % 10] : ""); return ones[Math.floor(n / 100)] + " hundred" + (n % 100 ? " and " + pkWords(n % 100) : ""); } // Worklist suggestion chip text ('CH2 lists P5' idiom). function pkSuggestionText(it) { if (!it || !it.suggestionUnits || Number(it.suggestionUnits) < 2) return null; if (it.suggestionSource === "ch2-price-file") return "CH2 lists P" + it.suggestionUnits; if (it.suggestionSource === "invoice-uom") return "Invoice UOM suggests ×" + it.suggestionUnits; return "Suggests ×" + it.suggestionUnits; } const PK_STATUS = { unconfirmed: { label: "Unconfirmed", bg: "var(--hold-bg)", fg: "var(--hold-text)" }, confirmed_base: { label: "Confirmed · single unit", bg: "var(--paid-bg)", fg: "var(--paid-text)" }, confirmed_outer: { label: "Confirmed · has outer", bg: "var(--paid-bg)", fg: "var(--paid-text)" }, converted: { label: "Converted", bg: "var(--brand-bg)", fg: "var(--brand)" }, }; function PkStatusBadge({ status }) { const cfg = PK_STATUS[status] || { label: status || "—", bg: "var(--bg-subtle)", fg: "var(--text-subtle)" }; return {cfg.label}; } function PkChip({ children, tone }) { const bg = tone === "warn" ? "var(--hold-bg)" : "var(--brand-bg)"; const fg = tone === "warn" ? "var(--hold-text)" : "var(--brand)"; return {children}; } function PkBadge({ kind }) { // SELL = 'Sold at till as' · ORDER = 'Ordered from supplier as' (fixed vocabulary) const sell = kind === "sell"; return ( {sell ? "SELL" : "ORDER"} ); } // ================================================================= // PACK REVIEW — worklist + review-queue screen (LIVE · this pharmacy) // ================================================================= function PackReviewScreen({ initialTab }) { const { setScreen, setScreenState, pushToast } = window.useOffice(); const [tab, setTab] = pkUseState(initialTab || "worklist"); const [items, setItems] = pkUseState(null); const [wlErr, setWlErr] = pkUseState(null); const [queue, setQueue] = pkUseState(null); const [quErr, setQuErr] = pkUseState(null); const [sweeping, setSweeping] = pkUseState(false); pkUseEffect(() => { let alive = true; window.OfficeAPI.packWorklist() .then((rows) => { if (alive) setItems(rows || []); }) .catch((e) => { if (alive) setWlErr(String((e && e.message) || e)); }); window.OfficeAPI.packReviewQueue() .then((rows) => { if (alive) setQueue(rows || []); }) .catch((e) => { if (alive) setQuErr(String((e && e.message) || e)); }); return () => { alive = false; }; }, []); const openProduct = (pid) => { setScreenState((st) => ({ ...st, activeProductId: pid, productFrom: "packReview" })); setScreen("productDetail"); }; const unconfirmed = (items || []).filter((it) => it.status === "unconfirmed").length; const openQueue = (queue || []).filter((it) => (it.status || "open") === "open").length; function ack(it) { const id = it.id; window.OfficeAPI.ackReviewItem(id) .then((updated) => { setQueue((q) => (q || []).map((r) => (r.id === id ? (updated || { ...r, status: "dismissed" }) : r))); pushToast({ kind: "paid", icon: "check", title: "Review item acknowledged" }); }) .catch((e) => pushToast({ kind: "void", icon: "alert", title: "Couldn't acknowledge", meta: String((e && e.message) || e) })); } function runSweep() { setSweeping(true); window.OfficeAPI.packSweep() .then((r) => { setSweeping(false); pushToast({ kind: "paid", icon: "check-circle", title: "Sweep complete", meta: `checked ${r.checked} · late ${r.lateFound} · compensated ${r.compensated} · queued ${r.queued}` }); return window.OfficeAPI.packReviewQueue().then((rows) => setQueue(rows || [])); }) .catch((e) => { setSweeping(false); pushToast({ kind: "void", icon: "alert", title: "Sweep failed", meta: String((e && e.message) || e) }); }); } return (
Catalogue & stock · live from Aurora

Pack review

{tab === "worklist" && (

Pack facts to confirm

{items == null && !wlErr ? "loading…" : wlErr ? "" : `${items.length} product${items.length === 1 ? "" : "s"} · ${unconfirmed} unconfirmed — a human must confirm each structure (price-file UOMs are ambiguous)`}
{wlErr && (
Couldn't load the worklist: {wlErr}
)} {!wlErr && items != null && items.length === 0 && (
All pack facts confirmed
Nothing is waiting for review.
)} {!wlErr && items != null && items.length > 0 && (
Product
SKU
Pack suggestion
Status
{items.map((it) => { const sug = pkSuggestionText(it); return (
openProduct(it.productId)}>
{it.name || it.sku}
{it.sku || "—"}
{sug ? {sug} : }
Open ›
); })}
)}
)} {tab === "queue" && (

Conversion review queue

movements the compensation sweep refused to guess about — evidence shown, human decides
{quErr && (
Couldn't load the review queue: {quErr}
)} {!quErr && queue != null && queue.length === 0 && (
Review queue is empty
The sweep has nothing that needs a human.
)} {!quErr && queue != null && queue.length > 0 && queue.map((it) => { const status = it.status || "open"; const detail = it.detail != null ? (typeof it.detail === "string" ? it.detail : JSON.stringify(it.detail)) : null; return (
{String(it.reason || "").replace(/_/g, " ") || "—"} {status}
movement {it.movementId || it.movement_id || "—"} · event {it.eventId || it.event_id || "—"} · {String(it.createdAt || it.created_at || "").replace("T", " ").slice(0, 16)}
{detail &&
{detail}
}
{status === "open" && ( )}
); })}
)}
); } // ================================================================= // PACK LEVELS CARD — lives on the product page (Details tab) // ================================================================= function PackLevelsCard({ productId, product, barcodes, onChanged }) { const { pushToast } = window.useOffice(); const [pack, setPack] = pkUseState(null); const [packErr, setPackErr] = pkUseState(null); const [wlItem, setWlItem] = pkUseState(null); const [wizardOpen, setWizardOpen] = pkUseState(false); // add-outer inline editor const [adding, setAdding] = pkUseState(false); const [qtyStr, setQtyStr] = pkUseState(""); const [nameStr, setNameStr] = pkUseState(""); const [nameTouched, setNameTouched] = pkUseState(false); const [orderAt, setOrderAt] = pkUseState("outer"); // ORDER radio: outer (default) | base const [saving, setSaving] = pkUseState(false); const [saveErr, setSaveErr] = pkUseState(null); const reload = () => { setPackErr(null); return window.OfficeAPI.packProduct(productId) .then(setPack) .catch((e) => setPackErr(String((e && e.message) || e))); }; pkUseEffect(() => { let alive = true; setPack(null); setPackErr(null); setWlItem(null); window.OfficeAPI.packProduct(productId) .then((d) => { if (alive) setPack(d); }) .catch((e) => { if (alive) setPackErr(String((e && e.message) || e)); }); window.OfficeAPI.packWorklist() .then((rows) => { if (alive) setWlItem((rows || []).find((it) => it.productId === productId) || null); }) .catch(() => {}); // chip only — absence is honest return () => { alive = false; }; }, [productId]); const levels = (pack && pack.levels) || []; const events = (pack && pack.events) || []; const settings = (pack && pack.settings) || {}; const converted = settings.status === "converted" || events.length > 0; const outer = levels.find((l) => l.seq > 0) || null; const base = levels.find((l) => l.seq === 0) || null; const qty = parseInt(qtyStr, 10); const qtyOk = Number.isInteger(qty) && qty >= 2 && qty <= 10000; const autoName = qtyOk ? "Outer of " + qty : "Outer of N"; const effName = (nameTouched ? nameStr : autoName).trim(); // Primary (sell-level) code from the synced barcodes on file — the implicit // base row's code when no persisted levels exist yet. const primaryBc = (barcodes || []).find((b) => b.isPrimary) || (barcodes || []).find((b) => pkParseBarcodeType(b.barcodeType).units === 1) || null; const suggestion = pkSuggestionText(wlItem); // Factor precedence: the human-CONFIRMED outer beats the price-file // suggestion beats the legacy carton_size number. const suggestedFactor = (outer && Number(outer.qtyOfParent) >= 2 && Number(outer.qtyOfParent)) || (wlItem && Number(wlItem.suggestionUnits) >= 2 && Number(wlItem.suggestionUnits)) || (Number(product && product.cartonSize) >= 2 && Number(product.cartonSize)) || null; function saveOuter() { setSaveErr(null); if (!qtyOk) { setSaveErr("Units per outer must be a whole number between 2 and 10,000."); return; } if (!effName || effName.length > 40) { setSaveErr("Level name must be 1–40 characters."); return; } if (effName.toLowerCase() === "pack") { // Spec vocabulary ruling: the bare word 'pack' is banned as a level name. setSaveErr("'Pack' is ambiguous as a level name — call it '" + ("Outer of " + (qtyOk ? qty : "N")) + "' instead."); return; } setSaving(true); // BUILD-1 rule (pinned contract): for UNCONVERTED products isSell rides the // OUTER — today's truth: the stock on record is counted in outers. The // conversion event (never this endpoint) is what moves selling to the base. const input = { levels: [ { name: "Each", qtyOfParent: null, isSell: false, isOrder: orderAt === "base" }, { name: effName, qtyOfParent: qty, isSell: true, isOrder: orderAt === "outer" }, ], confirm: true, }; window.OfficeAPI.packStructure(productId, input) .then(() => { setSaving(false); setAdding(false); setQtyStr(""); setNameStr(""); setNameTouched(false); pushToast({ kind: "paid", icon: "check", title: "Pack structure confirmed", meta: "1 " + effName + " = " + qty + " Each" }); return reload().then(() => { onChanged && onChanged(); }); }) .catch((e) => { setSaving(false); setSaveErr(String((e && e.message) || e)); }); } function confirmBaseOnly() { setSaveErr(null); setSaving(true); window.OfficeAPI.packStructure(productId, { levels: [], confirm: true }) .then(() => { setSaving(false); setWlItem((it) => (it ? { ...it, status: "confirmed_base" } : it)); pushToast({ kind: "paid", icon: "check", title: "Confirmed — sold as single units", meta: "No outer; structure stays factor 1." }); return reload().then(() => { onChanged && onChanged(); }); }) .catch((e) => { setSaving(false); setSaveErr(String((e && e.message) || e)); }); } const levelRow = (name, containsTxt, isSell, isOrder, barcode, key, note) => (
{name} {note &&
{note}
}
{containsTxt}
{isSell && } {isOrder && }
{barcode || "no barcode"}
); const lastEvent = events.length > 0 ? events[0] : null; const convertedSince = lastEvent && lastEvent.executedAt ? String(lastEvent.executedAt).slice(0, 10) : null; return (

Pack levels

how this product is sold at the till and ordered from the supplier
{suggestion && !converted && {suggestion}}
{packErr && (
Pack levels unavailable: {packErr}
)} {!packErr && pack == null &&
Loading pack structure…
} {!packErr && pack != null && (<> {levels.length === 0 ? levelRow("Each", "single unit", true, true, primaryBc ? primaryBc.value : null, "implicit-base", "the unit sold at the till today") : levels.map((l) => { const parent = l.seq === 0 ? null : (levels.find((x) => x.seq === l.seq - 1) || null); return levelRow( l.name, l.seq === 0 ? "single unit" : "contains " + l.qtyOfParent + " × " + (parent ? parent.name : "Each"), !!l.isSell, !!l.isOrder, l.barcode || null, l.id || ("lv" + l.seq), null); })} {(outer || (adding && qtyOk)) && (
1 {outer ? outer.name : effName} = {outer ? outer.qtyOfParent : qty} Each
)} {/* ---- add-outer inline editor (unconverted products only) ---- */} {!converted && !outer && !adding && (
{wlItem && wlItem.status === "unconfirmed" && ( )}
)} {!converted && adding && (
setQtyStr(e.target.value.replace(/[^\d]/g, ""))} placeholder="e.g. 5"/>
{ setNameTouched(true); setNameStr(e.target.value); }}/>
{qtyOk &&
1 {effName} = {qty} Each · today the till sells the {effName}; converting to sell singles is a separate, journalled step below.
} {saveErr &&
{saveErr}
}
)} {!converted && !adding && saveErr &&
{saveErr}
} {/* ---- conversion entry point ---- */}
{converted ? (
Sold as Each{convertedSince ? " since " + convertedSince : ""} {outer ? " · ordered as " + outer.name : ""}
) : (outer || suggestedFactor) ? ( ) : (
No outer on record and no pack suggestion — add an outer level above before converting to singles.
)}
)} {wizardOpen && ( setWizardOpen(false)} onConverted={() => { setWizardOpen(false); reload(); onChanged && onChanged(); }} /> )}
); } window.PackLevelsCard = PackLevelsCard; // ================================================================= // THE WIZARD — 'Sell as singles…' (4 steps + success), sup-modal idiom // ================================================================= const PK_CHECKS = [ { code: "unsettled_event", label: "No prior unsettled conversion of this product" }, { code: "recent_till_activity", label: "No till stock activity in the last 15 minutes" }, { code: "negative_stock", label: "No site holds negative stock" }, { code: "stored_in_robot", label: "Not stored in the ROWA robot" }, { code: "sold_by_weight", label: "Not sold by weight" }, { code: "no_cost", label: "Cost price on file" }, ]; function PackConversionWizard({ productId, product, barcodes, outerName, initialFactor, onClose, onConverted }) { const { pushToast, activeUser } = window.useOffice(); const [step, setStep] = pkUseState(1); const [factorStr, setFactorStr] = pkUseState(String(initialFactor || "")); const [pf, setPf] = pkUseState(null); // preflight result for the CURRENT factor const [pfLoading, setPfLoading] = pkUseState(false); const [pfErr, setPfErr] = pkUseState(null); const [counts, setCounts] = pkUseState({}); // siteId -> string (operator-typed, starts EMPTY) const [sellStr, setSellStr] = pkUseState(""); // dollars text; parses to integer cents const [sellTouched, setSellTouched] = pkUseState(false); const [armStr, setArmStr] = pkUseState(""); // type-the-factor-to-arm const [submitting, setSubmitting] = pkUseState(false); const [submitErr, setSubmitErr] = pkUseState(null); const [doneEvent, setDoneEvent] = pkUseState(null); const factor = parseInt(factorStr, 10); const factorOk = Number.isInteger(factor) && factor >= 2 && factor <= 10000; // Pre-flight is advisory + read-only: re-run whenever the factor changes. pkUseEffect(() => { if (!factorOk) { setPf(null); setPfErr(null); return undefined; } let alive = true; setPfLoading(true); setPfErr(null); const t = setTimeout(() => { window.OfficeAPI.packPreflight(productId, factor) .then((r) => { if (alive) { setPf(r); setPfLoading(false); } }) .catch((e) => { if (alive) { setPf(null); setPfErr(String((e && e.message) || e)); setPfLoading(false); } }); }, 350); return () => { alive = false; clearTimeout(t); }; }, [productId, factor, factorOk]); const sites = (pf && pf.sites) || []; const blockers = (pf && pf.blockers) || []; const costPerSingle = sites.length > 0 ? sites[0].costPerSingleCents : null; const tiered = sites.length > 0 && sites[0].suggestedSellCents ? sites[0].suggestedSellCents.tiered : null; const divide = sites.length > 0 && sites[0].suggestedSellCents ? sites[0].suggestedSellCents.divide : null; // D1 (owner ruling): show BOTH ÷N and tiered-.99 suggestions; PRE-FILL with // tiered; the human must confirm or edit — never silently committed. pkUseEffect(() => { if (step === 3 && !sellTouched && tiered != null && sellStr === "") { setSellStr((tiered / 100).toFixed(2)); } }, [step, tiered]); const sellCents = Math.round((parseFloat(sellStr) || 0) * 100); const sellOk = Number.isInteger(sellCents) && sellCents > 0; // GST comes from the pre-flight envelope (server truth), never a product-row guess. const taxable = !!(pf && pf.taxable); const sellEx = taxable ? sellCents / 1.1 : sellCents; const gp = sellOk && costPerSingle != null && sellEx > 0 ? ((sellEx - costPerSingle) / sellEx) * 100 : null; const countOk = (v) => /^\d+$/.test(String(v || "").trim()); const allCounted = sites.every((s) => countOk(counts[s.siteId])); const executedBy = ((activeUser && (activeUser.first + " " + (activeUser.last || "")).trim()) || "Office user"); // The consent block — rendered verbatim AND sent verbatim as consentText. const consentText = pkUseMemo(() => { if (!factorOk || !pf) return ""; const L = []; L.push("Pack conversion — " + ((product && (product.fullName || product.name)) || productId)); L.push("1 " + outerName + " becomes " + factor + " Each (" + pkWords(factor) + " singles per " + outerName + ")."); sites.forEach((s) => { const c = parseInt(counts[s.siteId], 10); L.push((s.siteName || s.siteId) + ": stock on record " + s.onHandOldUnits + " → set to the shelf count of " + c + " Each (expected " + s.expectedSingles + ")."); }); if (pf.currentCostCents != null && costPerSingle != null) { L.push("Cost: " + pkMoney(pf.currentCostCents) + " per " + outerName + " → " + pkMoney(costPerSingle) + " per Each."); } L.push("Sell price: " + pkMoney(sellCents) + " per Each" + (gp != null ? " (GP " + gp.toFixed(1) + "%" + (taxable ? ", GST-inclusive price" : "") + ")" : "") + ", entered by " + executedBy + "."); const codes = (barcodes || []).map((b) => b.value); L.push(codes.length ? "Barcode plan: " + codes.join(", ") + " stay attached; each scan rings 1 Each after conversion — do not sell sealed outers until the till update ships." : "Barcode plan: no barcodes on file — the product will not scan; add the single's barcode on the Barcodes tab."); L.push(pf.hasLabelTemplate ? "A new shelf label will be queued for every converted site." : "No label template exists yet — print the new shelf label from Print labels after converting."); L.push("The counts above are physical shelf counts taken now (count-anchored)."); return L.join("\n"); }, [factorOk, pf, counts, sellCents, gp, outerName, factor, executedBy]); const armed = armStr.trim() === String(factor); function convert() { if (!armed || submitting) return; setSubmitting(true); setSubmitErr(null); window.OfficeAPI.packConvert(productId, { factor, counts: sites.map((s) => ({ siteId: s.siteId, countedSingles: parseInt(counts[s.siteId], 10) })), sellPriceCents: sellCents, consentText, executedBy, }) .then((event) => { setSubmitting(false); setDoneEvent(event || {}); pushToast({ kind: "paid", icon: "check-circle", title: "Converted to singles", meta: "1 " + outerName + " → " + factor + " Each" }); }) .catch((e) => { setSubmitting(false); setSubmitErr(String((e && e.message) || e)); }); } const stepTitle = doneEvent ? "Converted" : step === 1 ? "Step 1 of 4 · Checks" : step === 2 ? "Step 2 of 4 · Count the shelf" : step === 3 ? "Step 3 of 4 · Price" : "Step 4 of 4 · Confirm"; return (
e.stopPropagation()}>
Sell as singles
{(product && (product.fullName || product.name)) || productId} · {stepTitle}
{/* ---------- SUCCESS ---------- */} {doneEvent && (<>
Conversion committed — 1 {outerName} → {factor} Each
{(doneEvent.sites || []).length > 0 && (
Site
Before
Counted
Drift
{doneEvent.sites.map((s, i) => (
{s.siteName || s.siteId}
{s.beforeQty}
{s.countedSingles}
{s.driftCents != null ? pkMoney(s.driftCents) : "—"}
))}
)}
{(pf && pf.hasLabelTemplate ? "New shelf labels are queued in Print labels. " : "No label template exists yet — print the new shelf labels from Print labels. ")}Break open all outers, restock as singles and swap the labels now — sealed outers must not be sold until the till update ships.
)} {/* ---------- STEP 1 · CHECKS ---------- */} {!doneEvent && step === 1 && (<>
{ setFactorStr(e.target.value.replace(/[^\d]/g, "")); setCounts({}); setSellTouched(false); setSellStr(""); setArmStr(""); }}/> {!factorOk && factorStr !== "" &&
Enter a whole number of 2 or more.
}
{!factorOk &&
Enter how many sellable singles one {outerName} contains — the checks run against that factor.
} {factorOk && pfLoading &&
Running checks…
} {factorOk && !pfLoading && pfErr && (
{pfErr}
)} {factorOk && !pfLoading && pf && (<>
{PK_CHECKS.map((c) => { const hit = blockers.find((b) => b.code === c.code); return (
{hit ? hit.message : c.label}
); })} {blockers.filter((b) => !PK_CHECKS.some((c) => c.code === b.code)).map((b, i) => (
{b.message}
))}
Today: cost {pf.currentCostCents != null ? pkMoney(pf.currentCostCents) : "—"} · sell {pf.currentSellCents != null ? pkMoney(pf.currentSellCents) : "—"} per {outerName}. {" "}These checks are advisory — every guard re-runs on the server at execution.
)} )} {/* ---------- STEP 2 · COUNT ---------- */} {!doneEvent && step === 2 && (<>
Count the shelf in singles at every site — the ledger is set to YOUR count, not to arithmetic. 1 {outerName} = {factor} Each.
{sites.length === 0 &&
No site has stock movement records for this product — there is nothing to count; the conversion still journals per-site.
} {sites.map((s) => { const v = counts[s.siteId] || ""; const entered = countOk(v) ? parseInt(v, 10) : null; const variance = entered != null ? entered - s.expectedSingles : null; return (
{s.siteName || s.siteId}
On record: {s.onHandOldUnits} × {outerName} = {s.expectedSingles} Each expected. Count the shelf:
setCounts((c) => ({ ...c, [s.siteId]: e.target.value.replace(/[^\d]/g, "") }))}/> expected {s.expectedSingles} {variance != null && variance !== 0 && ( variance {variance > 0 ? "+" : ""}{variance} Each — will be recorded as a count variance )}
); })} )} {/* ---------- STEP 3 · PRICE ---------- */} {!doneEvent && step === 3 && (<>
Cost per Each becomes {costPerSingle != null ? pkMoney(costPerSingle) : "—"} {pf && pf.currentCostCents != null ? (was {pkMoney(pf.currentCostCents)} per {outerName}) : null}.
{divide != null && ( )} {tiered != null && ( )}
$ { setSellTouched(true); setSellStr(e.target.value.replace(/[^\d.]/g, "")); }}/> {gp != null && ( = 0 ? "var(--paid-text)" : "var(--void-text)"}}> GP {gp.toFixed(1)}%{taxable ? " (GST-incl. price)" : ""} )}
{!sellOk && sellStr !== "" &&
Enter a price above $0.00.
}
)} {/* ---------- STEP 4 · CONFIRM (consent dialog) ---------- */} {!doneEvent && step === 4 && (<>
{consentText}
setArmStr(e.target.value.replace(/[^\d]/g, ""))} placeholder={String(factor)}/>
{submitErr &&
{submitErr}
} )}
{doneEvent ? "" : "Org-wide, journalled, forward-only — there is no undo; a regretted split is reversed by a new merge event."}
{doneEvent ? ( ) : (<> {step > 1 && } {step < 4 && ( )} {step === 4 && ( )} )}
); } window.PackConversionWizard = PackConversionWizard; // ================================================================= // BARCODES TAB — level-grouped list + add/delete (synced barcodes CRUD) // ================================================================= function PackBarcodesPanel({ productId, barcodes, onBarcodes, onReload }) { const { pushToast } = window.useOffice(); const [pack, setPack] = pkUseState(null); const [adding, setAdding] = pkUseState(false); const [value, setValue] = pkUseState(""); const [isPrimary, setIsPrimary] = pkUseState(false); const [busy, setBusy] = pkUseState(false); const [err, setErr] = pkUseState(null); pkUseEffect(() => { let alive = true; window.OfficeAPI.packProduct(productId) .then((d) => { if (alive) setPack(d); }) .catch(() => {}); // levels only name the groups below; the list renders without them return () => { alive = false; }; }, [productId]); const levels = (pack && pack.levels) || []; // The add form attaches at the SELLING unit only (units=1): the till fleet // can't resolve outer scans yet, so outer codes are not offered here. // Group rows by parsed grammar level. const rows = (barcodes || []).map((b) => ({ ...b, parsed: pkParseBarcodeType(b.barcodeType) })); const groups = []; rows.forEach((r) => { let g = groups.find((x) => x.units === r.parsed.units); if (!g) { g = { units: r.parsed.units, rows: [] }; groups.push(g); } g.rows.push(r); }); groups.sort((a, b) => a.units - b.units); const levelName = (u) => { if (u === 1) return "Each"; const lv = levels.find((l) => Number(l.unitsInBase) === u); return lv ? lv.name : "Outer of " + u; }; const digits = value.replace(/[^\d]/g, ""); const valueOk = digits.length >= 6 && digits.length <= 14; function submit() { if (!valueOk || busy) return; setBusy(true); setErr(null); // Always the selling unit (units=1) — outer codes wait on the till fleet update. window.OfficeAPI.addBarcode(productId, { value: digits, units: 1, isPrimary }) .then((list) => { setBusy(false); setAdding(false); setValue(""); setIsPrimary(false); if (list && onBarcodes) onBarcodes(list); else if (onReload) onReload(); pushToast({ kind: "paid", icon: "barcode", title: "Barcode attached", meta: digits + " · " + levelName(1) }); }) .catch((e) => { setBusy(false); setErr(String((e && e.message) || e)); }); } function remove(b) { if (!b.id) return; if (!window.confirm("Remove barcode " + b.value + "? Tills stop resolving it once the change syncs.")) return; window.OfficeAPI.deleteBarcode(productId, b.id) .then(() => { onReload && onReload(); pushToast({ kind: "paid", icon: "check", title: "Barcode removed", meta: b.value }); }) .catch((e) => pushToast({ kind: "void", icon: "alert", title: "Couldn't remove barcode", meta: String((e && e.message) || e) })); } return (

Barcodes

{(barcodes || []).length} on file · grouped by pack level · a value owns exactly one level
{adding && (
setValue(e.target.value)} placeholder="e.g. 9312345678904" onKeyDown={(e) => { if (e.key === "Enter") submit(); }}/>
Each — 1 unit per scan
Outer barcodes arrive with the till fleet update.
{!valueOk && digits.length > 0 &&
Barcode values are 6–14 digits.
} {err &&
{err}
}
)} {(barcodes || []).length === 0 && !adding && (
No barcodes on file — the product resolves by SKU only at the till.
)} {groups.map((g) => (
{levelName(g.units)}{g.units > 1 ? " · one scan = " + g.units + " units" : " · one scan = 1 unit"}
{g.rows.map((b, i) => (
{b.value}
{pkSymLabel(b.parsed.symbology)}{b.parsed.units > 1 ? " · outer of " + b.parsed.units : ""} {b.isPrimary && PRIMARY} {b.id && ( )}
))}
))}
); } window.PackBarcodesPanel = PackBarcodesPanel; // ----------------------------------------------------------------- // Register // ----------------------------------------------------------------- function PackReviewWorklistScreen() { return ; } function PackReviewQueueScreen() { return ; } window.OFFICE_SCREENS = Object.assign(window.OFFICE_SCREENS || {}, { packReview: PackReviewWorklistScreen, packReviewQueue: PackReviewQueueScreen, });