/* 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 ( <>
); } /* ---- 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.
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 ( ); } /* 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
Failed to load: {err}
; if (!jobs || !orders) return
Loading live plan data…
; 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' })}}

{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' && <>
{view === 'job' && ( <>
setQuery(e.target.value)} />
{famOpts.map((f) => ( ))} {sampleCount > 0 && ( )} {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();