// Source Updates flow. // When an upstream source (e.g. an Excel workbook) is edited, AI detects which // fields in this Record changed and surfaces them as pending updates that the // user can Accept or Decline — per-field, or in bulk per source. // // Components exported on window: // SourceUpdatesProvider wraps a Record subtree; holds pending state // useSourceUpdates access full context // useSourceUpdate(id) hook per single update // SourceUpdatedBadge indicator pill/dot in the record header // SourceUpdatedDot inline marker on a changed value // StatTileWithUpdate drop-in StatTile wrapper that handles the // pending/accepted/declined value display // RecordSourceUpdatesPanel side panel listing all pending changes const { useState: useStateSU, useContext: useContextSU, useMemo: useMemoSU, useEffect: useEffectSU, useRef: useRefSU, createContext: createContextSU } = React; /* ===== Mock data: what AI detected ===== */ const SU_SEED_UPDATES = [ { id: 'u-lti', sourceFile: 'Safety-Stats-March-2026.xlsx', sourceKind: 'xlsx', sourcePages: 'Sheet: KPIs · Row 4', sourceBy: 'Vera Hanna', sourceAgo: '12 min ago', field: 'Lost-time incidents (March)', location: 'Safety KPIs', oldValue: '0', newValue: '1', affectedOutputs: ['dashboard', 'report'], status: 'pending', }, { id: 'u-hrs', sourceFile: 'Safety-Stats-March-2026.xlsx', sourceKind: 'xlsx', sourcePages: 'Sheet: KPIs · Row 7', sourceBy: 'Vera Hanna', sourceAgo: '12 min ago', field: 'Hours worked (March)', location: 'Safety KPIs', oldValue: '20,407', newValue: '20,612', affectedOutputs: ['dashboard'], status: 'pending', }, { id: 'u-nlti', sourceFile: 'Safety-Stats-March-2026.xlsx', sourceKind: 'xlsx', sourcePages: 'Sheet: KPIs · Row 11', sourceBy: 'Vera Hanna', sourceAgo: '12 min ago', field: 'NLTI rate (March)', location: 'Safety KPIs', oldValue: '0.00', newValue: '4.85', affectedOutputs: ['dashboard', 'report', 'slide'], status: 'pending', }, { id: 'u-pipe', sourceFile: 'AWST_Pipe_Install_Tracker.xlsx', sourceKind: 'xlsx', sourcePages: 'Sheet: Joints · Cell B142', sourceBy: 'Proxa AI auto-extract', sourceAgo: '1 hr ago', field: 'Welded pipe installed (PTD)', location: 'Pipe installation · Tunnel pipe progress', oldValue: '380 m', newValue: '412 m', affectedOutputs: ['dashboard', 'report', 'slide'], status: 'pending', }, { id: 'u-weldqc', sourceFile: 'AWST_Pipe_Install_Tracker.xlsx', sourceKind: 'xlsx', sourcePages: 'Sheet: QC · Row 8', sourceBy: 'Proxa AI auto-extract', sourceAgo: '1 hr ago', field: 'Weld QC acceptance rate', location: 'Pipe installation', oldValue: '96.4%', newValue: '97.1%', affectedOutputs: ['dashboard', 'report'], status: 'pending', }, { id: 'u-eac', sourceFile: 'Cost-Tracker-March-2026.xlsx', sourceKind: 'xlsx', sourcePages: 'Sheet: Summary · Row 3', sourceBy: 'James Anderson', sourceAgo: '2 hrs ago', field: 'Cost @ Completion (EAC)', location: 'Financial position', oldValue: '$312M', newValue: '$308M', affectedOutputs: ['dashboard', 'report', 'slide'], status: 'pending', }, { id: 'u-pco008', sourceFile: 'AWST_Commercial_Register.xlsx', sourceKind: 'xlsx', sourcePages: 'Sheet: Open items · Row 8', sourceBy: 'James Anderson', sourceAgo: '2 hrs ago', field: 'PCO-008 status', location: 'Open commercial items', oldValue: 'In negotiation', newValue: 'Partial payment agreed', affectedOutputs: ['dashboard', 'report'], status: 'pending', }, ]; /* ===== Context ===== */ const SourceUpdatesContext = createContextSU(null); function SourceUpdatesProvider({ children, initialUpdates }) { const seed = Array.isArray(initialUpdates) ? initialUpdates : SU_SEED_UPDATES; const [updates, setUpdates] = useStateSU(seed); const [panelOpen, setPanelOpen] = useStateSU(false); // Optional filter: a specific source file name, or 'All sources'. const [sourceFilter, setSourceFilter] = useStateSU('All sources'); // Track recently-resolved ids so we can flash a highlight in the dashboard. const [flashIds, setFlashIds] = useStateSU([]); const setStatus = (id, status) => { setUpdates((arr) => arr.map((u) => (u.id === id ? { ...u, status } : u))); setFlashIds((arr) => [...arr, id]); setTimeout(() => { setFlashIds((arr) => arr.filter((x) => x !== id)); }, 1600); }; const accept = (id) => setStatus(id, 'accepted'); const decline = (id) => setStatus(id, 'declined'); const undo = (id) => setUpdates((arr) => arr.map((u) => (u.id === id ? { ...u, status: 'pending' } : u))); const acceptAll = () => updates.forEach((u) => { if (u.status === 'pending') accept(u.id); }); const declineAll = () => updates.forEach((u) => { if (u.status === 'pending') decline(u.id); }); // Reset entirely — useful for demo to replay const reset = () => setUpdates(seed.map((u) => ({ ...u }))); const pendingCount = updates.filter((u) => u.status === 'pending').length; const hasAnyUnresolved = pendingCount > 0; const allCount = updates.length; const resolvedCount = updates.filter((u) => u.status !== 'pending').length; // Group by source file for the panel layout const grouped = useMemoSU(() => { const byFile = {}; updates.forEach((u) => { if (!byFile[u.sourceFile]) byFile[u.sourceFile] = { sourceFile: u.sourceFile, sourceKind: u.sourceKind, sourceBy: u.sourceBy, sourceAgo: u.sourceAgo, items: [] }; byFile[u.sourceFile].items.push(u); }); return Object.values(byFile); }, [updates]); // Distinct source files, in first-seen order, for the filter dropdown. const sourceFiles = useMemoSU(() => { const seen = []; updates.forEach((u) => { if (!seen.includes(u.sourceFile)) seen.push(u.sourceFile); }); return seen; }, [updates]); const value = { updates, grouped, sourceFiles, pendingCount, resolvedCount, allCount, hasAnyUnresolved, flashIds, accept, decline, acceptAll, declineAll, undo, reset, panelOpen, setPanelOpen, sourceFilter, setSourceFilter, // openPanel(file?) — optionally pre-select a source filter. openPanel: (file) => { setSourceFilter(file && sourceFiles.includes(file) ? file : 'All sources'); setPanelOpen(true); }, closePanel: () => setPanelOpen(false), }; return {children}; } function useSourceUpdates() { const ctx = useContextSU(SourceUpdatesContext); if (!ctx) { // graceful no-op when not wrapped (e.g. on screens other than the record) return null; } return ctx; } function useSourceUpdate(id) { const ctx = useSourceUpdates(); if (!ctx || !id) return null; return ctx.updates.find((u) => u.id === id) || null; } /* ===== Inline dot marker (used inside changed cells) ===== */ function SourceUpdatedDot({ update, title }) { const ctx = useSourceUpdates(); if (!update || !ctx) return null; if (update.status !== 'pending') return null; const tip = title || `Source updated · ${update.oldValue} → ${update.newValue} · click to review`; return ( ); } /* ===== StatTile wrapper that consumes a single update ===== */ function StatTileWithUpdate({ updateId, defaultValue, label, sub, tone }) { const ctx = useSourceUpdates(); const showMarkers = false; const update = updateId ? (ctx ? ctx.updates.find((u) => u.id === updateId) : null) : null; const flashing = update && ctx && ctx.flashIds.includes(update.id); let shown = defaultValue; let stateClass = ''; if (update) { if (update.status === 'accepted') { shown = update.newValue; stateClass = 'stat-tile--accepted'; } else if (update.status === 'declined') { shown = update.oldValue; stateClass = 'stat-tile--declined'; } else if (showMarkers) { shown = update.oldValue; stateClass = 'stat-tile--pending'; } else { // markers off + pending: still show old value (no diff hint) shown = update.oldValue; stateClass = ''; } } const showDot = update && update.status === 'pending' && showMarkers; const showGhost = update && update.status === 'pending' && showMarkers; return (
{label} {showDot && }
{shown} {showGhost && ( )}
{sub &&
{sub}
}
); } /* ===== Indicator badge in the record header ===== 'pill' — pill with count + label e.g. "↻ 3 updates" */ function SourceUpdatedBadge({ variant }) { const ctx = useSourceUpdates(); const v = variant || 'pill'; if (!ctx) return null; const { pendingCount, openPanel } = ctx; // Hide entirely once everything is resolved if (pendingCount === 0) return null; if (v === 'dot') { return ( ); } return ( ); } /* ===== Panel shell (mirrors RP styles from record_panels.jsx) ===== */ function SUPanelShell({ title, onClose, toolbar, footer, children }) { useEffectSU(() => { const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = prev; }; }, []); return (
); } /* ===== Per-row Accept/Decline/Undo ===== */ function SUFieldRow({ update }) { const ctx = useSourceUpdates(); if (!ctx) return null; const pending = update.status === 'pending'; const accepted = update.status === 'accepted'; const declined = update.status === 'declined'; const outputs = update.affectedOutputs || []; const OUT_LABEL = { dashboard: 'Dashboard', report: 'Report', slide: 'Slide' }; return (
{update.field}
{update.location}
was {update.oldValue} now {update.newValue}
{outputs.length > 0 && (
Affects: {outputs.map((o) => OUT_LABEL[o] || o).join(', ')}
)}
{pending && ( )} {!pending && (
{accepted ? ( Accepted ) : ( Declined )}
)}
); } /* ===== Source group card ===== */ function SUSourceGroup({ group }) { const ctx = useSourceUpdates(); if (!ctx) return null; const pendingItems = group.items.filter((u) => u.status === 'pending'); const groupAcceptAll = () => pendingItems.forEach((u) => ctx.accept(u.id)); const groupDeclineAll = () => pendingItems.forEach((u) => ctx.decline(u.id)); return (
{group.sourceFile}
Updated by {group.sourceBy} · {group.sourceAgo} · {group.items.length} {group.items.length === 1 ? 'change' : 'changes'}
{pendingItems.length > 1 && (
)}
{group.items.map((u) => )}
); } /* ===== Main panel ===== */ function RecordSourceUpdatesPanel({ onClose }) { const ctx = useSourceUpdates(); if (!ctx) return null; const { grouped, pendingCount, resolvedCount, allCount, acceptAll, declineAll, reset, sourceFiles, sourceFilter, setSourceFilter } = ctx; const filterActive = sourceFilter && sourceFilter !== 'All sources'; const visibleGroups = filterActive ? grouped.filter((g) => g.sourceFile === sourceFilter) : grouped; const filterOptions = ['All sources'].concat(sourceFiles); // Counts scoped to the current filter (drives the summary line). const scopedItems = visibleGroups.reduce((arr, g) => arr.concat(g.items), []); const scopedPending = scopedItems.filter((u) => u.status === 'pending').length; const scopedResolved = scopedItems.length - scopedPending; const toolbar = (
{scopedPending > 0 ? ( ) : ( )}
); const summary = (
{scopedPending > 0 ? {scopedPending} pending {scopedPending === 1 ? 'change' : 'changes'} · {scopedResolved} resolved{filterActive ? ' · in this source' : ''} : All {filterActive ? scopedItems.length : allCount} changes resolved{filterActive ? ' · in this source' : ''}} {filterActive && ( )}
); const footer = (
Detected by Proxa AI · monitoring 7 sources across 3 outputs
); return (
Upstream data has changed since this record was last updated. Review what's different and choose what to bring in.
{summary}
{visibleGroups.length > 0 ? visibleGroups.map((g) => ) :
No changes from this source.
}
); } Object.assign(window, { SourceUpdatesProvider, useSourceUpdates, useSourceUpdate, SourceUpdatedBadge, SourceUpdatedDot, StatTileWithUpdate, RecordSourceUpdatesPanel, });