// 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