diff --git a/apps/azure/e2e/full-lifecycle.spec.ts b/apps/azure/e2e/full-lifecycle.spec.ts new file mode 100644 index 000000000..6b473cd08 --- /dev/null +++ b/apps/azure/e2e/full-lifecycle.spec.ts @@ -0,0 +1,191 @@ +import { test, expect, type Page } from '@playwright/test'; + +const DB_NAME = 'VaRiScoutAzure'; +const NOW = 1_777_161_600_000; // 2026-04-26T00:00:00.000Z + +async function waitForAppReady(page: Page) { + await page.goto('/'); + await expect( + page + .locator('text=Start Your Analysis') + .or(page.locator('[data-testid="project-dashboard"]')) + .or(page.locator('text=Process Hubs').first()) + ).toBeVisible({ timeout: 15000 }); +} + +async function seedLifecycleHub(page: Page) { + await waitForAppReady(page); + + await page.evaluate( + ({ dbName, now }) => + new Promise((resolve, reject) => { + const openReq = indexedDB.open(dbName); + openReq.onerror = () => reject(new Error(`IDB open failed: ${openReq.error?.message}`)); + openReq.onsuccess = () => { + const db = openReq.result; + const hubId = 'lifecycle-hub'; + const investigationId = 'lifecycle-investigation'; + const projectId = 'lifecycle-ip'; + const sustainmentId = 'lifecycle-sustainment'; + + try { + const tx = db.transaction( + [ + 'projects', + 'processHubs', + 'improvementProjects', + 'actionItems', + 'sustainmentRecords', + ], + 'readwrite' + ); + + tx.objectStore('projects').put({ + name: 'Lifecycle Demo Hub', + location: 'personal', + modified: new Date('2026-05-01T00:00:00.000Z'), + synced: false, + data: {}, + }); + + tx.objectStore('processHubs').put({ + id: hubId, + name: 'Lifecycle Demo Hub', + processGoal: 'Demonstrate the full response path lifecycle.', + outcomes: [ + { + id: 'outcome-1', + hubId, + columnName: 'fill_weight', + characteristicType: 'nominalIsBest', + createdAt: now, + deletedAt: null, + }, + ], + investigations: [ + { + id: investigationId, + name: 'Fill weight lifecycle', + createdAt: now, + updatedAt: now, + deletedAt: null, + metadata: { + processHubId: hubId, + investigationStatus: 'controlled', + findingCounts: {}, + questionCounts: {}, + actionCounts: { total: 1, completed: 1, overdue: 0 }, + }, + }, + ], + createdAt: now, + updatedAt: now, + deletedAt: null, + }); + + tx.objectStore('improvementProjects').put({ + id: projectId, + hubId, + status: 'closed', + metadata: { title: 'Reduce fill-weight drift', investigationId }, + goal: { + outcomeGoal: { outcomeSpecId: 'outcome-1', target: 1.33 }, + freeText: 'Hold Cpk above 1.33.', + }, + sections: { + background: {}, + investigationLineage: {}, + approach: { actionItemIds: ['action-1'] }, + outcomeReference: { sustainmentRecordId: sustainmentId }, + }, + createdAt: now, + updatedAt: now, + deletedAt: null, + }); + + tx.objectStore('actionItems').put({ + id: 'action-1', + hubId, + text: 'Lock revised fill-weight standard work', + stepId: 'step-1', + status: 'done', + completedAt: '2026-04-20T00:00:00.000Z', + parentImprovementProjectId: projectId, + parentImprovementIdeaId: null, + createdAt: now, + deletedAt: null, + }); + + tx.objectStore('sustainmentRecords').put({ + id: sustainmentId, + investigationId, + hubId, + status: 'confirmed-sustained', + title: 'Hold fill-weight gain', + improvementProjectId: projectId, + targetSummary: 'Cpk stays above 1.33.', + consecutiveOnTargetTicks: 4, + hasOverride: false, + lastEvaluatedSnapshotId: 'snapshot-1', + cadence: 'weekly', + latestVerdict: 'holding', + latestReviewAt: '2026-03-01T00:00:00.000Z', + createdAt: now - 70 * 24 * 60 * 60 * 1000, + updatedAt: now - 60 * 24 * 60 * 60 * 1000, + deletedAt: null, + }); + + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(new Error(`IDB write failed: ${tx.error?.message}`)); + }; + } catch (error) { + db.close(); + reject(error); + } + }; + }), + { dbName: DB_NAME, now: NOW } + ); + + await page.reload(); + await expect(page.locator('text=Process Hubs').first()).toBeVisible({ timeout: 15000 }); +} + +test.describe('Azure full response-path lifecycle', () => { + test('opens a confirmed sustainment handoff prompt and advances handoff controls', async ({ + page, + }) => { + await seedLifecycleHub(page); + + await page.getByRole('button', { name: 'Open Lifecycle Demo Hub' }).click(); + + await expect(page.getByText(/Hold fill-weight gain confirmed sustained/i)).toBeVisible({ + timeout: 10000, + }); + await page.getByRole('button', { name: 'Record control handoff' }).click(); + + await expect(page.getByRole('heading', { name: 'Handoff' })).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Hold fill-weight gain')).toBeVisible(); + + await page.getByLabel('System name').fill('QMS-42'); + await page.getByLabel('Process owner').fill('Process owner'); + await page.getByLabel('Escalation path').fill('Escalate drift to production manager'); + await page + .getByRole('textbox', { name: 'Reaction plan' }) + .fill('Restore standard work before next shift'); + await page.getByRole('button', { name: 'Acknowledge handoff' }).click(); + await page.getByRole('button', { name: 'Mark operational' }).click(); + + await expect(page.getByText('Status operational')).toBeVisible({ timeout: 5000 }); + await expect( + page + .getByRole('button', { name: 'Run sponsor signoff' }) + .or(page.getByRole('button', { name: 'Sponsor signoff locked' })) + ).toBeVisible(); + }); +}); diff --git a/apps/azure/src/App.tsx b/apps/azure/src/App.tsx index be474ed75..418ace863 100644 --- a/apps/azure/src/App.tsx +++ b/apps/azure/src/App.tsx @@ -190,6 +190,7 @@ function AppContent({ // When true, Editor mounts directly into PasteScreen (used by "Add framing" CTA). // Reset to false once consumed so subsequent navigations don't re-trigger paste. const [pendingStartPaste, setPendingStartPaste] = useState(false); + const [pendingHandoffTargetId, setPendingHandoffTargetId] = useState(null); // Resolve deep link from URL params const deepLink = useMemo(() => { @@ -247,16 +248,23 @@ function AppContent({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const navigateToEditor = (projectId?: string, processHubId?: string, startPaste?: boolean) => { + const navigateToEditor = ( + projectId?: string, + processHubId?: string, + startPaste?: boolean, + handoffTargetId?: string + ) => { setCurrentProject(projectId || null); setPendingProcessHubId(processHubId || null); if (startPaste) setPendingStartPaste(true); + setPendingHandoffTargetId(handoffTargetId || null); setCurrentView('editor'); }; const navigateToDashboard = () => { setCurrentProject(null); setPendingStartPaste(false); + setPendingHandoffTargetId(null); setCurrentView('dashboard'); }; @@ -292,8 +300,8 @@ function AppContent({ )} {currentView === 'dashboard' && !deepLinkError && ( - navigateToEditor(id, processHubId, startPaste) + onOpenProject={(id, processHubId, startPaste, handoffTargetId) => + navigateToEditor(id, processHubId, startPaste, handoffTargetId) } onLoadSample={handleLoadSample} /> @@ -303,6 +311,7 @@ function AppContent({ projectId={currentProject} onBack={navigateToDashboard} initialProcessHubId={currentProject ? undefined : (pendingProcessHubId ?? undefined)} + initialHandoffTargetId={pendingHandoffTargetId ?? undefined} initialSample={pendingSample} startPasteOnMount={pendingStartPaste} onOpenSettings={() => setIsSettingsOpen(true)} diff --git a/apps/azure/src/components/ControlHandoffEditor.tsx b/apps/azure/src/components/ControlHandoffEditor.tsx index 49591b983..30a33482d 100644 --- a/apps/azure/src/components/ControlHandoffEditor.tsx +++ b/apps/azure/src/components/ControlHandoffEditor.tsx @@ -67,6 +67,7 @@ const ControlHandoffEditor: React.FC = ({ id: existingHandoff?.id ?? crypto.randomUUID(), investigationId, hubId, + status: existingHandoff?.status ?? 'pending', surface, systemName, // operationalOwner is the person operating the control, NOT the submitter. diff --git a/apps/azure/src/components/ProcessHubReviewPanel.tsx b/apps/azure/src/components/ProcessHubReviewPanel.tsx index cfab0fa46..a086ed5ed 100644 --- a/apps/azure/src/components/ProcessHubReviewPanel.tsx +++ b/apps/azure/src/components/ProcessHubReviewPanel.tsx @@ -83,12 +83,14 @@ const ProcessHubReviewPanel: React.FC = ({ improvementProjects: rollup.hub.improvementProjects ?? [], sustainmentRecords: rollup.sustainmentRecords, sustainmentReviews: rollup.hub.sustainmentReviews ?? [], + controlHandoffs: rollup.controlHandoffs, now: Date.now(), }), [ rollup.hub.id, rollup.hub.improvementProjects, rollup.hub.sustainmentReviews, + rollup.controlHandoffs, rollup.sustainmentRecords, ] ); @@ -131,6 +133,10 @@ const ProcessHubReviewPanel: React.FC = ({ onOpenInvestigation(targetId); return; } + if (surface === 'handoff' && targetId) { + onRecordHandoff(targetId); + return; + } if (surface === 'improvement-projects' && targetId) { onOpenInvestigation(targetProject?.metadata.investigationId ?? targetId); return; diff --git a/apps/azure/src/components/__tests__/ProcessHubSustainmentRegion.test.tsx b/apps/azure/src/components/__tests__/ProcessHubSustainmentRegion.test.tsx index 67551cb31..c09ac5768 100644 --- a/apps/azure/src/components/__tests__/ProcessHubSustainmentRegion.test.tsx +++ b/apps/azure/src/components/__tests__/ProcessHubSustainmentRegion.test.tsx @@ -189,6 +189,7 @@ describe('ProcessHubSustainmentRegion', () => { id: 'ho-1', investigationId: 'inv-3', hubId: 'hub-1', + status: 'operational', surface: 'qms-procedure', systemName: 'QMS', operationalOwner: { displayName: 'Alice' }, @@ -328,6 +329,7 @@ describe('ProcessHubSustainmentRegion', () => { id: 'ho-2', investigationId: 'inv-5', hubId: 'hub-1', + status: 'operational', surface: 'dashboard-only', systemName: 'Dashboard', operationalOwner: { displayName: 'Bob' }, diff --git a/apps/azure/src/components/editor/FrameView.tsx b/apps/azure/src/components/editor/FrameView.tsx index 098e30aa0..060184b2b 100644 --- a/apps/azure/src/components/editor/FrameView.tsx +++ b/apps/azure/src/components/editor/FrameView.tsx @@ -21,6 +21,7 @@ import { } from '@variscout/stores'; import type { CanvasInvestigationFocus } from '@variscout/hooks'; import type { + ControlHandoff, EvidenceSnapshot, StepCapabilityStamp, SustainmentRecord, @@ -36,6 +37,7 @@ import { useInvestigationFeatureStore } from '../../features/investigation/inves const EMPTY_PRIOR_STEP_STATS: ReadonlyMap = new Map(); const EMPTY_ACTION_ITEMS: ActionItem[] = []; const EMPTY_SUSTAINMENT_RECORDS: SustainmentRecord[] = []; +const EMPTY_CONTROL_HANDOFFS: ControlHandoff[] = []; function mergeActionItems( current: readonly ActionItem[], @@ -100,6 +102,8 @@ const FrameView: React.FC = () => { const [actionItems, setActionItems] = React.useState(EMPTY_ACTION_ITEMS); const [sustainmentRecords, setSustainmentRecords] = React.useState(EMPTY_SUSTAINMENT_RECORDS); + const [controlHandoffs, setControlHandoffs] = + React.useState(EMPTY_CONTROL_HANDOFFS); const activeHubIdRef = React.useRef(activeHubId); React.useEffect(() => { @@ -130,6 +134,7 @@ const FrameView: React.FC = () => { React.useEffect(() => { setActionItems(EMPTY_ACTION_ITEMS); setSustainmentRecords(EMPTY_SUSTAINMENT_RECORDS); + setControlHandoffs(EMPTY_CONTROL_HANDOFFS); if (!activeHubId) { return; @@ -138,15 +143,17 @@ const FrameView: React.FC = () => { let cancelled = false; void (async () => { try { - const [items, records] = await Promise.all([ + const [items, records, handoffs] = await Promise.all([ azureHubRepository.actionItems.listByHub(activeHubId), azureHubRepository.sustainmentRecords.listByHub(activeHubId), + azureHubRepository.controlHandoffs.listByHub(activeHubId), ]); if (!cancelled) { setActionItems(items); setSustainmentRecords( records.filter((record: SustainmentRecord) => record.deletedAt === null) ); + setControlHandoffs(handoffs.filter(handoff => handoff.deletedAt === null)); } } catch { // Keep any in-memory quick actions if the local repository is unavailable. @@ -190,9 +197,16 @@ const FrameView: React.FC = () => { description: record.status, })), }, - { surfaceType: 'handoff', items: [] }, + { + surfaceType: 'handoff', + items: controlHandoffs.map(handoff => ({ + id: handoff.id, + label: handoff.systemName || handoff.operationalOwner.displayName || 'Handoff', + description: handoff.status, + })), + }, ]; - }, [activeHubId, hypotheses, projectsByHub, sustainmentRecords]); + }, [activeHubId, controlHandoffs, hypotheses, projectsByHub, sustainmentRecords]); const signals: WorkflowReadinessSignals = React.useMemo(() => { const improvementProjects = (activeHubId ? (projectsByHub[activeHubId] ?? []) : []).filter( @@ -215,9 +229,10 @@ const FrameView: React.FC = () => { return surveyInboxRules({ improvementProjects, sustainmentRecords, + controlHandoffs, now: Date.now(), }); - }, [activeHubId, projectsByHub, sustainmentRecords]); + }, [activeHubId, controlHandoffs, projectsByHub, sustainmentRecords]); const handleSeeData = React.useCallback(() => { usePanelsStore.getState().showAnalysis(); @@ -314,6 +329,10 @@ const FrameView: React.FC = () => { usePanelsStore.getState().showSustainment(prompt.action?.opensId); return; } + if (surface === 'handoff') { + usePanelsStore.getState().showHandoff(prompt.action?.opensId); + return; + } if (surface === 'improvement-projects') { usePanelsStore.getState().showCharter(); return; @@ -337,9 +356,13 @@ const FrameView: React.FC = () => { usePanelsStore.getState().showSustainment(item.id); return; } + if (controlHandoffs.some(handoff => handoff.id === item.id)) { + usePanelsStore.getState().showHandoff(item.id); + return; + } usePanelsStore.getState().showInvestigation(); }, - [activeHubId, sustainmentRecords] + [activeHubId, controlHandoffs, sustainmentRecords] ); return ( diff --git a/apps/azure/src/components/editor/__tests__/FrameView.test.tsx b/apps/azure/src/components/editor/__tests__/FrameView.test.tsx index 96edcce51..7662a92a5 100644 --- a/apps/azure/src/components/editor/__tests__/FrameView.test.tsx +++ b/apps/azure/src/components/editor/__tests__/FrameView.test.tsx @@ -57,6 +57,7 @@ const hoisted = vi.hoisted(() => ({ listByHubMock: vi.fn(), actionItemsListByHubMock: vi.fn(), sustainmentRecordsListByHubMock: vi.fn(), + controlHandoffsListByHubMock: vi.fn(), dispatchMock: vi.fn(), })); @@ -249,6 +250,9 @@ vi.mock('../../../persistence', () => ({ sustainmentRecords: { listByHub: hoisted.sustainmentRecordsListByHubMock, }, + controlHandoffs: { + listByHub: hoisted.controlHandoffsListByHubMock, + }, }, })); @@ -276,6 +280,8 @@ describe('FrameView (Azure shell)', () => { hoisted.actionItemsListByHubMock.mockResolvedValue([]); hoisted.sustainmentRecordsListByHubMock.mockReset(); hoisted.sustainmentRecordsListByHubMock.mockResolvedValue([]); + hoisted.controlHandoffsListByHubMock.mockReset(); + hoisted.controlHandoffsListByHubMock.mockResolvedValue([]); hoisted.dispatchMock.mockReset(); hoisted.dispatchMock.mockResolvedValue(undefined); improvementProjectStateRef.current = { diff --git a/apps/azure/src/components/handoff/HandoffPanel.tsx b/apps/azure/src/components/handoff/HandoffPanel.tsx index 59e56d350..713ef6ee5 100644 --- a/apps/azure/src/components/handoff/HandoffPanel.tsx +++ b/apps/azure/src/components/handoff/HandoffPanel.tsx @@ -1,28 +1,295 @@ -import React from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { HandoffForm, type HandoffChangePatch } from '@variscout/ui'; +import { useTier } from '@variscout/hooks'; +import type { ControlHandoff, ProcessHub, SustainmentRecord } from '@variscout/core'; +import { azureHubRepository } from '../../persistence'; interface HandoffPanelProps { + activeHub?: ProcessHub; + targetId?: string; onBack: () => void; } -const HandoffPanel: React.FC = ({ onBack }) => { +function makeId(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID(); + return `handoff-${Date.now()}`; +} + +function liveConfirmedRecords(records: SustainmentRecord[] | undefined): SustainmentRecord[] { + return (records ?? []).filter( + record => record.deletedAt === null && record.status === 'confirmed-sustained' + ); +} + +function selectRecord(records: SustainmentRecord[], targetId: string | undefined) { + if (targetId) { + const byId = records.find(record => record.id === targetId); + if (byId) return byId; + const byHandoff = records.find(record => record.controlHandoffId === targetId); + if (byHandoff) return byHandoff; + } + return records[0] ?? null; +} + +function selectHandoff( + handoffs: ControlHandoff[], + record: SustainmentRecord | null, + targetId: string | undefined +) { + if (targetId) { + const byId = handoffs.find(handoff => handoff.id === targetId); + if (byId) return byId; + } + if (!record) return null; return ( -
-

Handoff

-

- Handoff transfers ownership of a confirmed-sustained improvement to the process owner with a - control plan. The full handoff surface ships in a future release. -

-

Available in a future release.

- + handoffs.find(handoff => handoff.id === record.controlHandoffId) ?? + handoffs.find(handoff => handoff.investigationId === record.investigationId) ?? + null + ); +} + +function buildDraftHandoff(hub: ProcessHub, record: SustainmentRecord): ControlHandoff { + const now = Date.now(); + return { + id: makeId(), + investigationId: record.investigationId, + hubId: hub.id, + status: 'pending', + surface: 'qms-procedure', + systemName: '', + operationalOwner: record.owner ?? hub.processOwner ?? { displayName: '' }, + handoffDate: now, + description: record.targetSummary ?? '', + retainSustainmentReview: true, + recordedBy: { displayName: 'Azure user' }, + escalationPath: record.openConcerns, + reactionPlan: '', + createdAt: now, + deletedAt: null, + }; +} + +const HandoffPanel: React.FC = ({ activeHub, targetId, onBack }) => { + const { isPaid } = useTier(); + const [records, setRecords] = useState([]); + const [handoffs, setHandoffs] = useState([]); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(Boolean(activeHub)); + const creatingForRecordRef = useRef(null); + + useEffect(() => { + setRecords(liveConfirmedRecords(activeHub?.sustainmentRecords)); + setHandoffs((activeHub?.controlHandoffs ?? []).filter(handoff => handoff.deletedAt === null)); + setError(null); + setIsLoading(Boolean(activeHub)); + + if (!activeHub) { + setIsLoading(false); + return; + } + + let cancelled = false; + void Promise.all([ + azureHubRepository.sustainmentRecords.listByHub(activeHub.id), + azureHubRepository.controlHandoffs.listByHub(activeHub.id), + ]) + .then(([loadedRecords, loadedHandoffs]) => { + if (cancelled) return; + setRecords(liveConfirmedRecords(loadedRecords)); + setHandoffs(loadedHandoffs.filter(handoff => handoff.deletedAt === null)); + }) + .catch(() => { + if (cancelled) return; + setRecords(liveConfirmedRecords(activeHub.sustainmentRecords)); + setHandoffs( + (activeHub.controlHandoffs ?? []).filter(handoff => handoff.deletedAt === null) + ); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [activeHub]); + + const selectedRecord = useMemo(() => selectRecord(records, targetId), [records, targetId]); + const selectedHandoff = useMemo( + () => selectHandoff(handoffs, selectedRecord, targetId), + [handoffs, selectedRecord, targetId] + ); + + useEffect(() => { + if (!activeHub || isLoading || !selectedRecord || selectedHandoff) return; + if (creatingForRecordRef.current === selectedRecord.id) return; + + const draft = buildDraftHandoff(activeHub, selectedRecord); + creatingForRecordRef.current = selectedRecord.id; + setError(null); + let cancelled = false; + + void (async () => { + await azureHubRepository.dispatch({ + kind: 'CONTROL_HANDOFF_CREATE', + hubId: activeHub.id, + handoff: draft, + }); + if (!selectedRecord.controlHandoffId) { + await azureHubRepository.dispatch({ + kind: 'SUSTAINMENT_RECORD_UPDATE', + recordId: selectedRecord.id, + patch: { controlHandoffId: draft.id }, + }); + } + })() + .then(() => { + if (cancelled) return; + setHandoffs(current => [...current, draft]); + setRecords(current => + current.map(record => + record.id === selectedRecord.id ? { ...record, controlHandoffId: draft.id } : record + ) + ); + }) + .catch(() => { + if (!cancelled) setError('Could not create a handoff.'); + }) + .finally(() => { + if (creatingForRecordRef.current === selectedRecord.id) creatingForRecordRef.current = null; + }); + + return () => { + cancelled = true; + }; + }, [activeHub, isLoading, selectedHandoff, selectedRecord]); + + const patchHandoff = useCallback( + (patch: HandoffChangePatch) => { + if (!selectedHandoff) return; + const next = { ...selectedHandoff, ...patch }; + setHandoffs(current => + current.map(handoff => (handoff.id === selectedHandoff.id ? next : handoff)) + ); + void azureHubRepository + .dispatch({ kind: 'CONTROL_HANDOFF_UPDATE', handoffId: selectedHandoff.id, patch }) + .catch(() => setError('Could not save handoff changes.')); + }, + [selectedHandoff] + ); + + const acknowledge = useCallback(() => { + if (!selectedHandoff) return; + const acknowledgedAt = Date.now(); + const acknowledgedBy = selectedHandoff.operationalOwner; + setHandoffs(current => + current.map(handoff => + handoff.id === selectedHandoff.id + ? { + ...handoff, + status: 'acknowledged', + acknowledgedAt, + ownerAcknowledgement: { acknowledgedBy }, + } + : handoff + ) + ); + void azureHubRepository.dispatch({ + kind: 'CONTROL_HANDOFF_ACKNOWLEDGE', + handoffId: selectedHandoff.id, + acknowledgedAt, + acknowledgedBy, + }); + }, [selectedHandoff]); + + const markOperational = useCallback(() => { + if (!selectedHandoff) return; + const operationalAt = Date.now(); + setHandoffs(current => + current.map(handoff => + handoff.id === selectedHandoff.id + ? { ...handoff, status: 'operational', operationalAt } + : handoff + ) + ); + void azureHubRepository.dispatch({ + kind: 'CONTROL_HANDOFF_MARK_OPERATIONAL', + handoffId: selectedHandoff.id, + operationalAt, + }); + }, [selectedHandoff]); + + const sponsorSignoff = useCallback(() => { + if (!selectedHandoff) return; + const signoff = { approvedAt: Date.now(), approvedBy: { displayName: 'Sponsor' } }; + setHandoffs(current => + current.map(handoff => + handoff.id === selectedHandoff.id + ? { + ...handoff, + status: 'operational', + operationalAt: handoff.operationalAt ?? signoff.approvedAt, + signoff: { ...(handoff.signoff ?? {}), ...signoff }, + } + : handoff + ) + ); + void azureHubRepository.dispatch({ + kind: 'CONTROL_HANDOFF_SIGNOFF', + handoffId: selectedHandoff.id, + signoff, + }); + }, [selectedHandoff]); + + const heading = activeHub?.name ?? 'No active hub'; + + return ( +
+
+
+

Handoff

+

{heading}

+
+ +
+ + {!activeHub ? ( +

+ Create or select a Process Hub before opening handoff. +

+ ) : error ? ( +

+ {error} +

+ ) : isLoading ? ( +

+ Loading handoff... +

+ ) : selectedHandoff ? ( + + ) : selectedRecord ? ( +

+ Creating handoff... +

+ ) : ( +

+ Confirm sustainment before recording handoff. +

+ )}
); }; diff --git a/apps/azure/src/features/panels/__tests__/panelsStore.test.ts b/apps/azure/src/features/panels/__tests__/panelsStore.test.ts index 19492ca34..865eabd1e 100644 --- a/apps/azure/src/features/panels/__tests__/panelsStore.test.ts +++ b/apps/azure/src/features/panels/__tests__/panelsStore.test.ts @@ -451,8 +451,9 @@ describe('panelsStore', () => { }); it('showHandoff sets activeView to handoff', () => { - usePanelsStore.getState().showHandoff(); + usePanelsStore.getState().showHandoff('sr-1'); expect(usePanelsStore.getState().activeView).toBe('handoff'); + expect(usePanelsStore.getState().handoffTargetId).toBe('sr-1'); }); }); }); diff --git a/apps/azure/src/features/panels/panelsStore.ts b/apps/azure/src/features/panels/panelsStore.ts index 14ab4fc68..aea779d68 100644 --- a/apps/azure/src/features/panels/panelsStore.ts +++ b/apps/azure/src/features/panels/panelsStore.ts @@ -38,6 +38,7 @@ interface PanelsState { /** ID of idea highlighted via matrix<->card bidirectional navigation */ highlightedIdeaId: string | null; sustainmentTargetId: string | null; + handoffTargetId: string | null; } // ── Actions ────────────────────────────────────────────────────────────────── @@ -51,7 +52,7 @@ interface PanelsActions { showReport: () => void; showCharter: () => void; showSustainment: (targetId?: string) => void; - showHandoff: () => void; + showHandoff: (targetId?: string) => void; openDataTable: () => void; closeDataTable: () => void; setFindingsOpen: (open: boolean) => void; @@ -116,6 +117,7 @@ export const usePanelsStore = create(set => ({ activeImprovementView: 'plan', highlightedIdeaId: null, sustainmentTargetId: null, + handoffTargetId: null, // Workspace navigation (ADR-055 + header-redesign spec, extended with 'frame' per ADR-070) showDashboard: () => set(() => ({ activeView: 'dashboard' })), @@ -143,7 +145,12 @@ export const usePanelsStore = create(set => ({ isFindingsOpen: false, sustainmentTargetId: targetId ?? null, })), - showHandoff: () => set(() => ({ activeView: 'handoff', isFindingsOpen: false })), + showHandoff: targetId => + set(() => ({ + activeView: 'handoff', + isFindingsOpen: false, + handoffTargetId: targetId ?? null, + })), // Data table openDataTable: () => set({ isDataTableOpen: true }), diff --git a/apps/azure/src/pages/Dashboard.tsx b/apps/azure/src/pages/Dashboard.tsx index 725e718a8..10a2004f3 100644 --- a/apps/azure/src/pages/Dashboard.tsx +++ b/apps/azure/src/pages/Dashboard.tsx @@ -44,9 +44,17 @@ import EvidenceSheet from '../components/EvidenceSheet'; import ProcessHubView from '../components/ProcessHubView'; import SampleDataPicker from '../components/SampleDataPicker'; import StateItemNotesDrawer from '../components/StateItemNotesDrawer'; +import { usePanelsStore } from '../features/panels/panelsStore'; + +const PENDING_HANDOFF_TARGET_KEY = 'variscout.pendingHandoffTargetId'; interface DashboardProps { - onOpenProject: (id?: string, processHubId?: string, startPaste?: boolean) => void; + onOpenProject: ( + id?: string, + processHubId?: string, + startPaste?: boolean, + handoffTargetId?: string + ) => void; /** Load a .vrs project file (from SharePoint download) */ onLoadProjectFile?: (file: File) => void; /** Load a sample dataset directly into a new analysis */ @@ -253,10 +261,16 @@ export const Dashboard: React.FC = ({ ); const handleRecordHandoff = useCallback( - (investigationId: string) => { - onOpenProject(investigationId); + (targetId: string) => { + const record = sustainmentRecords.find( + r => r.id === targetId || r.controlHandoffId === targetId || r.investigationId === targetId + ); + const handoffTargetId = record?.id ?? targetId; + window.sessionStorage.setItem(PENDING_HANDOFF_TARGET_KEY, handoffTargetId); + usePanelsStore.getState().showHandoff(handoffTargetId); + onOpenProject(undefined, record?.hubId, false, handoffTargetId); }, - [onOpenProject] + [sustainmentRecords, onOpenProject] ); const handleResponsePathAction = useCallback( diff --git a/apps/azure/src/pages/Editor.tsx b/apps/azure/src/pages/Editor.tsx index 4eac4ef89..054d2b71f 100644 --- a/apps/azure/src/pages/Editor.tsx +++ b/apps/azure/src/pages/Editor.tsx @@ -116,6 +116,7 @@ const WhatIfPage = lazyWithRetry(() => import('../components/WhatIfPage')); const ReportView = lazyWithRetry(() => import('../components/views/ReportView')); const INVESTIGATION_DEPTHS: InvestigationDepth[] = ['quick', 'focused', 'chartered']; +const PENDING_HANDOFF_TARGET_KEY = 'variscout.pendingHandoffTargetId'; const INVESTIGATION_STATUSES: InvestigationStatus[] = [ 'issue-captured', 'framing', @@ -286,6 +287,8 @@ interface EditorProps { initialSample?: SampleDataset | null; /** Process Hub to assign when starting a new investigation from the hub home */ initialProcessHubId?: string; + /** Open Handoff after cross-view navigation from Survey Inbox. */ + initialHandoffTargetId?: string; /** * When true, open PasteScreen immediately on mount (used by "Add framing" CTA * to route directly to the paste flow rather than stopping at EditorEmptyState). @@ -303,6 +306,7 @@ export const Editor: React.FC = ({ initialMode, initialSample, initialProcessHubId, + initialHandoffTargetId, startPasteOnMount, }) => { const { @@ -477,6 +481,11 @@ export const Editor: React.FC = ({ // Panel visibility and chart/table sync (Zustand store) const activeView = usePanelsStore(s => s.activeView); + const handoffTargetId = usePanelsStore(s => s.handoffTargetId); + const sustainmentTargetId = usePanelsStore(s => s.sustainmentTargetId); + const [navigationHandoffTargetId, setNavigationHandoffTargetId] = useState( + initialHandoffTargetId ?? null + ); const isCoScoutOpen = usePanelsStore(s => s.isCoScoutOpen); const isWhatIfOpen = usePanelsStore(s => s.isWhatIfOpen); const isPISidebarOpen = usePanelsStore(s => s.isPISidebarOpen); @@ -489,6 +498,22 @@ export const Editor: React.FC = ({ usePanelsStore.getState().initFromViewState(viewState); }, [viewState]); + useEffect(() => { + const pendingHandoffTargetId = + initialHandoffTargetId ?? window.sessionStorage.getItem(PENDING_HANDOFF_TARGET_KEY); + if (pendingHandoffTargetId) { + window.sessionStorage.removeItem(PENDING_HANDOFF_TARGET_KEY); + setNavigationHandoffTargetId(pendingHandoffTargetId); + usePanelsStore.getState().showHandoff(pendingHandoffTargetId); + } + }, [initialHandoffTargetId]); + + useEffect(() => { + if (handoffTargetId && activeView !== 'handoff') { + usePanelsStore.getState().showHandoff(handoffTargetId); + } + }, [activeView, handoffTargetId]); + // Bridge hook: persists Zustand panel state to DataContext (IndexedDB/OneDrive) usePanelsPersistence(handleViewStateChange); @@ -1556,7 +1581,22 @@ export const Editor: React.FC = ({ }} className="flex-1 flex flex-col min-h-0 bg-surface rounded-xl border border-edge overflow-hidden" > - {rawData.length === 0 ? ( + {activeView === 'handoff' || navigationHandoffTargetId ? ( + { + setNavigationHandoffTargetId(null); + usePanelsStore.getState().showFrame(); + }} + /> + ) : activeView === 'sustainment' ? ( + usePanelsStore.getState().showFrame()} + /> + ) : rawData.length === 0 ? ( = ({ usePanelsStore.getState().showInvestigation(); }} /> - ) : activeView === 'sustainment' ? ( - usePanelsStore.getState().showFrame()} - /> - ) : activeView === 'handoff' ? ( - usePanelsStore.getState().showFrame()} /> ) : activeView === 'investigation' ? ( { await saveProcessHubToIndexedDB(hubRow); // Drop stale rows for this hub, then bulk-put incoming snapshot rows. @@ -81,6 +94,17 @@ export class AzureHubRepository implements HubRepository { if (incomingSustainmentReviews.length > 0) { await db.sustainmentReviews.bulkPut(incomingSustainmentReviews); } + + const incomingControlHandoffs = controlHandoffs ?? []; + const incomingHandoffIds = new Set(incomingControlHandoffs.map(handoff => handoff.id)); + await db.controlHandoffs + .where('hubId') + .equals(action.hub.id) + .filter(handoff => !incomingHandoffIds.has(handoff.id)) + .delete(); + if (incomingControlHandoffs.length > 0) { + await db.controlHandoffs.bulkPut(incomingControlHandoffs); + } } ); return; @@ -100,7 +124,13 @@ export class AzureHubRepository implements HubRepository { async get(id) { return db.transaction( 'r', - [db.processHubs, db.improvementProjects, db.sustainmentRecords, db.sustainmentReviews], + [ + db.processHubs, + db.improvementProjects, + db.sustainmentRecords, + db.sustainmentReviews, + db.controlHandoffs, + ], async () => { const hub = await db.processHubs.get(id); if (!hub) return undefined; @@ -112,7 +142,13 @@ export class AzureHubRepository implements HubRepository { async list() { return db.transaction( 'r', - [db.processHubs, db.improvementProjects, db.sustainmentRecords, db.sustainmentReviews], + [ + db.processHubs, + db.improvementProjects, + db.sustainmentRecords, + db.sustainmentReviews, + db.controlHandoffs, + ], async () => { const all = await db.processHubs.toArray(); const live = all.filter(h => h.deletedAt === null); @@ -293,24 +329,39 @@ export class AzureHubRepository implements HubRepository { ); }, }; + + controlHandoffs: ControlHandoffReadAPI = { + async get(id) { + const row = await db.controlHandoffs.get(id); + if (!row || row.deletedAt !== null) return undefined; + return row; + }, + async listByHub(hubId) { + const rows = await db.controlHandoffs.where('hubId').equals(hubId).toArray(); + return rows.filter(row => row.deletedAt === null); + }, + }; } async function hydrateHub(hub: ProcessHub): Promise { - const [ips, sustainmentRecords, sustainmentReviews] = await Promise.all([ + const [ips, sustainmentRecords, sustainmentReviews, controlHandoffs] = await Promise.all([ db.improvementProjects.where('hubId').equals(hub.id).toArray(), db.sustainmentRecords.where('hubId').equals(hub.id).toArray(), db.sustainmentReviews.where('hubId').equals(hub.id).toArray(), + db.controlHandoffs.where('hubId').equals(hub.id).toArray(), ]); const liveIps = ips.filter(p => p.deletedAt === null); const liveSustainmentRecords = sustainmentRecords.filter(record => record.deletedAt === null); const liveSustainmentReviews = sortReviewsDescending( sustainmentReviews.filter(review => review.deletedAt === null) ); + const liveControlHandoffs = controlHandoffs.filter(handoff => handoff.deletedAt === null); return { ...hub, ...(liveIps.length > 0 ? { improvementProjects: liveIps } : {}), ...(liveSustainmentRecords.length > 0 ? { sustainmentRecords: liveSustainmentRecords } : {}), ...(liveSustainmentReviews.length > 0 ? { sustainmentReviews: liveSustainmentReviews } : {}), + ...(liveControlHandoffs.length > 0 ? { controlHandoffs: liveControlHandoffs } : {}), }; } diff --git a/apps/azure/src/persistence/__tests__/AzureHubRepository.test.ts b/apps/azure/src/persistence/__tests__/AzureHubRepository.test.ts index 59a7ba8e7..b24880423 100644 --- a/apps/azure/src/persistence/__tests__/AzureHubRepository.test.ts +++ b/apps/azure/src/persistence/__tests__/AzureHubRepository.test.ts @@ -71,6 +71,14 @@ vi.mock('../../db/schema', () => ({ bulkPut: vi.fn().mockResolvedValue([]), clear: vi.fn(), }, + controlHandoffs: { + get: vi.fn(), + where: vi.fn(() => ({ + equals: vi.fn(() => ({ filter: vi.fn(() => ({ delete: vi.fn().mockResolvedValue(0) })) })), + })), + bulkPut: vi.fn().mockResolvedValue([]), + clear: vi.fn(), + }, // transaction executes the callback immediately (no real transaction scope needed in mocks). transaction: vi.fn((_mode: string, _tables: unknown[], callback: () => Promise) => callback() diff --git a/apps/azure/src/persistence/__tests__/applyAction.sustainment.test.ts b/apps/azure/src/persistence/__tests__/applyAction.sustainment.test.ts index a748ac44d..4797c5a89 100644 --- a/apps/azure/src/persistence/__tests__/applyAction.sustainment.test.ts +++ b/apps/azure/src/persistence/__tests__/applyAction.sustainment.test.ts @@ -1,6 +1,6 @@ import 'fake-indexeddb/auto'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { EvidenceSnapshot, SustainmentRecord } from '@variscout/core'; +import type { ControlHandoff, EvidenceSnapshot, SustainmentRecord } from '@variscout/core'; import type { ProcessHub } from '@variscout/core/processHub'; import { applyAction } from '../applyAction'; import { db } from '../../db/schema'; @@ -62,6 +62,7 @@ beforeEach(async () => { db.processHubs.clear(), db.sustainmentRecords.clear(), db.sustainmentReviews.clear(), + db.controlHandoffs.clear(), db.evidenceSnapshots.clear(), ]); }); @@ -71,6 +72,7 @@ afterEach(async () => { db.processHubs.clear(), db.sustainmentRecords.clear(), db.sustainmentReviews.clear(), + db.controlHandoffs.clear(), db.evidenceSnapshots.clear(), ]); }); @@ -162,3 +164,90 @@ describe('applyAction (Azure) — sustainment records', () => { expect(reviews[0]).toMatchObject({ snapshotId: 'snapshot-green', verdict: 'holding' }); }); }); + +function makeHandoff( + id: string, + hubId: string, + overrides: Partial = {} +): ControlHandoff { + return { + id, + investigationId: 'inv-1', + hubId, + status: 'pending', + surface: 'qms-procedure', + systemName: 'QMS-42', + operationalOwner: { displayName: 'Process owner' }, + handoffDate: NOW, + description: 'Control handoff', + retainSustainmentReview: true, + recordedBy: { displayName: 'Analyst' }, + createdAt: NOW, + deletedAt: null, + ...overrides, + }; +} + +describe('applyAction (Azure) — control handoffs', () => { + it('creates, updates, acknowledges, signs off, marks operational, and archives handoffs', async () => { + await db.processHubs.put(makeHub('hub-handoff')); + + await applyAction({ + kind: 'CONTROL_HANDOFF_CREATE', + hubId: 'hub-handoff', + handoff: makeHandoff('handoff-1', 'hub-handoff'), + }); + await applyAction({ + kind: 'CONTROL_HANDOFF_UPDATE', + handoffId: 'handoff-1', + patch: { escalationPath: 'Escalate to manager', reactionPlan: 'Return to standard work' }, + }); + await applyAction({ + kind: 'CONTROL_HANDOFF_ACKNOWLEDGE', + handoffId: 'handoff-1', + acknowledgedAt: NOW + 1, + acknowledgedBy: { displayName: 'Process owner' }, + notes: 'Accepted', + }); + await applyAction({ + kind: 'CONTROL_HANDOFF_SIGNOFF', + handoffId: 'handoff-1', + signoff: { approvedAt: NOW + 2, approvedBy: { displayName: 'Sponsor' } }, + }); + await applyAction({ + kind: 'CONTROL_HANDOFF_MARK_OPERATIONAL', + handoffId: 'handoff-1', + operationalAt: NOW + 3, + }); + await applyAction({ kind: 'CONTROL_HANDOFF_ARCHIVE', handoffId: 'handoff-1' }); + + const stored = await db.controlHandoffs.get('handoff-1'); + expect(stored).toMatchObject({ + status: 'operational', + escalationPath: 'Escalate to manager', + reactionPlan: 'Return to standard work', + acknowledgedAt: NOW + 1, + operationalAt: NOW + 3, + ownerAcknowledgement: { + acknowledgedBy: { displayName: 'Process owner' }, + notes: 'Accepted', + }, + signoff: { approvedAt: NOW + 2, approvedBy: { displayName: 'Sponsor' } }, + }); + expect(stored?.deletedAt).toEqual(expect.any(Number)); + }); + + it('rejects create payloads whose handoff hubId does not match the action hubId', async () => { + await db.processHubs.put(makeHub('hub-guard')); + + await expect( + applyAction({ + kind: 'CONTROL_HANDOFF_CREATE', + hubId: 'hub-guard', + handoff: makeHandoff('handoff-mismatch', 'other-hub'), + }) + ).rejects.toThrow(/hubId mismatch/); + + expect(await db.controlHandoffs.get('handoff-mismatch')).toBeUndefined(); + }); +}); diff --git a/apps/azure/src/persistence/applyAction.ts b/apps/azure/src/persistence/applyAction.ts index ac771be5a..83bdf6e77 100644 --- a/apps/azure/src/persistence/applyAction.ts +++ b/apps/azure/src/persistence/applyAction.ts @@ -384,6 +384,65 @@ export async function applyAction(action: HubAction): Promise { return; } + case 'CONTROL_HANDOFF_CREATE': { + const hub = await db.processHubs.get(action.hubId); + if (!hub) { + throw new Error(`CONTROL_HANDOFF_CREATE: parent hub ${action.hubId} does not exist`); + } + if (action.handoff.hubId !== action.hubId) { + throw new Error( + `CONTROL_HANDOFF_CREATE hubId mismatch: action hub '${action.hubId}' does not match handoff hub '${action.handoff.hubId}'` + ); + } + await db.controlHandoffs.add(action.handoff); + return; + } + + case 'CONTROL_HANDOFF_UPDATE': { + const existing = await db.controlHandoffs.get(action.handoffId); + if (!existing) return; + await db.controlHandoffs.update(action.handoffId, action.patch); + return; + } + + case 'CONTROL_HANDOFF_ARCHIVE': { + await db.controlHandoffs.update(action.handoffId, { deletedAt: Date.now() }); + return; + } + + case 'CONTROL_HANDOFF_ACKNOWLEDGE': { + const acknowledgedAt = action.acknowledgedAt ?? Date.now(); + await db.controlHandoffs.update(action.handoffId, { + status: 'acknowledged', + acknowledgedAt, + ownerAcknowledgement: { + acknowledgedBy: action.acknowledgedBy, + notes: action.notes, + }, + }); + return; + } + + case 'CONTROL_HANDOFF_MARK_OPERATIONAL': { + await db.controlHandoffs.update(action.handoffId, { + status: 'operational', + operationalAt: action.operationalAt ?? Date.now(), + }); + return; + } + + case 'CONTROL_HANDOFF_SIGNOFF': { + const existing = await db.controlHandoffs.get(action.handoffId); + if (!existing) return; + const operationalAt = existing.operationalAt ?? action.signoff.approvedAt ?? Date.now(); + await db.controlHandoffs.update(action.handoffId, { + status: 'operational', + operationalAt, + signoff: { ...(existing.signoff ?? {}), ...action.signoff }, + }); + return; + } + // ------------------------------------------------------------------------- // Session-only — Azure has no dedicated Dexie table today; F3 normalizes. // ------------------------------------------------------------------------- diff --git a/apps/azure/src/services/__tests__/blobClient.test.ts b/apps/azure/src/services/__tests__/blobClient.test.ts index 323a9a325..c3ceb0d36 100644 --- a/apps/azure/src/services/__tests__/blobClient.test.ts +++ b/apps/azure/src/services/__tests__/blobClient.test.ts @@ -467,6 +467,7 @@ describe('blobClient', () => { id: 'hoff-1', investigationId: 'inv-1', hubId: 'hub-1', + status: 'operational', surface: 'qms-procedure', systemName: 'QMS-101', operationalOwner: { userId: 'u2', displayName: 'Bob' }, diff --git a/apps/azure/src/services/__tests__/sustainmentStorage.test.ts b/apps/azure/src/services/__tests__/sustainmentStorage.test.ts index 1e50a1451..7330d8b1a 100644 --- a/apps/azure/src/services/__tests__/sustainmentStorage.test.ts +++ b/apps/azure/src/services/__tests__/sustainmentStorage.test.ts @@ -84,6 +84,7 @@ describe('sustainment storage round-trip', () => { id: 'h-1', investigationId: 'inv-1', hubId: 'hub-1', + status: 'operational', surface: 'mes-recipe', systemName: 'MES', operationalOwner: { userId: 'u-1', displayName: 'Op' }, @@ -152,6 +153,7 @@ describe('sustainment projection recompute', () => { id: 'h-1', investigationId: 'inv-1', hubId: 'hub-1', + status: 'operational', surface: 'mes-recipe', systemName: 'MES', operationalOwner: { userId: 'u-1', displayName: 'Op' }, @@ -176,6 +178,7 @@ describe('sustainment projection recompute', () => { id: 'h-2', investigationId: 'inv-1', hubId: 'hub-1', + status: 'operational', surface: 'qms-procedure', systemName: 'Doc Control', operationalOwner: { userId: 'u-1', displayName: 'Op' }, diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index afbdc462b..84a19576f 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -1063,7 +1063,11 @@ function AppMain() { onBack={panels.showFrame} /> ) : panels.activeView === 'handoff' ? ( - + ) : panels.activeView === 'investigation' ? ( void; } -const HandoffPanel: React.FC = ({ onBack }) => { +function makeId(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID(); + return `handoff-${Date.now()}`; +} + +function liveConfirmedRecords(records: SustainmentRecord[] | undefined): SustainmentRecord[] { + return (records ?? []).filter( + record => record.deletedAt === null && record.status === 'confirmed-sustained' + ); +} + +function selectRecord(records: SustainmentRecord[], targetId: string | undefined) { + if (targetId) { + const byId = records.find(record => record.id === targetId); + if (byId) return byId; + const byHandoff = records.find(record => record.controlHandoffId === targetId); + if (byHandoff) return byHandoff; + } + return records[0] ?? null; +} + +function selectHandoff( + handoffs: ControlHandoff[], + record: SustainmentRecord | null, + targetId: string | undefined +) { + if (targetId) { + const byId = handoffs.find(handoff => handoff.id === targetId); + if (byId) return byId; + } + if (!record) return null; return ( -
-

Handoff

-

- Handoff transfers ownership of a confirmed-sustained improvement to the process owner with a - control plan. The full handoff surface ships in a future release. -

-

Available in a future release.

- + handoffs.find(handoff => handoff.id === record.controlHandoffId) ?? + handoffs.find(handoff => handoff.investigationId === record.investigationId) ?? + null + ); +} + +function buildDraftHandoff(hub: ProcessHub, record: SustainmentRecord): ControlHandoff { + const now = Date.now(); + return { + id: makeId(), + investigationId: record.investigationId, + hubId: hub.id, + status: 'pending', + surface: 'qms-procedure', + systemName: '', + operationalOwner: record.owner ?? hub.processOwner ?? { displayName: '' }, + handoffDate: now, + description: record.targetSummary ?? '', + retainSustainmentReview: true, + recordedBy: { displayName: 'Local browser' }, + escalationPath: record.openConcerns, + reactionPlan: '', + createdAt: now, + deletedAt: null, + }; +} + +const HandoffPanel: React.FC = ({ activeHub, targetId, onBack }) => { + const { isPaid } = useTier(); + const [records, setRecords] = useState([]); + const [handoffs, setHandoffs] = useState([]); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(Boolean(activeHub)); + const creatingForRecordRef = useRef(null); + + useEffect(() => { + setRecords(liveConfirmedRecords(activeHub?.sustainmentRecords)); + setHandoffs((activeHub?.controlHandoffs ?? []).filter(handoff => handoff.deletedAt === null)); + setError(null); + setIsLoading(Boolean(activeHub)); + + if (!activeHub) { + setIsLoading(false); + return; + } + + let cancelled = false; + void Promise.all([ + pwaHubRepository.sustainmentRecords.listByHub(activeHub.id), + pwaHubRepository.controlHandoffs.listByHub(activeHub.id), + ]) + .then(([loadedRecords, loadedHandoffs]) => { + if (cancelled) return; + setRecords(liveConfirmedRecords(loadedRecords)); + setHandoffs(loadedHandoffs.filter(handoff => handoff.deletedAt === null)); + }) + .catch(() => { + if (cancelled) return; + setRecords(liveConfirmedRecords(activeHub.sustainmentRecords)); + setHandoffs( + (activeHub.controlHandoffs ?? []).filter(handoff => handoff.deletedAt === null) + ); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [activeHub]); + + const selectedRecord = useMemo(() => selectRecord(records, targetId), [records, targetId]); + const selectedHandoff = useMemo( + () => selectHandoff(handoffs, selectedRecord, targetId), + [handoffs, selectedRecord, targetId] + ); + + useEffect(() => { + if (!activeHub || isLoading || !selectedRecord || selectedHandoff) return; + if (creatingForRecordRef.current === selectedRecord.id) return; + + const draft = buildDraftHandoff(activeHub, selectedRecord); + creatingForRecordRef.current = selectedRecord.id; + setError(null); + let cancelled = false; + + void (async () => { + await pwaHubRepository.dispatch({ + kind: 'CONTROL_HANDOFF_CREATE', + hubId: activeHub.id, + handoff: draft, + }); + if (!selectedRecord.controlHandoffId) { + await pwaHubRepository.dispatch({ + kind: 'SUSTAINMENT_RECORD_UPDATE', + recordId: selectedRecord.id, + patch: { controlHandoffId: draft.id }, + }); + } + })() + .then(() => { + if (cancelled) return; + setHandoffs(current => [...current, draft]); + setRecords(current => + current.map(record => + record.id === selectedRecord.id ? { ...record, controlHandoffId: draft.id } : record + ) + ); + }) + .catch(() => { + if (!cancelled) setError('Could not create a handoff.'); + }) + .finally(() => { + if (creatingForRecordRef.current === selectedRecord.id) creatingForRecordRef.current = null; + }); + + return () => { + cancelled = true; + }; + }, [activeHub, isLoading, selectedHandoff, selectedRecord]); + + const patchHandoff = useCallback( + (patch: HandoffChangePatch) => { + if (!selectedHandoff) return; + const next = { ...selectedHandoff, ...patch }; + setHandoffs(current => + current.map(handoff => (handoff.id === selectedHandoff.id ? next : handoff)) + ); + void pwaHubRepository + .dispatch({ kind: 'CONTROL_HANDOFF_UPDATE', handoffId: selectedHandoff.id, patch }) + .catch(() => setError('Could not save handoff changes.')); + }, + [selectedHandoff] + ); + + const acknowledge = useCallback(() => { + if (!selectedHandoff) return; + const acknowledgedAt = Date.now(); + const acknowledgedBy = selectedHandoff.operationalOwner; + setHandoffs(current => + current.map(handoff => + handoff.id === selectedHandoff.id + ? { + ...handoff, + status: 'acknowledged', + acknowledgedAt, + ownerAcknowledgement: { acknowledgedBy }, + } + : handoff + ) + ); + void pwaHubRepository.dispatch({ + kind: 'CONTROL_HANDOFF_ACKNOWLEDGE', + handoffId: selectedHandoff.id, + acknowledgedAt, + acknowledgedBy, + }); + }, [selectedHandoff]); + + const markOperational = useCallback(() => { + if (!selectedHandoff) return; + const operationalAt = Date.now(); + setHandoffs(current => + current.map(handoff => + handoff.id === selectedHandoff.id + ? { ...handoff, status: 'operational', operationalAt } + : handoff + ) + ); + void pwaHubRepository.dispatch({ + kind: 'CONTROL_HANDOFF_MARK_OPERATIONAL', + handoffId: selectedHandoff.id, + operationalAt, + }); + }, [selectedHandoff]); + + const sponsorSignoff = useCallback(() => { + if (!selectedHandoff) return; + const signoff = { approvedAt: Date.now(), approvedBy: { displayName: 'Sponsor' } }; + setHandoffs(current => + current.map(handoff => + handoff.id === selectedHandoff.id + ? { + ...handoff, + status: 'operational', + operationalAt: handoff.operationalAt ?? signoff.approvedAt, + signoff: { ...(handoff.signoff ?? {}), ...signoff }, + } + : handoff + ) + ); + void pwaHubRepository.dispatch({ + kind: 'CONTROL_HANDOFF_SIGNOFF', + handoffId: selectedHandoff.id, + signoff, + }); + }, [selectedHandoff]); + + const heading = activeHub?.name ?? 'No active hub'; + + return ( +
+
+
+

Handoff

+

{heading}

+
+ +
+ + {!activeHub ? ( +

+ Create or select a Process Hub before opening handoff. +

+ ) : error ? ( +

+ {error} +

+ ) : isLoading ? ( +

+ Loading handoff... +

+ ) : selectedHandoff ? ( + + ) : selectedRecord ? ( +

+ Creating handoff... +

+ ) : ( +

+ Confirm sustainment before recording handoff. +

+ )}
); }; diff --git a/apps/pwa/src/components/views/FrameView.tsx b/apps/pwa/src/components/views/FrameView.tsx index 1d6704e8d..3d1a77a3a 100644 --- a/apps/pwa/src/components/views/FrameView.tsx +++ b/apps/pwa/src/components/views/FrameView.tsx @@ -23,6 +23,7 @@ import type { CanvasInvestigationFocus } from '@variscout/hooks'; import type { EvidenceSnapshot, StepCapabilityStamp, + ControlHandoff, SustainmentRecord, WorkflowReadinessSignals, } from '@variscout/core'; @@ -37,6 +38,7 @@ import { useInvestigationFeatureStore } from '../../features/investigation/inves const EMPTY_PRIOR_STEP_STATS: ReadonlyMap = new Map(); const EMPTY_ACTION_ITEMS: ActionItem[] = []; const EMPTY_SUSTAINMENT_RECORDS: SustainmentRecord[] = []; +const EMPTY_CONTROL_HANDOFFS: ControlHandoff[] = []; function mergeActionItems( current: readonly ActionItem[], @@ -102,6 +104,8 @@ const FrameView: React.FC = () => { const [actionItems, setActionItems] = React.useState(EMPTY_ACTION_ITEMS); const [sustainmentRecords, setSustainmentRecords] = React.useState(EMPTY_SUSTAINMENT_RECORDS); + const [controlHandoffs, setControlHandoffs] = + React.useState(EMPTY_CONTROL_HANDOFFS); const activeHubIdRef = React.useRef(activeHubId); React.useEffect(() => { @@ -132,6 +136,7 @@ const FrameView: React.FC = () => { React.useEffect(() => { setActionItems(EMPTY_ACTION_ITEMS); setSustainmentRecords(EMPTY_SUSTAINMENT_RECORDS); + setControlHandoffs(EMPTY_CONTROL_HANDOFFS); if (!activeHubId) { return; @@ -140,26 +145,31 @@ const FrameView: React.FC = () => { let cancelled = false; void (async () => { try { - const [items, records] = await Promise.all([ + const [items, records, handoffs] = await Promise.all([ pwaHubRepository.actionItems.listByHub(activeHubId), pwaHubRepository.sustainmentRecords.listByHub(activeHubId), + pwaHubRepository.controlHandoffs.listByHub(activeHubId), ]); if (!cancelled) { setActionItems(items); setSustainmentRecords( records.filter((record: SustainmentRecord) => record.deletedAt === null) ); + setControlHandoffs(handoffs.filter(handoff => handoff.deletedAt === null)); } } catch { // Session-only hubs may not exist in IndexedDB; keep any in-memory quick actions. - if (!cancelled) setSustainmentRecords(activeHub?.sustainmentRecords ?? []); + if (!cancelled) { + setSustainmentRecords(activeHub?.sustainmentRecords ?? []); + setControlHandoffs(activeHub?.controlHandoffs ?? []); + } } })(); return () => { cancelled = true; }; - }, [activeHub?.sustainmentRecords, activeHubId]); + }, [activeHub?.controlHandoffs, activeHub?.sustainmentRecords, activeHubId]); const contextLinkGroups: readonly ContextLinkGroup[] = React.useMemo(() => { const improvementProjects = ( @@ -193,9 +203,23 @@ const FrameView: React.FC = () => { description: record.status, })), }, - { surfaceType: 'handoff', items: [] }, + { + surfaceType: 'handoff', + items: controlHandoffs.map(handoff => ({ + id: handoff.id, + label: handoff.systemName || handoff.operationalOwner.displayName || 'Handoff', + description: handoff.status, + })), + }, ]; - }, [activeHub?.improvementProjects, activeHubId, hypotheses, projectsByHub, sustainmentRecords]); + }, [ + activeHub?.improvementProjects, + activeHubId, + controlHandoffs, + hypotheses, + projectsByHub, + sustainmentRecords, + ]); const signals: WorkflowReadinessSignals = React.useMemo(() => { const improvementProjects = ( @@ -220,12 +244,14 @@ const FrameView: React.FC = () => { improvementProjects, sustainmentRecords, sustainmentReviews: activeHub?.sustainmentReviews ?? [], + controlHandoffs, now: Date.now(), }); }, [ activeHub?.improvementProjects, activeHub?.sustainmentReviews, activeHubId, + controlHandoffs, projectsByHub, sustainmentRecords, ]); @@ -323,6 +349,10 @@ const FrameView: React.FC = () => { usePanelsStore.getState().showSustainment(prompt.action?.opensId); return; } + if (surface === 'handoff') { + usePanelsStore.getState().showHandoff(prompt.action?.opensId); + return; + } if (surface === 'improvement-projects') { usePanelsStore.getState().showCharter(); return; @@ -345,9 +375,13 @@ const FrameView: React.FC = () => { usePanelsStore.getState().showSustainment(item.id); return; } + if (controlHandoffs.some(handoff => handoff.id === item.id)) { + usePanelsStore.getState().showHandoff(item.id); + return; + } usePanelsStore.getState().showInvestigation(); }, - [activeHubId, sustainmentRecords] + [activeHubId, controlHandoffs, sustainmentRecords] ); return ( diff --git a/apps/pwa/src/components/views/__tests__/FrameView.test.tsx b/apps/pwa/src/components/views/__tests__/FrameView.test.tsx index bb276a6dc..a66a661e8 100644 --- a/apps/pwa/src/components/views/__tests__/FrameView.test.tsx +++ b/apps/pwa/src/components/views/__tests__/FrameView.test.tsx @@ -56,6 +56,7 @@ const hoisted = vi.hoisted(() => ({ listByHubMock: vi.fn(), actionItemsListByHubMock: vi.fn(), sustainmentRecordsListByHubMock: vi.fn(), + controlHandoffsListByHubMock: vi.fn(), dispatchMock: vi.fn(), sessionStateRef: { current: { hub: { id: 'hub-1' } as { id: string } | null } }, })); @@ -248,6 +249,9 @@ vi.mock('../../../persistence', () => ({ sustainmentRecords: { listByHub: hoisted.sustainmentRecordsListByHubMock, }, + controlHandoffs: { + listByHub: hoisted.controlHandoffsListByHubMock, + }, }, })); @@ -278,6 +282,8 @@ describe('FrameView (PWA shell)', () => { hoisted.actionItemsListByHubMock.mockResolvedValue([]); hoisted.sustainmentRecordsListByHubMock.mockReset(); hoisted.sustainmentRecordsListByHubMock.mockResolvedValue([]); + hoisted.controlHandoffsListByHubMock.mockReset(); + hoisted.controlHandoffsListByHubMock.mockResolvedValue([]); hoisted.dispatchMock.mockReset(); hoisted.dispatchMock.mockResolvedValue(undefined); hoisted.sessionStateRef.current = { hub: { id: 'hub-1' } }; diff --git a/apps/pwa/src/db/schema.ts b/apps/pwa/src/db/schema.ts index 1e832e0ed..1dd762278 100644 --- a/apps/pwa/src/db/schema.ts +++ b/apps/pwa/src/db/schema.ts @@ -36,6 +36,7 @@ import type { RowProvenanceTag, SustainmentRecord, SustainmentReview, + ControlHandoff, } from '@variscout/core'; import type { Finding, @@ -63,7 +64,11 @@ export interface MetaRow { */ export type HubRow = Omit< ProcessHub, - 'outcomes' | 'canonicalProcessMap' | 'sustainmentRecords' | 'sustainmentReviews' + | 'outcomes' + | 'canonicalProcessMap' + | 'sustainmentRecords' + | 'sustainmentReviews' + | 'controlHandoffs' >; /** @@ -94,6 +99,7 @@ export type ImprovementProjectRow = ImprovementProject; export type ActionItemRow = ActionItem & { hubId: ProcessHub['id'] }; export type SustainmentRecordRow = SustainmentRecord; export type SustainmentReviewRow = SustainmentReview; +export type ControlHandoffRow = ControlHandoff; // --------------------------------------------------------------------------- // Database @@ -115,6 +121,7 @@ export class PwaDatabase extends Dexie { actionItems!: Table; sustainmentRecords!: Table; sustainmentReviews!: Table; + controlHandoffs!: Table; canvasState!: Table; meta!: Table; @@ -144,6 +151,9 @@ export class PwaDatabase extends Dexie { sustainmentRecords: '&id, investigationId, hubId, nextReviewDue, updatedAt, deletedAt', sustainmentReviews: '&id, recordId, investigationId, hubId, reviewedAt', }); + this.version(4).stores({ + controlHandoffs: '&id, investigationId, hubId, status, handoffDate, deletedAt', + }); } } diff --git a/apps/pwa/src/features/panels/__tests__/panelsStore.test.ts b/apps/pwa/src/features/panels/__tests__/panelsStore.test.ts index eb680c0f2..9f0126374 100644 --- a/apps/pwa/src/features/panels/__tests__/panelsStore.test.ts +++ b/apps/pwa/src/features/panels/__tests__/panelsStore.test.ts @@ -147,8 +147,9 @@ describe('panelsStore', () => { }); it('showHandoff sets activeView to handoff', () => { - usePanelsStore.getState().showHandoff(); + usePanelsStore.getState().showHandoff('sr-1'); expect(usePanelsStore.getState().activeView).toBe('handoff'); + expect(usePanelsStore.getState().handoffTargetId).toBe('sr-1'); }); }); }); diff --git a/apps/pwa/src/features/panels/panelsStore.ts b/apps/pwa/src/features/panels/panelsStore.ts index a6b6fee02..2db3b63c2 100644 --- a/apps/pwa/src/features/panels/panelsStore.ts +++ b/apps/pwa/src/features/panels/panelsStore.ts @@ -31,6 +31,7 @@ interface PanelsState { showResetConfirm: boolean; openSpecEditorRequested: boolean; sustainmentTargetId: string | null; + handoffTargetId: string | null; } // ── Actions ────────────────────────────────────────────────────────────────── @@ -44,7 +45,7 @@ interface PanelsActions { showReport: () => void; showCharter: () => void; showSustainment: (targetId?: string) => void; - showHandoff: () => void; + showHandoff: (targetId?: string) => void; // Simple toggles setSettingsOpen: (open: boolean) => void; @@ -89,6 +90,7 @@ export const initialPanelsState: PanelsState = { showResetConfirm: false, openSpecEditorRequested: false, sustainmentTargetId: null, + handoffTargetId: null, }; // ── Store ──────────────────────────────────────────────────────────────────── @@ -109,7 +111,8 @@ export const usePanelsStore = create(set => ({ isFindingsOpen: false, sustainmentTargetId: targetId ?? null, }), - showHandoff: () => set({ activeView: 'handoff', isFindingsOpen: false }), + showHandoff: targetId => + set({ activeView: 'handoff', isFindingsOpen: false, handoffTargetId: targetId ?? null }), // Simple toggles setSettingsOpen: open => set({ isSettingsOpen: open }), diff --git a/apps/pwa/src/hooks/useAppPanels.ts b/apps/pwa/src/hooks/useAppPanels.ts index a74a49e1d..945cd1b59 100644 --- a/apps/pwa/src/hooks/useAppPanels.ts +++ b/apps/pwa/src/hooks/useAppPanels.ts @@ -47,6 +47,7 @@ export interface UseAppPanelsReturn { isDesktop: boolean; openSpecEditorRequested: boolean; sustainmentTargetId: string | null; + handoffTargetId: string | null; setOpenSpecEditorRequested: (v: boolean) => void; openDataTableAtRow: (index: number) => void; handleToggleFindingsPanel: () => void; @@ -153,6 +154,7 @@ export function useAppPanels(options: UseAppPanelsOptions): UseAppPanelsReturn { isDesktop, openSpecEditorRequested: store.openSpecEditorRequested, sustainmentTargetId: store.sustainmentTargetId, + handoffTargetId: store.handoffTargetId, isPISidebarOpen: store.isPISidebarOpen, // Setters (delegate to store) diff --git a/apps/pwa/src/persistence/PwaHubRepository.ts b/apps/pwa/src/persistence/PwaHubRepository.ts index d867cab40..5eafa1744 100644 --- a/apps/pwa/src/persistence/PwaHubRepository.ts +++ b/apps/pwa/src/persistence/PwaHubRepository.ts @@ -46,6 +46,7 @@ import type { ActionItemReadAPI, SustainmentRecordReadAPI, SustainmentReviewReadAPI, + ControlHandoffReadAPI, } from '@variscout/core/persistence'; import type { HubAction } from '@variscout/core/actions'; import type { ProcessHub } from '@variscout/core/processHub'; @@ -89,9 +90,10 @@ export class PwaHubRepository implements HubRepository { db.canvasState.get(hubMeta.id), db.improvementProjects.where('hubId').equals(hubMeta.id).toArray(), ]); - const [sustainmentRecords, sustainmentReviews] = await Promise.all([ + const [sustainmentRecords, sustainmentReviews, controlHandoffs] = await Promise.all([ this.sustainmentRecords.listByHub(hubMeta.id), this.sustainmentReviews.listByHub(hubMeta.id), + this.controlHandoffs.listByHub(hubMeta.id), ]); const liveOutcomes = outcomes.filter(o => o.deletedAt === null); const liveProjects = improvementProjects.filter(p => p.deletedAt === null); @@ -103,6 +105,7 @@ export class PwaHubRepository implements HubRepository { ...(liveProjects.length > 0 ? { improvementProjects: liveProjects } : {}), ...(sustainmentRecords.length > 0 ? { sustainmentRecords } : {}), ...(sustainmentReviews.length > 0 ? { sustainmentReviews } : {}), + ...(controlHandoffs.length > 0 ? { controlHandoffs } : {}), } as ProcessHub; } @@ -126,6 +129,7 @@ export class PwaHubRepository implements HubRepository { db.improvementProjects, db.sustainmentRecords, db.sustainmentReviews, + db.controlHandoffs, ], async () => { const hubMeta = await db.hubs.get(id); @@ -145,6 +149,7 @@ export class PwaHubRepository implements HubRepository { db.improvementProjects, db.sustainmentRecords, db.sustainmentReviews, + db.controlHandoffs, ], async () => { const allHubs = await db.hubs.toArray(); @@ -333,6 +338,18 @@ export class PwaHubRepository implements HubRepository { ); }, }; + + controlHandoffs: ControlHandoffReadAPI = { + get: async id => { + const row = await db.controlHandoffs.get(id); + if (!row || row.deletedAt !== null) return undefined; + return row; + }, + listByHub: async hubId => { + const rows = await db.controlHandoffs.where('hubId').equals(hubId).toArray(); + return rows.filter(row => row.deletedAt === null); + }, + }; } // --------------------------------------------------------------------------- diff --git a/apps/pwa/src/persistence/__tests__/applyAction.sustainment.test.ts b/apps/pwa/src/persistence/__tests__/applyAction.sustainment.test.ts index 5c42185dd..0247a35a4 100644 --- a/apps/pwa/src/persistence/__tests__/applyAction.sustainment.test.ts +++ b/apps/pwa/src/persistence/__tests__/applyAction.sustainment.test.ts @@ -1,6 +1,11 @@ import 'fake-indexeddb/auto'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { EvidenceSnapshot, RowProvenanceTag, SustainmentRecord } from '@variscout/core'; +import type { + ControlHandoff, + EvidenceSnapshot, + RowProvenanceTag, + SustainmentRecord, +} from '@variscout/core'; import type { ProcessHub } from '@variscout/core/processHub'; import { applyAction } from '../applyAction'; import { db } from '../../db/schema'; @@ -74,6 +79,7 @@ beforeEach(async () => { db.hubs.clear(), db.sustainmentRecords.clear(), db.sustainmentReviews.clear(), + db.controlHandoffs.clear(), db.evidenceSnapshots.clear(), db.rowProvenance.clear(), ]); @@ -84,6 +90,7 @@ afterEach(async () => { db.hubs.clear(), db.sustainmentRecords.clear(), db.sustainmentReviews.clear(), + db.controlHandoffs.clear(), db.evidenceSnapshots.clear(), db.rowProvenance.clear(), ]); @@ -177,3 +184,90 @@ describe('applyAction — sustainment records', () => { expect(await db.rowProvenance.where('snapshotId').equals('snapshot-green').count()).toBe(1); }); }); + +function makeHandoff( + id: string, + hubId: string, + overrides: Partial = {} +): ControlHandoff { + return { + id, + investigationId: 'inv-1', + hubId, + status: 'pending', + surface: 'qms-procedure', + systemName: 'QMS-42', + operationalOwner: { displayName: 'Process owner' }, + handoffDate: NOW, + description: 'Control handoff', + retainSustainmentReview: true, + recordedBy: { displayName: 'Analyst' }, + createdAt: NOW, + deletedAt: null, + ...overrides, + }; +} + +describe('applyAction — control handoffs', () => { + it('creates, updates, acknowledges, signs off, marks operational, and archives handoffs', async () => { + await applyAction(db, { kind: 'HUB_PERSIST_SNAPSHOT', hub: makeHub('hub-handoff') }); + + await applyAction(db, { + kind: 'CONTROL_HANDOFF_CREATE', + hubId: 'hub-handoff', + handoff: makeHandoff('handoff-1', 'hub-handoff'), + }); + await applyAction(db, { + kind: 'CONTROL_HANDOFF_UPDATE', + handoffId: 'handoff-1', + patch: { escalationPath: 'Escalate to manager', reactionPlan: 'Return to standard work' }, + }); + await applyAction(db, { + kind: 'CONTROL_HANDOFF_ACKNOWLEDGE', + handoffId: 'handoff-1', + acknowledgedAt: NOW + 1, + acknowledgedBy: { displayName: 'Process owner' }, + notes: 'Accepted', + }); + await applyAction(db, { + kind: 'CONTROL_HANDOFF_SIGNOFF', + handoffId: 'handoff-1', + signoff: { approvedAt: NOW + 2, approvedBy: { displayName: 'Sponsor' } }, + }); + await applyAction(db, { + kind: 'CONTROL_HANDOFF_MARK_OPERATIONAL', + handoffId: 'handoff-1', + operationalAt: NOW + 3, + }); + await applyAction(db, { kind: 'CONTROL_HANDOFF_ARCHIVE', handoffId: 'handoff-1' }); + + const stored = await db.controlHandoffs.get('handoff-1'); + expect(stored).toMatchObject({ + status: 'operational', + escalationPath: 'Escalate to manager', + reactionPlan: 'Return to standard work', + acknowledgedAt: NOW + 1, + operationalAt: NOW + 3, + ownerAcknowledgement: { + acknowledgedBy: { displayName: 'Process owner' }, + notes: 'Accepted', + }, + signoff: { approvedAt: NOW + 2, approvedBy: { displayName: 'Sponsor' } }, + }); + expect(stored?.deletedAt).toEqual(expect.any(Number)); + }); + + it('rejects create payloads whose handoff hubId does not match the action hubId', async () => { + await applyAction(db, { kind: 'HUB_PERSIST_SNAPSHOT', hub: makeHub('hub-guard') }); + + await expect( + applyAction(db, { + kind: 'CONTROL_HANDOFF_CREATE', + hubId: 'hub-guard', + handoff: makeHandoff('handoff-mismatch', 'other-hub'), + }) + ).rejects.toThrow(/hubId mismatch/); + + expect(await db.controlHandoffs.get('handoff-mismatch')).toBeUndefined(); + }); +}); diff --git a/apps/pwa/src/persistence/applyAction.ts b/apps/pwa/src/persistence/applyAction.ts index 3a99a10fb..fe8058c68 100644 --- a/apps/pwa/src/persistence/applyAction.ts +++ b/apps/pwa/src/persistence/applyAction.ts @@ -87,6 +87,7 @@ export async function applyAction(db: PwaDatabase, action: HubAction): Promise { await db.hubs.put(hubMeta); @@ -158,6 +160,17 @@ export async function applyAction(db: PwaDatabase, action: HubAction): Promise 0) { await db.sustainmentReviews.bulkPut(incomingSustainmentReviews); } + + const incomingControlHandoffs = controlHandoffs ?? []; + const incomingHandoffIds = new Set(incomingControlHandoffs.map(handoff => handoff.id)); + await db.controlHandoffs + .where('hubId') + .equals(hubMeta.id) + .filter(handoff => !incomingHandoffIds.has(handoff.id)) + .delete(); + if (incomingControlHandoffs.length > 0) { + await db.controlHandoffs.bulkPut(incomingControlHandoffs); + } } ); return; @@ -292,6 +305,65 @@ export async function applyAction(db: PwaDatabase, action: HubAction): Promise { }); }); +describe('ControlHandoff V1 lifecycle shape', () => { + it('supports pending, acknowledged, and operational lifecycle state plus signoff metadata', () => { + const states: ControlHandoffStatus[] = ['pending', 'acknowledged', 'operational']; + const handoff: ControlHandoff = { + id: 'handoff-1', + investigationId: 'inv-1', + hubId: 'hub-1', + status: 'acknowledged', + surface: 'qms-procedure', + systemName: 'QMS-42', + operationalOwner: { displayName: 'Process owner' }, + handoffDate: 1_746_352_800_000, + description: 'Control transferred to operations.', + retainSustainmentReview: true, + recordedBy: { displayName: 'Analyst' }, + acknowledgedAt: 1_746_352_900_000, + ownerAcknowledgement: { + acknowledgedBy: { displayName: 'Process owner' }, + notes: 'Accepted into daily control.', + }, + escalationPath: 'Escalate misses to the production manager.', + reactionPlan: 'Restore standard work and open a focused investigation if drift repeats.', + signoff: { + requestedAt: 1_746_353_000_000, + approvedAt: 1_746_353_100_000, + approvedBy: { displayName: 'Sponsor' }, + }, + createdAt: 1_746_352_800_000, + deletedAt: null, + }; + + expect(states).toHaveLength(3); + expect(handoff.status).toBe('acknowledged'); + expect(handoff.signoff?.approvedBy?.displayName).toBe('Sponsor'); + }); +}); + function makeRecord(nextReviewDue?: string): SustainmentRecord { return { id: 'rec-1', @@ -348,6 +386,7 @@ function makeHandoff( id: `h-${investigationId}`, investigationId, hubId: 'hub-1', + status: 'operational', surface, systemName: 'System', operationalOwner: { userId: 'u-1', displayName: 'Op' }, diff --git a/packages/core/src/actions/HubAction.ts b/packages/core/src/actions/HubAction.ts index 5014e1591..563f9e5fe 100644 --- a/packages/core/src/actions/HubAction.ts +++ b/packages/core/src/actions/HubAction.ts @@ -11,6 +11,7 @@ import type { CanvasAction } from './canvasActions'; import type { ImprovementProjectAction } from './improvementProjectActions'; import type { ActionItemAction } from './actionItemActions'; import type { SustainmentAction } from './sustainmentActions'; +import type { ControlHandoffAction } from './controlHandoffActions'; /** * Top-level discriminated union for all hub write operations. @@ -30,4 +31,5 @@ export type HubAction = | CanvasAction | ImprovementProjectAction | ActionItemAction - | SustainmentAction; + | SustainmentAction + | ControlHandoffAction; diff --git a/packages/core/src/actions/__tests__/exhaustiveness.test.ts b/packages/core/src/actions/__tests__/exhaustiveness.test.ts index 2523016e6..e02026696 100644 --- a/packages/core/src/actions/__tests__/exhaustiveness.test.ts +++ b/packages/core/src/actions/__tests__/exhaustiveness.test.ts @@ -114,6 +114,19 @@ function _exhaustive(action: HubAction): void { return; case 'SUSTAINMENT_TICK_EVALUATED': return; + // Control Handoff + case 'CONTROL_HANDOFF_CREATE': + return; + case 'CONTROL_HANDOFF_UPDATE': + return; + case 'CONTROL_HANDOFF_ARCHIVE': + return; + case 'CONTROL_HANDOFF_ACKNOWLEDGE': + return; + case 'CONTROL_HANDOFF_MARK_OPERATIONAL': + return; + case 'CONTROL_HANDOFF_SIGNOFF': + return; default: return assertNever(action); } diff --git a/packages/core/src/actions/__tests__/sustainmentActions.test.ts b/packages/core/src/actions/__tests__/sustainmentActions.test.ts index bf950b7a7..58139f6c3 100644 --- a/packages/core/src/actions/__tests__/sustainmentActions.test.ts +++ b/packages/core/src/actions/__tests__/sustainmentActions.test.ts @@ -67,4 +67,54 @@ describe('SustainmentAction', () => { 'SUSTAINMENT_TICK_EVALUATED', ]); }); + + it('covers control handoff action kinds and includes them in HubAction', () => { + const actions: HubAction[] = [ + { + kind: 'CONTROL_HANDOFF_CREATE', + hubId: 'hub-1', + handoff: { + id: 'handoff-1', + investigationId: 'investigation-1', + hubId: 'hub-1', + status: 'pending', + surface: 'qms-procedure', + systemName: 'QMS-42', + operationalOwner: { displayName: 'Owner' }, + handoffDate: 1, + description: 'Control transfer', + retainSustainmentReview: true, + recordedBy: { displayName: 'Analyst' }, + createdAt: 1, + deletedAt: null, + }, + }, + { + kind: 'CONTROL_HANDOFF_UPDATE', + handoffId: 'handoff-1', + patch: { escalationPath: 'Escalate to production manager' }, + }, + { kind: 'CONTROL_HANDOFF_ARCHIVE', handoffId: 'handoff-1' }, + { + kind: 'CONTROL_HANDOFF_ACKNOWLEDGE', + handoffId: 'handoff-1', + acknowledgedBy: { displayName: 'Owner' }, + }, + { kind: 'CONTROL_HANDOFF_MARK_OPERATIONAL', handoffId: 'handoff-1' }, + { + kind: 'CONTROL_HANDOFF_SIGNOFF', + handoffId: 'handoff-1', + signoff: { approvedAt: 2, approvedBy: { displayName: 'Sponsor' } }, + }, + ]; + + expect(actions.map(action => action.kind)).toEqual([ + 'CONTROL_HANDOFF_CREATE', + 'CONTROL_HANDOFF_UPDATE', + 'CONTROL_HANDOFF_ARCHIVE', + 'CONTROL_HANDOFF_ACKNOWLEDGE', + 'CONTROL_HANDOFF_MARK_OPERATIONAL', + 'CONTROL_HANDOFF_SIGNOFF', + ]); + }); }); diff --git a/packages/core/src/actions/controlHandoffActions.ts b/packages/core/src/actions/controlHandoffActions.ts new file mode 100644 index 000000000..23abd658d --- /dev/null +++ b/packages/core/src/actions/controlHandoffActions.ts @@ -0,0 +1,41 @@ +import type { ProcessHub, ProcessParticipantRef } from '../processHub'; +import type { ControlHandoff } from '../sustainment'; +import type { ImprovementProjectSignoff } from '../improvementProject'; + +export type ControlHandoffAction = + | { + kind: 'CONTROL_HANDOFF_CREATE'; + hubId: ProcessHub['id']; + handoff: ControlHandoff; + } + | { + kind: 'CONTROL_HANDOFF_UPDATE'; + handoffId: ControlHandoff['id']; + patch: Partial< + Omit< + ControlHandoff, + 'id' | 'createdAt' | 'hubId' | 'investigationId' | 'updatedAt' | 'deletedAt' | 'signoff' + > + >; + } + | { + kind: 'CONTROL_HANDOFF_ARCHIVE'; + handoffId: ControlHandoff['id']; + } + | { + kind: 'CONTROL_HANDOFF_ACKNOWLEDGE'; + handoffId: ControlHandoff['id']; + acknowledgedBy: ProcessParticipantRef; + acknowledgedAt?: number; + notes?: string; + } + | { + kind: 'CONTROL_HANDOFF_MARK_OPERATIONAL'; + handoffId: ControlHandoff['id']; + operationalAt?: number; + } + | { + kind: 'CONTROL_HANDOFF_SIGNOFF'; + handoffId: ControlHandoff['id']; + signoff: ImprovementProjectSignoff; + }; diff --git a/packages/core/src/actions/index.ts b/packages/core/src/actions/index.ts index 406893f7e..5822e459b 100644 --- a/packages/core/src/actions/index.ts +++ b/packages/core/src/actions/index.ts @@ -11,4 +11,5 @@ export type { CanvasAction } from './canvasActions'; export type { ImprovementProjectAction } from './improvementProjectActions'; export type { ActionItemAction } from './actionItemActions'; export type { SustainmentAction } from './sustainmentActions'; +export type { ControlHandoffAction } from './controlHandoffActions'; export type { HubAction } from './HubAction'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 646aa2e5f..1b16175b3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -545,6 +545,7 @@ export type { SustainmentRecord, SustainmentReview, ControlHandoff, + ControlHandoffStatus, SustainmentCadence, SustainmentVerdict, ControlHandoffSurface, diff --git a/packages/core/src/persistence/HubRepository.ts b/packages/core/src/persistence/HubRepository.ts index 6a7839643..f2971cbb5 100644 --- a/packages/core/src/persistence/HubRepository.ts +++ b/packages/core/src/persistence/HubRepository.ts @@ -3,7 +3,7 @@ import type { ProcessHub, OutcomeSpec, ProcessHubInvestigation } from '../proces import type { EvidenceSource, EvidenceSnapshot, EvidenceSourceCursor } from '../evidenceSources'; import type { Finding, Question, CausalLink, Hypothesis, ActionItem } from '../findings/types'; import type { ProcessMap } from '../frame/types'; -import type { SustainmentRecord, SustainmentReview } from '../sustainment'; +import type { ControlHandoff, SustainmentRecord, SustainmentReview } from '../sustainment'; export interface HubReadAPI { get(id: ProcessHub['id']): Promise; @@ -78,6 +78,11 @@ export interface SustainmentReviewReadAPI { ): Promise; } +export interface ControlHandoffReadAPI { + get(id: ControlHandoff['id']): Promise; + listByHub(hubId: ProcessHub['id']): Promise; +} + /** * Single-interface repository for all hub domain writes + grouped reads. * Write path: one `dispatch(action)` entry point — all mutations flow through it. @@ -102,4 +107,5 @@ export interface HubRepository { actionItems: ActionItemReadAPI; sustainmentRecords: SustainmentRecordReadAPI; sustainmentReviews: SustainmentReviewReadAPI; + controlHandoffs: ControlHandoffReadAPI; } diff --git a/packages/core/src/persistence/index.ts b/packages/core/src/persistence/index.ts index 6766c18a2..7966f70ea 100644 --- a/packages/core/src/persistence/index.ts +++ b/packages/core/src/persistence/index.ts @@ -13,6 +13,7 @@ export type { ActionItemReadAPI, SustainmentRecordReadAPI, SustainmentReviewReadAPI, + ControlHandoffReadAPI, } from './HubRepository'; export type { EntityKind, CascadeRule, CascadeRuleset } from './cascadeRules'; export { cascadeRules, transitiveCascade } from './cascadeRules'; diff --git a/packages/core/src/processHub.ts b/packages/core/src/processHub.ts index 386fa38a3..bc7dc2624 100644 --- a/packages/core/src/processHub.ts +++ b/packages/core/src/processHub.ts @@ -128,11 +128,12 @@ export interface ProcessHub extends EntityBase { /** * Sustainment entities owned by this hub. In-memory hydrated lists, loaded * by HubRepository reads from normalized tables. Mutations flow through - * `SUSTAINMENT_*` HubAction kinds; persistence must decompose these out of - * hub rows before writing. + * `SUSTAINMENT_*` / `CONTROL_HANDOFF_*` HubAction kinds; persistence must + * decompose these out of hub rows before writing. */ sustainmentRecords?: SustainmentRecord[]; sustainmentReviews?: import('./sustainment').SustainmentReview[]; + controlHandoffs?: import('./sustainment').ControlHandoff[]; } export const DEFAULT_PROCESS_HUB: ProcessHub = { diff --git a/packages/core/src/survey/__tests__/handoff.test.ts b/packages/core/src/survey/__tests__/handoff.test.ts new file mode 100644 index 000000000..6b90a29d1 --- /dev/null +++ b/packages/core/src/survey/__tests__/handoff.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { surveyHandoffRules } from '../handoff'; +import type { ControlHandoff, SustainmentRecord } from '../../sustainment'; + +const NOW = Date.UTC(2026, 4, 12); +const DAY_MS = 24 * 60 * 60 * 1000; + +const sustainmentRecord = (overrides: Partial): SustainmentRecord => + ({ + id: 'sr-1', + investigationId: 'inv-1', + hubId: 'hub-1', + status: 'confirmed-sustained', + title: 'Mix temperature control', + consecutiveOnTargetTicks: 4, + hasOverride: false, + lastEvaluatedSnapshotId: 'snapshot-1', + cadence: 'weekly', + createdAt: NOW - 50 * DAY_MS, + deletedAt: null, + updatedAt: NOW - 50 * DAY_MS, + ...overrides, + }) as SustainmentRecord; + +const controlHandoff = (overrides: Partial): ControlHandoff => + ({ + id: 'handoff-1', + investigationId: 'inv-1', + hubId: 'hub-1', + status: 'pending', + surface: 'qms-procedure', + systemName: 'QMS', + operationalOwner: { displayName: 'Ops owner' }, + handoffDate: NOW - 8 * DAY_MS, + description: 'Update procedure controls', + retainSustainmentReview: true, + recordedBy: { displayName: 'Investigator' }, + createdAt: NOW - 8 * DAY_MS, + deletedAt: null, + ...overrides, + }) as ControlHandoff; + +describe('surveyHandoffRules', () => { + it('prompts for handoff when confirmed sustainment is older than 6 weeks without live handoff', () => { + const hints = surveyHandoffRules({ + sustainmentRecords: [sustainmentRecord({ id: 'sr-old', investigationId: 'inv-old' })], + controlHandoffs: [], + now: NOW, + }); + + expect(hints).toEqual([ + expect.objectContaining({ + kind: 'lifecycle-gap', + surface: 'handoff', + targetEntityId: 'sr-old', + severity: 'warning', + action: { + label: 'Record control handoff', + opensSurface: 'handoff', + opensId: 'sr-old', + }, + }), + ]); + expect(hints[0].message).toContain('Mix temperature control'); + }); + + it('does not prompt for old confirmed sustainment when a linked live handoff exists', () => { + const hints = surveyHandoffRules({ + sustainmentRecords: [ + sustainmentRecord({ + id: 'sr-linked', + investigationId: 'inv-linked', + controlHandoffId: 'handoff-linked', + }), + ], + controlHandoffs: [ + controlHandoff({ + id: 'handoff-linked', + investigationId: 'inv-linked', + status: 'acknowledged', + acknowledgedAt: NOW - 7 * DAY_MS, + deletedAt: null, + }), + ], + now: NOW, + }); + + expect(hints).toHaveLength(0); + }); + + it('prompts when pending handoff awaits owner acknowledgement for 7 days', () => { + const hints = surveyHandoffRules({ + controlHandoffs: [controlHandoff({ id: 'handoff-stale' })], + now: NOW, + }); + + expect(hints).toEqual([ + expect.objectContaining({ + kind: 'lifecycle-gap', + surface: 'handoff', + targetEntityId: 'handoff-stale', + severity: 'warning', + action: { + label: 'Open handoff', + opensSurface: 'handoff', + opensId: 'handoff-stale', + }, + }), + ]); + expect(hints[0].message).toContain('Ops owner'); + }); + + it('does not prompt for archived or recently pending handoffs', () => { + const hints = surveyHandoffRules({ + controlHandoffs: [ + controlHandoff({ id: 'handoff-archived', deletedAt: NOW - DAY_MS }), + controlHandoff({ id: 'handoff-recent', createdAt: NOW - 6 * DAY_MS }), + ], + now: NOW, + }); + + expect(hints).toHaveLength(0); + }); +}); diff --git a/packages/core/src/survey/__tests__/inbox.test.ts b/packages/core/src/survey/__tests__/inbox.test.ts index 0105ccd7e..115299bc1 100644 --- a/packages/core/src/survey/__tests__/inbox.test.ts +++ b/packages/core/src/survey/__tests__/inbox.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { surveyInboxRules } from '../inbox'; import type { ImprovementProject } from '../../improvementProject'; -import type { SustainmentRecord } from '../../sustainment'; +import type { ControlHandoff, SustainmentRecord } from '../../sustainment'; const NOW = Date.UTC(2026, 4, 12); const DAY_MS = 24 * 60 * 60 * 1000; @@ -42,6 +42,24 @@ const improvementProject = (overrides: Partial): Improvement ...overrides, }) as ImprovementProject; +const controlHandoff = (overrides: Partial): ControlHandoff => + ({ + id: 'handoff-1', + investigationId: 'inv-1', + hubId: 'hub-1', + status: 'pending', + surface: 'qms-procedure', + systemName: 'QMS', + operationalOwner: { displayName: 'Ops owner' }, + handoffDate: NOW - 8 * DAY_MS, + description: 'Update procedure controls', + retainSustainmentReview: true, + recordedBy: { displayName: 'Investigator' }, + createdAt: NOW - 8 * DAY_MS, + deletedAt: null, + ...overrides, + }) as ControlHandoff; + describe('surveyInboxRules', () => { it('aggregates sustainment hints into inbox prompts sorted by severity then message and id', () => { const prompts = surveyInboxRules({ @@ -75,4 +93,28 @@ describe('surveyInboxRules', () => { }, }); }); + + it('aggregates handoff lifecycle gaps into inbox prompts', () => { + const prompts = surveyInboxRules({ + controlHandoffs: [controlHandoff({ id: 'handoff-stale' })], + now: NOW, + }); + + expect(prompts).toEqual([ + expect.objectContaining({ + id: 'inbox:lifecycle-gap:handoff-stale', + severity: 'warning', + action: { + label: 'Open handoff', + opensSurface: 'handoff', + opensId: 'handoff-stale', + }, + sourceHint: expect.objectContaining({ + kind: 'lifecycle-gap', + surface: 'inbox', + targetEntityId: 'handoff-stale', + }), + }), + ]); + }); }); diff --git a/packages/core/src/survey/handoff.ts b/packages/core/src/survey/handoff.ts new file mode 100644 index 000000000..e317184c5 --- /dev/null +++ b/packages/core/src/survey/handoff.ts @@ -0,0 +1,100 @@ +import type { ControlHandoff, SustainmentRecord } from '../sustainment'; +import type { SurveyHint, SurveyRule } from './types'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const CONFIRMED_WITHOUT_HANDOFF_DAYS = 42; +const PENDING_ACKNOWLEDGEMENT_DAYS = 7; + +function timestamp(value: Date | number | string | undefined): number | undefined { + if (value instanceof Date) return value.getTime(); + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function isLiveRecord(record: SustainmentRecord): boolean { + return record.deletedAt === null; +} + +function isLiveHandoff(handoff: ControlHandoff): boolean { + return handoff.deletedAt === null; +} + +function confirmedAt(record: SustainmentRecord): number { + return timestamp(record.latestReviewAt) ?? record.updatedAt ?? record.createdAt; +} + +function hasLiveHandoff(record: SustainmentRecord, handoffs: ControlHandoff[]): boolean { + return handoffs.some( + handoff => + isLiveHandoff(handoff) && + (handoff.id === record.controlHandoffId || handoff.investigationId === record.investigationId) + ); +} + +function needsOwnerAcknowledgement(handoff: ControlHandoff): boolean { + return ( + isLiveHandoff(handoff) && + handoff.status === 'pending' && + handoff.acknowledgedAt === undefined && + handoff.ownerAcknowledgement === undefined + ); +} + +export const surveyHandoffRules: SurveyRule = ctx => { + const hints: SurveyHint[] = []; + const now = timestamp(ctx.now); + if (now === undefined) return hints; + + const handoffs = ctx.controlHandoffs ?? []; + + for (const record of ctx.sustainmentRecords ?? []) { + if (!isLiveRecord(record)) continue; + if (record.status !== 'confirmed-sustained') continue; + if (hasLiveHandoff(record, handoffs)) continue; + + const ageMs = now - confirmedAt(record); + if (ageMs < CONFIRMED_WITHOUT_HANDOFF_DAYS * DAY_MS) continue; + + hints.push({ + kind: 'lifecycle-gap', + surface: 'handoff', + targetEntityId: record.id, + message: `${record.title} confirmed sustained more than 6 weeks ago without live handoff`, + severity: 'warning', + action: { + label: 'Record control handoff', + opensSurface: 'handoff', + opensId: record.id, + }, + }); + } + + for (const handoff of handoffs) { + if (!needsOwnerAcknowledgement(handoff)) continue; + + const recordedAt = timestamp(handoff.createdAt); + if (recordedAt === undefined) continue; + + const ageMs = now - recordedAt; + if (ageMs < PENDING_ACKNOWLEDGEMENT_DAYS * DAY_MS) continue; + + hints.push({ + kind: 'lifecycle-gap', + surface: 'handoff', + targetEntityId: handoff.id, + message: `${handoff.operationalOwner.displayName} has not acknowledged ${handoff.systemName} handoff`, + severity: 'warning', + action: { + label: 'Open handoff', + opensSurface: 'handoff', + opensId: handoff.id, + }, + }); + } + + return hints; +}; diff --git a/packages/core/src/survey/inbox.ts b/packages/core/src/survey/inbox.ts index c9b01c615..e8b28bacd 100644 --- a/packages/core/src/survey/inbox.ts +++ b/packages/core/src/survey/inbox.ts @@ -1,3 +1,4 @@ +import { surveyHandoffRules } from './handoff'; import { surveySustainmentRules } from './sustainment'; import type { SurveyContext, SurveyHint } from './types'; @@ -27,7 +28,7 @@ function toInboxPrompt(hint: SurveyHint): SurveyInboxPrompt { } export function surveyInboxRules(ctx: SurveyContext): SurveyInboxPrompt[] { - return surveySustainmentRules(ctx) + return [...surveySustainmentRules(ctx), ...surveyHandoffRules(ctx)] .map(toInboxPrompt) .sort((a, b) => { const severity = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]; diff --git a/packages/core/src/survey/index.ts b/packages/core/src/survey/index.ts index 60abb1333..d16909533 100644 --- a/packages/core/src/survey/index.ts +++ b/packages/core/src/survey/index.ts @@ -36,6 +36,7 @@ export { SURVEY_RECOMMENDATION_KIND_LABELS, SURVEY_STATUS_LABELS } from './types // --- Surface 2: cross-phase rule registry --- export { surveyWallRules, deriveHypothesisStatus } from './wall'; export { surveySustainmentRules } from './sustainment'; +export { surveyHandoffRules } from './handoff'; export { surveyInboxRules } from './inbox'; export type { SurveyInboxPrompt } from './inbox'; export type { SurveyHint, SurveyRule, SurveyContext, SurveyHintKind } from './types'; diff --git a/packages/core/src/survey/types.ts b/packages/core/src/survey/types.ts index 139c85a31..7688934d6 100644 --- a/packages/core/src/survey/types.ts +++ b/packages/core/src/survey/types.ts @@ -12,7 +12,7 @@ import type { SignalTrustGrade, } from '../signalCards'; import type { ImprovementProject } from '../improvementProject'; -import type { SustainmentRecord, SustainmentReview } from '../sustainment'; +import type { ControlHandoff, SustainmentRecord, SustainmentReview } from '../sustainment'; export type SurveyStatus = 'can-do-now' | 'can-do-with-caution' | 'cannot-do-yet' | 'ask-for-next'; @@ -197,6 +197,7 @@ export interface SurveyContext { improvementProjects?: ImprovementProject[]; sustainmentRecords?: SustainmentRecord[]; sustainmentReviews?: SustainmentReview[]; + controlHandoffs?: ControlHandoff[]; now?: Date | number; } diff --git a/packages/core/src/sustainment.ts b/packages/core/src/sustainment.ts index 7a095fb06..7cf5bad6d 100644 --- a/packages/core/src/sustainment.ts +++ b/packages/core/src/sustainment.ts @@ -8,7 +8,7 @@ import type { ProcessParticipantRef, } from './processHub'; import type { EvidenceSnapshot } from './evidenceSources'; -import type { ImprovementProjectGoal } from './improvementProject'; +import type { ImprovementProjectGoal, ImprovementProjectSignoff } from './improvementProject'; export type SustainmentCadence = | 'weekly' @@ -21,6 +21,7 @@ export type SustainmentCadence = export type SustainmentVerdict = 'holding' | 'drifting' | 'broken' | 'inconclusive'; export type SustainmentStatus = 'pending' | 'confirmed-sustained' | 'drifted'; +export type ControlHandoffStatus = 'pending' | 'acknowledged' | 'operational'; export type ControlHandoffSurface = | 'mes-recipe' @@ -90,6 +91,7 @@ export interface ControlHandoff extends EntityBase { // (the system timestamp when this handoff entity was created). investigationId: ProcessHubInvestigation['id']; hubId: ProcessHub['id']; + status: ControlHandoffStatus; surface: ControlHandoffSurface; systemName: string; operationalOwner: ProcessParticipantRef; @@ -99,6 +101,15 @@ export interface ControlHandoff extends EntityBase { referenceUri?: string; retainSustainmentReview: boolean; recordedBy: ProcessParticipantRef; + acknowledgedAt?: number; + operationalAt?: number; + ownerAcknowledgement?: { + acknowledgedBy: ProcessParticipantRef; + notes?: string; + }; + escalationPath?: string; + reactionPlan?: string; + signoff?: ImprovementProjectSignoff; } export interface SustainmentMetadataProjection { diff --git a/packages/ui/src/components/Handoff/HandoffForm.tsx b/packages/ui/src/components/Handoff/HandoffForm.tsx new file mode 100644 index 000000000..283e28528 --- /dev/null +++ b/packages/ui/src/components/Handoff/HandoffForm.tsx @@ -0,0 +1,281 @@ +import React from 'react'; +import { CheckCircle2, Lock } from 'lucide-react'; +import type { ControlHandoff, ControlHandoffSurface, SustainmentRecord } from '@variscout/core'; +import { CollapsibleSection } from '../ImprovementProject/CollapsibleSection'; + +export interface HandoffFormProps { + handoff: ControlHandoff; + sustainmentRecord?: SustainmentRecord; + isPaidTier?: boolean; + onHandoffChange?: (patch: HandoffChangePatch) => void; + onAcknowledge?: () => void; + onMarkOperational?: () => void; + onSponsorSignoff?: () => void; +} + +export type HandoffChangePatch = Partial< + Pick< + ControlHandoff, + | 'surface' + | 'systemName' + | 'description' + | 'referenceUri' + | 'retainSustainmentReview' + | 'escalationPath' + | 'reactionPlan' + > +> & { + operationalOwner?: ControlHandoff['operationalOwner']; + handoffDate?: ControlHandoff['handoffDate']; +}; + +const surfaceOptions: ControlHandoffSurface[] = [ + 'mes-recipe', + 'scada-alarm', + 'qms-procedure', + 'work-instruction', + 'training-record', + 'audit-program', + 'dashboard-only', + 'ticket-queue', + 'other', +]; + +const labelClassName = 'block space-y-2'; +const labelTextClassName = 'text-sm font-medium text-content'; +const inputClassName = + 'w-full rounded-md border border-edge bg-surface px-3 py-2 text-sm text-content shadow-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'; +const disabledInputClassName = `${inputClassName} disabled:cursor-not-allowed disabled:bg-surface-secondary disabled:text-content/60`; +const metadataClassName = + 'rounded border border-edge bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content/70'; +const actionButtonClassName = + 'inline-flex items-center justify-center gap-2 rounded-md border border-edge bg-surface px-3 py-2 text-sm font-medium text-content hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-60'; + +function formatLabel(value: string | undefined): string { + return value?.replaceAll('-', ' ') ?? 'not set'; +} + +function dateInputValue(value: number | undefined): string { + if (value === undefined) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return date.toISOString().slice(0, 10); +} + +function timestampFromDateInput(value: string): number | undefined { + if (!value) return undefined; + const time = new Date(`${value}T00:00:00.000Z`).getTime(); + return Number.isNaN(time) ? undefined : time; +} + +export const HandoffForm: React.FC = ({ + handoff, + sustainmentRecord, + isPaidTier = false, + onHandoffChange, + onAcknowledge, + onMarkOperational, + onSponsorSignoff, +}) => { + const isReadOnly = !onHandoffChange; + const isAcknowledged = handoff.status === 'acknowledged' || handoff.status === 'operational'; + const isOperational = handoff.status === 'operational'; + + return ( +
+ +
+ + + + + + + + +