/* =============================================================
CoreIQ Office — Floor & Shelf Planner (main screen)
Registers window.OFFICE_SCREENS.planner
Rail + scan modal live in office-screens-planner-panels.jsx
(referenced via window.PlannerRail / window.PlannerScanModal).
============================================================= */
const { useState: plUseState, useMemo: plUseMemo, useRef: plUseRef, useEffect: plUseEffect } = React;
const PL = window.PLANNER_DATA;
const SCALE = 56; // px per metre (top-down)
// ---- heat colour helpers --------------------------------------
function lerp(a, b, t) { return a + (b - a) * t; }
function clamp01(x) { return Math.max(0, Math.min(1, x)); }
// sequential: light slate → teal
function heatSeq(t) {
t = clamp01(t);
const h = lerp(210, 186, t), s = lerp(34, 68, t), l = lerp(86, 40, t);
return `hsl(${h}, ${s}%, ${l}%)`;
}
// diverging for space-to-sales: <1 over-spaced (rose) · ~1 balanced (slate) · >1 under-spaced (green)
function heatS2S(idx) {
if (idx >= 1) {
const t = clamp01((idx - 1) / 0.8); // 1 → 1.8
return `hsl(${lerp(205, 152, t)}, ${lerp(20, 55, t)}%, ${lerp(72, 42, t)}%)`;
}
const t = clamp01((1 - idx) / 0.6); // 1 → 0.4
return `hsl(${lerp(205, 350, t)}, ${lerp(20, 68, t)}%, ${lerp(72, 55, t)}%)`;
}
// per-bay metric value (category metric attributed to the bay by linear share)
function bayMetricValue(plan, bay, metric, period, basis) {
if (!bay.cat) return null;
const storeId = plan.storeId;
if (metric === "s2s") return window.plannerSpaceToSales(plan, bay.cat, period).index;
const catM = window.plannerCatLinearM(plan, bay.cat) || bay.m || 1;
const share = (bay.m || 0) / catM;
if (metric === "sales") return window.plannerCatSales(bay.cat, storeId, period) * share;
if (metric === "value") return window.plannerCatInventoryValue(bay.cat, storeId, basis) * share;
if (metric === "units") return window.plannerCatUnits(bay.cat, storeId, period) * share;
if (metric === "gp") return window.plannerCatGP(bay.cat, storeId, period) * share;
return null;
}
function fmtMetric(metric, v, money) {
if (v == null) return "—";
if (metric === "s2s") return v.toFixed(2) + "×";
if (metric === "units") return Math.round(v).toLocaleString();
return money(v);
}
// ---- single fixture ------------------------------------------
function Fixture({ plan, fixture, ctx }) {
const { mode, metric, period, basis, showLabels, selBay, onSelectBay,
dragCat, dropBay, setDropBay, onDrop, range, money } = ctx;
const vertical = fixture.h >= fixture.w;
const isService = !(window.PLANNER_DATA.ASSIGNABLE_TYPES.includes(fixture.type)) || (fixture.bays || []).length === 0;
const style = {
left: fixture.x * SCALE, top: fixture.y * SCALE,
width: fixture.w * SCALE, height: fixture.h * SCALE,
};
if (isService) {
return (
{fixture.label}
);
}
return (
{(fixture.bays || []).map(bay => {
const selected = selBay && selBay.fixtureId === fixture.id && selBay.bayId === bay.id;
const isDrop = dropBay && dropBay.fixtureId === fixture.id && dropBay.bayId === bay.id;
let bg, tag = null, content = null;
if (!bay.cat) {
// empty
content =
;
} else if (mode === "plan") {
bg = window.plannerDeptColor(window.plannerCatDept(bay.cat), 0.92);
tag =
{window.plannerCatName(bay.cat)};
} else {
const v = bayMetricValue(plan, bay, metric, period, basis);
if (metric === "s2s") bg = heatS2S(v);
else { const t = range.max > range.min ? (v - range.min) / (range.max - range.min) : 0.5; bg = heatSeq(t); }
tag =
{fmtMetric(metric, v, money)};
}
return (
onSelectBay(fixture.id, bay.id)}
onDragOver={dragCat ? (e) => { e.preventDefault(); setDropBay({ fixtureId: fixture.id, bayId: bay.id }); } : undefined}
onDragLeave={dragCat ? () => setDropBay(null) : undefined}
onDrop={dragCat ? (e) => { e.preventDefault(); onDrop(fixture.id, bay.id); } : undefined}
title={bay.cat ? window.plannerCatName(bay.cat) : "Empty bay — assign a category"}
>
{content}{tag}
);
})}
{showLabels && mode === "plan" && (
{fixture.label}
)}
);
}
// ---- the floor plan canvas -----------------------------------
function FloorPlan({ plan, ctx }) {
const { view } = ctx;
const W = plan.room.w * SCALE, H = plan.room.h * SCALE;
return (
{plan.zones.map(z => (
{z.label}
))}
{/* entrance */}
{plan.room.entrance && (
<>
Entrance
>
)}
{plan.fixtures.map(f =>
)}
);
}
// ---- segmented control ----------------------------------------
function Seg({ value, onChange, options, accent }) {
return (
{options.map(o => (
))}
);
}
// =================================================================
// PLANNER SCREEN
// =================================================================
function FloorPlanner() {
const { scope, pushToast } = window.useOffice();
const initStore = (scope && scope !== "all") ? scope : "S-001";
const [storeId, setStoreId] = plUseState(initStore);
const [mode, setMode] = plUseState("plan"); // plan | perf
const [period, setPeriod] = plUseState("week");
const [view, setView] = plUseState("top"); // top | 3d
const [metric, setMetric] = plUseState("s2s");
const [basis, setBasis] = plUseState("cost"); // cost | retail
const [showLabels, setShowLabels] = plUseState(true);
const [selBay, setSelBay] = plUseState(null);
const [dragCat, setDragCat] = plUseState(null);
const [dropBay, setDropBay] = plUseState(null);
const [scanOpen, setScanOpen] = plUseState(false);
// mutable copies of all plans
const [plans, setPlans] = plUseState(() => JSON.parse(JSON.stringify(PL.PLANS)));
const plan = plans[storeId];
const money = window.officeMoney;
// expose tweak hooks (read external tweak state if present)
plUseEffect(() => {
const t = window.__plannerTweaks;
if (!t) return;
if (t.metric) setMetric(t.metric);
if (t.period) setPeriod(t.period);
if (t.basis) setBasis(t.basis);
if (typeof t.showLabels === "boolean") setShowLabels(t.showLabels);
}, []);
function assignBay(fixtureId, bayId, catId) {
setPlans(prev => {
const next = { ...prev, [storeId]: JSON.parse(JSON.stringify(prev[storeId])) };
const f = next[storeId].fixtures.find(x => x.id === fixtureId);
const b = f && f.bays.find(x => x.id === bayId);
if (b) b.cat = catId;
return next;
});
setDropBay(null); setDragCat(null);
pushToast({ kind: "paid", icon: "check-circle", title: `${window.plannerCatName(catId)} assigned`, meta: "Bay updated · plan saved" });
}
function clearBay(fixtureId, bayId) {
setPlans(prev => {
const next = { ...prev, [storeId]: JSON.parse(JSON.stringify(prev[storeId])) };
const f = next[storeId].fixtures.find(x => x.id === fixtureId);
const b = f && f.bays.find(x => x.id === bayId);
if (b) b.cat = null;
return next;
});
}
function onSelectBay(fixtureId, bayId) {
// click-to-assign: if dragging not active but a category is "armed" via selBay+chip, handled in rail.
setSelBay({ fixtureId, bayId });
}
// metric range across bays (perf mode shading)
const range = plUseMemo(() => {
if (mode !== "perf" || metric === "s2s") return { min: 0, max: 1 };
let min = Infinity, max = -Infinity;
window.plannerAllBays(plan).forEach(b => {
const v = bayMetricValue(plan, b, metric, period, basis);
if (v == null) return; if (v < min) min = v; if (v > max) max = v;
});
return min === Infinity ? { min: 0, max: 1 } : { min, max };
}, [plan, mode, metric, period, basis]);
const ctx = {
mode, metric, period, basis, showLabels, view,
selBay, onSelectBay,
dragCat, dropBay, setDropBay,
onDrop: (fid, bid) => dragCat && assignBay(fid, bid, dragCat),
range, money,
};
const store = window.findOfficeStore(storeId);
return (
{/* toolbar */}
{window.OFFICE_DATA.STORES.map(s => (
))}
{mode === "perf" && (
Heatmap
)}
{(mode === "perf" || true) && (
Period
)}
{/* body */}
{window.PlannerRail
?
:
}
{scanOpen && window.PlannerScanModal && (
setScanOpen(false)}
onApply={() => { setScanOpen(false); pushToast({ kind: "paid", icon: "check-circle", title: "Floor plan imported", meta: `${plan.fixtures.length} fixtures detected · ${store.name}` }); }}/>
)}
);
}
window.OFFICE_SCREENS = Object.assign(window.OFFICE_SCREENS || {}, { planner: FloorPlanner });
// shared for the rail file
window.PlannerShared = { SCALE, heatSeq, heatS2S, bayMetricValue, fmtMetric };