diff --git a/packages/core/src/improvementProject/types.ts b/packages/core/src/improvementProject/types.ts index cc3e31aef..ad8da1e88 100644 --- a/packages/core/src/improvementProject/types.ts +++ b/packages/core/src/improvementProject/types.ts @@ -49,6 +49,7 @@ export interface ImprovementProjectGoal { export interface ImprovementProjectBackgroundSection { /** Snapshot copy of capability summary at IP open. Drift indicator triggers refresh. */ snapshotText?: string; + snapshotSourceHash?: string; snapshottedAt?: string; manualNarrative?: string; } diff --git a/packages/ui/src/components/ImprovementProject/CollapsibleSection.tsx b/packages/ui/src/components/ImprovementProject/CollapsibleSection.tsx new file mode 100644 index 000000000..203c1429a --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/CollapsibleSection.tsx @@ -0,0 +1,61 @@ +import React, { useId, useState } from 'react'; + +export interface CollapsibleSectionProps { + title: string; + children: React.ReactNode; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export const CollapsibleSection: React.FC = ({ + title, + children, + open, + defaultOpen = false, + onOpenChange, +}) => { + const generatedId = useId(); + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const isControlled = open !== undefined; + const isOpen = isControlled ? open : uncontrolledOpen; + const headerId = `improvement-section-header-${generatedId}`; + const panelId = `improvement-section-panel-${generatedId}`; + + const handleToggle = () => { + const nextOpen = !isOpen; + if (!isControlled) { + setUncontrolledOpen(nextOpen); + } + onOpenChange?.(nextOpen); + }; + + return ( +
+ + + {isOpen && ( +
+ {children} +
+ )} +
+ ); +}; diff --git a/packages/ui/src/components/ImprovementProject/ImprovementProjectForm.tsx b/packages/ui/src/components/ImprovementProject/ImprovementProjectForm.tsx new file mode 100644 index 000000000..9f2210bcf --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/ImprovementProjectForm.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { CollapsibleSection } from './CollapsibleSection'; +import { ProgressIndicator } from './ProgressIndicator'; +import { + HeaderMetadataSection, + type HeaderMetadataSectionProps, +} from './sections/HeaderMetadataSection'; +import { BackgroundSection, type BackgroundSectionProps } from './sections/BackgroundSection'; +import { GoalSection, type GoalSectionProps } from './sections/GoalSection'; +import { + InvestigationLineageSection, + type InvestigationLineageSectionProps, +} from './sections/InvestigationLineageSection'; +import { ApproachSection, type ApproachSectionProps } from './sections/ApproachSection'; +import { + OutcomeReferenceSection, + type OutcomeReferenceSectionProps, +} from './sections/OutcomeReferenceSection'; + +export type ImprovementProjectSectionKey = + | 'metadata' + | 'background' + | 'goal' + | 'lineage' + | 'approach' + | 'outcome'; + +type SectionContent = React.ReactNode | (() => React.ReactNode); + +interface ImprovementProjectSection { + key: ImprovementProjectSectionKey; + title: string; + defaultOpen: boolean; +} + +const SECTIONS: ImprovementProjectSection[] = [ + { key: 'metadata', title: 'Project metadata', defaultOpen: true }, + { key: 'background', title: 'Background / Current State', defaultOpen: true }, + { key: 'goal', title: 'Goal', defaultOpen: false }, + { key: 'lineage', title: 'Investigation lineage', defaultOpen: false }, + { key: 'approach', title: 'Approach / Countermeasures', defaultOpen: false }, + { key: 'outcome', title: 'Outcome reference', defaultOpen: false }, +]; + +const DEFAULT_CONTENT: Record = { + metadata:

Project metadata placeholder

, + background:

Background / Current State placeholder

, + goal:

Goal placeholder

, + lineage:

Investigation lineage placeholder

, + approach:

Approach / Countermeasures placeholder

, + outcome:

Outcome reference placeholder

, +}; + +export interface ImprovementProjectFormProps { + currentStep?: number; + metadataProps?: HeaderMetadataSectionProps; + backgroundProps?: BackgroundSectionProps; + goalProps?: GoalSectionProps; + lineageProps?: InvestigationLineageSectionProps; + approachProps?: ApproachSectionProps; + outcomeReferenceProps?: OutcomeReferenceSectionProps; + sectionContent?: Partial>; +} + +function renderSectionContent( + content: SectionContent | undefined, + key: ImprovementProjectSectionKey, + metadataProps?: HeaderMetadataSectionProps, + backgroundProps?: BackgroundSectionProps, + goalProps?: GoalSectionProps, + lineageProps?: InvestigationLineageSectionProps, + approachProps?: ApproachSectionProps, + outcomeReferenceProps?: OutcomeReferenceSectionProps +) { + if (typeof content === 'function') { + return content(); + } + + if (content === undefined && key === 'metadata' && metadataProps) { + return ; + } + + if (content === undefined && key === 'background' && backgroundProps) { + return ; + } + + if (content === undefined && key === 'goal' && goalProps) { + return ; + } + + if (content === undefined && key === 'lineage' && lineageProps) { + return ; + } + + if (content === undefined && key === 'approach' && approachProps) { + return ; + } + + if (content === undefined && key === 'outcome' && outcomeReferenceProps) { + return ; + } + + return content ?? DEFAULT_CONTENT[key]; +} + +export const ImprovementProjectForm: React.FC = ({ + currentStep = 1, + metadataProps, + backgroundProps, + goalProps, + lineageProps, + approachProps, + outcomeReferenceProps, + sectionContent, +}) => { + return ( +
+ + +
+ {SECTIONS.map(section => ( + + {renderSectionContent( + sectionContent?.[section.key], + section.key, + metadataProps, + backgroundProps, + goalProps, + lineageProps, + approachProps, + outcomeReferenceProps + )} + + ))} +
+
+ ); +}; diff --git a/packages/ui/src/components/ImprovementProject/ProgressIndicator.tsx b/packages/ui/src/components/ImprovementProject/ProgressIndicator.tsx new file mode 100644 index 000000000..5c98f3efb --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/ProgressIndicator.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +const DEFAULT_STEPS = [ + 'Project metadata', + 'Background / Current State', + 'Goal', + 'Investigation lineage', + 'Approach / Countermeasures', + 'Outcome reference', +]; + +export interface ProgressIndicatorProps { + currentStep?: number; + steps?: string[]; +} + +export const ProgressIndicator: React.FC = ({ + currentStep = 1, + steps = DEFAULT_STEPS, +}) => { + const boundedCurrentStep = Math.min(Math.max(currentStep, 1), steps.length); + + return ( +
    + {steps.map((label, index) => { + const stepNumber = index + 1; + const state = + stepNumber < boundedCurrentStep + ? 'complete' + : stepNumber === boundedCurrentStep + ? 'current' + : 'upcoming'; + + return ( +
  1. + + {label} +
  2. + ); + })} +
+ ); +}; diff --git a/packages/ui/src/components/ImprovementProject/__tests__/ApproachSection.test.tsx b/packages/ui/src/components/ImprovementProject/__tests__/ApproachSection.test.tsx new file mode 100644 index 000000000..e8c3c65a8 --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/ApproachSection.test.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { ActionItem, ImprovementIdea } from '@variscout/core/findings'; +import { ApproachSection } from '../sections/ApproachSection'; +import { ImprovementProjectForm } from '../ImprovementProjectForm'; +import type { ApproachSectionProps } from '../sections/ApproachSection'; + +const makeIdea = (overrides: Partial & Pick) => + ({ + createdAt: 1, + deletedAt: null, + ...overrides, + }) as ImprovementIdea; + +const makeAction = (overrides: Partial & Pick) => + ({ + createdAt: 1, + deletedAt: null, + ...overrides, + }) as ActionItem; + +describe('ApproachSection', () => { + const populatedProps: ApproachSectionProps = { + improvementIdeas: [ + makeIdea({ + id: 'idea-1', + text: 'Simplify setup with visual guides', + direction: 'simplify', + timeframe: 'weeks', + selected: true, + }), + ], + actionItems: [ + makeAction({ + id: 'action-1', + text: 'Pilot setup checklist', + assignee: { upn: 'lee@example.com', displayName: 'Lee Process' }, + dueDate: '2026-06-15', + completedAt: 1770940800000, + }), + ], + narrative: 'Start with one line and verify before rollout.', + }; + + it('renders idea and action metadata', () => { + render(); + + expect(screen.getByText('Simplify setup with visual guides')).toBeInTheDocument(); + expect(screen.getByText('simplify')).toBeInTheDocument(); + expect(screen.getByText('weeks')).toBeInTheDocument(); + expect(screen.getByText('selected')).toBeInTheDocument(); + + expect(screen.getByText('Pilot setup checklist')).toBeInTheDocument(); + expect(screen.getByText('Lee Process')).toBeInTheDocument(); + expect(screen.getByText('Due 2026-06-15')).toBeInTheDocument(); + expect(screen.getByText('completed')).toBeInTheDocument(); + }); + + it('clicking idea and action calls onNavigate with the correct kind and id', () => { + const onNavigate = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /simplify setup with visual guides/i })); + fireEvent.click(screen.getByRole('button', { name: /pilot setup checklist/i })); + + expect(onNavigate).toHaveBeenNthCalledWith(1, { + kind: 'improvementIdea', + id: 'idea-1', + }); + expect(onNavigate).toHaveBeenNthCalledWith(2, { + kind: 'actionItem', + id: 'action-1', + }); + }); + + it('does not render focusable no-op controls when onNavigate is omitted', () => { + render(); + + const ideaList = screen.getByRole('list', { name: /improvement ideas/i }); + const actionList = screen.getByRole('list', { name: /action items/i }); + + expect(within(ideaList).queryByRole('button')).not.toBeInTheDocument(); + expect(within(actionList).queryByRole('button')).not.toBeInTheDocument(); + }); + + it('narrative textarea calls onNarrativeChange', () => { + const onNarrativeChange = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByLabelText(/approach narrative/i), { + target: { value: 'Pilot the countermeasure before rollout.' }, + }); + + expect(onNarrativeChange).toHaveBeenCalledWith('Pilot the countermeasure before rollout.'); + }); + + it('renders empty states for no ideas and no actions', () => { + render(); + + expect(screen.getByText('No improvement ideas linked yet.')).toBeInTheDocument(); + expect(screen.getByText('No action items linked yet.')).toBeInTheDocument(); + }); +}); + +describe('ImprovementProjectForm approach integration', () => { + it('renders ApproachSection in section five when approach props are provided', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Approach / Countermeasures' })); + + expect(screen.getByLabelText(/approach narrative/i)).toHaveValue( + 'Pilot the setup guide in one cell.' + ); + expect(screen.getByText('Simplify setup guide')).toBeInTheDocument(); + }); + + it('keeps sectionContent approach override ahead of approach props', () => { + render( + Custom approach override, + }} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'Approach / Countermeasures' })); + + expect(screen.getByText('Custom approach override')).toBeInTheDocument(); + expect(screen.queryByLabelText(/approach narrative/i)).not.toBeInTheDocument(); + expect(screen.queryByText('Simplify setup guide')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/__tests__/BackgroundSection.test.tsx b/packages/ui/src/components/ImprovementProject/__tests__/BackgroundSection.test.tsx new file mode 100644 index 000000000..45399b8ce --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/BackgroundSection.test.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { BackgroundSection } from '../sections/BackgroundSection'; +import { BackgroundSnapshot } from '../sections/BackgroundSnapshot'; + +const snapshot = { + value: 'Baseline Cpk is 0.84 across the last 12 weeks.', + sourceHash: 'baseline-hash', +}; + +const matchingCurrent = { + value: 'Baseline Cpk is 0.84 across the last 12 weeks.', + hash: 'baseline-hash', +}; + +const changedCurrent = { + value: 'Live Cpk is 1.12 across the last 12 weeks.', + hash: 'live-hash', +}; + +describe('BackgroundSnapshot', () => { + it('hides drift indicator when hashes match and shows it when hashes differ', () => { + const { rerender } = render( + + ); + + expect(screen.queryByText(/live source changed/i)).not.toBeInTheDocument(); + + rerender(); + + expect(screen.getByText(/live source changed/i)).toBeInTheDocument(); + }); + + it('shows refresh only for drift with a callback and sends the refreshed snapshot payload', () => { + const onRefreshFromLive = vi.fn(); + const { rerender } = render( + + ); + + expect(screen.queryByRole('button', { name: /refresh from live/i })).not.toBeInTheDocument(); + + rerender(); + + expect(screen.queryByRole('button', { name: /refresh from live/i })).not.toBeInTheDocument(); + + rerender( + + ); + + fireEvent.click(screen.getByRole('button', { name: /refresh from live/i })); + + expect(onRefreshFromLive).toHaveBeenCalledTimes(1); + expect(onRefreshFromLive).toHaveBeenCalledWith({ + value: changedCurrent.value, + sourceHash: changedCurrent.hash, + snapshottedAt: expect.any(String), + }); + expect(Date.parse(onRefreshFromLive.mock.calls[0][0].snapshottedAt)).not.toBeNaN(); + }); + + it('clears drift after rerendering with the refreshed snapshot hash', () => { + let refreshedSnapshot = snapshot; + const onRefreshFromLive = vi.fn(nextSnapshot => { + refreshedSnapshot = nextSnapshot; + }); + + const { rerender } = render( + + ); + + expect(screen.getByText(/live source changed/i)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /refresh from live/i })); + + rerender( + + ); + + expect(screen.queryByText(/live source changed/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /refresh from live/i })).not.toBeInTheDocument(); + }); +}); + +describe('BackgroundSection', () => { + it('keeps manual narrative changes independent from refresh', () => { + const onManualNarrativeChange = vi.fn(); + const onRefreshFromLive = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByLabelText(/manual narrative/i), { + target: { value: 'Updated manual context' }, + }); + + expect(onManualNarrativeChange).toHaveBeenCalledTimes(1); + expect(onManualNarrativeChange).toHaveBeenCalledWith('Updated manual context'); + expect(onRefreshFromLive).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/__tests__/CollapsibleSection.test.tsx b/packages/ui/src/components/ImprovementProject/__tests__/CollapsibleSection.test.tsx new file mode 100644 index 000000000..9ba809d8f --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/CollapsibleSection.test.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { CollapsibleSection } from '../CollapsibleSection'; + +describe('CollapsibleSection', () => { + it('renders closed by default and hides children', () => { + render( + +

Goal content

+
+ ); + + const button = screen.getByRole('button', { name: 'Goal' }); + expect(button).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText('Goal content')).not.toBeInTheDocument(); + expect(screen.queryByRole('region', { name: 'Goal' })).not.toBeInTheDocument(); + }); + + it('renders open by default when defaultOpen is true', () => { + render( + +

Metadata content

+
+ ); + + const button = screen.getByRole('button', { name: 'Project metadata' }); + const panel = screen.getByRole('region', { name: 'Project metadata' }); + + expect(button).toHaveAttribute('aria-expanded', 'true'); + expect(button).toHaveAttribute('aria-controls', panel.id); + expect(panel).toHaveAttribute('aria-labelledby', button.id); + expect(screen.getByText('Metadata content')).toBeInTheDocument(); + }); + + it('toggles uncontrolled sections and reports changes', () => { + const onOpenChange = vi.fn(); + + render( + +

Approach content

+
+ ); + + const button = screen.getByRole('button', { name: 'Approach / Countermeasures' }); + fireEvent.click(button); + + expect(onOpenChange).toHaveBeenCalledWith(true); + expect(button).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('Approach content')).toBeInTheDocument(); + }); + + it('supports controlled open state without mutating itself', () => { + const onOpenChange = vi.fn(); + + render( + +

Outcome content

+
+ ); + + const button = screen.getByRole('button', { name: 'Outcome reference' }); + fireEvent.click(button); + + expect(onOpenChange).toHaveBeenCalledWith(true); + expect(button).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText('Outcome content')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/__tests__/GoalSection.test.tsx b/packages/ui/src/components/ImprovementProject/__tests__/GoalSection.test.tsx new file mode 100644 index 000000000..1d705419c --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/GoalSection.test.tsx @@ -0,0 +1,281 @@ +import React from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { Hypothesis } from '@variscout/core/findings'; +import { GoalSection } from '../sections/GoalSection'; +import { ImprovementProjectForm } from '../ImprovementProjectForm'; + +const makeHypothesis = ( + overrides: Partial & Pick +): Hypothesis => + ({ + createdAt: 1, + deletedAt: null, + synthesis: '', + questionIds: [], + findingIds: [], + updatedAt: 1, + investigationId: 'inv-1', + ...overrides, + }) as Hypothesis; + +describe('GoalSection', () => { + it('renders Y required guidance when no outcome FK or free text exists and clears it for either path', () => { + const { rerender } = render(); + + expect(screen.getByText(/choose an outcome target or describe the goal/i)).toBeInTheDocument(); + + rerender(); + expect( + screen.queryByText(/choose an outcome target or describe the goal/i) + ).not.toBeInTheDocument(); + + rerender(); + expect( + screen.queryByText(/choose an outcome target or describe the goal/i) + ).not.toBeInTheDocument(); + }); + + it('emits merged outcome goal changes from outcome, baseline, target, and deadline controls', () => { + const onOutcomeGoalChange = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByLabelText(/outcome spec/i), { target: { value: 'scrap' } }); + fireEvent.change(screen.getByLabelText(/baseline/i), { target: { value: '79.5' } }); + fireEvent.change(screen.getByLabelText(/^target$/i), { target: { value: '94.25' } }); + fireEvent.change(screen.getByLabelText(/deadline/i), { target: { value: '2026-07-15' } }); + + expect(onOutcomeGoalChange).toHaveBeenNthCalledWith(1, { + outcomeSpecId: 'scrap', + baseline: 82, + target: 92, + deadline: '2026-06-01', + }); + expect(onOutcomeGoalChange).toHaveBeenNthCalledWith(2, { + outcomeSpecId: 'yield', + baseline: 79.5, + target: 92, + deadline: '2026-06-01', + }); + expect(onOutcomeGoalChange).toHaveBeenNthCalledWith(3, { + outcomeSpecId: 'yield', + baseline: 82, + target: 94.25, + deadline: '2026-06-01', + }); + expect(onOutcomeGoalChange).toHaveBeenNthCalledWith(4, { + outcomeSpecId: 'yield', + baseline: 82, + target: 92, + deadline: '2026-07-15', + }); + }); + + it('renders only confirmed hypothesis suggestions and appends a deterministic factor control', () => { + const onFactorControlsChange = vi.fn(); + + render( + + ); + + expect( + screen.getByRole('button', { name: /use night shift setup drift/i }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /use candidate hypothesis/i }) + ).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /use already linked/i })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /use night shift setup drift/i })); + + expect(onFactorControlsChange).toHaveBeenCalledWith([ + { factor: 'Shift', targetCondition: 'Day shift', linkedHypothesisId: 'h-linked' }, + { + factor: 'Shift', + targetCondition: 'Target condition for Night shift setup drift', + linkedHypothesisId: 'h-confirmed', + }, + ]); + }); + + it('adds, removes, and edits factor control rows', () => { + const onFactorControlsChange = vi.fn(); + const factorControls = [ + { factor: 'Shift', targetCondition: 'Day shift', linkedHypothesisId: 'h-1' }, + { factor: 'Machine', targetCondition: 'Calibrated' }, + ]; + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /add factor control/i })); + expect(onFactorControlsChange).toHaveBeenNthCalledWith(1, [ + ...factorControls, + { factor: '', targetCondition: '', linkedHypothesisId: undefined }, + ]); + + const rows = screen.getAllByTestId('goal-factor-control-row'); + fireEvent.change(within(rows[0]).getByLabelText(/factor/i), { + target: { value: 'Team' }, + }); + fireEvent.change(within(rows[1]).getByLabelText(/target condition/i), { + target: { value: 'PM complete' }, + }); + fireEvent.change(within(rows[1]).getByLabelText(/linked hypothesis/i), { + target: { value: 'h-2' }, + }); + fireEvent.click(within(rows[0]).getByRole('button', { name: /remove factor control/i })); + + expect(onFactorControlsChange).toHaveBeenNthCalledWith(2, [ + { factor: 'Team', targetCondition: 'Day shift', linkedHypothesisId: 'h-1' }, + factorControls[1], + ]); + expect(onFactorControlsChange).toHaveBeenNthCalledWith(3, [ + factorControls[0], + { factor: 'Machine', targetCondition: 'PM complete' }, + ]); + expect(onFactorControlsChange).toHaveBeenNthCalledWith(4, [ + factorControls[0], + { factor: 'Machine', targetCondition: 'Calibrated', linkedHypothesisId: 'h-2' }, + ]); + expect(onFactorControlsChange).toHaveBeenNthCalledWith(5, [factorControls[1]]); + }); + + it('keeps mechanism goals optional and emits description plus linked finding IDs', () => { + const onMechanismGoalsChange = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /add mechanism goal/i })); + expect(onMechanismGoalsChange).toHaveBeenNthCalledWith(1, [ + { description: 'Standardize handoff checks', linkedFindingIds: ['finding-1'] }, + { description: '', linkedFindingIds: [] }, + ]); + + const row = screen.getByTestId('goal-mechanism-goal-row'); + fireEvent.change(within(row).getByLabelText(/mechanism description/i), { + target: { value: 'Pilot setup checklist' }, + }); + const findingsSelect = within(row).getByLabelText(/linked findings/i) as HTMLSelectElement; + for (const option of Array.from(findingsSelect.options)) { + option.selected = option.value === 'finding-1' || option.value === 'finding-2'; + } + fireEvent.change(findingsSelect); + + expect(onMechanismGoalsChange).toHaveBeenNthCalledWith(2, [ + { description: 'Pilot setup checklist', linkedFindingIds: ['finding-1'] }, + ]); + expect(onMechanismGoalsChange).toHaveBeenNthCalledWith(3, [ + { description: 'Standardize handoff checks', linkedFindingIds: ['finding-1', 'finding-2'] }, + ]); + }); + + it('removes a mechanism goal row and emits the full next array', () => { + const onMechanismGoalsChange = vi.fn(); + const mechanismGoals = [ + { description: 'Standardize handoff checks', linkedFindingIds: ['finding-1'] }, + { description: 'Stabilize calibration routine', linkedFindingIds: ['finding-2'] }, + ]; + + render( + + ); + + const rows = screen.getAllByTestId('goal-mechanism-goal-row'); + fireEvent.click(within(rows[0]).getByRole('button', { name: /remove mechanism goal/i })); + + expect(onMechanismGoalsChange).toHaveBeenCalledTimes(1); + expect(onMechanismGoalsChange).toHaveBeenCalledWith([mechanismGoals[1]]); + }); +}); + +describe('ImprovementProjectForm goal integration', () => { + it('renders GoalSection in section three when goal props are provided', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Goal' })); + + expect(screen.getByLabelText(/fallback goal/i)).toHaveValue('Improve first-pass yield'); + }); + + it('keeps sectionContent goal override ahead of goal props', () => { + render( + Custom goal override }} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'Goal' })); + + expect(screen.getByText('Custom goal override')).toBeInTheDocument(); + expect(screen.queryByLabelText(/fallback goal/i)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/__tests__/HeaderMetadataSection.test.tsx b/packages/ui/src/components/ImprovementProject/__tests__/HeaderMetadataSection.test.tsx new file mode 100644 index 000000000..7bf868292 --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/HeaderMetadataSection.test.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { HeaderMetadataSection } from '../sections/HeaderMetadataSection'; +import { ImprovementProjectForm } from '../ImprovementProjectForm'; + +describe('HeaderMetadataSection', () => { + it('renders title required validation for a blank title and clears it for a nonblank title', () => { + const { rerender } = render(); + + const titleInput = screen.getByLabelText(/project title/i); + expect(titleInput).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByText(/project title is required/i)).toBeInTheDocument(); + + rerender(); + + expect(screen.getByLabelText(/project title/i)).toHaveAttribute('aria-invalid', 'false'); + expect(screen.queryByText(/project title is required/i)).not.toBeInTheDocument(); + }); + + it('calls the business case callback when the textarea changes', () => { + const onBusinessCaseChange = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByLabelText(/business case/i), { + target: { value: 'Expected savings from fewer escalations' }, + }); + + expect(onBusinessCaseChange).toHaveBeenCalledWith('Expected savings from fewer escalations'); + }); + + it('calls the financial impact callback with merged amount and currency values', () => { + const onFinancialImpactChange = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByLabelText(/financial impact amount/i), { + target: { value: '25000' }, + }); + fireEvent.change(screen.getByLabelText(/financial impact currency/i), { + target: { value: 'EUR' }, + }); + fireEvent.change(screen.getByLabelText(/financial impact amount/i), { + target: { value: '1e309' }, + }); + + expect(onFinancialImpactChange).toHaveBeenNthCalledWith(1, { + amount: 25000, + currency: 'USD', + }); + expect(onFinancialImpactChange).toHaveBeenNthCalledWith(2, { + amount: 12000, + currency: 'EUR', + }); + expect(onFinancialImpactChange).toHaveBeenNthCalledWith(3, { + amount: undefined, + currency: 'USD', + }); + }); + + it('calls the investigation callback with selected ids and undefined when cleared', () => { + const onInvestigationIdChange = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByLabelText(/linked investigation/i), { + target: { value: 'inv-2' }, + }); + fireEvent.change(screen.getByLabelText(/linked investigation/i), { + target: { value: '' }, + }); + + expect(onInvestigationIdChange).toHaveBeenNthCalledWith(1, 'inv-2'); + expect(onInvestigationIdChange).toHaveBeenNthCalledWith(2, undefined); + }); + + it('calls onTeamChange with full next arrays for add, remove, role, and display name edits', () => { + const onTeamChange = vi.fn(); + const team = [ + { role: 'champion' as const, person: { id: 'person-1', displayName: 'Ari Champion' } }, + { role: 'teamMember' as const, person: { displayName: 'Tia Member' } }, + ]; + + render(); + + fireEvent.click(screen.getByRole('button', { name: /add team member/i })); + expect(onTeamChange).toHaveBeenNthCalledWith(1, [ + ...team, + { role: 'teamMember', person: { displayName: '' } }, + ]); + + const rows = screen.getAllByTestId('metadata-team-row'); + fireEvent.change(within(rows[0]).getByLabelText(/role/i), { + target: { value: 'sponsor' }, + }); + expect(onTeamChange).toHaveBeenNthCalledWith(2, [ + { role: 'sponsor', person: { id: 'person-1', displayName: 'Ari Champion' } }, + team[1], + ]); + + fireEvent.change(within(rows[1]).getByLabelText(/display name/i), { + target: { value: 'Taylor Lead' }, + }); + expect(onTeamChange).toHaveBeenNthCalledWith(3, [ + team[0], + { role: 'teamMember', person: { displayName: 'Taylor Lead' } }, + ]); + + fireEvent.click(within(rows[0]).getByRole('button', { name: /remove/i })); + expect(onTeamChange).toHaveBeenNthCalledWith(4, [team[1]]); + }); +}); + +describe('ImprovementProjectForm metadata integration', () => { + it('renders HeaderMetadataSection in Section 1 when metadataProps are provided', () => { + render(); + + expect(screen.getByLabelText(/project title/i)).toHaveValue('Reduce rework'); + }); + + it('keeps sectionContent.metadata override compatibility when metadataProps are provided', () => { + render( + Custom metadata override }} + /> + ); + + expect(screen.getByText('Custom metadata override')).toBeInTheDocument(); + expect(screen.queryByLabelText(/project title/i)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/__tests__/ImprovementProjectForm.test.tsx b/packages/ui/src/components/ImprovementProject/__tests__/ImprovementProjectForm.test.tsx new file mode 100644 index 000000000..84964dfba --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/ImprovementProjectForm.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ImprovementProjectForm } from '../ImprovementProjectForm'; + +const sectionNames = [ + 'Project metadata', + 'Background / Current State', + 'Goal', + 'Investigation lineage', + 'Approach / Countermeasures', + 'Outcome reference', +]; + +describe('ImprovementProjectForm', () => { + it('renders the six-section shell and progress indicator', () => { + render(); + + expect(screen.getAllByRole('listitem')).toHaveLength(6); + for (const name of sectionNames) { + expect(screen.getByRole('button', { name })).toBeInTheDocument(); + } + }); + + it('opens sections one and two by default and collapses sections three through six', () => { + render(); + + expect(screen.getByRole('button', { name: 'Project metadata' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + expect(screen.getByRole('button', { name: 'Background / Current State' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + + for (const name of sectionNames.slice(2)) { + expect(screen.getByRole('button', { name })).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('region', { name })).not.toBeInTheDocument(); + } + }); + + it('renders supplied section bodies without app state wiring', () => { + render( + Metadata fields, + background:
Background fields
, + }} + /> + ); + + expect(screen.getByText('Metadata fields')).toBeInTheDocument(); + expect(screen.getByText('Background fields')).toBeInTheDocument(); + }); + + it('renders the background section in section two when background props are provided', () => { + render( + + ); + + expect(screen.getByText('Snapshotted process state')).toBeInTheDocument(); + expect(screen.getByLabelText(/manual narrative/i)).toHaveValue('Manual project context'); + }); + + it('keeps the sectionContent background override ahead of background props', () => { + render( + Custom background override, + }} + /> + ); + + expect(screen.getByText('Custom background override')).toBeInTheDocument(); + expect(screen.queryByText('Snapshotted process state')).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/manual narrative/i)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/__tests__/InvestigationLineageSection.test.tsx b/packages/ui/src/components/ImprovementProject/__tests__/InvestigationLineageSection.test.tsx new file mode 100644 index 000000000..65675b234 --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/InvestigationLineageSection.test.tsx @@ -0,0 +1,192 @@ +import React from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { Finding, Hypothesis } from '@variscout/core/findings'; +import { ImprovementProjectForm } from '../ImprovementProjectForm'; +import { InvestigationLineageSection } from '../sections/InvestigationLineageSection'; + +const makeHypothesis = ( + overrides: Partial & Pick +): Hypothesis => + ({ + createdAt: 1, + deletedAt: null, + synthesis: '', + questionIds: [], + findingIds: [], + updatedAt: 1, + investigationId: 'inv-1', + ...overrides, + }) as Hypothesis; + +const makeFinding = (overrides: Partial & Pick): Finding => + ({ + createdAt: 1, + deletedAt: null, + context: { type: 'chart', chartId: 'chart-1' }, + evidenceType: 'data', + status: 'observed', + comments: [], + statusChangedAt: 1, + investigationId: 'inv-1', + ...overrides, + }) as Finding; + +describe('InvestigationLineageSection', () => { + it('renders hypothesis chips with name, status, synthesis, and theme metadata', () => { + render( + + ); + + const chip = screen.getByText('Night shift setup drift').closest('article'); + + expect(chip).toHaveTextContent('Night shift setup drift'); + expect(chip).toHaveTextContent('confirmed'); + expect(chip).toHaveTextContent('Setup standards vary after handoff.'); + expect(chip).toHaveTextContent('handoff'); + expect(chip).toHaveTextContent('setup'); + }); + + it('renders finding chips with text, evidence type, and status metadata', () => { + render( + + ); + + const chip = screen.getByText('Setup time spikes on night shift.').closest('article'); + + expect(chip).toHaveTextContent('Setup time spikes on night shift.'); + expect(chip).toHaveTextContent('gemba'); + expect(chip).toHaveTextContent('analyzed'); + }); + + it('clicking hypothesis and finding chips fires onNavigate with the correct target', () => { + const onNavigate = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /nozzle wear/i })); + fireEvent.click(screen.getByRole('button', { name: /scrap rises after 2 pm/i })); + + expect(onNavigate).toHaveBeenNthCalledWith(1, { kind: 'hypothesis', id: 'h-1' }); + expect(onNavigate).toHaveBeenNthCalledWith(2, { kind: 'finding', id: 'f-1' }); + }); + + it('does not render a textbox or narrative editor', () => { + render( + + ); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + it('renders non-interactive chips when no navigation callback is provided', () => { + render( + + ); + + expect(screen.queryByRole('button', { name: /nozzle wear/i })).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /scrap rises after 2 pm/i }) + ).not.toBeInTheDocument(); + }); + + it('uses unique heading ids when multiple lineage sections render', () => { + render( +
+ + +
+ ); + + const hypothesisHeadingIds = screen + .getAllByRole('heading', { name: 'Linked hypotheses' }) + .map(heading => heading.id); + const findingHeadingIds = screen + .getAllByRole('heading', { name: 'Linked findings' }) + .map(heading => heading.id); + + expect(new Set(hypothesisHeadingIds).size).toBe(hypothesisHeadingIds.length); + expect(new Set(findingHeadingIds).size).toBe(findingHeadingIds.length); + }); + + it('renders empty states for no linked hypotheses and no linked findings', () => { + render(); + + expect(screen.getByText(/no linked hypotheses yet/i)).toBeInTheDocument(); + expect(screen.getByText(/no linked findings yet/i)).toBeInTheDocument(); + }); +}); + +describe('ImprovementProjectForm investigation lineage integration', () => { + it('renders InvestigationLineageSection in section four when lineage props are provided', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Investigation lineage' })); + + const section = screen.getByRole('region', { name: 'Investigation lineage' }); + expect(within(section).getByText('Night shift setup drift')).toBeInTheDocument(); + expect(within(section).getByText('Setup time spikes on night shift.')).toBeInTheDocument(); + }); + + it('keeps sectionContent lineage override ahead of lineage props', () => { + render( + Custom lineage override, + }} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'Investigation lineage' })); + + expect(screen.getByText('Custom lineage override')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /night shift setup drift/i }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/__tests__/OutcomeReferenceSection.test.tsx b/packages/ui/src/components/ImprovementProject/__tests__/OutcomeReferenceSection.test.tsx new file mode 100644 index 000000000..d4b92cc74 --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/OutcomeReferenceSection.test.tsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { ControlHandoff, SustainmentRecord } from '@variscout/core'; +import { ImprovementProjectForm } from '../ImprovementProjectForm'; +import { OutcomeReferenceSection } from '../sections/OutcomeReferenceSection'; + +const makeSustainmentRecord = ( + overrides: Partial & + Pick +): SustainmentRecord & { title?: string } => + ({ + createdAt: 1, + deletedAt: null, + updatedAt: 1, + investigationId: 'inv-1', + hubId: 'hub-1', + ...overrides, + }) as SustainmentRecord & { title?: string }; + +const makeHandoff = ( + overrides: Partial & Pick +): ControlHandoff => + ({ + createdAt: 1, + deletedAt: null, + investigationId: 'inv-1', + hubId: 'hub-1', + operationalOwner: { displayName: 'Process Owner' }, + handoffDate: Date.UTC(2026, 5, 15), + description: 'Control transferred to operating system.', + retainSustainmentReview: true, + recordedBy: { displayName: 'Improvement Lead' }, + ...overrides, + }) as ControlHandoff; + +describe('OutcomeReferenceSection', () => { + it('renders the required empty state when no sustainment record is linked', () => { + render(); + + expect( + screen.getByText('Sustainment: not yet started - set up after Improvement closes.') + ).toBeInTheDocument(); + }); + + it('renders sustainment summary metadata and clicking calls onNavigate with its target', () => { + const onNavigate = vi.fn(); + + render( + + ); + + const card = screen.getByRole('button', { name: /mix temperature sustainment/i }); + + expect(card).toHaveTextContent('Mix temperature sustainment'); + expect(card).toHaveTextContent('holding'); + expect(card).toHaveTextContent('monthly'); + expect(card).toHaveTextContent('Next review 2026-07-01'); + expect(card).toHaveTextContent('Avery Owner'); + + fireEvent.click(card); + + expect(onNavigate).toHaveBeenCalledWith({ kind: 'sustainmentRecord', id: 'sr-1' }); + }); + + it('renders handoff summary metadata and clicking calls onNavigate with its target', () => { + const onNavigate = vi.fn(); + + render( + + ); + + const card = screen.getByRole('button', { name: /qms-42/i }); + + expect(card).toHaveTextContent('qms procedure'); + expect(card).toHaveTextContent('QMS-42'); + expect(card).toHaveTextContent('Jordan Ops'); + expect(card).toHaveTextContent('Effective 2026-07-02'); + + fireEvent.click(card); + + expect(onNavigate).toHaveBeenCalledWith({ kind: 'controlHandoff', id: 'handoff-1' }); + }); + + it('does not render focusable no-op buttons when onNavigate is omitted', () => { + render( + + ); + + expect( + screen.queryByRole('button', { name: /mix temperature sustainment/i }) + ).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /wi-900/i })).not.toBeInTheDocument(); + }); + + it('does not render editable form fields', () => { + const { container } = render( + + ); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument(); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + expect(container.querySelector('input, textarea, select')).toBeNull(); + }); +}); + +describe('ImprovementProjectForm outcome reference integration', () => { + it('renders OutcomeReferenceSection in section six when outcome reference props are provided', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Outcome reference' })); + + const section = screen.getByRole('region', { name: 'Outcome reference' }); + expect(within(section).getByText('Mix temperature sustainment')).toBeInTheDocument(); + expect(within(section).getByText('holding')).toBeInTheDocument(); + }); + + it('keeps sectionContent outcome override ahead of outcome reference props', () => { + render( + Custom outcome override, + }} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'Outcome reference' })); + + expect(screen.getByText('Custom outcome override')).toBeInTheDocument(); + expect(screen.queryByText('Mix temperature sustainment')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/__tests__/ProgressIndicator.test.tsx b/packages/ui/src/components/ImprovementProject/__tests__/ProgressIndicator.test.tsx new file mode 100644 index 000000000..bbea2a932 --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/ProgressIndicator.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ProgressIndicator } from '../ProgressIndicator'; + +describe('ProgressIndicator', () => { + it('renders exactly six PR-RPS-6 progress segments by default', () => { + render(); + + expect(screen.getAllByRole('listitem')).toHaveLength(6); + expect(screen.getByRole('list', { name: 'Improvement project progress' })).toBeInTheDocument(); + }); + + it('exposes segment state through accessible labels', () => { + render(); + + expect(screen.getByLabelText('Step 1 of 6, Project metadata, complete')).toBeInTheDocument(); + expect( + screen.getByLabelText('Step 2 of 6, Background / Current State, current') + ).toBeInTheDocument(); + expect(screen.getByLabelText('Step 3 of 6, Goal, upcoming')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/__tests__/public-export.test.ts b/packages/ui/src/components/ImprovementProject/__tests__/public-export.test.ts new file mode 100644 index 000000000..2a793bd87 --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/public-export.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { + CollapsibleSection, + HeaderMetadataSection, + ImprovementProjectForm, + ProgressIndicator, +} from '../../../index'; +import type { + CollapsibleSectionProps, + HeaderMetadataSectionProps, + ImprovementProjectFormProps, + ImprovementProjectSectionKey, + ProgressIndicatorProps, +} from '../../../index'; + +describe('ImprovementProject public exports', () => { + it('exposes the ImprovementProject component group from @variscout/ui', () => { + const sectionKey: ImprovementProjectSectionKey = 'metadata'; + const metadataProps: HeaderMetadataSectionProps = { title: 'Reduce rework' }; + const formProps: ImprovementProjectFormProps = { metadataProps }; + const sectionProps: CollapsibleSectionProps = { title: 'Project metadata', children: null }; + const progressProps: ProgressIndicatorProps = { currentStep: 1 }; + + expect(sectionKey).toBe('metadata'); + expect(metadataProps.title).toBe('Reduce rework'); + expect(formProps.metadataProps).toBe(metadataProps); + expect(sectionProps.title).toBe('Project metadata'); + expect(progressProps.currentStep).toBe(1); + expect(ImprovementProjectForm).toBeTypeOf('function'); + expect(HeaderMetadataSection).toBeTypeOf('function'); + expect(CollapsibleSection).toBeTypeOf('function'); + expect(ProgressIndicator).toBeTypeOf('function'); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/index.ts b/packages/ui/src/components/ImprovementProject/index.ts new file mode 100644 index 000000000..481104e9a --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/index.ts @@ -0,0 +1,23 @@ +export { CollapsibleSection } from './CollapsibleSection'; +export type { CollapsibleSectionProps } from './CollapsibleSection'; +export { ImprovementProjectForm } from './ImprovementProjectForm'; +export type { + ImprovementProjectFormProps, + ImprovementProjectSectionKey, +} from './ImprovementProjectForm'; +export { ProgressIndicator } from './ProgressIndicator'; +export type { ProgressIndicatorProps } from './ProgressIndicator'; +export { BackgroundSection } from './sections/BackgroundSection'; +export type { BackgroundSectionProps } from './sections/BackgroundSection'; +export { BackgroundSnapshot } from './sections/BackgroundSnapshot'; +export type { BackgroundSnapshotProps } from './sections/BackgroundSnapshot'; +export { GoalSection } from './sections/GoalSection'; +export type { GoalSectionProps } from './sections/GoalSection'; +export { HeaderMetadataSection } from './sections/HeaderMetadataSection'; +export type { HeaderMetadataSectionProps } from './sections/HeaderMetadataSection'; +export { InvestigationLineageSection } from './sections/InvestigationLineageSection'; +export type { InvestigationLineageSectionProps } from './sections/InvestigationLineageSection'; +export { ApproachSection } from './sections/ApproachSection'; +export type { ApproachSectionProps } from './sections/ApproachSection'; +export { OutcomeReferenceSection } from './sections/OutcomeReferenceSection'; +export type { OutcomeReferenceSectionProps } from './sections/OutcomeReferenceSection'; diff --git a/packages/ui/src/components/ImprovementProject/sections/ApproachSection.tsx b/packages/ui/src/components/ImprovementProject/sections/ApproachSection.tsx new file mode 100644 index 000000000..aeba6598e --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/sections/ApproachSection.tsx @@ -0,0 +1,148 @@ +import React, { useId } from 'react'; +import type { ActionItem, ImprovementIdea } from '@variscout/core/findings'; + +export interface ApproachSectionProps { + improvementIdeas?: ImprovementIdea[]; + actionItems?: ActionItem[]; + narrative?: string; + onNarrativeChange?: (value: string) => void; + onNavigate?: (target: { kind: 'improvementIdea' | 'actionItem'; id: string }) => void; +} + +const panelClassName = 'rounded-md border border-edge bg-surface-secondary p-4'; +const cardClassName = + 'w-full rounded-md border border-edge bg-surface p-3 text-left text-sm text-content'; +const interactiveCardClassName = `${cardClassName} transition-colors hover:bg-surface-secondary focus:outline-none focus:ring-2 focus:ring-ring`; +const chipClassName = + 'rounded-full border border-edge bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content/80'; +const textareaClassName = + 'min-h-28 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'; + +function formatCompleted(action: ActionItem): string | undefined { + return action.completedAt ? 'completed' : undefined; +} + +function ideaMetadata(idea: ImprovementIdea): string[] { + return [idea.direction, idea.timeframe, idea.selected ? 'selected' : undefined].filter( + (value): value is string => Boolean(value) + ); +} + +function actionMetadata(action: ActionItem): string[] { + return [ + action.assignee?.displayName, + action.dueDate ? `Due ${action.dueDate}` : undefined, + formatCompleted(action), + ].filter((value): value is string => Boolean(value)); +} + +function MetadataChips({ values }: { values: string[] }) { + if (values.length === 0) return null; + + return ( +
+ {values.map(value => ( + + {value} + + ))} +
+ ); +} + +export const ApproachSection: React.FC = ({ + improvementIdeas = [], + actionItems = [], + narrative = '', + onNarrativeChange, + onNavigate, +}) => { + const generatedId = useId(); + const ideasHeadingId = `approach-ideas-heading-${generatedId}`; + const actionsHeadingId = `approach-actions-heading-${generatedId}`; + const narrativeHeadingId = `approach-narrative-heading-${generatedId}`; + const narrativeTextareaId = `approach-narrative-textarea-${generatedId}`; + + return ( +
+
+

+ Improvement ideas +

+ + {improvementIdeas.length === 0 ? ( +

No improvement ideas linked yet.

+ ) : ( +
    + {improvementIdeas.map(idea => ( +
  • + {onNavigate ? ( + + ) : ( +
    +

    {idea.text}

    + +
    + )} +
  • + ))} +
+ )} +
+ +
+

+ Action items +

+ + {actionItems.length === 0 ? ( +

No action items linked yet.

+ ) : ( +
    + {actionItems.map(action => ( +
  • + {onNavigate ? ( + + ) : ( +
    +

    {action.text}

    + +
    + )} +
  • + ))} +
+ )} +
+ +
+

+ Narrative +

+