/* NUtec Planning v2 — Go slice front end.
* Plan board fed by live nutec_factory data via the Go API.
* Visual language follows the Claude Design concept (plan board, phase track). */
const { useState, useEffect, useMemo } = React;
const PHASES = ['Captured', 'Blending', 'Filtration', 'Filling', 'Packaging', 'FM Store'];
const PHASE_COLOR = {
'Captured': 'var(--nu-grey-500)',
'Blending': 'var(--gem-sapphire)',
'Filtration': 'var(--gem-aquamarine)',
'Filling': 'var(--brand)',
'Packaging': 'var(--gem-quartz)',
'FM Store': 'var(--gem-emerald)',
'FX store': 'var(--prod-nucoat)',
};
const FAMILY_COLOR = {
'Solvent': 'var(--gem-topaz)',
'UV': 'var(--gem-amethyst)',
'Water Based': 'var(--gem-aquamarine)',
'Coatings': 'var(--prod-nucoat)',
'Chips': 'var(--gem-granite)',
'Hand Sanitiser': 'var(--nu-grey-500)',
};
function famColor(f) { return FAMILY_COLOR[f] || 'var(--nu-grey-500)'; }
function fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' });
}
function daysFromToday(iso) {
if (!iso) return null;
return Math.round((new Date(iso) - new Date().setHours(0, 0, 0, 0)) / 86400000);
}
function Check() {
return (
);
}
/* ---- Phase track: nodes with fill advancing to the reached phase ---- */
function Track({ names, idx, done }) {
const n = names.length;
const eff = done ? n - 1 : idx;
const color = PHASE_COLOR[names[Math.min(eff, n - 1)]] || 'var(--brand)';
return (
{names.map((p, i) => {
const reached = i <= eff;
return (
{reached && }
);
})}
);
}
function PhaseTrack({ phaseIdx }) {
return ;
}
/* Blend jobs only ever traverse Captured → Blending → Filtration; their liquid
* then moves on inside the F job. A three-node track keeps that honest. */
const BLEND_PHASES = ['Captured', 'Blending', 'Filtration'];
function blendDone(j) { return j.rtStatus === 'Complete' || !!j.completedAt; }
function BlendTrack({ j }) {
return (
);
}
function blendStatusText(j) {
if (blendDone(j)) return 'complete' + (j.completedAt ? ` ${fmtWhen(j.completedAt)}` : '');
if (j.startedAt) return `${j.phase} since ${fmtWhen(j.startedAt)}`;
return 'not started';
}
/* A solo fill/pack job's own journey has no blending — those phases belong to
* its (completed or never-created) blend job. FX jobs end in the cold store. */
const FILL_PHASES = ['Captured', 'Filling', 'Packaging', 'FM Store'];
const FX_PHASES = ['Captured', 'Filling', 'FX store'];
function SoloTrack({ j }) {
if (j.jobKind === 'Blend') {
return
;
}
if (j.fx) {
const idx = j.phaseIdx >= PHASE_ORDER_FILLING ? 1 : 0;
return
;
}
if (j.jobKind === 'FillPack' && FILL_PHASES.includes(j.phase)) {
return
;
}
return ;
}
const PHASE_ORDER_FILLING = 3;
function DueCell({ iso, muted }) {
const d = daysFromToday(iso);
return (
{fmtDate(iso)}
{d != null && (
{d < 0 ? `${-d}d overdue` : d === 0 ? 'due today' : `in ${d}d`}
)}
);
}
function IntermediateTag() {
return intermediate ;
}
function SampleTag() {
return sample ;
}
function FxTag() {
return FX · cold store ;
}
function DraftTag() {
return draft ;
}
const SOURCE_TITLE = {
allocated: 'A stock-lot allocation names this job for the order line',
planned: 'Explicitly linked when the job was planned',
inferred: 'Same F-code as open order lines — not an explicit link',
};
function DemandText({ demand }) {
if (!demand) return (stock production) ;
const approx = demand.source === 'inferred';
return (
{demand.customer
? <>{approx ? '≈ ' : ''}{demand.customer}{demand.orderNo ? ` · ${demand.orderNo.replace(/^0+/, '')}` : demand.lines > 1 ? ` · ${demand.lines} lines` : ''}>
: <>{approx ? '≈ ' : ''}{demand.lines} open line{demand.lines > 1 ? 's' : ''} · {demand.customers} customers>}
);
}
/* ---- Job detail drawer ---- */
function fmtWhen(iso) {
if (!iso) return null;
const d = new Date(iso);
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) + ' ' +
d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
}
function Drawer({ jobNo, onClose, onSelect }) {
const [detail, setDetail] = useState(null);
const [err, setErr] = useState(null);
useEffect(() => {
if (!jobNo) return;
setDetail(null); setErr(null);
fetch(`/api/plan/jobs/${encodeURIComponent(jobNo)}`)
.then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
.then(setDetail)
.catch((e) => setErr(String(e)));
}, [jobNo]);
useEffect(() => {
function k(e) { if (e.key === 'Escape') onClose(); }
window.addEventListener('keydown', k);
return () => window.removeEventListener('keydown', k);
}, [onClose]);
const open = !!jobNo;
const d = detail;
const curIdx = d ? d.phaseIdx : -1;
return (
<>
{open && (
<>
✕
{err &&
Failed to load {jobNo}: {err}
}
{!d && !err &&
Loading {jobNo}…
}
{d && (
<>
{(d.family || '?')[0]}
{d.job}
{d.jobKind === 'Blend' ? 'blend job' : d.jobKind === 'FillPack' ? 'fill/pack job' : ''}
{d.intermediate && }
{d.stockCode}
{d.description || 'no item master row'}{d.family ? ` · ${d.family}` : ''}{d.colour && d.colour !== 'N/A' ? ` · ${d.colour}` : ''}
{d.phase}
{d.planStatus}{d.rtStatus ? ` · riteTIME ${d.rtStatus}` : ' · no scans yet'}
>
)}
{d && (
Job details
Stock code {d.stockCode}
Planned qty {Number(d.qty).toLocaleString()} {d.jobKind === 'Blend' ? 'kg' : 'units'}
{d.packType && <>Packaging {d.packType} >}
Job start {fmtDate(d.jobStart)}
Delivery {fmtDate(d.jobDelivery)}
{d.workCentre && <>Work centre {d.workCentre} >}
{d.lastOp && <>Last scan op {d.lastOp} >}
{d.receiptedQty > 0 && <>FM receipted {Number(d.receiptedQty).toLocaleString()} units >}
Milestones
{(d.milestones || []).map((m, i) => {
const done = !!m.reachedAt && i < curIdx;
const now = i === curIdx;
return (
{m.reachedAt ? (done ? 'Reached' : now ? 'Current' : 'Seen') : (now ? 'Current' : 'Pending')} · {m.phase}
{m.reachedAt && {fmtWhen(m.reachedAt)}{m.workCentre ? ` · ${m.workCentre}` : ''} }
);
})}
{(d.related || []).length > 0 && (
Related jobs · base {d.baseJob}
{d.related.map((r) => (
onSelect(r.job)}>
{r.job}
{r.stockCode}
{r.phase}{r.planStatus === 'Complete' ? ' · complete' : ''}
))}
)}
{(d.receipts || []).length > 0 && (
FM store receipts
{d.receipts.map((r, i) => (
{fmtWhen(r.at)}{r.bin ? ` · bin ${r.bin}` : ''}{r.isDisposal ? ' · disposal' : ''}
{Number(r.qty).toLocaleString()}
))}
)}
{(d.orderLines || []).length > 0 && (
Linked sales orders
{d.orderLines.map((ol, i) => (
{ol.source === 'inferred' ? '≈ ' : ''}{ol.orderNo.replace(/^0+/, '')}
{ol.customer}
{ol.source}
{Number(ol.qty).toLocaleString()} u · {fmtDate(ol.requestedDate)}
))}
)}
)}
>
)}
>
);
}
/* ---- Jobs board: rows grouped by base job (blend M job + its F products) ---- */
function groupJobs(jobs) {
const map = new Map();
for (const j of jobs) {
const key = j.baseJob || j.job;
if (!map.has(key)) map.set(key, []);
map.get(key).push(j);
}
const groups = [...map.entries()].map(([base, members]) => {
// Several blend jobs per base are sequential batches of one liquid
// (e.g. KX2 inks: M1 + M2 at 250 kg each form a 500 kg batch that is
// bottled by a single F job). Sort by job number to keep the sequence.
const blends = members.filter((m) => m.jobKind === 'Blend').sort((a, b) => a.job < b.job ? -1 : 1);
const products = members.filter((m) => m.jobKind !== 'Blend');
const blendKg = blends.reduce((s, m) => s + m.qty, 0);
const lead = members.reduce((a, b) => (b.phaseIdx > a.phaseIdx ? b : a));
const due = members.map((m) => m.jobDelivery).filter(Boolean).sort()[0] || null;
const receipted = members.reduce((s, m) => s + m.receiptedQty, 0);
const head = blends[0] || members[0];
return { base, members, blends, products, blendKg, lead, due, receipted, head };
});
groups.sort((a, b) => (a.due || '9999') < (b.due || '9999') ? -1 : 1);
return groups;
}
function JobIdCell({ j, showKind = true }) {
return (
{j.job} {' '}
{showKind && {j.jobKind === 'Blend' ? 'blend' : j.jobKind === 'FillPack' ? 'fill/pack' : ''} }
{j.intermediate && }
{j.sample && }
{j.fx && }
{j.planStatus === 'Draft' && }
{j.stockCode} · {j.description || 'no item master row'}{j.colour && j.colour !== 'N/A' ? ` · ${j.colour}` : ''}
{!j.intermediate && !j.sample &&
}
);
}
function ReceiptChip({ qty }) {
return qty > 0
? {Number(qty).toLocaleString()} receipted
: — ;
}
function PhaseBadge({ phase }) {
return {phase}
;
}
/* A base with only one open job renders as a plain row. */
function SingleJobRow({ j, onSelect }) {
return (
onSelect(j.job)}>
{(j.family || '?')[0]}
{Number(j.qty).toLocaleString()} {j.jobKind === 'Blend' ? 'kg' : 'units'} planned
{j.workCentre ? <> · at {j.workCentre} > : null}
{j.lastOp ? <> · last scan {j.lastOp}> : null}
{j.fx ? <> · fills to the FX cold store>
: j.jobKind === 'FillPack' && !j.sample ? <> · no open blend job — from stock or a completed blend> : null}
{(j.jobKind === 'Blend' || j.fx) && blendDone(j)
?
Complete
:
}
);
}
/* Blend job + its fill/pack products as one group: the header carries the
* unified phase track (blend phases come from the M job, later phases from
* the leading product); product sub-rows show their own state without a
* misleading blending segment of their own. */
function GroupSubRow({ j, label, unit, onSelect }) {
return (
onSelect(j.job)}>
{label
?
{j.job} {label} {j.intermediate && }{j.sample && }
{j.stockCode} · {j.description || 'no item master row'}
:
}
{unit === 'kg'
?
{Number(j.qty).toLocaleString()} kg · {blendStatusText(j)}
{!blendDone(j) && j.workCentre ? <> · at {j.workCentre} > : null}
:
{Number(j.qty).toLocaleString()} {unit}
{j.workCentre ? <> · at {j.workCentre} > : null}
}
{unit === 'kg' && blendDone(j)
?
Complete
:
}
);
}
function GroupRows({ g, onSelect }) {
const head = g.head;
const oneBlend = g.blends.length === 1 ? g.blends[0] : null;
const seq = g.blends.length > 1;
return (
<>
onSelect(head.job)}>
{(head.family || '?')[0]}
{oneBlend ? oneBlend.job : g.base} {' '}
{seq ? `${g.blends.length} blend batches` : oneBlend ? 'blend' : 'group'} · {g.products.length} product{g.products.length !== 1 ? 's' : ''}
{head.intermediate && }
{g.blends.length > 0
?
{g.blends[0].stockCode} · {g.blends[0].description || 'no item master row'}
:
blend job complete or not on the floor
}
{g.blendKg > 0 ? <>{Number(g.blendKg).toLocaleString()} kg blend{seq ? ' in sequence' : ''}> : null}
{seq && <> · {g.blends.map((b, i) => (
{i > 0 && ' — '}batch {i + 1} {blendDone(b) ? 'complete' : b.startedAt ? b.phase.toLowerCase() : 'not started'}
))}>}
{oneBlend && oneBlend.workCentre ? <> · at {oneBlend.workCentre} > : null}
{g.blends.length === 0 && g.lead.workCentre ? <>at {g.lead.workCentre} > : null}
{seq && g.blends.map((b, i) => (
))}
{g.products.map((p) => (
))}
>
);
}
function JobsBoard({ groups, onSelect }) {
return (
Job
Production progress
FM store
Delivery
Phase
{groups.map((g) => g.members.length === 1
?
:
)}
{groups.length === 0 &&
No jobs match your filters.
}
);
}
/* ---- Orders: shared line renderer (plan "by sales order" view + sales page) ---- */
function lineHeld(l) { return l.stockOnHold === 'P' || l.stockOnHold === 'F'; }
function HoldTag({ code }) {
return (
stock hold · {code}
);
}
function OrderLineRow({ l, onSelectJob }) {
const jobs = l.jobs || [];
const best = jobs.reduce((a, b) => (b.phaseIdx > (a ? a.phaseIdx : -1) ? b : a), null);
return (
{(l.family || '?')[0]}
{l.fCode} {l.description}
{lineHeld(l) && }
{Number(l.qty).toLocaleString()}
{best
? <>
{jobs.map((jr) => (
{ e.stopPropagation(); onSelectJob(jr.job); }}>
{jr.source === 'inferred' ? '≈ ' : ''}{jr.job}
))}
>
:
no jobs on the floor }
{l.allocatedQty > 0
? `${x.lot} (${x.qtyAllocated})`).join(', ')}>{l.allocatedQty.toLocaleString()} · {(l.lots || []).length} lot{(l.lots || []).length > 1 ? 's' : ''}
: — }
{l.status}
);
}
/* ---- Orders board ---- */
function OrderRow({ o, onSelectJob }) {
const [open, setOpen] = useState(false);
const total = o.lines.reduce((s, l) => s + l.qty, 0);
const allocated = o.lines.reduce((s, l) => s + (l.allocatedQty || 0), 0);
const soonest = o.lines.map((l) => l.requestedDate).filter(Boolean).sort()[0];
return (
setOpen(!open)}>
{o.orderNo} {o.customerPo ? · PO {o.customerPo} : null}
{o.customer}{o.currency ? ` · ${o.currency}` : ''} · {o.lines.length} line{o.lines.length > 1 ? 's' : ''}
{total.toLocaleString()} u
{allocated > 0
? {allocated.toLocaleString()} allocated from FM store
: nothing allocated }
{open && (
{o.lines.map((l) => )}
)}
);
}
function OrdersBoard({ orders, onSelectJob }) {
return (
Sales order
Qty
FM store
Requested
{orders.map((o) =>
)}
{orders.length === 0 &&
No open sales orders.
}
);
}
/* ---- Sales orders page ----
* Read-only dashboard over every open sales order, in the concept's
* OrdersDashboard shape (stat tiles · issues feed · orders table).
* The only system check for now: an open line for an item whose
* item_masters.stock_on_hold is P or F slipped through the capture
* gate. Credit and QC checks are out of scope at this stage. */
function AlertIcon({ size = 13 }) {
return (
);
}
const CCY_SYMBOL = { USD: '$', EUR: '€', GBP: '£', R: 'R', ZAR: 'R' };
function fmtValue(v, ccy) {
return (CCY_SYMBOL[ccy] || (ccy ? ccy + ' ' : '')) + Math.round(v).toLocaleString('en-US');
}
function analyzeSalesOrder(o) {
const held = o.lines.filter(lineHeld);
let value = 0, priced = false;
for (const l of o.lines) if (l.unitPrice != null) { value += l.qty * l.unitPrice; priced = true; }
const requested = o.lines.map((l) => l.requestedDate).filter(Boolean).sort()[0] || null;
const qty = o.lines.reduce((s, l) => s + l.qty, 0);
return { ...o, held, value: priced ? value : null, requested, qty };
}
function SalesOrderRow({ o, open, onToggle, onSelectJob }) {
return (
{o.orderNo.replace(/^0+/, '')} {o.customerPo ? · PO {o.customerPo} : null}
{o.customer}{o.incoterms ? ` · ${o.incoterms}` : ''} · {o.lines.length} line{o.lines.length > 1 ? 's' : ''}
{o.qty.toLocaleString()} u
{o.value != null
? <>
{fmtValue(o.value, o.currency)}
{o.currency || '—'}
>
:
unpriced }
{o.held.length
? {o.held.length} held line{o.held.length > 1 ? 's' : ''}
: Clear }
{open && (
{o.lines.map((l) => )}
)}
);
}
/* Stat tiles shared by the plan board and sales orders page. */
function StatTiles({ stats }) {
return (
{stats.map((s) => (
{s.lbl}
{s.num}
{s.sub &&
{s.sub}
}
))}
);
}
function SalesOrdersPage({ orders, onSelectJob }) {
const [filter, setFilter] = useState('all'); // all | holds
const [query, setQuery] = useState('');
const [openOrders, setOpenOrders] = useState({});
const analyzed = useMemo(() => orders.map(analyzeSalesOrder), [orders]);
const issues = analyzed.flatMap((o) => o.held.map((l) => ({ order: o, line: l })));
const heldOrders = analyzed.filter((o) => o.held.length);
const overdue = (o) => o.requested && daysFromToday(o.requested) < 0;
const stats = [
{ lbl: 'Open orders', num: analyzed.length, sub: `${analyzed.reduce((s, o) => s + o.lines.length, 0)} lines`, accent: 'var(--brand)' },
{ lbl: 'Overdue', num: analyzed.filter(overdue).length, sub: 'past requested date', accent: 'var(--warning)' },
{ lbl: 'Orders with held items', num: heldOrders.length, sub: `${issues.length} held line${issues.length === 1 ? '' : 's'}`, accent: 'var(--danger)', dn: issues.length > 0 },
{ lbl: 'Clear orders', num: analyzed.length - heldOrders.length, sub: 'no stock-hold issues', accent: 'var(--gem-emerald)' },
];
const q = query.trim().toLowerCase();
const shown = analyzed.filter((o) => {
if (filter === 'holds' && !o.held.length) return false;
if (q && !(`${o.orderNo} ${o.customer} ${o.customerPo} ` +
o.lines.map((l) => `${l.fCode} ${l.description}`).join(' ')).toLowerCase().includes(q)) return false;
return true;
});
const sorted = [...shown].sort((a, b) =>
(b.held.length ? 1 : 0) - (a.held.length ? 1 : 0) ||
(a.requested || '9999').localeCompare(b.requested || '9999') ||
a.orderNo.localeCompare(b.orderNo));
function revealOrder(orderNo) {
setOpenOrders((cur) => ({ ...cur, [orderNo]: true }));
// the row exists after this render tick — then bring it into view
requestAnimationFrame(() => {
const el = document.getElementById('so-' + orderNo);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
return (
<>
Order issues
Open lines for items the item master has on stock hold.
{issues.length > 0 &&
{issues.length}}
{issues.length === 0 && (
All open orders clear — no held items detected.
)}
{issues.map((i, idx) => (
revealOrder(i.order.orderNo)}>
Stock hold · {i.line.stockOnHold}
{i.order.orderNo.replace(/^0+/, '')} · {i.order.customer}
{i.line.fCode} {i.line.description ? ` ${i.line.description}` : ''} is on stock hold ({i.line.stockOnHold}),
yet line {i.line.lineNo} is open — {Number(i.line.qty).toLocaleString()} u
{i.line.requestedDate ? ` requested ${fmtDate(i.line.requestedDate)}` : ''} · {i.line.status}
))}
Open sales orders
Every open order line, straight from the v1 sync — click a row for its lines.
setFilter('all')}>All
setFilter('holds')}>With holds
setQuery(e.target.value)} />
Sales order
Qty
Value
Requested
Status
{sorted.map((o) => (
setOpenOrders((cur) => ({ ...cur, [o.orderNo]: !cur[o.orderNo] }))}
onSelectJob={onSelectJob} />
))}
{sorted.length === 0 && No orders match.
}
>
);
}
/* ---- App shell: sidebar nav + topbar (concept's Sidebar/Topbar) ---- */
const NAV_ICONS = {
timeline: <> >,
sales: <> >,
chevdown: ,
lock: <> >,
user: <> >,
};
function Icon({ name, size = 17, style }) {
return (
{NAV_ICONS[name]}
);
}
const NAV = [
{ group: 'Planning', short: 'Plan' },
{ id: 'plan', label: 'Plan board', icon: 'timeline' },
{ group: 'Operations', short: 'Ops' },
{ id: 'sales', label: 'Sales orders', icon: 'sales' },
];
function Sidebar({ active, onNav, counts, collapsed, setCollapsed }) {
return (
setCollapsed(false)} />
setCollapsed(!collapsed)}>
{NAV.map((row, i) =>
row.group ? (
{row.group}
{row.short}
) : (
onNav(row.id)}
title={collapsed ? row.label : undefined}>
{row.label}
{counts[row.id] != null && {counts[row.id]} }
)
)}
Go slice · read-only
);
}
/* The browser's IANA timezone gives the location without a geolocation
* permission prompt (e.g. Africa/Johannesburg → Johannesburg). */
const LOCAL_CITY = (Intl.DateTimeFormat().resolvedOptions().timeZone || '')
.split('/').pop().replace(/_/g, ' ') || 'Local';
function useClock() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 30000);
return () => clearInterval(t);
}, []);
return now.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
}
function Topbar({ crumbs }) {
const time = useClock();
return (
{crumbs.map((c, i) => (
{i > 0 && / }
{i === crumbs.length - 1 ? {c} : {c} }
))}
{LOCAL_CITY} · {time}
Viewer
);
}
/* ---- App root ---- */
const PAGE_TITLES = {
plan: ['Planning', 'Plan board'],
sales: ['Operations', 'Sales orders'],
};
function pageFromHash() { return window.location.hash === '#sales' ? 'sales' : 'plan'; }
function App() {
const [page, setPage] = useState(pageFromHash);
const [collapsed, setCollapsed] = useState(false);
const [view, setView] = useState('job');
const [jobs, setJobs] = useState(null);
const [orders, setOrders] = useState(null);
const [err, setErr] = useState(null);
const [query, setQuery] = useState('');
const [families, setFamilies] = useState([]);
const [hideCaptured, setHideCaptured] = useState(false);
const [showSamples, setShowSamples] = useState(false);
const [selectedJob, setSelectedJob] = useState(null);
const [updatedAt, setUpdatedAt] = useState(null);
const [refreshing, setRefreshing] = useState(false);
const load = React.useCallback(() => {
setRefreshing(true);
Promise.all([
fetch('/api/plan/jobs').then((r) => { if (!r.ok) throw new Error(`jobs: HTTP ${r.status}`); return r.json(); }),
fetch('/api/plan/orders').then((r) => { if (!r.ok) throw new Error(`orders: HTTP ${r.status}`); return r.json(); }),
]).then(([j, o]) => { setJobs(j.jobs || []); setOrders(o.orders || []); setUpdatedAt(new Date()); setErr(null); })
.catch((e) => setErr(String(e)))
.finally(() => setRefreshing(false));
}, []);
// Load on mount, then keep the board live: poll every 60 s (the underlying
// tables are refreshed by v1's sync workers on a similar cadence).
useEffect(() => {
load();
const t = setInterval(load, 60000);
return () => clearInterval(t);
}, [load]);
// Pages are hash-routed (#plan / #sales) so the back button and deep links work.
useEffect(() => {
function onHash() { setPage(pageFromHash()); }
window.addEventListener('hashchange', onHash);
return () => window.removeEventListener('hashchange', onHash);
}, []);
function nav(p) { window.location.hash = p; setPage(p); }
const famOpts = useMemo(() => jobs ? [...new Set(jobs.map((j) => j.family).filter(Boolean))].sort() : [], [jobs]);
const planJobs = useMemo(() => jobs ? jobs.filter((j) => showSamples || !j.sample) : [], [jobs, showSamples]);
const groups = useMemo(() => groupJobs(planJobs), [planJobs]);
// Filters act on whole groups: a group stays if any member matches, so a
// blend job always appears alongside its products.
const shownGroups = useMemo(() => {
const q = query.trim().toLowerCase();
return groups.filter((g) => {
if (q && !g.members.some((j) => `${j.job} ${j.stockCode} ${j.description}`.toLowerCase().includes(q))) return false;
if (families.length && !g.members.some((j) => families.includes(j.family))) return false;
if (hideCaptured && g.lead.phase === 'Captured') return false;
return true;
});
}, [groups, query, families, hideCaptured]);
if (err) return ;
if (!jobs || !orders) return ;
const kpis = [
{ lbl: 'Open jobs', num: planJobs.length, accent: 'var(--brand)' },
{ lbl: 'On the floor', num: planJobs.filter((j) => j.phaseIdx > 0 && j.phase !== 'FM Store').length, accent: 'var(--gem-sapphire)' },
{ lbl: 'In FM store', num: planJobs.filter((j) => j.phase === 'FM Store').length, accent: 'var(--gem-emerald)' },
{ lbl: 'Open orders', num: orders.length, accent: 'var(--gem-quartz)' },
];
const sampleCount = jobs.filter((j) => j.sample).length;
const navCounts = {
plan: planJobs.length,
sales: orders.filter((o) => o.lines.some(lineHeld)).length || null,
};
return (
{PAGE_TITLES[page][1]}
Go slice · live data
{updatedAt && updated {updatedAt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })} }
{refreshing ? 'Refreshing…' : 'Refresh'}
{page === 'sales'
? 'Every open sales order, checked against item-master stock holds — read-only.'
: 'Reading nutec_factory (maintained by the v1 sync) — read-only vertical slice.'}
{page === 'sales' &&
}
{page === 'plan' && <>
setView('job')}>By job
setView('order')}>By sales order
{view === 'job' && (
<>
setQuery(e.target.value)} />
{famOpts.map((f) => (
setFamilies((cur) => cur.includes(f) ? cur.filter((x) => x !== f) : [...cur, f])}>
{f}
))}
setHideCaptured(!hideCaptured)}>Hide captured
{sampleCount > 0 && (
setShowSamples(!showSamples)}
title="Retained-sample jobs (S-suffix) are off the plan by default">
Samples · {sampleCount}
)}
{shownGroups.length}/{groups.length} groups
>
)}
{view === 'job' && (
{PHASES.map((p) => {p} )}
)}
{view === 'job' ?
:
}
>}
{page === 'sales'
? <>Hold flags come straight from item_masters.stock_on_hold (SysPro InvMaster hold status, P or F).
Credit and QC checks are out of scope for this slice.>
: <>Phases derive from real events only: WipMaster capture, riteTIME work-centre scans (via the governed
work-centre→phase map) and FM-store receipt movements. No fabricated percentages.>}
setSelectedJob(null)} onSelect={setSelectedJob} />
);
}
ReactDOM.createRoot(document.getElementById('root')).render( );