From a940a1014437ea9d42e0b853355023002a5e8cfb Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 19 Dec 2025 17:02:14 +0400 Subject: [PATCH 01/73] feat: Report builder --- app/src/CalculatorRouter.tsx | 5 + app/src/components/Sidebar.tsx | 2 + app/src/pages/ReportBuilder.page.tsx | 1746 ++++++++++++++++++++++++++ 3 files changed, 1753 insertions(+) create mode 100644 app/src/pages/ReportBuilder.page.tsx diff --git a/app/src/CalculatorRouter.tsx b/app/src/CalculatorRouter.tsx index e28e04783..18667a493 100644 --- a/app/src/CalculatorRouter.tsx +++ b/app/src/CalculatorRouter.tsx @@ -8,6 +8,7 @@ import StandardLayout from './components/StandardLayout'; import DashboardPage from './pages/Dashboard.page'; import PoliciesPage from './pages/Policies.page'; import PopulationsPage from './pages/Populations.page'; +import ReportBuilderPage from './pages/ReportBuilder.page'; import ReportOutputPage from './pages/ReportOutput.page'; import ReportsPage from './pages/Reports.page'; import SimulationsPage from './pages/Simulations.page'; @@ -119,6 +120,10 @@ const router = createBrowserRouter( path: 'policies', element: , }, + { + path: 'report-builder', + element: , + }, { path: 'account', element:
Account settings page
, diff --git a/app/src/components/Sidebar.tsx b/app/src/components/Sidebar.tsx index 1c0fc0073..4eabc5dd1 100644 --- a/app/src/components/Sidebar.tsx +++ b/app/src/components/Sidebar.tsx @@ -5,6 +5,7 @@ import { IconCpu, IconFileDescription, IconGitBranch, + IconLayoutGrid, IconMail, IconPlus, IconScale, @@ -32,6 +33,7 @@ export default function Sidebar({ isOpen = true }: SidebarProps) { // All internal navigation paths include the country prefix for consistency with v1 app const navItems = [ { label: 'Reports', icon: IconFileDescription, path: `/${countryId}/reports` }, + { label: 'Report Builder', icon: IconLayoutGrid, path: `/${countryId}/report-builder` }, { label: 'Simulations', icon: IconGitBranch, path: `/${countryId}/simulations` }, { label: 'Policies', icon: IconScale, path: `/${countryId}/policies` }, { label: 'Households', icon: IconUsers, path: `/${countryId}/households` }, diff --git a/app/src/pages/ReportBuilder.page.tsx b/app/src/pages/ReportBuilder.page.tsx new file mode 100644 index 000000000..bf72aeb0b --- /dev/null +++ b/app/src/pages/ReportBuilder.page.tsx @@ -0,0 +1,1746 @@ +/** + * ReportBuilder - A visual, building-block approach to report configuration + * + * Design Direction: Refined utilitarian with distinct color coding. + * - Policy: Secondary (slate) - authoritative, grounded + * - Population: Primary (teal) - brand-focused, people + * - Dynamics: Blue - forward-looking, data-driven + * + * Three view modes: + * - Card view: 50/50 grid with square chips + * - Row view: Stacked horizontal rows + * - Horizontal view: Full-width stacked simulations + */ +import { useState, useCallback, useEffect } from 'react'; +import { + Box, + Stack, + Group, + Text, + ActionIcon, + Paper, + Modal, + TextInput, + Select, + Button, + Tooltip, + Transition, + Divider, + ScrollArea, + Tabs, +} from '@mantine/core'; +import { + IconPlus, + IconScale, + IconUsers, + IconChartLine, + IconCheck, + IconX, + IconPencil, + IconChevronDown, + IconChevronRight, + IconTrash, + IconSparkles, + IconFileDescription, + IconLayoutList, + IconLayoutColumns, + IconHome, + IconWorld, + IconRowInsertBottom, + IconSearch, +} from '@tabler/icons-react'; +import { colors, spacing, typography } from '@/designTokens'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { + SimulationStateProps, + PolicyStateProps, + PopulationStateProps, +} from '@/types/pathwayState'; +import { initializeSimulationState } from '@/utils/pathwayState/initializeSimulationState'; +import { initializePolicyState } from '@/utils/pathwayState/initializePolicyState'; +import { initializePopulationState } from '@/utils/pathwayState/initializePopulationState'; +import { CURRENT_YEAR } from '@/constants'; +import { useUserPolicies } from '@/hooks/useUserPolicy'; +import { useUserHouseholds } from '@/hooks/useUserHousehold'; +import { MOCK_USER_ID } from '@/constants'; + +// ============================================================================ +// TYPES +// ============================================================================ + +interface ReportBuilderState { + label: string | null; + year: string; + simulations: SimulationStateProps[]; +} + +type IngredientType = 'policy' | 'population' | 'dynamics'; +type ViewMode = 'cards' | 'rows' | 'horizontal'; + +interface IngredientPickerState { + isOpen: boolean; + simulationIndex: number; + ingredientType: IngredientType; +} + +// ============================================================================ +// DESIGN TOKENS +// ============================================================================ + +const FONT_SIZES = { + title: '28px', + normal: '14px', + small: '12px', + tiny: '10px', +}; + +// Distinct color palette for each ingredient type +const INGREDIENT_COLORS = { + policy: { + icon: colors.secondary[600], + bg: colors.secondary[50], + border: colors.secondary[200], + accent: colors.secondary[500], + }, + population: { + icon: colors.primary[600], + bg: colors.primary[50], + border: colors.primary[200], + accent: colors.primary[500], + }, + dynamics: { + // Muted gray-green for dynamics (distinct from teal and slate) + icon: colors.gray[500], + bg: colors.gray[50], + border: colors.gray[200], + accent: colors.gray[400], + }, +}; + +// Sample populations +const SAMPLE_POPULATIONS = { + household: { + label: 'Sample household', + type: 'household' as const, + household: { + id: 'sample-household', + countryId: 'us' as const, + householdData: { people: { person1: { age: { 2025: 40 } } } }, + }, + geography: null, + }, + nationwide: { + label: 'Sample nationwide', + type: 'geography' as const, + household: null, + geography: { + id: 'us-nationwide', + countryId: 'us' as const, + scope: 'national' as const, + geographyId: 'us', + name: 'United States', + }, + }, +}; + +// ============================================================================ +// STYLES +// ============================================================================ + +const styles = { + pageContainer: { + minHeight: '100vh', + background: `linear-gradient(180deg, ${colors.gray[50]} 0%, ${colors.background.secondary} 100%)`, + padding: `${spacing.lg} ${spacing['3xl']}`, + }, + + headerSection: { + marginBottom: spacing.xl, + }, + + mainTitle: { + fontFamily: typography.fontFamily.primary, + fontSize: FONT_SIZES.title, + fontWeight: typography.fontWeight.bold, + color: colors.gray[900], + letterSpacing: '-0.02em', + margin: 0, + }, + + canvasContainer: { + background: colors.white, + borderRadius: spacing.radius.xl, + border: `1px solid ${colors.border.light}`, + boxShadow: `0 4px 24px ${colors.shadow.light}`, + padding: spacing['2xl'], + position: 'relative' as const, + overflow: 'hidden', + }, + + canvasGrid: { + background: ` + linear-gradient(90deg, ${colors.gray[100]}18 1px, transparent 1px), + linear-gradient(${colors.gray[100]}18 1px, transparent 1px) + `, + backgroundSize: '20px 20px', + position: 'absolute' as const, + inset: 0, + pointerEvents: 'none' as const, + }, + + simulationsGrid: { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gridTemplateRows: 'auto auto auto auto', // header, policy, population, dynamics + gap: `${spacing.sm} ${spacing['2xl']}`, + position: 'relative' as const, + zIndex: 1, + minHeight: '450px', + alignItems: 'start', + }, + + simulationsContainerHorizontal: { + display: 'flex', + flexDirection: 'column' as const, + gap: spacing.xl, + position: 'relative' as const, + zIndex: 1, + }, + + simulationCard: { + background: colors.white, + borderRadius: spacing.radius.lg, + border: `2px solid ${colors.border.light}`, + padding: spacing.xl, + transition: 'all 0.2s ease', + position: 'relative' as const, + display: 'grid', + gridRow: 'span 4', // span all 4 rows (header + 3 panels) + gridTemplateRows: 'subgrid', + gap: spacing.sm, + }, + + simulationCardHorizontal: { + background: colors.white, + borderRadius: spacing.radius.lg, + border: `2px solid ${colors.border.light}`, + padding: spacing.xl, + width: '100%', + transition: 'all 0.2s ease', + position: 'relative' as const, + }, + + simulationCardActive: { + borderColor: colors.primary[400], + boxShadow: `0 0 0 4px ${colors.primary[50]}, 0 8px 32px ${colors.shadow.medium}`, + }, + + simulationHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.lg, + }, + + simulationTitle: { + fontFamily: typography.fontFamily.primary, + fontSize: FONT_SIZES.normal, + fontWeight: typography.fontWeight.semibold, + color: colors.gray[800], + }, + + // Ingredient section (bubble/card container, not clickable) + ingredientSection: { + padding: spacing.md, + borderRadius: spacing.radius.lg, + border: `1px solid`, + background: 'white', + }, + + ingredientSectionHeader: { + display: 'flex', + alignItems: 'center', + gap: spacing.sm, + marginBottom: spacing.md, + }, + + ingredientSectionIcon: { + width: 32, + height: 32, + borderRadius: spacing.radius.md, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + + // Chip grid for card view (square chips, 3 per row) + chipGridSquare: { + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: spacing.sm, + }, + + // Row layout for row view + chipRowContainer: { + display: 'flex', + flexDirection: 'column' as const, + gap: spacing.xs, + }, + + // Square chip (expands to fill grid cell, min 80px height) + chipSquare: { + minHeight: 80, + borderRadius: spacing.radius.md, + borderWidth: 1, + borderStyle: 'solid', + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + justifyContent: 'center', + gap: 6, + cursor: 'pointer', + transition: 'background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease', + padding: spacing.sm, + }, + + chipSquareSelected: { + borderWidth: 2, + boxShadow: `0 0 0 2px`, + }, + + // Row chip (80 height) + chipRow: { + display: 'flex', + alignItems: 'center', + gap: spacing.md, + padding: `${spacing.md} ${spacing.lg}`, + borderRadius: spacing.radius.md, + borderWidth: 1, + borderStyle: 'solid', + cursor: 'pointer', + transition: 'background 0.15s ease, border-color 0.15s ease', + minHeight: 80, + }, + + chipRowSelected: { + borderWidth: 2, + }, + + chipRowIcon: { + width: 40, + height: 40, + borderRadius: spacing.radius.md, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, + + // Perforated "Create custom" chip (expands to fill grid cell) + chipCustomSquare: { + minHeight: 80, + borderRadius: spacing.radius.md, + borderWidth: 2, + borderStyle: 'dashed', + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + justifyContent: 'center', + gap: 6, + cursor: 'pointer', + transition: 'background 0.15s ease, border-color 0.15s ease', + padding: spacing.sm, + }, + + chipCustomRow: { + display: 'flex', + alignItems: 'center', + gap: spacing.md, + padding: `${spacing.md} ${spacing.lg}`, + borderRadius: spacing.radius.md, + borderWidth: 2, + borderStyle: 'dashed', + cursor: 'pointer', + transition: 'background 0.15s ease, border-color 0.15s ease', + minHeight: 80, + }, + + addSimulationCard: { + background: colors.white, + borderRadius: spacing.radius.lg, + border: `2px dashed ${colors.border.medium}`, + padding: spacing.xl, + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + justifyContent: 'center', + gap: spacing.md, + cursor: 'pointer', + transition: 'all 0.2s ease', + gridRow: 'span 4', // span all 4 rows to match SimulationBlock + }, + + addSimulationCardHorizontal: { + background: colors.white, + borderRadius: spacing.radius.lg, + border: `2px dashed ${colors.border.medium}`, + padding: spacing.xl, + width: '100%', + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + justifyContent: 'center', + gap: spacing.md, + cursor: 'pointer', + transition: 'all 0.2s ease', + minHeight: '120px', + }, + + reportMetaCard: { + background: colors.white, + borderRadius: spacing.radius.lg, + border: `1px solid ${colors.border.light}`, + padding: spacing.xl, + marginBottom: spacing.xl, + }, + + inheritedBadge: { + fontSize: FONT_SIZES.tiny, + color: colors.gray[500], + fontStyle: 'italic', + marginLeft: spacing.xs, + }, +}; + +// ============================================================================ +// SUB-COMPONENTS +// ============================================================================ + +interface OptionChipSquareProps { + icon: React.ReactNode; + label: string; + description?: string; + isSelected: boolean; + onClick: () => void; + colorConfig: typeof INGREDIENT_COLORS.policy; +} + +function OptionChipSquare({ + icon, + label, + description, + isSelected, + onClick, + colorConfig, +}: OptionChipSquareProps) { + const [isHovered, setIsHovered] = useState(false); + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={onClick} + > + + {icon} + + + {label} + + {description && ( + + {description} + + )} + + ); +} + +interface OptionChipRowProps { + icon: React.ReactNode; + label: string; + description?: string; + isSelected: boolean; + onClick: () => void; + colorConfig: typeof INGREDIENT_COLORS.policy; +} + +function OptionChipRow({ + icon, + label, + description, + isSelected, + onClick, + colorConfig, +}: OptionChipRowProps) { + const [isHovered, setIsHovered] = useState(false); + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={onClick} + > + + {icon} + + + + {label} + + {description && ( + + {description} + + )} + + {isSelected && ( + + )} + + ); +} + +interface CreateCustomChipProps { + label: string; + onClick: () => void; + variant: 'square' | 'row'; + colorConfig: typeof INGREDIENT_COLORS.policy; +} + +function CreateCustomChip({ label, onClick, variant, colorConfig }: CreateCustomChipProps) { + const [isHovered, setIsHovered] = useState(false); + + if (variant === 'square') { + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={onClick} + > + + + {label} + + + ); + } + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={onClick} + > + + + + + {label} + + + ); +} + +interface SavedPolicy { + id: string; + label: string; + paramCount: number; +} + +interface BrowseMoreChipProps { + label: string; + description?: string; + onClick: () => void; + variant: 'square' | 'row'; + colorConfig: typeof INGREDIENT_COLORS.policy; +} + +function BrowseMoreChip({ label, description, onClick, variant, colorConfig }: BrowseMoreChipProps) { + const [isHovered, setIsHovered] = useState(false); + + if (variant === 'square') { + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={onClick} + > + + + {label} + + {description && ( + + {description} + + )} + + ); + } + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={onClick} + > + + + + + + {label} + + {description && ( + + {description} + + )} + + + ); +} + +interface IngredientSectionProps { + type: IngredientType; + currentId?: string; + onQuickSelectPolicy?: (type: 'current-law') => void; + onSelectSavedPolicy?: (id: string, label: string, paramCount: number) => void; + onQuickSelectPopulation?: (type: 'household' | 'nationwide') => void; + onCreateCustom: () => void; + onBrowseMore?: () => void; + isInherited?: boolean; + inheritedPopulationType?: 'household' | 'nationwide' | null; + savedPolicies?: SavedPolicy[]; + viewMode: ViewMode; +} + +function IngredientSection({ + type, + currentId, + onQuickSelectPolicy, + onSelectSavedPolicy, + onQuickSelectPopulation, + onCreateCustom, + onBrowseMore, + isInherited, + inheritedPopulationType, + savedPolicies = [], + viewMode, +}: IngredientSectionProps) { + const colorConfig = INGREDIENT_COLORS[type]; + const IconComponent = { + policy: IconScale, + population: IconUsers, + dynamics: IconChartLine, + }[type]; + + const typeLabels = { + policy: 'Policy', + population: 'Population', + dynamics: 'Dynamics', + }; + + const useRowLayout = viewMode === 'rows' || viewMode === 'horizontal'; + const chipVariant = useRowLayout ? 'row' : 'square'; + const iconSize = useRowLayout ? 20 : 16; + + const ChipComponent = useRowLayout ? OptionChipRow : OptionChipSquare; + + return ( + + {/* Section header */} + + + + + + {typeLabels[type]} + + {isInherited && ( + (inherited from baseline) + )} + + + {/* Chips container */} + {isInherited && inheritedPopulationType ? ( + + + {useRowLayout ? ( + <> + + {inheritedPopulationType === 'household' ? ( + + ) : ( + + )} + + + + {inheritedPopulationType === 'household' ? 'Household' : 'Nationwide'} + + + Inherited from baseline + + + + ) : ( + <> + + {inheritedPopulationType === 'household' ? ( + + ) : ( + + )} + + + {inheritedPopulationType === 'household' ? 'Household' : 'Nationwide'} + + + Inherited + + + )} + + + ) : ( + + {type === 'policy' && onQuickSelectPolicy && ( + <> + {/* Current law - always first */} + } + label="Current law" + description="No changes" + isSelected={currentId === 'current-law'} + onClick={() => onQuickSelectPolicy('current-law')} + colorConfig={colorConfig} + /> + {/* Saved policies - up to 3 shown (total 4 with Current law) */} + {savedPolicies.slice(0, 3).map((policy) => ( + } + label={policy.label} + description={`${policy.paramCount} param${policy.paramCount !== 1 ? 's' : ''}`} + isSelected={currentId === policy.id} + onClick={() => onSelectSavedPolicy?.(policy.id, policy.label, policy.paramCount)} + colorConfig={colorConfig} + /> + ))} + {/* Browse more - always shown for searching/browsing all policies */} + {onBrowseMore && ( + 3 ? `${savedPolicies.length - 3} more` : 'Search all'} + onClick={onBrowseMore} + variant={chipVariant} + colorConfig={colorConfig} + /> + )} + {/* Create custom - always last */} + + + )} + + {type === 'population' && onQuickSelectPopulation && ( + <> + } + label="Household" + description="Single family" + isSelected={currentId === 'sample-household'} + onClick={() => onQuickSelectPopulation('household')} + colorConfig={colorConfig} + /> + } + label="Nationwide" + description="Economy-wide" + isSelected={currentId === 'us-nationwide'} + onClick={() => onQuickSelectPopulation('nationwide')} + colorConfig={colorConfig} + /> + {/* Browse more - always shown for searching/browsing all populations */} + {onBrowseMore && ( + + )} + + + )} + + {type === 'dynamics' && ( + + + + + Dynamics coming soon + + + + )} + + )} + + ); +} + +interface SimulationBlockProps { + simulation: SimulationStateProps; + index: number; + onLabelChange: (label: string) => void; + onQuickSelectPolicy: (policyType: 'current-law') => void; + onSelectSavedPolicy: (id: string, label: string, paramCount: number) => void; + onQuickSelectPopulation: (populationType: 'household' | 'nationwide') => void; + onCreateCustomPolicy: () => void; + onBrowseMorePolicies: () => void; + onCreateCustomPopulation: () => void; + onBrowseMorePopulations: () => void; + onRemove?: () => void; + canRemove: boolean; + isRequired?: boolean; + populationInherited?: boolean; + inheritedPopulation?: PopulationStateProps | null; + savedPolicies: SavedPolicy[]; + viewMode: ViewMode; +} + +function SimulationBlock({ + simulation, + index, + onLabelChange, + onQuickSelectPolicy, + onSelectSavedPolicy, + onQuickSelectPopulation, + onCreateCustomPolicy, + onBrowseMorePolicies, + onCreateCustomPopulation, + onBrowseMorePopulations, + onRemove, + canRemove, + isRequired, + populationInherited, + inheritedPopulation, + savedPolicies, + viewMode, +}: SimulationBlockProps) { + const [isEditingLabel, setIsEditingLabel] = useState(false); + const [labelInput, setLabelInput] = useState(simulation.label || ''); + + const isPolicyConfigured = !!simulation.policy.id; + const effectivePopulation = populationInherited && inheritedPopulation + ? inheritedPopulation + : simulation.population; + const isPopulationConfigured = !!( + effectivePopulation?.household?.id || effectivePopulation?.geography?.id + ); + const isFullyConfigured = isPolicyConfigured && isPopulationConfigured; + + const handleLabelSubmit = () => { + onLabelChange(labelInput || (index === 0 ? 'Baseline simulation' : 'Reform simulation')); + setIsEditingLabel(false); + }; + + const defaultLabel = index === 0 ? 'Baseline simulation' : 'Reform simulation'; + const isHorizontal = viewMode === 'horizontal'; + const cardStyle = isHorizontal ? styles.simulationCardHorizontal : styles.simulationCard; + + const currentPolicyId = simulation.policy.id; + const currentPopulationId = effectivePopulation?.household?.id || effectivePopulation?.geography?.id; + + // Determine inherited population type for display + const inheritedPopulationType = populationInherited && inheritedPopulation + ? (inheritedPopulation.household?.id ? 'household' : inheritedPopulation.geography?.id ? 'nationwide' : null) + : null; + + return ( + + {/* Status indicator */} + + + {/* Header */} + + + {isEditingLabel ? ( + setLabelInput(e.target.value)} + onBlur={handleLabelSubmit} + onKeyDown={(e) => e.key === 'Enter' && handleLabelSubmit()} + size="sm" + autoFocus + styles={{ + input: { + fontWeight: typography.fontWeight.semibold, + fontSize: FONT_SIZES.normal, + }, + }} + /> + ) : ( + + + {simulation.label || defaultLabel} + + { + setLabelInput(simulation.label || defaultLabel); + setIsEditingLabel(true); + }} + > + + + + )} + + + + {isRequired && ( + + Required + + )} + {isFullyConfigured && ( + + + + + + )} + {canRemove && ( + + + + )} + + + + {/* Panels - direct children for subgrid alignment */} + + + + + {}} + viewMode={viewMode} + /> + + ); +} + +interface AddSimulationCardProps { + onClick: () => void; + disabled?: boolean; + viewMode: ViewMode; +} + +function AddSimulationCard({ onClick, disabled, viewMode }: AddSimulationCardProps) { + const [isHovered, setIsHovered] = useState(false); + const isHorizontal = viewMode === 'horizontal'; + const cardStyle = isHorizontal ? styles.addSimulationCardHorizontal : styles.addSimulationCard; + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={disabled ? undefined : onClick} + > + + + + + Add reform simulation + + + Compare policy changes against your baseline + + + ); +} + +// ============================================================================ +// INGREDIENT PICKER MODAL +// ============================================================================ + +interface IngredientPickerModalProps { + isOpen: boolean; + onClose: () => void; + type: IngredientType; + onSelect: (item: PolicyStateProps | PopulationStateProps | null) => void; + onCreateNew: () => void; +} + +function IngredientPickerModal({ + isOpen, + onClose, + type, + onSelect, + onCreateNew, +}: IngredientPickerModalProps) { + const userId = MOCK_USER_ID.toString(); + const { data: policies } = useUserPolicies(userId); + const { data: households } = useUserHouseholds(userId); + const colorConfig = INGREDIENT_COLORS[type]; + + const getTitle = () => { + switch (type) { + case 'policy': return 'Select policy'; + case 'population': return 'Select population'; + case 'dynamics': return 'Configure dynamics'; + } + }; + + const getIcon = () => { + const iconProps = { size: 20, color: colorConfig.icon }; + switch (type) { + case 'policy': return ; + case 'population': return ; + case 'dynamics': return ; + } + }; + + const handleSelectPolicy = (policyId: string, label: string, paramCount: number) => { + onSelect({ id: policyId, label, parameters: Array(paramCount).fill({}) }); + onClose(); + }; + + const handleSelectCurrentLaw = () => { + onSelect({ id: 'current-law', label: 'Current law', parameters: [] }); + onClose(); + }; + + const handleSelectHousehold = (householdId: string, label: string) => { + onSelect({ + label, + type: 'household', + household: { id: householdId, countryId: 'us', householdData: { people: {} } }, + geography: null, + }); + onClose(); + }; + + const handleSelectGeography = (geoId: string, label: string, scope: 'national' | 'subnational') => { + onSelect({ + label, + type: 'geography', + household: null, + geography: { id: geoId, countryId: 'us', scope, geographyId: geoId, name: label }, + }); + onClose(); + }; + + return ( + + + {getIcon()} + + {getTitle()} + + } + size="lg" + radius="lg" + styles={{ + header: { borderBottom: `1px solid ${colors.border.light}`, paddingBottom: spacing.md }, + body: { padding: spacing.xl }, + }} + > + + {type === 'policy' && ( + <> + + + + + + + Current law + Use existing tax and benefit rules without modifications + + + + + + + {policies?.map((p) => ( + handleSelectPolicy(p.policy?.id || '', p.userPolicy?.label || 'Unnamed', p.policy?.parameters?.length || 0)}> + + + {p.userPolicy?.label || 'Unnamed'} + {p.policy?.parameters?.length || 0} parameters + + + + + ))} + {(!policies || policies.length === 0) && No saved policies} + + + + + + )} + + {type === 'population' && ( + <> + handleSelectGeography('us-nationwide', 'Sample nationwide', 'national')}> + + + + + + Sample nationwide + Economy-wide simulation + + + + handleSelectHousehold('sample-household', 'Sample household')}> + + + + + + Sample household + Single household simulation + + + + + + + {households?.map((h) => ( + handleSelectHousehold(h.household?.id || '', h.userHousehold?.label || 'Unnamed')}> + + {h.userHousehold?.label || 'Unnamed'} + + + + ))} + {(!households || households.length === 0) && No saved households} + + + + + + )} + + {type === 'dynamics' && ( + + + + + + Dynamics coming soon + Dynamic behavioral responses will be available in a future update. + + + )} + + + ); +} + +// ============================================================================ +// SIMULATION CANVAS +// ============================================================================ + +interface SimulationCanvasProps { + reportState: ReportBuilderState; + setReportState: React.Dispatch>; + pickerState: IngredientPickerState; + setPickerState: React.Dispatch>; + viewMode: ViewMode; +} + +function SimulationCanvas({ + reportState, + setReportState, + pickerState, + setPickerState, + viewMode, +}: SimulationCanvasProps) { + const countryId = useCurrentCountry(); + const userId = MOCK_USER_ID.toString(); + const { data: policies } = useUserPolicies(userId); + const isNationwideSelected = reportState.simulations[0]?.population?.geography?.id === 'us-nationwide'; + + // Transform policies data into SavedPolicy format + const savedPolicies: SavedPolicy[] = (policies || []).map((p) => ({ + id: p.policy?.id || '', + label: p.userPolicy?.label || 'Unnamed policy', + paramCount: p.policy?.parameters?.length || 0, + })); + + const handleAddSimulation = useCallback(() => { + if (reportState.simulations.length >= 2) return; + const newSim = initializeSimulationState(); + newSim.label = 'Reform simulation'; + newSim.population = { ...reportState.simulations[0].population }; + setReportState((prev) => ({ ...prev, simulations: [...prev.simulations, newSim] })); + }, [reportState.simulations, setReportState]); + + const handleRemoveSimulation = useCallback((index: number) => { + if (index === 0) return; + setReportState((prev) => ({ ...prev, simulations: prev.simulations.filter((_, i) => i !== index) })); + }, [setReportState]); + + const handleSimulationLabelChange = useCallback((index: number, label: string) => { + setReportState((prev) => ({ + ...prev, + simulations: prev.simulations.map((sim, i) => i === index ? { ...sim, label } : sim), + })); + }, [setReportState]); + + const handleIngredientSelect = useCallback( + (item: PolicyStateProps | PopulationStateProps | null) => { + const { simulationIndex, ingredientType } = pickerState; + setReportState((prev) => { + const newSimulations = prev.simulations.map((sim, i) => { + if (i !== simulationIndex) return sim; + if (ingredientType === 'policy') return { ...sim, policy: item as PolicyStateProps }; + if (ingredientType === 'population') return { ...sim, population: item as PopulationStateProps }; + return sim; + }); + if (ingredientType === 'population' && simulationIndex === 0 && newSimulations.length > 1) { + newSimulations[1] = { ...newSimulations[1], population: { ...(item as PopulationStateProps) } }; + } + return { ...prev, simulations: newSimulations }; + }); + }, + [pickerState, setReportState] + ); + + const handleQuickSelectPolicy = useCallback( + (simulationIndex: number) => { + const policyState: PolicyStateProps = { id: 'current-law', label: 'Current law', parameters: [] }; + setReportState((prev) => ({ + ...prev, + simulations: prev.simulations.map((sim, i) => i === simulationIndex ? { ...sim, policy: policyState } : sim), + })); + }, + [setReportState] + ); + + const handleSelectSavedPolicy = useCallback( + (simulationIndex: number, policyId: string, label: string, paramCount: number) => { + const policyState: PolicyStateProps = { id: policyId, label, parameters: Array(paramCount).fill({}) }; + setReportState((prev) => ({ + ...prev, + simulations: prev.simulations.map((sim, i) => i === simulationIndex ? { ...sim, policy: policyState } : sim), + })); + }, + [setReportState] + ); + + const handleBrowseMorePolicies = useCallback( + (simulationIndex: number) => { + setPickerState({ + isOpen: true, + simulationIndex, + ingredientType: 'policy', + }); + }, + [setPickerState] + ); + + const handleBrowseMorePopulations = useCallback( + (simulationIndex: number) => { + setPickerState({ + isOpen: true, + simulationIndex, + ingredientType: 'population', + }); + }, + [setPickerState] + ); + + const handleQuickSelectPopulation = useCallback( + (simulationIndex: number, populationType: 'household' | 'nationwide') => { + const populationState = populationType === 'household' ? SAMPLE_POPULATIONS.household : SAMPLE_POPULATIONS.nationwide; + setReportState((prev) => { + let newSimulations = prev.simulations.map((sim, i) => + i === simulationIndex ? { ...sim, population: { ...populationState } } : sim + ); + + // If switching baseline from nationwide to household, check if reform should be cleared + if (simulationIndex === 0 && populationType === 'household' && newSimulations.length > 1) { + const reform = newSimulations[1]; + // Check if reform has only default/inherited values (no custom policy configured) + const hasDefaultPolicy = !reform.policy.id || reform.policy.id === 'current-law'; + const hasDefaultLabel = !reform.label || reform.label === 'Reform simulation'; + + // If reform is essentially default, remove it when switching to household + if (hasDefaultPolicy && hasDefaultLabel) { + newSimulations = [newSimulations[0]]; + } else { + // Otherwise just update the inherited population + newSimulations[1] = { ...newSimulations[1], population: { ...populationState } }; + } + } else if (simulationIndex === 0 && newSimulations.length > 1) { + // Update the reform's inherited population + newSimulations[1] = { ...newSimulations[1], population: { ...populationState } }; + } + + return { ...prev, simulations: newSimulations }; + }); + }, + [setReportState] + ); + + const handleCreateCustom = useCallback( + (simulationIndex: number, ingredientType: IngredientType) => { + if (ingredientType === 'policy') { + window.location.href = `/${countryId}/policies/create`; + } else if (ingredientType === 'population') { + window.location.href = `/${countryId}/households/create`; + } + }, + [countryId] + ); + + const isHorizontal = viewMode === 'horizontal'; + const containerStyle = isHorizontal ? styles.simulationsContainerHorizontal : styles.simulationsGrid; + + return ( + <> + + + + handleSimulationLabelChange(0, label)} + onQuickSelectPolicy={() => handleQuickSelectPolicy(0)} + onSelectSavedPolicy={(id, label, paramCount) => handleSelectSavedPolicy(0, id, label, paramCount)} + onQuickSelectPopulation={(type) => handleQuickSelectPopulation(0, type)} + onCreateCustomPolicy={() => handleCreateCustom(0, 'policy')} + onBrowseMorePolicies={() => handleBrowseMorePolicies(0)} + onCreateCustomPopulation={() => handleCreateCustom(0, 'population')} + onBrowseMorePopulations={() => handleBrowseMorePopulations(0)} + canRemove={false} + savedPolicies={savedPolicies} + viewMode={viewMode} + /> + + {reportState.simulations.length > 1 ? ( + handleSimulationLabelChange(1, label)} + onQuickSelectPolicy={() => handleQuickSelectPolicy(1)} + onSelectSavedPolicy={(id, label, paramCount) => handleSelectSavedPolicy(1, id, label, paramCount)} + onQuickSelectPopulation={(type) => handleQuickSelectPopulation(1, type)} + onCreateCustomPolicy={() => handleCreateCustom(1, 'policy')} + onBrowseMorePolicies={() => handleBrowseMorePolicies(1)} + onCreateCustomPopulation={() => handleCreateCustom(1, 'population')} + onBrowseMorePopulations={() => handleBrowseMorePopulations(1)} + onRemove={() => handleRemoveSimulation(1)} + canRemove={!isNationwideSelected} + isRequired={isNationwideSelected} + populationInherited={true} + inheritedPopulation={reportState.simulations[0].population} + savedPolicies={savedPolicies} + viewMode={viewMode} + /> + ) : ( + + )} + + + + setPickerState((prev) => ({ ...prev, isOpen: false }))} + type={pickerState.ingredientType} + onSelect={handleIngredientSelect} + onCreateNew={() => handleCreateCustom(pickerState.simulationIndex, pickerState.ingredientType)} + /> + + ); +} + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +export default function ReportBuilderPage() { + const [activeTab, setActiveTab] = useState('cards'); + + const initialSim = initializeSimulationState(); + initialSim.label = 'Baseline simulation'; + + const [reportState, setReportState] = useState({ + label: null, + year: CURRENT_YEAR, + simulations: [initialSim], + }); + + const [pickerState, setPickerState] = useState({ + isOpen: false, + simulationIndex: 0, + ingredientType: 'policy', + }); + + const [isEditingLabel, setIsEditingLabel] = useState(false); + const [labelInput, setLabelInput] = useState(''); + + const isNationwideSelected = reportState.simulations[0]?.population?.geography?.id === 'us-nationwide'; + + useEffect(() => { + if (isNationwideSelected && reportState.simulations.length === 1) { + const newSim = initializeSimulationState(); + newSim.label = 'Reform simulation'; + newSim.population = { ...reportState.simulations[0].population }; + setReportState((prev) => ({ ...prev, simulations: [...prev.simulations, newSim] })); + } + }, [isNationwideSelected, reportState.simulations]); + + const handleReportLabelSubmit = () => { + setReportState((prev) => ({ ...prev, label: labelInput || 'Untitled report' })); + setIsEditingLabel(false); + }; + + const isReportConfigured = reportState.simulations.every( + (sim) => !!sim.policy.id && !!(sim.population.household?.id || sim.population.geography?.id) + ); + + const viewMode = (activeTab || 'cards') as ViewMode; + + return ( + + +

Report builder

+
+ + + + + + + {isEditingLabel ? ( + setLabelInput(e.target.value)} + onBlur={handleReportLabelSubmit} + onKeyDown={(e) => e.key === 'Enter' && handleReportLabelSubmit()} + placeholder="Enter report name..." + size="sm" + autoFocus + styles={{ input: { fontWeight: typography.fontWeight.semibold, fontSize: FONT_SIZES.normal, width: 300 } }} + /> + ) : ( + + {reportState.label || 'Untitled report'} + { setLabelInput(reportState.label || ''); setIsEditingLabel(true); }}> + + + + )} + + + + Year: + setReportState((prev) => ({ ...prev, year: value || CURRENT_YEAR }))} + data={['2023', '2024', '2025', '2026']} + size="xs" + w={60} + variant="unstyled" + rightSection={null} + styles={{ + input: { + fontFamily: typography.fontFamily.primary, + fontSize: FONT_SIZES.normal, + fontWeight: 500, + color: colors.gray[600], + cursor: 'pointer', + padding: 0, + minHeight: 'auto', + } + }} + /> + + {/* Divider */} + + + {/* Progress dots - fixed width */} + + {steps.map((completed, i) => ( + + ))} + + + {/* Divider */} + + + {/* Expand/Pin toggle button */} + { + if (isPinned) { + setIsPinned(false); + setIsExpanded(false); + } else { + setIsPinned(true); + setIsExpanded(true); + } + }} + aria-label={isPinned ? 'Collapse setup panel' : 'Expand setup panel'} + style={{ flexShrink: 0 }} + > + {isPinned || isExpanded ? ( + + ) : ( + + )} + + + {/* Divider */} + + + {/* Run button */} + isReportConfigured && console.log('Run report')} + onMouseEnter={(e: React.MouseEvent) => { + if (isReportConfigured) { + e.currentTarget.style.transform = 'scale(1.05)'; + e.currentTarget.style.boxShadow = `0 6px 16px rgba(44, 122, 123, 0.4)`; + } + }} + onMouseLeave={(e: React.MouseEvent) => { + e.currentTarget.style.transform = 'scale(1)'; + e.currentTarget.style.boxShadow = isReportConfigured + ? `0 4px 12px rgba(44, 122, 123, 0.3)` + : 'none'; + }} + > + + Run + + + + {/* Expanded content - visible on hover */} + + + {/* Baseline row */} + + Baseline + + {baselinePolicyConfigured ? ( + <> + + {baselinePolicyLabel} + + ) : ( + <> + + Select policy + + )} + + + + + {baselinePopulationConfigured ? ( + <> + + {baselinePopulationLabel} + + ) : ( + <> + + Select population + + )} + + + + {/* Reform row (if applicable) */} + {hasReform && ( + + Reform + + {reformPolicyConfigured ? ( + <> + + {reformPolicyLabel} + + ) : ( + <> + + Select policy + + )} + + (inherits population) + + )} + + {/* Ready message */} + {isReportConfigured && ( + + + + Ready to run your analysis + + + )} + + + + + {/* CSS for pulse animation */} + + + ); +} + // ============================================================================ // MAIN COMPONENT // ============================================================================ @@ -1650,9 +2058,6 @@ export default function ReportBuilderPage() { ingredientType: 'policy', }); - const [isEditingLabel, setIsEditingLabel] = useState(false); - const [labelInput, setLabelInput] = useState(''); - const isNationwideSelected = reportState.simulations[0]?.population?.geography?.id === 'us-nationwide'; useEffect(() => { @@ -1664,11 +2069,6 @@ export default function ReportBuilderPage() { } }, [isNationwideSelected, reportState.simulations]); - const handleReportLabelSubmit = () => { - setReportState((prev) => ({ ...prev, label: labelInput || 'Untitled report' })); - setIsEditingLabel(false); - }; - const isReportConfigured = reportState.simulations.every( (sim) => !!sim.policy.id && !!(sim.population.household?.id || sim.population.geography?.id) ); @@ -1681,50 +2081,11 @@ export default function ReportBuilderPage() {

Report builder

- - - - - - {isEditingLabel ? ( - setLabelInput(e.target.value)} - onBlur={handleReportLabelSubmit} - onKeyDown={(e) => e.key === 'Enter' && handleReportLabelSubmit()} - placeholder="Enter report name..." - size="sm" - autoFocus - styles={{ input: { fontWeight: typography.fontWeight.semibold, fontSize: FONT_SIZES.normal, width: 300 } }} - /> - ) : ( - - {reportState.label || 'Untitled report'} - { setLabelInput(reportState.label || ''); setIsEditingLabel(true); }}> - - - - )} - - - - Year: - setReportState((prev) => ({ ...prev, year: value || CURRENT_YEAR }))} + data={['2023', '2024', '2025', '2026']} + size="xs" + w={60} + variant="unstyled" + rightSection={null} + withCheckIcon={false} + styles={{ + input: { + fontFamily: typography.fontFamily.primary, + fontSize: FONT_SIZES.normal, + fontWeight: 500, + color: colors.gray[600], + cursor: 'pointer', + padding: 0, + minHeight: 'auto', + } + }} + /> + + {/* Divider */} + + + {/* Progress dots - fixed width */} + + {steps.map((completed, i) => ( + + ))} + + + {/* Divider */} + + + {/* Run button */} + isReportConfigured && console.log('Run report')} + onMouseEnter={(e: React.MouseEvent) => { + if (isReportConfigured) { + e.currentTarget.style.transform = 'scale(1.05)'; + e.currentTarget.style.boxShadow = `0 6px 16px rgba(44, 122, 123, 0.4)`; + } + }} + onMouseLeave={(e: React.MouseEvent) => { + e.currentTarget.style.transform = 'scale(1)'; + e.currentTarget.style.boxShadow = isReportConfigured + ? `0 4px 12px rgba(44, 122, 123, 0.3)` + : 'none'; + }} + > + + Run + + + + {/* Expanded content - visible on hover */} + + + {/* Baseline row */} + + Baseline + + {baselinePolicyConfigured ? ( + <> + + {baselinePolicyLabel} + + ) : ( + <> + + Select policy + + )} + + + + + {baselinePopulationConfigured ? ( + <> + + {baselinePopulationLabel} + + ) : ( + <> + + Select population + + )} + + + + {/* Reform row (if applicable) */} + {hasReform && ( + + Reform + + {reformPolicyConfigured ? ( + <> + + {reformPolicyLabel} + + ) : ( + <> + + Select policy + + )} + + (inherits population) + + )} + + {/* Ready message */} + {isReportConfigured && ( + + + + Ready to run your analysis + + + )} + + + + + {/* CSS for pulse animation */} + +
+ ); +} diff --git a/app/src/pages/reportBuilder/components/SimulationBlock.tsx b/app/src/pages/reportBuilder/components/SimulationBlock.tsx new file mode 100644 index 000000000..1a7cb87eb --- /dev/null +++ b/app/src/pages/reportBuilder/components/SimulationBlock.tsx @@ -0,0 +1,230 @@ +/** + * SimulationBlock - A simulation configuration card + */ + +import { useState } from 'react'; +import { + Box, + Group, + Text, + TextInput, + ActionIcon, + Paper, + Tooltip, +} from '@mantine/core'; +import { + IconCheck, + IconPencil, + IconTrash, +} from '@tabler/icons-react'; + +import { colors, spacing, typography } from '@/designTokens'; +import type { SimulationStateProps, PopulationStateProps } from '@/types/pathwayState'; + +import type { SavedPolicy, RecentPopulation, ViewMode } from '../types'; +import { FONT_SIZES } from '../constants'; +import { styles } from '../styles'; +import { IngredientSection } from './IngredientSection'; + +interface SimulationBlockProps { + simulation: SimulationStateProps; + index: number; + countryId: 'us' | 'uk'; + onLabelChange: (label: string) => void; + onQuickSelectPolicy: (policyType: 'current-law') => void; + onSelectSavedPolicy: (id: string, label: string, paramCount: number) => void; + onQuickSelectPopulation: (populationType: 'nationwide') => void; + onSelectRecentPopulation: (population: PopulationStateProps) => void; + onDeselectPolicy: () => void; + onDeselectPopulation: () => void; + onCreateCustomPolicy: () => void; + onBrowseMorePolicies: () => void; + onBrowseMorePopulations: () => void; + onRemove?: () => void; + canRemove: boolean; + isRequired?: boolean; + populationInherited?: boolean; + inheritedPopulation?: PopulationStateProps | null; + savedPolicies: SavedPolicy[]; + recentPopulations: RecentPopulation[]; + viewMode: ViewMode; +} + +export function SimulationBlock({ + simulation, + index, + countryId, + onLabelChange, + onQuickSelectPolicy, + onSelectSavedPolicy, + onQuickSelectPopulation, + onSelectRecentPopulation, + onDeselectPolicy, + onDeselectPopulation, + onBrowseMorePolicies, + onBrowseMorePopulations, + onRemove, + canRemove, + isRequired, + populationInherited, + inheritedPopulation, + savedPolicies, + recentPopulations, + viewMode, +}: SimulationBlockProps) { + const [isEditingLabel, setIsEditingLabel] = useState(false); + const [labelInput, setLabelInput] = useState(simulation.label || ''); + + const isPolicyConfigured = !!simulation.policy.id; + const effectivePopulation = populationInherited && inheritedPopulation + ? inheritedPopulation + : simulation.population; + const isPopulationConfigured = !!( + effectivePopulation?.household?.id || effectivePopulation?.geography?.id + ); + const isFullyConfigured = isPolicyConfigured && isPopulationConfigured; + + const handleLabelSubmit = () => { + onLabelChange(labelInput || (index === 0 ? 'Baseline simulation' : 'Reform simulation')); + setIsEditingLabel(false); + }; + + const defaultLabel = index === 0 ? 'Baseline simulation' : 'Reform simulation'; + + const currentPolicyId = simulation.policy.id; + const currentPopulationId = effectivePopulation?.household?.id || effectivePopulation?.geography?.id; + + // Determine inherited population type for display + const inheritedPopulationType = populationInherited && inheritedPopulation + ? (inheritedPopulation.household?.id ? 'household' : inheritedPopulation.geography?.id ? 'nationwide' : null) + : null; + + return ( + + {/* Status indicator */} + + + {/* Header */} + + + {isEditingLabel ? ( + setLabelInput(e.target.value)} + onBlur={handleLabelSubmit} + onKeyDown={(e) => e.key === 'Enter' && handleLabelSubmit()} + size="sm" + autoFocus + styles={{ + input: { + fontWeight: typography.fontWeight.semibold, + fontSize: FONT_SIZES.normal, + }, + }} + /> + ) : ( + + + {simulation.label || defaultLabel} + + { + setLabelInput(simulation.label || defaultLabel); + setIsEditingLabel(true); + }} + > + + + + )} + + + + {isRequired && ( + + Required + + )} + {isFullyConfigured && ( + + + + + + )} + {canRemove && ( + + + + )} + + + + {/* Panels - direct children for subgrid alignment */} + {}} + onBrowseMore={onBrowseMorePolicies} + savedPolicies={savedPolicies} + viewMode={viewMode} + /> + + {}} + onBrowseMore={onBrowseMorePopulations} + isInherited={populationInherited} + inheritedPopulationType={inheritedPopulationType} + recentPopulations={recentPopulations} + viewMode={viewMode} + /> + + {}} + viewMode={viewMode} + /> + + ); +} diff --git a/app/src/pages/reportBuilder/components/SimulationCanvas.tsx b/app/src/pages/reportBuilder/components/SimulationCanvas.tsx new file mode 100644 index 000000000..bc726f6e4 --- /dev/null +++ b/app/src/pages/reportBuilder/components/SimulationCanvas.tsx @@ -0,0 +1,486 @@ +/** + * SimulationCanvas - Main orchestrator for simulation blocks + */ + +import { useState, useMemo, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { Box } from '@mantine/core'; + +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useUserPolicies } from '@/hooks/useUserPolicy'; +import { useUserHouseholds } from '@/hooks/useUserHousehold'; +import { RootState } from '@/store'; +import { HouseholdAdapter } from '@/adapters/HouseholdAdapter'; +import { + getUSStates, + getUSCongressionalDistricts, + getUKCountries, + getUKConstituencies, + getUKLocalAuthorities, + RegionOption, +} from '@/utils/regionStrategies'; +import { generateGeographyLabel } from '@/utils/geographyUtils'; +import { geographyUsageStore, householdUsageStore } from '@/api/usageTracking'; +import { countPolicyModifications } from '@/utils/countParameterChanges'; +import { initializeSimulationState } from '@/utils/pathwayState/initializeSimulationState'; +import { initializePolicyState } from '@/utils/pathwayState/initializePolicyState'; +import { initializePopulationState } from '@/utils/pathwayState/initializePopulationState'; +import { Geography } from '@/types/ingredients/Geography'; +import { PolicyStateProps, PopulationStateProps } from '@/types/pathwayState'; +import { MOCK_USER_ID } from '@/constants'; + +import type { + ReportBuilderState, + IngredientPickerState, + IngredientType, + ViewMode, + SavedPolicy, + RecentPopulation, + PolicyBrowseState, +} from '../types'; +import { COUNTRY_CONFIG, getSamplePopulations } from '../constants'; +import { styles } from '../styles'; +import { SimulationBlock } from './SimulationBlock'; +import { AddSimulationCard } from './AddSimulationCard'; +import { IngredientPickerModal, PolicyBrowseModal, PopulationBrowseModal, PolicyCreationModal } from '../modals'; + +interface SimulationCanvasProps { + reportState: ReportBuilderState; + setReportState: React.Dispatch>; + pickerState: IngredientPickerState; + setPickerState: React.Dispatch>; + viewMode: ViewMode; +} + +export function SimulationCanvas({ + reportState, + setReportState, + pickerState, + setPickerState, + viewMode, +}: SimulationCanvasProps) { + const countryId = useCurrentCountry() as 'us' | 'uk'; + const countryConfig = COUNTRY_CONFIG[countryId] || COUNTRY_CONFIG.us; + const userId = MOCK_USER_ID.toString(); + const { data: policies } = useUserPolicies(userId); + const { data: households } = useUserHouseholds(userId); + const regionOptions = useSelector((state: RootState) => state.metadata.economyOptions.region); + const isGeographySelected = !!reportState.simulations[0]?.population?.geography?.id; + + // Suppress unused variable + void countryConfig; + + // State for modals + const [policyBrowseState, setPolicyBrowseState] = useState({ + isOpen: false, + simulationIndex: 0, + }); + + const [policyCreationState, setPolicyCreationState] = useState({ + isOpen: false, + simulationIndex: 0, + }); + + const [populationBrowseState, setPopulationBrowseState] = useState({ + isOpen: false, + simulationIndex: 0, + }); + + // Transform policies data into SavedPolicy format + const savedPolicies: SavedPolicy[] = useMemo(() => { + return (policies || []) + .map((p) => { + const policyId = p.association.policyId.toString(); + const label = p.association.label || `Policy #${policyId}`; + return { + id: policyId, + label, + paramCount: countPolicyModifications(p.policy), + createdAt: p.association.createdAt, + updatedAt: p.association.updatedAt, + }; + }) + .sort((a, b) => { + const aTime = a.updatedAt || a.createdAt || ''; + const bTime = b.updatedAt || b.createdAt || ''; + return bTime.localeCompare(aTime); + }); + }, [policies]); + + // Build recent populations from usage tracking + const recentPopulations: RecentPopulation[] = useMemo(() => { + const results: Array = []; + + const regions = regionOptions || []; + const allRegions: RegionOption[] = countryId === 'us' + ? [...getUSStates(regions), ...getUSCongressionalDistricts(regions)] + : [...getUKCountries(regions), ...getUKConstituencies(regions), ...getUKLocalAuthorities(regions)]; + + const recentGeoIds = geographyUsageStore.getRecentIds(10); + for (const geoId of recentGeoIds) { + if (geoId === 'us' || geoId === 'uk') continue; + + const timestamp = geographyUsageStore.getLastUsed(geoId) || ''; + const region = allRegions.find((r) => r.value === geoId); + + if (region) { + const geographyId = `${countryId}-${geoId}`; + const geography: Geography = { + id: geographyId, + countryId, + scope: 'subnational', + geographyId: geoId, + }; + results.push({ + id: geographyId, + label: region.label, + type: 'geography', + population: { + geography, + household: null, + label: generateGeographyLabel(geography), + type: 'geography', + }, + timestamp, + }); + } + } + + const recentHouseholdIds = householdUsageStore.getRecentIds(10); + for (const householdId of recentHouseholdIds) { + const timestamp = householdUsageStore.getLastUsed(householdId) || ''; + const householdData = households?.find((h) => String(h.association.householdId) === householdId); + if (householdData?.household) { + const household = HouseholdAdapter.fromMetadata(householdData.household); + const resolvedId = household.id || householdId; + results.push({ + id: resolvedId, + label: householdData.association.label || `Household #${householdId}`, + type: 'household', + population: { + geography: null, + household, + label: householdData.association.label || `Household #${householdId}`, + type: 'household', + }, + timestamp, + }); + } + } + + return results + .sort((a, b) => b.timestamp.localeCompare(a.timestamp)) + .slice(0, 10) + .map(({ timestamp: _t, ...rest }) => rest); + }, [countryId, households, regionOptions]); + + const handleAddSimulation = useCallback(() => { + if (reportState.simulations.length >= 2) return; + const newSim = initializeSimulationState(); + newSim.label = 'Reform simulation'; + newSim.population = { ...reportState.simulations[0].population }; + setReportState((prev) => ({ ...prev, simulations: [...prev.simulations, newSim] })); + }, [reportState.simulations, setReportState]); + + const handleRemoveSimulation = useCallback((index: number) => { + if (index === 0) return; + setReportState((prev) => ({ ...prev, simulations: prev.simulations.filter((_, i) => i !== index) })); + }, [setReportState]); + + const handleSimulationLabelChange = useCallback((index: number, label: string) => { + setReportState((prev) => ({ + ...prev, + simulations: prev.simulations.map((sim, i) => i === index ? { ...sim, label } : sim), + })); + }, [setReportState]); + + const handleIngredientSelect = useCallback( + (item: PolicyStateProps | PopulationStateProps | null) => { + const { simulationIndex, ingredientType } = pickerState; + setReportState((prev) => { + const newSimulations = prev.simulations.map((sim, i) => { + if (i !== simulationIndex) return sim; + if (ingredientType === 'policy') return { ...sim, policy: item as PolicyStateProps }; + if (ingredientType === 'population') return { ...sim, population: item as PopulationStateProps }; + return sim; + }); + if (ingredientType === 'population' && simulationIndex === 0 && newSimulations.length > 1) { + newSimulations[1] = { ...newSimulations[1], population: { ...(item as PopulationStateProps) } }; + } + return { ...prev, simulations: newSimulations }; + }); + }, + [pickerState, setReportState] + ); + + const handleQuickSelectPolicy = useCallback( + (simulationIndex: number) => { + const policyState: PolicyStateProps = { id: 'current-law', label: 'Current law', parameters: [] }; + setReportState((prev) => ({ + ...prev, + simulations: prev.simulations.map((sim, i) => i === simulationIndex ? { ...sim, policy: policyState } : sim), + })); + }, + [setReportState] + ); + + const handleSelectSavedPolicy = useCallback( + (simulationIndex: number, policyId: string, label: string, paramCount: number) => { + const policyState: PolicyStateProps = { id: policyId, label, parameters: Array(paramCount).fill({}) }; + setReportState((prev) => ({ + ...prev, + simulations: prev.simulations.map((sim, i) => i === simulationIndex ? { ...sim, policy: policyState } : sim), + })); + }, + [setReportState] + ); + + const handleBrowseMorePolicies = useCallback( + (simulationIndex: number) => { + setPolicyBrowseState({ isOpen: true, simulationIndex }); + }, + [] + ); + + const handlePolicySelectFromBrowse = useCallback( + (policy: PolicyStateProps) => { + const { simulationIndex } = policyBrowseState; + setReportState((prev) => ({ + ...prev, + simulations: prev.simulations.map((sim, i) => + i === simulationIndex ? { ...sim, policy } : sim + ), + })); + }, + [policyBrowseState, setReportState] + ); + + const handleBrowseMorePopulations = useCallback( + (simulationIndex: number) => { + setPopulationBrowseState({ isOpen: true, simulationIndex }); + }, + [] + ); + + const handlePopulationSelectFromBrowse = useCallback( + (population: PopulationStateProps) => { + const { simulationIndex } = populationBrowseState; + + setReportState((prev) => { + const newPopulation = { ...population }; + + let newSimulations = prev.simulations.map((sim, i) => + i === simulationIndex ? { ...sim, population: newPopulation } : sim + ); + + if (simulationIndex === 0 && newSimulations.length > 1) { + newSimulations[1] = { ...newSimulations[1], population: { ...newPopulation } }; + } + + return { ...prev, simulations: newSimulations }; + }); + }, + [populationBrowseState, setReportState] + ); + + const handleQuickSelectPopulation = useCallback( + (simulationIndex: number, populationType: 'nationwide') => { + const samplePopulations = getSamplePopulations(countryId); + const populationState = samplePopulations.nationwide; + + if (populationState.geography?.geographyId) { + geographyUsageStore.recordUsage(populationState.geography.geographyId); + } + + setReportState((prev) => { + let newSimulations = prev.simulations.map((sim, i) => + i === simulationIndex ? { ...sim, population: { ...populationState } } : sim + ); + + if (simulationIndex === 0 && newSimulations.length > 1) { + newSimulations[1] = { ...newSimulations[1], population: { ...populationState } }; + } + + return { ...prev, simulations: newSimulations }; + }); + }, + [countryId, setReportState] + ); + + const handleSelectRecentPopulation = useCallback( + (simulationIndex: number, population: PopulationStateProps) => { + if (population.geography?.geographyId) { + geographyUsageStore.recordUsage(population.geography.geographyId); + } else if (population.household?.id) { + householdUsageStore.recordUsage(population.household.id); + } + + setReportState((prev) => { + const newPopulation = { ...population }; + + let newSimulations = prev.simulations.map((sim, i) => + i === simulationIndex ? { ...sim, population: newPopulation } : sim + ); + + if (simulationIndex === 0 && newSimulations.length > 1) { + newSimulations[1] = { ...newSimulations[1], population: { ...newPopulation } }; + } + + return { ...prev, simulations: newSimulations }; + }); + }, + [setReportState] + ); + + const handleDeselectPolicy = useCallback( + (simulationIndex: number) => { + setReportState((prev) => ({ + ...prev, + simulations: prev.simulations.map((sim, i) => + i === simulationIndex + ? { ...sim, policy: initializePolicyState() } + : sim + ), + })); + }, + [setReportState] + ); + + const handleDeselectPopulation = useCallback( + (simulationIndex: number) => { + setReportState((prev) => { + let newSimulations = prev.simulations.map((sim, i) => + i === simulationIndex + ? { ...sim, population: initializePopulationState() } + : sim + ); + + if (simulationIndex === 0 && newSimulations.length > 1) { + newSimulations[1] = { + ...newSimulations[1], + population: initializePopulationState(), + }; + } + + return { ...prev, simulations: newSimulations }; + }); + }, + [setReportState] + ); + + const handleCreateCustom = useCallback( + (simulationIndex: number, ingredientType: IngredientType) => { + if (ingredientType === 'policy') { + setPolicyCreationState({ isOpen: true, simulationIndex }); + } else if (ingredientType === 'population') { + window.location.href = `/${countryId}/households/create`; + } + }, + [countryId] + ); + + const handlePolicyCreated = useCallback( + (simulationIndex: number, policy: PolicyStateProps) => { + setReportState((prev) => { + const newSimulations = [...prev.simulations]; + if (newSimulations[simulationIndex]) { + newSimulations[simulationIndex] = { + ...newSimulations[simulationIndex], + policy: { + id: policy.id, + label: policy.label, + parameters: policy.parameters, + }, + }; + } + return { ...prev, simulations: newSimulations }; + }); + }, + [setReportState] + ); + + return ( + <> + + + + handleSimulationLabelChange(0, label)} + onQuickSelectPolicy={() => handleQuickSelectPolicy(0)} + onSelectSavedPolicy={(id, label, paramCount) => handleSelectSavedPolicy(0, id, label, paramCount)} + onQuickSelectPopulation={() => handleQuickSelectPopulation(0, 'nationwide')} + onSelectRecentPopulation={(pop) => handleSelectRecentPopulation(0, pop)} + onDeselectPolicy={() => handleDeselectPolicy(0)} + onDeselectPopulation={() => handleDeselectPopulation(0)} + onCreateCustomPolicy={() => handleCreateCustom(0, 'policy')} + onBrowseMorePolicies={() => handleBrowseMorePolicies(0)} + onBrowseMorePopulations={() => handleBrowseMorePopulations(0)} + canRemove={false} + savedPolicies={savedPolicies} + recentPopulations={recentPopulations} + viewMode={viewMode} + /> + + {reportState.simulations.length > 1 ? ( + handleSimulationLabelChange(1, label)} + onQuickSelectPolicy={() => handleQuickSelectPolicy(1)} + onSelectSavedPolicy={(id, label, paramCount) => handleSelectSavedPolicy(1, id, label, paramCount)} + onQuickSelectPopulation={() => handleQuickSelectPopulation(1, 'nationwide')} + onSelectRecentPopulation={(pop) => handleSelectRecentPopulation(1, pop)} + onDeselectPolicy={() => handleDeselectPolicy(1)} + onDeselectPopulation={() => handleDeselectPopulation(1)} + onCreateCustomPolicy={() => handleCreateCustom(1, 'policy')} + onBrowseMorePolicies={() => handleBrowseMorePolicies(1)} + onBrowseMorePopulations={() => handleBrowseMorePopulations(1)} + onRemove={() => handleRemoveSimulation(1)} + canRemove={!isGeographySelected} + isRequired={isGeographySelected} + populationInherited={true} + inheritedPopulation={reportState.simulations[0].population} + savedPolicies={savedPolicies} + recentPopulations={recentPopulations} + viewMode={viewMode} + /> + ) : ( + + )} + + + + setPickerState((prev) => ({ ...prev, isOpen: false }))} + type={pickerState.ingredientType} + onSelect={handleIngredientSelect} + onCreateNew={() => handleCreateCustom(pickerState.simulationIndex, pickerState.ingredientType)} + /> + + setPolicyBrowseState((prev) => ({ ...prev, isOpen: false }))} + onSelect={handlePolicySelectFromBrowse} + /> + + setPopulationBrowseState((prev) => ({ ...prev, isOpen: false }))} + onSelect={handlePopulationSelectFromBrowse} + onCreateNew={() => handleCreateCustom(populationBrowseState.simulationIndex, 'population')} + /> + + setPolicyCreationState((prev) => ({ ...prev, isOpen: false }))} + onPolicyCreated={(policy) => handlePolicyCreated(policyCreationState.simulationIndex, policy)} + simulationIndex={policyCreationState.simulationIndex} + /> + + ); +} diff --git a/app/src/pages/reportBuilder/components/index.ts b/app/src/pages/reportBuilder/components/index.ts index 377b16853..ec72592f2 100644 --- a/app/src/pages/reportBuilder/components/index.ts +++ b/app/src/pages/reportBuilder/components/index.ts @@ -13,6 +13,9 @@ export { CreationStatusHeader, } from './shared'; -// Re-export page components (to be extracted) -// These are currently in the main ReportBuilder.page.tsx file -// and will be extracted progressively +// Page components +export { IngredientSection } from './IngredientSection'; +export { SimulationBlock } from './SimulationBlock'; +export { AddSimulationCard } from './AddSimulationCard'; +export { ReportMetaPanel } from './ReportMetaPanel'; +export { SimulationCanvas } from './SimulationCanvas'; diff --git a/app/src/pages/reportBuilder/index.ts b/app/src/pages/reportBuilder/index.ts index e3412e806..1f2df775b 100644 --- a/app/src/pages/reportBuilder/index.ts +++ b/app/src/pages/reportBuilder/index.ts @@ -2,7 +2,6 @@ * ReportBuilder Module * * This folder contains the refactored ReportBuilder page and its components. - * The original ReportBuilder.page.tsx is being progressively migrated here. * * Structure: * - types.ts: All TypeScript interfaces and types @@ -11,13 +10,17 @@ * - components/: Reusable UI components * - chips/: Chip components (OptionChipSquare, OptionChipRow, etc.) * - shared/: Shared components (CreationStatusHeader, ProgressDot, etc.) + * - IngredientSection, SimulationBlock, AddSimulationCard, etc. * - modals/: Modal components * - BrowseModalTemplate.tsx: Template for browse modals - * - IngredientPickerModal.tsx: Simple ingredient picker - * - PolicyBrowseModal.tsx: Policy browsing and creation (TODO: extract) - * - PopulationBrowseModal.tsx: Population browsing and creation (TODO: extract) + * - PolicyBrowseModal.tsx: Policy browsing and creation + * - PopulationBrowseModal.tsx: Population browsing and creation + * - PolicyCreationModal.tsx: Policy creation form */ +// Main page component +export { default } from './ReportBuilderPage'; + // Types export * from './types'; @@ -31,8 +34,4 @@ export * from './styles'; export * from './components'; // Modals -export { BrowseModalTemplate, CreationModeFooter } from './modals/BrowseModalTemplate'; -export { IngredientPickerModal } from './modals/IngredientPickerModal'; - -// Note: The main ReportBuilderPage component is still in ReportBuilder.page.tsx -// It will be migrated once all subcomponents are extracted +export * from './modals'; diff --git a/app/src/pages/reportBuilder/modals/BrowseModalTemplate.tsx b/app/src/pages/reportBuilder/modals/BrowseModalTemplate.tsx index ef74a82b4..b6d7a63b6 100644 --- a/app/src/pages/reportBuilder/modals/BrowseModalTemplate.tsx +++ b/app/src/pages/reportBuilder/modals/BrowseModalTemplate.tsx @@ -1,3 +1,13 @@ +/** + * BrowseModalTemplate - Shared template for browse modals (Policy, Population) + * + * Handles ONLY visual layout: + * - Modal shell with header + * - Sidebar (standard sections OR custom render) + * - Main content area + * - Optional status header slot + * - Optional footer slot + */ import { ReactNode } from 'react'; import { Box, @@ -28,10 +38,52 @@ export function BrowseModalTemplate({ headerSubtitle, colorConfig, sidebarSections, + renderSidebar, + sidebarWidth = BROWSE_MODAL_CONFIG.sidebarWidth, renderMainContent, statusHeader, footer, }: BrowseModalTemplateProps) { + // Render standard sidebar sections + const renderStandardSidebar = (sections: BrowseModalSidebarSection[]) => ( + + + {sections.map((section, sectionIndex) => ( + + {sectionIndex > 0 && } + + {section.label} + {section.items.map((item) => ( + + {item.icon} + + {item.label} + + {item.badge !== undefined && ( + + {item.badge} + + )} + + ))} + + + ))} + + + ); + return ( {/* Sidebar */} - - - - {sidebarSections.map((section, sectionIndex) => ( - - {sectionIndex > 0 && } - - {section.label} - {section.items.map((item) => ( - - {item.icon} - - {item.label} - - {item.badge !== undefined && ( - - {item.badge} - - )} - - ))} - - - ))} - - + + {renderSidebar ? renderSidebar() : (sidebarSections && renderStandardSidebar(sidebarSections))} {/* Main Content Area */} diff --git a/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx b/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx index 23966bb01..964785d9f 100644 --- a/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx +++ b/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx @@ -1,17 +1,443 @@ /** * PolicyBrowseModal - Full-featured policy browsing and creation modal * - * This modal supports: - * - Browse mode: Search and select from saved policies - * - Creation mode: Create new policies with parameter editing - * - * TODO: Extract from ReportBuilder.page.tsx and refactor to use: - * - BrowseModalTemplate for consistent modal layout - * - CreationStatusHeader for the creation mode status bar - * - * The modal is currently defined in ReportBuilder.page.tsx (lines ~1770-3080) - * and should be moved here with updated imports. + * Uses BrowseModalTemplate for visual layout and delegates to sub-components: + * - Browse mode: PolicyBrowseContent for main content + * - Creation mode: PolicyCreationContent + PolicyParameterTree */ +import { useState, useCallback, useEffect, useMemo } from 'react'; +import { Box, Text, UnstyledButton, Divider, ScrollArea, Stack } from '@mantine/core'; +import { IconScale, IconUsers, IconPlus, IconFolder } from '@tabler/icons-react'; +import { useSelector } from 'react-redux'; +import { colors, spacing, typography } from '@/designTokens'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { PolicyStateProps } from '@/types/pathwayState'; +import { useUserPolicies, useUpdatePolicyAssociation } from '@/hooks/useUserPolicy'; +import { MOCK_USER_ID } from '@/constants'; +import { RootState } from '@/store'; +import { getHierarchicalLabels, formatLabelParts } from '@/utils/parameterLabels'; +import { countPolicyModifications } from '@/utils/countParameterChanges'; +import { ParameterMetadata } from '@/types/metadata/parameterMetadata'; +import { useCreatePolicy } from '@/hooks/useCreatePolicy'; +import { PolicyAdapter } from '@/adapters'; +import { Policy } from '@/types/ingredients/Policy'; +import { PolicyCreationPayload } from '@/types/payloads'; +import { Parameter } from '@/types/subIngredients/parameter'; +import { ValueInterval, ValueIntervalCollection } from '@/types/subIngredients/valueInterval'; +import { getDateRange } from '@/libs/metadataUtils'; +import { ValueSetterMode } from '@/pathways/report/components/valueSetters'; +import { FONT_SIZES, INGREDIENT_COLORS } from '../constants'; +import { modalStyles } from '../styles'; +import { BrowseModalTemplate, CreationModeFooter } from './BrowseModalTemplate'; +import { + PolicyStatusHeader, + PolicyParameterTree, + PolicyCreationContent, + PolicyBrowseContent, + PolicyDetailsDrawer, +} from './policy'; + +interface PolicyBrowseModalProps { + isOpen: boolean; + onClose: () => void; + onSelect: (policy: PolicyStateProps) => void; +} + +export function PolicyBrowseModal({ + isOpen, + onClose, + onSelect, +}: PolicyBrowseModalProps) { + const countryId = useCurrentCountry() as 'us' | 'uk'; + const userId = MOCK_USER_ID.toString(); + const { data: policies, isLoading } = useUserPolicies(userId); + const { parameterTree, parameters, loading: metadataLoading } = useSelector( + (state: RootState) => state.metadata + ); + const { minDate, maxDate } = useSelector(getDateRange); + const updatePolicyAssociation = useUpdatePolicyAssociation(); + + // Browse mode state + const [searchQuery, setSearchQuery] = useState(''); + const [activeSection, setActiveSection] = useState<'my-policies' | 'public'>('my-policies'); + const [selectedPolicyId, setSelectedPolicyId] = useState(null); + const [drawerPolicyId, setDrawerPolicyId] = useState(null); + + // Creation mode state + const [isCreationMode, setIsCreationMode] = useState(false); + const [policyLabel, setPolicyLabel] = useState(''); + const [policyParameters, setPolicyParameters] = useState([]); + const [selectedParam, setSelectedParam] = useState(null); + const [expandedMenuItems, setExpandedMenuItems] = useState>(new Set()); + const [valueSetterMode, setValueSetterMode] = useState(ValueSetterMode.DEFAULT); + const [intervals, setIntervals] = useState([]); + const [startDate, setStartDate] = useState('2025-01-01'); + const [endDate, setEndDate] = useState('2025-12-31'); + const [parameterSearch, setParameterSearch] = useState(''); + const [isEditingLabel, setIsEditingLabel] = useState(false); + + // API hook for creating policy + const { createPolicy, isPending: isCreating } = useCreatePolicy(policyLabel || undefined); + + // Reset state on mount + useEffect(() => { + if (isOpen) { + setSearchQuery(''); + setSelectedPolicyId(null); + setDrawerPolicyId(null); + setIsCreationMode(false); + setPolicyLabel(''); + setPolicyParameters([]); + setSelectedParam(null); + setExpandedMenuItems(new Set()); + setIntervals([]); + setParameterSearch(''); + setIsEditingLabel(false); + } + }, [isOpen]); + + // Transform policies data, sorted by most recent + const userPolicies = useMemo(() => { + return (policies || []) + .map((p) => { + const policyId = p.association.policyId.toString(); + const label = p.association.label || `Policy #${policyId}`; + return { + id: policyId, + associationId: p.association.id, + label, + paramCount: countPolicyModifications(p.policy), + parameters: p.policy?.parameters || [], + createdAt: p.association.createdAt, + updatedAt: p.association.updatedAt, + }; + }) + .sort((a, b) => { + const aTime = a.updatedAt || a.createdAt || ''; + const bTime = b.updatedAt || b.createdAt || ''; + return bTime.localeCompare(aTime); + }); + }, [policies]); + + // Filter policies based on search + const filteredPolicies = useMemo(() => { + let result = userPolicies; + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + result = result.filter(p => { + if (p.label.toLowerCase().includes(query)) return true; + const paramDisplayNames = p.parameters.map(param => { + const hierarchicalLabels = getHierarchicalLabels(param.name, parameters); + return hierarchicalLabels.length > 0 + ? formatLabelParts(hierarchicalLabels) + : param.name.split('.').pop() || param.name; + }).join(' ').toLowerCase(); + if (paramDisplayNames.includes(query)) return true; + return false; + }); + } + return result; + }, [userPolicies, searchQuery, parameters]); + + // Get policies for current section + const displayedPolicies = useMemo(() => { + if (activeSection === 'public') return []; + return filteredPolicies; + }, [activeSection, filteredPolicies]); + + // Get section title + const getSectionTitle = () => { + switch (activeSection) { + case 'my-policies': return 'My policies'; + case 'public': return 'User-created policies'; + default: return 'Policies'; + } + }; + + // Handle policy selection + const handleSelectPolicy = (policy: { id: string; label: string; paramCount: number; associationId?: string }) => { + if (policy.associationId) { + updatePolicyAssociation.mutate({ + userPolicyId: policy.associationId, + updates: {}, + }); + } + onSelect({ id: policy.id, label: policy.label, parameters: Array(policy.paramCount).fill({}) }); + onClose(); + }; + + // Handle current law selection + const handleSelectCurrentLaw = () => { + onSelect({ id: 'current-law', label: 'Current law', parameters: [] }); + onClose(); + }; + + // ========== Creation Mode Logic ========== + + // Create local policy state object + const localPolicy: PolicyStateProps = useMemo(() => ({ + label: policyLabel, + parameters: policyParameters, + }), [policyLabel, policyParameters]); + + // Count modifications + const modificationCount = countPolicyModifications(localPolicy); + + // Handle search selection + const handleSearchSelect = useCallback((paramName: string) => { + const param = parameters[paramName]; + if (!param || param.type !== 'parameter') return; + const pathParts = paramName.split('.'); + const newExpanded = new Set(expandedMenuItems); + let currentPath = ''; + for (let i = 0; i < pathParts.length - 1; i++) { + currentPath = currentPath ? `${currentPath}.${pathParts[i]}` : pathParts[i]; + newExpanded.add(currentPath); + } + setExpandedMenuItems(newExpanded); + setSelectedParam(param); + setIntervals([]); + setValueSetterMode(ValueSetterMode.DEFAULT); + setParameterSearch(''); + }, [parameters, expandedMenuItems]); + + // Handle menu item click + const handleMenuItemClick = useCallback((paramName: string) => { + const param = parameters[paramName]; + if (param && param.type === 'parameter') { + setSelectedParam(param); + setIntervals([]); + setValueSetterMode(ValueSetterMode.DEFAULT); + } + setExpandedMenuItems(prev => { + const newSet = new Set(prev); + if (newSet.has(paramName)) { + newSet.delete(paramName); + } else { + newSet.add(paramName); + } + return newSet; + }); + }, [parameters]); + + // Handle value submission + const handleValueSubmit = useCallback(() => { + if (!selectedParam || intervals.length === 0) return; + const updatedParameters = [...policyParameters]; + let existingParam = updatedParameters.find(p => p.name === selectedParam.parameter); + if (!existingParam) { + existingParam = { name: selectedParam.parameter, values: [] }; + updatedParameters.push(existingParam); + } + const paramCollection = new ValueIntervalCollection(existingParam.values); + intervals.forEach(interval => { + paramCollection.addInterval(interval); + }); + existingParam.values = paramCollection.getIntervals(); + setPolicyParameters(updatedParameters); + setIntervals([]); + }, [selectedParam, intervals, policyParameters]); + + // Handle entering creation mode + const handleEnterCreationMode = useCallback(() => { + setPolicyLabel(''); + setPolicyParameters([]); + setSelectedParam(null); + setExpandedMenuItems(new Set()); + setIntervals([]); + setParameterSearch(''); + setIsEditingLabel(false); + setIsCreationMode(true); + }, []); + + // Exit creation mode + const handleExitCreationMode = useCallback(() => { + setIsCreationMode(false); + setPolicyLabel(''); + setPolicyParameters([]); + setSelectedParam(null); + setExpandedMenuItems(new Set()); + setIntervals([]); + setParameterSearch(''); + }, []); + + // Handle policy creation + const handleCreatePolicy = useCallback(async () => { + if (!policyLabel.trim()) return; + const policyData: Partial = { parameters: policyParameters }; + const payload: PolicyCreationPayload = PolicyAdapter.toCreationPayload(policyData as Policy); + try { + const result = await createPolicy(payload); + const createdPolicy: PolicyStateProps = { + id: result.result.policy_id, + label: policyLabel, + parameters: policyParameters, + }; + onSelect(createdPolicy); + onClose(); + } catch (error) { + console.error('Failed to create policy:', error); + } + }, [policyLabel, policyParameters, createPolicy, onSelect, onClose]); + + // Policy for drawer preview + const drawerPolicy = useMemo(() => { + if (!drawerPolicyId) return null; + return userPolicies.find(p => p.id === drawerPolicyId) || null; + }, [drawerPolicyId, userPolicies]); + + const colorConfig = INGREDIENT_COLORS.policy; + + // ========== Sidebar Rendering ========== + + // Browse mode sidebar sections + const browseSidebarSections = useMemo(() => [ + { + id: 'quick-select', + label: 'Quick select', + items: [ + { + id: 'current-law', + label: 'Current law', + icon: , + onClick: handleSelectCurrentLaw, + }, + ], + }, + { + id: 'library', + label: 'Library', + items: [ + { + id: 'my-policies', + label: 'My policies', + icon: , + badge: userPolicies.length, + isActive: activeSection === 'my-policies', + onClick: () => setActiveSection('my-policies'), + }, + { + id: 'public', + label: 'User-created policies', + icon: , + isActive: activeSection === 'public', + onClick: () => setActiveSection('public'), + }, + { + id: 'create-new', + label: 'Create new policy', + icon: , + isActive: isCreationMode, + onClick: handleEnterCreationMode, + }, + ], + }, + ], [activeSection, userPolicies.length, isCreationMode, handleEnterCreationMode, handleSelectCurrentLaw, colorConfig.icon]); + + // Creation mode custom sidebar + const renderCreationSidebar = () => ( + + ); + + // ========== Main Content Rendering ========== + + const renderMainContent = () => { + if (isCreationMode) { + return ( + + ); + } + + return ( + <> + { + setSelectedPolicyId(policy.id); + handleSelectPolicy(policy); + }} + onPolicyInfoClick={(policyId) => setDrawerPolicyId(policyId)} + onEnterCreationMode={handleEnterCreationMode} + getSectionTitle={getSectionTitle} + /> + setDrawerPolicyId(null)} + onSelect={() => { + if (drawerPolicy) { + handleSelectPolicy(drawerPolicy); + setDrawerPolicyId(null); + } + }} + /> + + ); + }; + + // ========== Render ========== -// Placeholder - component will be extracted from ReportBuilder.page.tsx -export const PolicyBrowseModal = null; + return ( + } + headerTitle={isCreationMode ? 'Create policy' : 'Select policy'} + headerSubtitle={isCreationMode ? 'Configure parameters for your new policy' : 'Choose an existing policy or create a new one'} + colorConfig={colorConfig} + sidebarSections={isCreationMode ? undefined : browseSidebarSections} + renderSidebar={isCreationMode ? renderCreationSidebar : undefined} + sidebarWidth={isCreationMode ? 280 : undefined} + renderMainContent={renderMainContent} + statusHeader={isCreationMode ? ( + + ) : undefined} + footer={isCreationMode ? ( + + ) : undefined} + /> + ); +} diff --git a/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx b/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx index 32afc3a5f..7e21063ac 100644 --- a/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx +++ b/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx @@ -5,11 +5,810 @@ * - Parameter tree navigation * - Value setter components * - Policy creation with API integration - * - * TODO: Extract from ReportBuilder.page.tsx - * The modal is currently defined in ReportBuilder.page.tsx (lines ~4030-4800) - * and should be moved here with updated imports. */ -// Placeholder - component will be extracted from ReportBuilder.page.tsx -export const PolicyCreationModal = null; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { + Modal, + Box, + Group, + Stack, + Text, + TextInput, + Button, + UnstyledButton, + Divider, + ScrollArea, + Skeleton, + NavLink, + Title, + ActionIcon, + Autocomplete, +} from '@mantine/core'; +import { + IconScale, + IconPencil, + IconChevronRight, + IconX, + IconSearch, +} from '@tabler/icons-react'; + +import { colors, spacing, typography } from '@/designTokens'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { RootState } from '@/store'; +import { useCreatePolicy } from '@/hooks/useCreatePolicy'; +import { PolicyAdapter } from '@/adapters'; +import { ParameterTreeNode } from '@/types/metadata'; +import { ParameterMetadata } from '@/types/metadata/parameterMetadata'; +import { PolicyStateProps } from '@/types/pathwayState'; +import { Policy } from '@/types/ingredients/Policy'; +import { PolicyCreationPayload } from '@/types/payloads'; +import { Parameter } from '@/types/subIngredients/parameter'; +import { ValueInterval, ValueIntervalCollection, ValuesList } from '@/types/subIngredients/valueInterval'; +import { getDateRange } from '@/libs/metadataUtils'; +import { getHierarchicalLabels, formatLabelParts } from '@/utils/parameterLabels'; +import { formatParameterValue } from '@/utils/policyTableHelpers'; +import { formatPeriod } from '@/utils/dateUtils'; +import { countPolicyModifications } from '@/utils/countParameterChanges'; +import { capitalize } from '@/utils/stringUtils'; +import { ValueSetterComponents, ValueSetterMode, ModeSelectorButton } from '@/pathways/report/components/valueSetters'; +import HistoricalValues from '@/pathways/report/components/policyParameterSelector/HistoricalValues'; + +import { FONT_SIZES, INGREDIENT_COLORS } from '../constants'; + +interface PolicyCreationModalProps { + isOpen: boolean; + onClose: () => void; + onPolicyCreated: (policy: PolicyStateProps) => void; + simulationIndex: number; +} + +export function PolicyCreationModal({ + isOpen, + onClose, + onPolicyCreated, + simulationIndex, +}: PolicyCreationModalProps) { + const countryId = useCurrentCountry() as 'us' | 'uk'; + + // Get metadata from Redux state + const { parameterTree, parameters, loading: metadataLoading } = useSelector( + (state: RootState) => state.metadata + ); + const { minDate, maxDate } = useSelector(getDateRange); + + // Local policy state + const [policyLabel, setPolicyLabel] = useState('New policy'); + const [policyParameters, setPolicyParameters] = useState([]); + const [isEditingLabel, setIsEditingLabel] = useState(false); + + // Parameter selection state + const [selectedParam, setSelectedParam] = useState(null); + const [expandedMenuItems, setExpandedMenuItems] = useState>(new Set()); + + // Value setter state + const [valueSetterMode, setValueSetterMode] = useState(ValueSetterMode.DEFAULT); + const [intervals, setIntervals] = useState([]); + const [startDate, setStartDate] = useState('2025-01-01'); + const [endDate, setEndDate] = useState('2025-12-31'); + + // Changes panel expanded state + const [changesExpanded, setChangesExpanded] = useState(false); + + // Parameter search state + const [parameterSearch, setParameterSearch] = useState(''); + + // API hook for creating policy + const { createPolicy, isPending: isCreating } = useCreatePolicy(policyLabel || undefined); + + // Suppress unused variable warnings + void countryId; + void simulationIndex; + + // Reset state when modal opens + useEffect(() => { + if (isOpen) { + setPolicyLabel('New policy'); + setPolicyParameters([]); + setSelectedParam(null); + setExpandedMenuItems(new Set()); + setIntervals([]); + setParameterSearch(''); + } + }, [isOpen]); + + // Create local policy state object for components + const localPolicy: PolicyStateProps = useMemo(() => ({ + label: policyLabel, + parameters: policyParameters, + }), [policyLabel, policyParameters]); + + // Count modifications + const modificationCount = countPolicyModifications(localPolicy); + + // Get modified parameter data for the Changes section - grouped by parameter with multiple changes each + const modifiedParams = useMemo(() => { + return policyParameters.map(p => { + const metadata = parameters[p.name]; + + // Get full hierarchical label for the parameter (no compacting) - same as report builder + const hierarchicalLabels = getHierarchicalLabels(p.name, parameters); + const displayLabel = hierarchicalLabels.length > 0 + ? formatLabelParts(hierarchicalLabels) + : p.name.split('.').pop() || p.name; + + // Build changes array for this parameter + const changes = p.values.map((interval) => ({ + period: formatPeriod(interval.startDate, interval.endDate), + value: formatParameterValue(interval.value, metadata?.unit), + })); + + return { + paramName: p.name, + label: displayLabel, + changes, + }; + }); + }, [policyParameters, parameters]); + + // Build flat list of all searchable parameters for autocomplete + const searchableParameters = useMemo(() => { + if (!parameters) return []; + + return Object.values(parameters) + .filter((param): param is ParameterMetadata => + param.type === 'parameter' && !!param.label && !param.parameter.includes('pycache') + ) + .map(param => { + const hierarchicalLabels = getHierarchicalLabels(param.parameter, parameters); + const fullLabel = hierarchicalLabels.length > 0 + ? formatLabelParts(hierarchicalLabels) + : param.label; + return { + value: param.parameter, + label: fullLabel, + }; + }) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [parameters]); + + // Handle search selection - expand tree path and select parameter + const handleSearchSelect = useCallback((paramName: string) => { + const param = parameters[paramName]; + if (!param || param.type !== 'parameter') return; + + // Expand all parent nodes in the tree path + const pathParts = paramName.split('.'); + const newExpanded = new Set(expandedMenuItems); + let currentPath = ''; + for (let i = 0; i < pathParts.length - 1; i++) { + currentPath = currentPath ? `${currentPath}.${pathParts[i]}` : pathParts[i]; + newExpanded.add(currentPath); + } + setExpandedMenuItems(newExpanded); + + // Select the parameter + setSelectedParam(param); + setIntervals([]); + setValueSetterMode(ValueSetterMode.DEFAULT); + + // Clear search + setParameterSearch(''); + }, [parameters, expandedMenuItems]); + + // Handle menu item click + const handleMenuItemClick = useCallback((paramName: string) => { + const param = parameters[paramName]; + if (param && param.type === 'parameter') { + setSelectedParam(param); + // Reset value setter state when selecting new parameter + setIntervals([]); + setValueSetterMode(ValueSetterMode.DEFAULT); + } + // Toggle expansion for non-leaf nodes + setExpandedMenuItems(prev => { + const newSet = new Set(prev); + if (newSet.has(paramName)) { + newSet.delete(paramName); + } else { + newSet.add(paramName); + } + return newSet; + }); + }, [parameters]); + + // Handle value submission + const handleValueSubmit = useCallback(() => { + if (!selectedParam || intervals.length === 0) return; + + const updatedParameters = [...policyParameters]; + let existingParam = updatedParameters.find(p => p.name === selectedParam.parameter); + + if (!existingParam) { + existingParam = { name: selectedParam.parameter, values: [] }; + updatedParameters.push(existingParam); + } + + // Use ValueIntervalCollection to properly merge intervals + const paramCollection = new ValueIntervalCollection(existingParam.values); + intervals.forEach(interval => { + paramCollection.addInterval(interval); + }); + + existingParam.values = paramCollection.getIntervals(); + setPolicyParameters(updatedParameters); + setIntervals([]); + }, [selectedParam, intervals, policyParameters]); + + // Handle policy creation + const handleCreatePolicy = useCallback(async () => { + if (!policyLabel.trim()) { + return; + } + + const policyData: Partial = { + parameters: policyParameters, + }; + + const payload: PolicyCreationPayload = PolicyAdapter.toCreationPayload(policyData as Policy); + + try { + const result = await createPolicy(payload); + const createdPolicy: PolicyStateProps = { + id: result.result.policy_id, + label: policyLabel, + parameters: policyParameters, + }; + onPolicyCreated(createdPolicy); + onClose(); + } catch (error) { + console.error('Failed to create policy:', error); + } + }, [policyLabel, policyParameters, createPolicy, onPolicyCreated, onClose]); + + // Render nested menu recursively - memoized to prevent expensive re-renders + const renderMenuItems = useCallback((items: ParameterTreeNode[]): React.ReactNode => { + return items + .filter(item => !item.name.includes('pycache')) + .map(item => ( + handleMenuItemClick(item.name)} + childrenOffset={16} + style={{ + borderRadius: spacing.radius.sm, + }} + > + {item.children && expandedMenuItems.has(item.name) && renderMenuItems(item.children)} + + )); + }, [selectedParam?.parameter, expandedMenuItems, handleMenuItemClick]); + + // Memoize the rendered tree to avoid expensive re-renders on unrelated state changes + const renderedMenuTree = useMemo(() => { + if (metadataLoading || !parameterTree) return null; + return renderMenuItems(parameterTree.children || []); + }, [metadataLoading, parameterTree, renderMenuItems]); + + // Get base and reform values for chart + const getChartValues = () => { + if (!selectedParam) return { baseValues: null, reformValues: null }; + + const baseValues = new ValueIntervalCollection(selectedParam.values as ValuesList); + const reformValues = new ValueIntervalCollection(baseValues); + + const paramToChart = policyParameters.find(p => p.name === selectedParam.parameter); + if (paramToChart && paramToChart.values && paramToChart.values.length > 0) { + const userIntervals = new ValueIntervalCollection(paramToChart.values as ValuesList); + for (const interval of userIntervals.getIntervals()) { + reformValues.addInterval(interval); + } + } + + return { baseValues, reformValues }; + }; + + const { baseValues, reformValues } = getChartValues(); + const colorConfig = INGREDIENT_COLORS.policy; + + const ValueSetterToRender = ValueSetterComponents[valueSetterMode]; + + // Dock styles matching ReportMetaPanel + const dockStyles = { + dock: { + background: 'rgba(255, 255, 255, 0.95)', + backdropFilter: 'blur(20px) saturate(180%)', + WebkitBackdropFilter: 'blur(20px) saturate(180%)', + borderRadius: spacing.radius.lg, + border: `1px solid ${modificationCount > 0 ? colorConfig.border : colors.border.light}`, + boxShadow: modificationCount > 0 + ? `0 4px 20px rgba(0, 0, 0, 0.08), 0 0 0 1px ${colorConfig.border}` + : `0 2px 12px ${colors.shadow.light}`, + padding: `${spacing.sm} ${spacing.lg}`, + transition: 'all 0.3s ease', + margin: spacing.md, + marginBottom: 0, + }, + divider: { + width: '1px', + height: '24px', + background: colors.gray[200], + flexShrink: 0, + }, + changesPanel: { + background: colors.white, + borderRadius: spacing.radius.md, + border: `1px solid ${colors.border.light}`, + marginTop: spacing.sm, + overflow: 'hidden', + }, + }; + + return ( + + + {/* Left side: Policy icon and name */} + + {/* Policy icon */} + + + + + {/* Editable policy name */} + + {isEditingLabel ? ( + setPolicyLabel(e.currentTarget.value)} + onBlur={() => setIsEditingLabel(false)} + onKeyDown={(e) => { + if (e.key === 'Enter') setIsEditingLabel(false); + if (e.key === 'Escape') setIsEditingLabel(false); + }} + autoFocus + size="xs" + style={{ width: 250 }} + styles={{ + input: { + fontFamily: typography.fontFamily.primary, + fontWeight: 600, + fontSize: FONT_SIZES.normal, + border: 'none', + background: 'transparent', + padding: 0, + }, + }} + /> + ) : ( + <> + + {policyLabel || 'New policy'} + + setIsEditingLabel(true)} + style={{ flexShrink: 0 }} + > + + + + )} + + + + {/* Right side: Modification count, View changes, Close */} + + {/* Modification count */} + + {modificationCount > 0 ? ( + <> + + + {modificationCount} parameter{modificationCount !== 1 ? 's' : ''} modified + + + ) : ( + + No changes yet + + )} + + + {/* Divider */} + + + {/* View Changes button */} + setChangesExpanded(!changesExpanded)} + style={{ + display: 'flex', + alignItems: 'center', + gap: spacing.xs, + padding: `${spacing.xs} ${spacing.sm}`, + borderRadius: spacing.radius.md, + background: changesExpanded ? colorConfig.bg : 'transparent', + border: `1px solid ${changesExpanded ? colorConfig.border : 'transparent'}`, + transition: 'all 0.1s ease', + }} + > + + View changes + + + + + {/* Close button */} + + + + + + + {/* Expandable changes panel */} + {changesExpanded && ( + + + {modifiedParams.length === 0 ? ( + + + No parameters have been modified yet. Select a parameter from the menu to make changes. + + + ) : ( + <> + {/* Header row */} + + + Parameter + + + Changes + + + {/* Data rows - one per parameter with multiple change lines */} + + {modifiedParams.map((param) => ( + { + const metadata = parameters[param.paramName]; + if (metadata) { + setSelectedParam(metadata); + setChangesExpanded(false); + } + }} + style={{ + display: 'grid', + gridTemplateColumns: '1fr 180px', + gap: spacing.md, + padding: `${spacing.sm} ${spacing.md}`, + borderBottom: `1px solid ${colors.border.light}`, + background: selectedParam?.parameter === param.paramName ? colorConfig.bg : colors.white, + alignItems: 'start', + }} + > + + {param.label} + + + {param.changes.map((change, idx) => ( + + + {change.period}: + {' '} + + {change.value} + + + ))} + + + ))} + + + )} + + + )} + + } + > + {/* Main content area */} + + {/* Left Sidebar - Parameter Tree */} + + {/* Parameter Tree */} + + + + PARAMETERS + + } + styles={{ + input: { + fontSize: FONT_SIZES.small, + height: 32, + minHeight: 32, + }, + dropdown: { + maxHeight: 300, + }, + option: { + fontSize: FONT_SIZES.small, + padding: `${spacing.xs} ${spacing.sm}`, + }, + }} + size="xs" + /> + + + + {metadataLoading || !parameterTree ? ( + + + + + + ) : ( + renderedMenuTree + )} + + + + + + {/* Main Content - Parameter Editor */} + + {!selectedParam ? ( + + + + + + + Select a parameter from the menu to modify its value for your policy reform. + + + + ) : ( + + + {/* Parameter Header */} + + + {capitalize(selectedParam.label || 'Label unavailable')} + + {selectedParam.description && ( + + {selectedParam.description} + + )} + + + {/* Value Setter */} + + + Set new value + + + + + + { + setIntervals([]); + setValueSetterMode(mode); + }} /> + + + + + + {/* Historical Values Chart */} + {baseValues && reformValues && ( + + + + )} + + + )} + + + + {/* Footer */} + + + + + + + + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx b/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx index 316411b91..60c8b419c 100644 --- a/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx +++ b/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx @@ -1,17 +1,487 @@ /** - * PopulationBrowseModal - Full-featured population browsing and creation modal + * PopulationBrowseModal - Geography and household selection modal * - * This modal supports: - * - Browse mode: Search geographies (nationwide, states, districts) - * - Creation mode: Create new households with HouseholdBuilderForm - * - * TODO: Extract from ReportBuilder.page.tsx and refactor to use: - * - BrowseModalTemplate for consistent modal layout - * - CreationStatusHeader for the creation mode status bar - * - * The modal is currently defined in ReportBuilder.page.tsx (lines ~3100-4030) - * and should be moved here with updated imports. + * Uses BrowseModalTemplate for visual layout and delegates to sub-components: + * - Browse mode: PopulationBrowseContent for main content + * - Creation mode: HouseholdCreationContent + PopulationStatusHeader */ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { useQueryClient } from '@tanstack/react-query'; +import { IconUsers, IconFolder, IconHome, IconPlus } from '@tabler/icons-react'; + +import { colors, spacing } from '@/designTokens'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useUserHouseholds } from '@/hooks/useUserHousehold'; +import { useCreateHousehold } from '@/hooks/useCreateHousehold'; +import { householdAssociationKeys } from '@/libs/queryKeys'; +import { HouseholdBuilder } from '@/utils/HouseholdBuilder'; +import { HouseholdAdapter } from '@/adapters/HouseholdAdapter'; +import { RootState } from '@/store'; +import { getBasicInputFields } from '@/libs/metadataUtils'; +import { + getUSStates, + getUSCongressionalDistricts, + getUKCountries, + getUKConstituencies, + getUKLocalAuthorities, + RegionOption, +} from '@/utils/regionStrategies'; +import { generateGeographyLabel } from '@/utils/geographyUtils'; +import { householdUsageStore, geographyUsageStore } from '@/api/usageTracking'; +import { USOutlineIcon, UKOutlineIcon } from '@/components/icons/CountryOutlineIcons'; +import { Geography } from '@/types/ingredients/Geography'; +import { Household } from '@/types/ingredients/Household'; +import { PopulationStateProps } from '@/types/pathwayState'; +import { CURRENT_YEAR, MOCK_USER_ID } from '@/constants'; + +import { INGREDIENT_COLORS } from '../constants'; +import { PopulationCategory } from '../types'; +import { BrowseModalTemplate, CreationModeFooter } from './BrowseModalTemplate'; +import { + PopulationStatusHeader, + PopulationBrowseContent, + HouseholdCreationContent, +} from './population'; + +interface PopulationBrowseModalProps { + isOpen: boolean; + onClose: () => void; + onSelect: (population: PopulationStateProps) => void; + onCreateNew: () => void; +} + +export function PopulationBrowseModal({ + isOpen, + onClose, + onSelect, + onCreateNew, +}: PopulationBrowseModalProps) { + const countryId = useCurrentCountry() as 'us' | 'uk'; + const userId = MOCK_USER_ID.toString(); + const queryClient = useQueryClient(); + const { data: households, isLoading: householdsLoading } = useUserHouseholds(userId); + const regionOptions = useSelector((state: RootState) => state.metadata.economyOptions.region); + const metadata = useSelector((state: RootState) => state.metadata); + const basicInputFields = useSelector(getBasicInputFields); + + // State + const [searchQuery, setSearchQuery] = useState(''); + const [activeCategory, setActiveCategory] = useState('national'); + + // Creation mode state + const [isCreationMode, setIsCreationMode] = useState(false); + const [householdLabel, setHouseholdLabel] = useState(''); + const [householdDraft, setHouseholdDraft] = useState(null); + const [isEditingLabel, setIsEditingLabel] = useState(false); + + // Get report year (default to current year) + const reportYear = CURRENT_YEAR.toString(); + + // Create household hook + const { createHousehold, isPending: isCreating } = useCreateHousehold(householdLabel || undefined); + + // Get all basic non-person fields dynamically + const basicNonPersonFields = useMemo(() => { + return Object.entries(basicInputFields) + .filter(([key]) => key !== 'person') + .flatMap(([, fields]) => fields); + }, [basicInputFields]); + + // Derive marital status and number of children from household draft + const householdPeople = useMemo(() => { + if (!householdDraft) return []; + return Object.keys(householdDraft.householdData.people || {}); + }, [householdDraft]); + + const maritalStatus = householdPeople.includes('your partner') ? 'married' : 'single'; + const numChildren = householdPeople.filter((p) => p.includes('dependent')).length; + + // Reset state on mount + useEffect(() => { + if (isOpen) { + setSearchQuery(''); + setActiveCategory('national'); + setIsCreationMode(false); + setHouseholdLabel(''); + setHouseholdDraft(null); + setIsEditingLabel(false); + } + }, [isOpen]); + + // Get geography categories based on country + const geographyCategories = useMemo(() => { + if (countryId === 'uk') { + const ukCountries = getUKCountries(regionOptions); + const ukConstituencies = getUKConstituencies(regionOptions); + const ukLocalAuthorities = getUKLocalAuthorities(regionOptions); + return [ + { id: 'countries' as const, label: 'Countries', count: ukCountries.length, regions: ukCountries }, + { id: 'constituencies' as const, label: 'Constituencies', count: ukConstituencies.length, regions: ukConstituencies }, + { id: 'local-authorities' as const, label: 'Local authorities', count: ukLocalAuthorities.length, regions: ukLocalAuthorities }, + ]; + } + // US + const usStates = getUSStates(regionOptions); + const usDistricts = getUSCongressionalDistricts(regionOptions); + return [ + { id: 'states' as const, label: 'States', count: usStates.length, regions: usStates }, + { id: 'districts' as const, label: 'Congressional districts', count: usDistricts.length, regions: usDistricts }, + ]; + }, [countryId, regionOptions]); + + // Get regions for active category + const activeRegions = useMemo(() => { + const category = geographyCategories.find((c) => c.id === activeCategory); + return category?.regions || []; + }, [activeCategory, geographyCategories]); + + // Transform households with usage tracking sort + const sortedHouseholds = useMemo(() => { + if (!households) return []; + + return [...households] + .map((h) => { + const householdIdStr = String(h.association.householdId); + const usageTimestamp = householdUsageStore.getLastUsed(householdIdStr); + const sortTimestamp = usageTimestamp || h.association.updatedAt || h.association.createdAt || ''; + return { + id: householdIdStr, + label: h.association.label || `Household #${householdIdStr}`, + memberCount: h.household?.household_json?.people + ? Object.keys(h.household.household_json.people).length + : 0, + sortTimestamp, + household: h.household, + }; + }) + .sort((a, b) => b.sortTimestamp.localeCompare(a.sortTimestamp)); + }, [households]); + + // Filter regions/households based on search + const filteredRegions = useMemo(() => { + if (!searchQuery.trim()) return activeRegions; + const query = searchQuery.toLowerCase(); + return activeRegions.filter((r) => r.label.toLowerCase().includes(query)); + }, [activeRegions, searchQuery]); + + const filteredHouseholds = useMemo(() => { + if (!searchQuery.trim()) return sortedHouseholds; + const query = searchQuery.toLowerCase(); + return sortedHouseholds.filter((h) => h.label.toLowerCase().includes(query)); + }, [sortedHouseholds, searchQuery]); + + // Handle geography selection + const handleSelectGeography = (region: RegionOption | null) => { + const geography: Geography = region + ? { + id: `${countryId}-${region.value}`, + countryId, + scope: 'subnational', + geographyId: region.value, + } + : { + id: countryId, + countryId, + scope: 'national', + geographyId: countryId, + }; + + geographyUsageStore.recordUsage(geography.geographyId); + + const label = generateGeographyLabel(geography); + onSelect({ + geography, + household: null, + label, + type: 'geography', + }); + onClose(); + }; + + // Handle household selection + const handleSelectHousehold = (householdData: (typeof sortedHouseholds)[0]) => { + const householdIdStr = String(householdData.id); + householdUsageStore.recordUsage(householdIdStr); + + let household: Household | null = null; + if (householdData.household) { + household = HouseholdAdapter.fromMetadata(householdData.household); + } else { + household = { + id: householdIdStr, + countryId, + householdData: { people: {} }, + }; + } + + const populationState: PopulationStateProps = { + geography: null, + household, + label: householdData.label, + type: 'household', + }; + + onSelect(populationState); + onClose(); + }; + + // Enter creation mode + const handleEnterCreationMode = useCallback(() => { + const builder = new HouseholdBuilder(countryId as 'us' | 'uk', reportYear); + builder.addAdult('you', 30, { employment_income: 0 }); + setHouseholdDraft(builder.build()); + setHouseholdLabel(''); + setIsCreationMode(true); + }, [countryId, reportYear]); + + // Exit creation mode (back to browse) + const handleExitCreationMode = useCallback(() => { + setIsCreationMode(false); + setHouseholdDraft(null); + setHouseholdLabel(''); + }, []); + + // Handle marital status change + const handleMaritalStatusChange = useCallback((newStatus: 'single' | 'married') => { + if (!householdDraft) return; + + const builder = new HouseholdBuilder(countryId as 'us' | 'uk', reportYear); + builder.loadHousehold(householdDraft); + + const hasPartner = householdPeople.includes('your partner'); + + if (newStatus === 'married' && !hasPartner) { + builder.addAdult('your partner', 30, { employment_income: 0 }); + builder.setMaritalStatus('you', 'your partner'); + } else if (newStatus === 'single' && hasPartner) { + builder.removePerson('your partner'); + } + + setHouseholdDraft(builder.build()); + }, [householdDraft, householdPeople, countryId, reportYear]); + + // Handle number of children change + const handleNumChildrenChange = useCallback((newCount: number) => { + if (!householdDraft) return; + + const builder = new HouseholdBuilder(countryId as 'us' | 'uk', reportYear); + builder.loadHousehold(householdDraft); + + const currentChildren = householdPeople.filter((p) => p.includes('dependent')); + const currentChildCount = currentChildren.length; + + if (newCount !== currentChildCount) { + currentChildren.forEach((child) => builder.removePerson(child)); + + if (newCount > 0) { + const hasPartner = householdPeople.includes('your partner'); + const parentIds = hasPartner ? ['you', 'your partner'] : ['you']; + const ordinals = ['first', 'second', 'third', 'fourth', 'fifth']; + + for (let i = 0; i < newCount; i++) { + const childName = `your ${ordinals[i] || `${i + 1}th`} dependent`; + builder.addChild(childName, 10, parentIds, { employment_income: 0 }); + } + } + } + + setHouseholdDraft(builder.build()); + }, [householdDraft, householdPeople, countryId, reportYear]); + + // Handle household creation submission + const handleCreateHousehold = useCallback(async () => { + if (!householdDraft || !householdLabel.trim()) { + return; + } + + const payload = HouseholdAdapter.toCreationPayload(householdDraft.householdData, countryId); + + try { + const result = await createHousehold(payload); + const householdId = result.result.household_id.toString(); + + householdUsageStore.recordUsage(householdId); + + const createdHousehold: Household = { + ...householdDraft, + id: householdId, + }; + + const populationState = { + geography: null, + household: createdHousehold, + label: householdLabel, + type: 'household' as const, + }; + + await queryClient.refetchQueries({ + queryKey: householdAssociationKeys.byUser(userId, countryId), + }); + + onSelect(populationState); + onClose(); + } catch (err) { + console.error('Failed to create household:', err); + } + }, [householdDraft, householdLabel, countryId, createHousehold, onSelect, onClose, queryClient, userId]); + + const colorConfig = INGREDIENT_COLORS.population; + + // Get section title + const getSectionTitle = () => { + if (activeCategory === 'national') return countryId === 'uk' ? 'UK-wide' : 'Nationwide'; + if (activeCategory === 'my-households') return 'My households'; + const category = geographyCategories.find((c) => c.id === activeCategory); + return category?.label || 'Regions'; + }; + + // Get item count for display + const getItemCount = () => { + if (activeCategory === 'national') return 1; + if (activeCategory === 'my-households') return filteredHouseholds.length; + return filteredRegions.length; + }; + + // ========== Sidebar Sections ========== + + const browseSidebarSections = useMemo(() => [ + { + id: 'quick-select', + label: 'Quick select', + items: [ + { + id: 'national', + label: countryId === 'uk' ? 'UK-wide' : 'Nationwide', + icon: countryId === 'uk' ? : , + isActive: activeCategory === 'national' && !isCreationMode, + onClick: () => { + setActiveCategory('national'); + setIsCreationMode(false); + }, + }, + ], + }, + { + id: 'geographies', + label: 'Geographies', + items: geographyCategories.map((category) => ({ + id: category.id, + label: category.label, + icon: , + badge: category.count, + isActive: activeCategory === category.id && !isCreationMode, + onClick: () => { + setActiveCategory(category.id); + setIsCreationMode(false); + }, + })), + }, + { + id: 'households', + label: 'Households', + items: [ + { + id: 'my-households', + label: 'My households', + icon: , + badge: sortedHouseholds.length, + isActive: activeCategory === 'my-households' && !isCreationMode, + onClick: () => { + setActiveCategory('my-households'); + setIsCreationMode(false); + }, + }, + { + id: 'create-new', + label: 'Create new household', + icon: , + isActive: isCreationMode, + onClick: handleEnterCreationMode, + }, + ], + }, + ], [countryId, activeCategory, isCreationMode, geographyCategories, sortedHouseholds.length, handleEnterCreationMode]); + + // ========== Main Content Rendering ========== + + const renderMainContent = () => { + if (isCreationMode) { + return ( + + ); + } + + return ( + ({ + id: h.id, + label: h.label, + memberCount: h.memberCount, + }))} + householdsLoading={householdsLoading} + getSectionTitle={getSectionTitle} + getItemCount={getItemCount} + onSelectGeography={handleSelectGeography} + onSelectHousehold={(household) => { + const fullHousehold = sortedHouseholds.find(h => h.id === household.id); + if (fullHousehold) { + handleSelectHousehold(fullHousehold); + } + }} + /> + ); + }; + + // ========== Render ========== -// Placeholder - component will be extracted from ReportBuilder.page.tsx -export const PopulationBrowseModal = null; + return ( + } + headerTitle={isCreationMode ? 'Create household' : 'Household(s)'} + headerSubtitle={isCreationMode + ? 'Configure your household composition and details' + : 'Choose a geographic region or create a household'} + colorConfig={colorConfig} + sidebarSections={browseSidebarSections} + renderMainContent={renderMainContent} + statusHeader={isCreationMode ? ( + + ) : undefined} + footer={isCreationMode ? ( + + ) : undefined} + /> + ); +} diff --git a/app/src/pages/reportBuilder/modals/policy/PolicyBrowseContent.tsx b/app/src/pages/reportBuilder/modals/policy/PolicyBrowseContent.tsx new file mode 100644 index 000000000..6d5052764 --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policy/PolicyBrowseContent.tsx @@ -0,0 +1,235 @@ +/** + * PolicyBrowseContent - Browse mode content (search bar + policy grid) + */ +import { Box, Group, Text, Stack, TextInput, ScrollArea, Paper, Button, Skeleton, ActionIcon } from '@mantine/core'; +import { IconSearch, IconPlus, IconInfoCircle, IconChevronRight, IconFolder, IconUsers } from '@tabler/icons-react'; +import { colors, spacing, typography } from '@/designTokens'; +import { FONT_SIZES, INGREDIENT_COLORS } from '../../constants'; + +interface PolicyItem { + id: string; + associationId?: string; + label: string; + paramCount: number; + createdAt?: string; + updatedAt?: string; +} + +type ActiveSection = 'my-policies' | 'public'; + +interface PolicyBrowseContentProps { + displayedPolicies: PolicyItem[]; + searchQuery: string; + setSearchQuery: (query: string) => void; + activeSection: ActiveSection; + isLoading: boolean; + selectedPolicyId: string | null; + onSelectPolicy: (policy: PolicyItem) => void; + onPolicyInfoClick: (policyId: string) => void; + onEnterCreationMode: () => void; + getSectionTitle: () => string; +} + +export function PolicyBrowseContent({ + displayedPolicies, + searchQuery, + setSearchQuery, + activeSection, + isLoading, + selectedPolicyId, + onSelectPolicy, + onPolicyInfoClick, + onEnterCreationMode, + getSectionTitle, +}: PolicyBrowseContentProps) { + const colorConfig = INGREDIENT_COLORS.policy; + + const modalStyles = { + searchBar: { + position: 'relative' as const, + }, + policyGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', + gap: spacing.md, + }, + policyCard: { + background: colors.white, + border: `1px solid ${colors.border.light}`, + borderRadius: spacing.radius.lg, + padding: spacing.lg, + cursor: 'pointer', + transition: 'all 0.2s ease', + position: 'relative' as const, + overflow: 'hidden', + }, + }; + + return ( + + + } + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + size="sm" + styles={{ + input: { + borderRadius: spacing.radius.md, + border: `1px solid ${colors.border.light}`, + fontSize: FONT_SIZES.small, + }, + }} + /> + + + + + {getSectionTitle()} + + + {displayedPolicies.length} {displayedPolicies.length === 1 ? 'policy' : 'policies'} + + + + + {isLoading ? ( + + {[1, 2, 3].map((i) => ( + + ))} + + ) : activeSection === 'public' ? ( + + + + + Coming soon + + Search and browse policies created by other PolicyEngine users. + + + ) : displayedPolicies.length === 0 ? ( + + + + + + {searchQuery ? 'No policies match your search' : 'No policies yet'} + + + {searchQuery + ? 'Try adjusting your search terms or browse all policies' + : 'Create your first policy to get started'} + + {!searchQuery && ( + + )} + + ) : ( + + {displayedPolicies.map((policy) => { + const isSelected = selectedPolicyId === policy.id; + return ( + onSelectPolicy(policy)} + > + + + + + {policy.label} + + + {policy.paramCount} param{policy.paramCount !== 1 ? 's' : ''} changed + + + + { + e.stopPropagation(); + onPolicyInfoClick(policy.id); + }} + > + + + + + + + ); + })} + + )} + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/policy/PolicyCreationContent.tsx b/app/src/pages/reportBuilder/modals/policy/PolicyCreationContent.tsx new file mode 100644 index 000000000..782507071 --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policy/PolicyCreationContent.tsx @@ -0,0 +1,170 @@ +/** + * PolicyCreationContent - Main content area for policy creation mode + */ +import { Dispatch, SetStateAction } from 'react'; +import { Box, Stack, Text, Title, Divider, Group, Button } from '@mantine/core'; +import { IconScale } from '@tabler/icons-react'; +import { colors, spacing } from '@/designTokens'; +import { ParameterMetadata } from '@/types/metadata/parameterMetadata'; +import { PolicyStateProps } from '@/types/pathwayState'; +import { ValueInterval, ValueIntervalCollection, ValuesList } from '@/types/subIngredients/valueInterval'; +import { capitalize } from '@/utils/stringUtils'; +import { ValueSetterComponents, ValueSetterMode, ModeSelectorButton } from '@/pathways/report/components/valueSetters'; +import HistoricalValues from '@/pathways/report/components/policyParameterSelector/HistoricalValues'; +import { FONT_SIZES } from '../../constants'; +import { Parameter } from '@/types/subIngredients/parameter'; + +interface PolicyCreationContentProps { + selectedParam: ParameterMetadata | null; + localPolicy: PolicyStateProps; + policyLabel: string; + policyParameters: Parameter[]; + minDate: string; + maxDate: string; + intervals: ValueInterval[]; + setIntervals: Dispatch>; + startDate: string; + setStartDate: Dispatch>; + endDate: string; + setEndDate: Dispatch>; + valueSetterMode: ValueSetterMode; + setValueSetterMode: (mode: ValueSetterMode) => void; + onValueSubmit: () => void; +} + +export function PolicyCreationContent({ + selectedParam, + localPolicy, + policyLabel, + policyParameters, + minDate, + maxDate, + intervals, + setIntervals, + startDate, + setStartDate, + endDate, + setEndDate, + valueSetterMode, + setValueSetterMode, + onValueSubmit, +}: PolicyCreationContentProps) { + // Get base and reform values for chart + const getChartValues = () => { + if (!selectedParam) return { baseValues: null, reformValues: null }; + const baseValues = new ValueIntervalCollection(selectedParam.values as ValuesList); + const reformValues = new ValueIntervalCollection(baseValues); + const paramToChart = policyParameters.find(p => p.name === selectedParam.parameter); + if (paramToChart && paramToChart.values && paramToChart.values.length > 0) { + const userIntervals = new ValueIntervalCollection(paramToChart.values as ValuesList); + for (const interval of userIntervals.getIntervals()) { + reformValues.addInterval(interval); + } + } + return { baseValues, reformValues }; + }; + + const { baseValues, reformValues } = getChartValues(); + const ValueSetterToRender = ValueSetterComponents[valueSetterMode]; + + if (!selectedParam) { + return ( + + + + + + + Select a parameter from the menu to modify its value for your policy reform. + + + + ); + } + + return ( + + + + + {capitalize(selectedParam.label || 'Label unavailable')} + + {selectedParam.description && ( + + {selectedParam.description} + + )} + + + + Set new value + + + + + + { + setIntervals([]); + setValueSetterMode(mode); + }} /> + + + + + {baseValues && reformValues && ( + + + + )} + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/policy/PolicyDetailsDrawer.tsx b/app/src/pages/reportBuilder/modals/policy/PolicyDetailsDrawer.tsx new file mode 100644 index 000000000..45b3c5eea --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policy/PolicyDetailsDrawer.tsx @@ -0,0 +1,194 @@ +/** + * PolicyDetailsDrawer - Sliding panel showing policy parameter details + */ +import { Fragment } from 'react'; +import { Box, Group, Stack, Text, ScrollArea, Button, ActionIcon, Tooltip, Transition } from '@mantine/core'; +import { IconX, IconChevronRight } from '@tabler/icons-react'; +import { colors, spacing } from '@/designTokens'; +import { ParameterMetadata } from '@/types/metadata/parameterMetadata'; +import { getHierarchicalLabels, formatLabelParts } from '@/utils/parameterLabels'; +import { formatParameterValue } from '@/utils/policyTableHelpers'; +import { formatPeriod } from '@/utils/dateUtils'; +import { Parameter } from '@/types/subIngredients/parameter'; +import { FONT_SIZES, INGREDIENT_COLORS } from '../../constants'; + +interface PolicyDetailsDrawerProps { + policy: { + id: string; + associationId?: string; + label: string; + paramCount: number; + parameters: Parameter[]; + } | null; + parameters: Record; + onClose: () => void; + onSelect: () => void; +} + +export function PolicyDetailsDrawer({ + policy, + parameters, + onClose, + onSelect, +}: PolicyDetailsDrawerProps) { + const colorConfig = INGREDIENT_COLORS.policy; + + return ( + <> + {/* Overlay */} + + {(transitionStyles) => ( + + )} + + + {/* Drawer */} + + {(transitionStyles) => ( + e.stopPropagation()} + > + {policy && ( + <> + + + + + {policy.label} + + + {policy.paramCount} parameter{policy.paramCount !== 1 ? 's' : ''} changed from current law + + + + + + + + + + + + Parameter + + + Changes + + {(() => { + const groupedParams: Array<{ + paramName: string; + label: string; + changes: Array<{ period: string; value: string }>; + }> = []; + policy.parameters.forEach((param) => { + const paramName = param.name; + const hierarchicalLabels = getHierarchicalLabels(paramName, parameters); + const displayLabel = hierarchicalLabels.length > 0 + ? formatLabelParts(hierarchicalLabels) + : paramName.split('.').pop() || paramName; + const metadata = parameters[paramName]; + const changes = (param.values || []).map((interval) => ({ + period: formatPeriod(interval.startDate, interval.endDate), + value: formatParameterValue(interval.value, metadata?.unit ?? undefined), + })); + groupedParams.push({ paramName, label: displayLabel, changes }); + }); + return groupedParams.map((param) => ( + + + + + {param.label} + + + + + {param.changes.map((change, idx) => ( + + {change.period} + + ))} + + + {param.changes.map((change, idx) => ( + + {change.value} + + ))} + + + )); + })()} + + + + + + + + )} + + )} + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/policy/PolicyParameterTree.tsx b/app/src/pages/reportBuilder/modals/policy/PolicyParameterTree.tsx new file mode 100644 index 000000000..eb36ea4b6 --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policy/PolicyParameterTree.tsx @@ -0,0 +1,128 @@ +/** + * PolicyParameterTree - Parameter tree navigation for policy creation mode + */ +import { useCallback, useMemo } from 'react'; +import { Box, Text, Stack, Skeleton, NavLink, Autocomplete, ScrollArea } from '@mantine/core'; +import { IconSearch } from '@tabler/icons-react'; +import { colors, spacing } from '@/designTokens'; +import { ParameterTreeNode } from '@/types/metadata'; +import { ParameterMetadata } from '@/types/metadata/parameterMetadata'; +import { getHierarchicalLabels, formatLabelParts } from '@/utils/parameterLabels'; +import { FONT_SIZES } from '../../constants'; + +interface PolicyParameterTreeProps { + parameterTree: ParameterTreeNode | null; + parameters: Record; + metadataLoading: boolean; + selectedParam: ParameterMetadata | null; + expandedMenuItems: Set; + parameterSearch: string; + setParameterSearch: (search: string) => void; + onMenuItemClick: (paramName: string) => void; + onSearchSelect: (paramName: string) => void; +} + +export function PolicyParameterTree({ + parameterTree, + parameters, + metadataLoading, + selectedParam, + expandedMenuItems, + parameterSearch, + setParameterSearch, + onMenuItemClick, + onSearchSelect, +}: PolicyParameterTreeProps) { + // Build flat list of all searchable parameters + const searchableParameters = useMemo(() => { + if (!parameters) return []; + return Object.values(parameters) + .filter((param): param is ParameterMetadata => + param.type === 'parameter' && !!param.label && !param.parameter.includes('pycache') + ) + .map(param => { + const hierarchicalLabels = getHierarchicalLabels(param.parameter, parameters); + const fullLabel = hierarchicalLabels.length > 0 + ? formatLabelParts(hierarchicalLabels) + : param.label; + return { + value: param.parameter, + label: fullLabel, + }; + }) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [parameters]); + + // Render nested menu recursively + const renderMenuItems = useCallback((items: ParameterTreeNode[]): React.ReactNode => { + return items + .filter(item => !item.name.includes('pycache')) + .map(item => ( + onMenuItemClick(item.name)} + childrenOffset={16} + style={{ borderRadius: spacing.radius.sm }} + > + {item.children && expandedMenuItems.has(item.name) && renderMenuItems(item.children)} + + )); + }, [selectedParam?.parameter, expandedMenuItems, onMenuItemClick]); + + // Memoize the rendered tree + const renderedMenuTree = useMemo(() => { + if (metadataLoading || !parameterTree) return null; + return renderMenuItems(parameterTree.children || []); + }, [metadataLoading, parameterTree, renderMenuItems]); + + return ( + + + + PARAMETERS + + } + styles={{ + input: { fontSize: FONT_SIZES.small, height: 32, minHeight: 32 }, + dropdown: { maxHeight: 300 }, + option: { fontSize: FONT_SIZES.small, padding: `${spacing.xs} ${spacing.sm}` }, + }} + size="xs" + /> + + + + {metadataLoading || !parameterTree ? ( + + + + + + ) : ( + renderedMenuTree + )} + + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/policy/PolicyStatusHeader.tsx b/app/src/pages/reportBuilder/modals/policy/PolicyStatusHeader.tsx new file mode 100644 index 000000000..d82955936 --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policy/PolicyStatusHeader.tsx @@ -0,0 +1,143 @@ +/** + * PolicyStatusHeader - Glassmorphic status bar for policy creation mode + */ +import { Box, Group, Text, TextInput, ActionIcon } from '@mantine/core'; +import { IconScale, IconPencil } from '@tabler/icons-react'; +import { colors, spacing, typography } from '@/designTokens'; +import { FONT_SIZES, INGREDIENT_COLORS } from '../../constants'; + +interface PolicyStatusHeaderProps { + policyLabel: string; + setPolicyLabel: (label: string) => void; + isEditingLabel: boolean; + setIsEditingLabel: (editing: boolean) => void; + modificationCount: number; +} + +export function PolicyStatusHeader({ + policyLabel, + setPolicyLabel, + isEditingLabel, + setIsEditingLabel, + modificationCount, +}: PolicyStatusHeaderProps) { + const colorConfig = INGREDIENT_COLORS.policy; + + const dockStyles = { + statusHeader: { + background: 'rgba(255, 255, 255, 0.95)', + backdropFilter: 'blur(20px) saturate(180%)', + WebkitBackdropFilter: 'blur(20px) saturate(180%)', + borderRadius: spacing.radius.lg, + border: `1px solid ${modificationCount > 0 ? colorConfig.border : colors.border.light}`, + boxShadow: modificationCount > 0 + ? `0 4px 20px rgba(0, 0, 0, 0.08), 0 0 0 1px ${colorConfig.border}` + : `0 2px 12px ${colors.shadow.light}`, + padding: `${spacing.sm} ${spacing.lg}`, + transition: 'all 0.3s ease', + margin: spacing.md, + marginBottom: 0, + }, + }; + + return ( + + + + + + + + {isEditingLabel ? ( + setPolicyLabel(e.currentTarget.value)} + onBlur={() => setIsEditingLabel(false)} + onKeyDown={(e) => { + if (e.key === 'Enter') setIsEditingLabel(false); + if (e.key === 'Escape') setIsEditingLabel(false); + }} + autoFocus + placeholder="Enter policy name..." + size="xs" + style={{ width: 250 }} + styles={{ + input: { + fontFamily: typography.fontFamily.primary, + fontWeight: 600, + fontSize: FONT_SIZES.normal, + border: 'none', + background: 'transparent', + padding: 0, + }, + }} + /> + ) : ( + <> + setIsEditingLabel(true)} + > + {policyLabel || 'Click to name your policy...'} + + setIsEditingLabel(true)} + style={{ flexShrink: 0 }} + > + + + + )} + + + + + {modificationCount > 0 ? ( + <> + + + {modificationCount} parameter{modificationCount !== 1 ? 's' : ''} modified + + + ) : ( + + No changes yet + + )} + + + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/policy/index.ts b/app/src/pages/reportBuilder/modals/policy/index.ts new file mode 100644 index 000000000..ba38a34f4 --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policy/index.ts @@ -0,0 +1,8 @@ +/** + * Policy modal sub-components + */ +export { PolicyStatusHeader } from './PolicyStatusHeader'; +export { PolicyParameterTree } from './PolicyParameterTree'; +export { PolicyCreationContent } from './PolicyCreationContent'; +export { PolicyBrowseContent } from './PolicyBrowseContent'; +export { PolicyDetailsDrawer } from './PolicyDetailsDrawer'; diff --git a/app/src/pages/reportBuilder/modals/population/HouseholdCreationContent.tsx b/app/src/pages/reportBuilder/modals/population/HouseholdCreationContent.tsx new file mode 100644 index 000000000..da688b135 --- /dev/null +++ b/app/src/pages/reportBuilder/modals/population/HouseholdCreationContent.tsx @@ -0,0 +1,60 @@ +/** + * HouseholdCreationContent - Household creation form wrapper + */ +import { Box, LoadingOverlay, ScrollArea } from '@mantine/core'; +import HouseholdBuilderForm from '@/components/household/HouseholdBuilderForm'; +import { Household } from '@/types/ingredients/Household'; +import { MetadataState } from '@/types/metadata'; + +interface HouseholdCreationContentProps { + householdDraft: Household | null; + metadata: MetadataState; + reportYear: string; + maritalStatus: 'single' | 'married'; + numChildren: number; + basicPersonFields: string[]; + basicNonPersonFields: string[]; + isCreating: boolean; + onChange: (household: Household) => void; + onMaritalStatusChange: (status: 'single' | 'married') => void; + onNumChildrenChange: (count: number) => void; +} + +export function HouseholdCreationContent({ + householdDraft, + metadata, + reportYear, + maritalStatus, + numChildren, + basicPersonFields, + basicNonPersonFields, + isCreating, + onChange, + onMaritalStatusChange, + onNumChildrenChange, +}: HouseholdCreationContentProps) { + if (!householdDraft) { + return null; + } + + return ( + + + + + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/population/PopulationBrowseContent.tsx b/app/src/pages/reportBuilder/modals/population/PopulationBrowseContent.tsx new file mode 100644 index 000000000..251f9ffc0 --- /dev/null +++ b/app/src/pages/reportBuilder/modals/population/PopulationBrowseContent.tsx @@ -0,0 +1,276 @@ +/** + * PopulationBrowseContent - Browse mode content for population modal + * + * Handles: + * - National selection + * - Region grids (states, districts, etc.) + * - Household list + */ +import { Box, Group, Text, Stack, TextInput, ScrollArea, Paper, Button, Skeleton, UnstyledButton } from '@mantine/core'; +import { IconSearch, IconHome, IconChevronRight } from '@tabler/icons-react'; +import { colors, spacing } from '@/designTokens'; +import { RegionOption } from '@/utils/regionStrategies'; +import { USOutlineIcon, UKOutlineIcon } from '@/components/icons/CountryOutlineIcons'; +import { FONT_SIZES, INGREDIENT_COLORS } from '../../constants'; +import { PopulationCategory } from '../../types'; + +interface HouseholdItem { + id: string; + label: string; + memberCount: number; +} + +interface PopulationBrowseContentProps { + countryId: 'us' | 'uk'; + activeCategory: PopulationCategory; + searchQuery: string; + setSearchQuery: (query: string) => void; + filteredRegions: RegionOption[]; + filteredHouseholds: HouseholdItem[]; + householdsLoading: boolean; + getSectionTitle: () => string; + getItemCount: () => number; + onSelectGeography: (region: RegionOption | null) => void; + onSelectHousehold: (household: HouseholdItem) => void; +} + +export function PopulationBrowseContent({ + countryId, + activeCategory, + searchQuery, + setSearchQuery, + filteredRegions, + filteredHouseholds, + householdsLoading, + getSectionTitle, + getItemCount, + onSelectGeography, + onSelectHousehold, +}: PopulationBrowseContentProps) { + const colorConfig = INGREDIENT_COLORS.population; + + const styles = { + regionGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', + gap: spacing.sm, + }, + regionChip: { + padding: `${spacing.sm} ${spacing.md}`, + borderRadius: spacing.radius.md, + border: `1px solid ${colors.border.light}`, + background: colors.white, + cursor: 'pointer', + transition: 'all 0.15s ease', + fontSize: FONT_SIZES.small, + textAlign: 'center' as const, + }, + householdCard: { + padding: spacing.md, + borderRadius: spacing.radius.md, + border: `1px solid ${colors.border.light}`, + background: colors.white, + cursor: 'pointer', + transition: 'all 0.15s ease', + }, + }; + + return ( + + {/* Search Bar */} + {activeCategory !== 'national' && ( + } + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + size="sm" + styles={{ + input: { + borderRadius: spacing.radius.md, + border: `1px solid ${colors.border.light}`, + fontSize: FONT_SIZES.small, + '&:focus': { + borderColor: colorConfig.accent, + }, + }, + }} + /> + )} + + {/* Section Header */} + + + {getSectionTitle()} + + + {getItemCount()} {getItemCount() === 1 ? 'option' : 'options'} + + + + {/* Content */} + + {activeCategory === 'national' ? ( + // National selection - single prominent option + + onSelectGeography(null)} + > + + {countryId === 'uk' ? : } + + + {countryId === 'uk' ? 'Households UK-wide' : 'Households nationwide'} + + + Simulate policy effects across the entire {countryId === 'uk' ? 'United Kingdom' : 'United States'} + + + + + + + ) : activeCategory === 'my-households' ? ( + // Households list + householdsLoading ? ( + + {[1, 2, 3].map((i) => ( + + ))} + + ) : filteredHouseholds.length === 0 ? ( + + + + + + {searchQuery ? 'No households match your search' : 'No households yet'} + + + {searchQuery + ? 'Try adjusting your search terms' + : 'Create a custom household using the button in the sidebar'} + + + ) : ( + + {filteredHouseholds.map((household) => ( + onSelectHousehold(household)} + > + + + + + + + + {household.label} + + + {household.memberCount} {household.memberCount === 1 ? 'member' : 'members'} + + + + + + + ))} + + ) + ) : ( + // Geography grid + filteredRegions.length === 0 ? ( + + + No regions match your search + + + ) : ( + + {filteredRegions.map((region) => ( + onSelectGeography(region)} + onMouseEnter={(e) => { + e.currentTarget.style.borderColor = colorConfig.border; + e.currentTarget.style.background = colorConfig.bg; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = colors.border.light; + e.currentTarget.style.background = colors.white; + }} + > + {region.label} + + ))} + + ) + )} + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/population/PopulationStatusHeader.tsx b/app/src/pages/reportBuilder/modals/population/PopulationStatusHeader.tsx new file mode 100644 index 000000000..21f176040 --- /dev/null +++ b/app/src/pages/reportBuilder/modals/population/PopulationStatusHeader.tsx @@ -0,0 +1,149 @@ +/** + * PopulationStatusHeader - Glassmorphic status bar for household creation mode + */ +import { Box, Group, Text, TextInput, ActionIcon } from '@mantine/core'; +import { IconHome, IconPencil } from '@tabler/icons-react'; +import { colors, spacing, typography } from '@/designTokens'; +import { FONT_SIZES, INGREDIENT_COLORS } from '../../constants'; + +interface PopulationStatusHeaderProps { + householdLabel: string; + setHouseholdLabel: (label: string) => void; + isEditingLabel: boolean; + setIsEditingLabel: (editing: boolean) => void; + memberCount: number; +} + +export function PopulationStatusHeader({ + householdLabel, + setHouseholdLabel, + isEditingLabel, + setIsEditingLabel, + memberCount, +}: PopulationStatusHeaderProps) { + const colorConfig = INGREDIENT_COLORS.population; + + const dockStyles = { + statusHeader: { + background: 'rgba(255, 255, 255, 0.95)', + backdropFilter: 'blur(20px) saturate(180%)', + WebkitBackdropFilter: 'blur(20px) saturate(180%)', + borderRadius: spacing.radius.lg, + border: `1px solid ${memberCount > 0 ? colorConfig.border : colors.border.light}`, + boxShadow: memberCount > 0 + ? `0 4px 20px rgba(0, 0, 0, 0.08), 0 0 0 1px ${colorConfig.border}` + : `0 2px 12px ${colors.shadow.light}`, + padding: `${spacing.sm} ${spacing.lg}`, + transition: 'all 0.3s ease', + margin: spacing.md, + marginBottom: 0, + }, + }; + + return ( + + + {/* Left side: Household icon and editable name */} + + {/* Household icon */} + + + + + {/* Editable household name */} + + {isEditingLabel ? ( + setHouseholdLabel(e.currentTarget.value)} + onBlur={() => setIsEditingLabel(false)} + onKeyDown={(e) => { + if (e.key === 'Enter') setIsEditingLabel(false); + if (e.key === 'Escape') setIsEditingLabel(false); + }} + autoFocus + placeholder="Enter household name..." + size="xs" + style={{ width: 250 }} + styles={{ + input: { + fontFamily: typography.fontFamily.primary, + fontWeight: 600, + fontSize: FONT_SIZES.normal, + border: 'none', + background: 'transparent', + padding: 0, + }, + }} + /> + ) : ( + <> + setIsEditingLabel(true)} + > + {householdLabel || 'Click to name your household...'} + + setIsEditingLabel(true)} + style={{ flexShrink: 0 }} + > + + + + )} + + + + {/* Right side: Member count */} + + + {memberCount > 0 ? ( + <> + + + {memberCount} member{memberCount !== 1 ? 's' : ''} + + + ) : ( + + No members yet + + )} + + + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/population/index.ts b/app/src/pages/reportBuilder/modals/population/index.ts new file mode 100644 index 000000000..33ff56f9e --- /dev/null +++ b/app/src/pages/reportBuilder/modals/population/index.ts @@ -0,0 +1,6 @@ +/** + * Population modal sub-components + */ +export { PopulationStatusHeader } from './PopulationStatusHeader'; +export { PopulationBrowseContent } from './PopulationBrowseContent'; +export { HouseholdCreationContent } from './HouseholdCreationContent'; diff --git a/app/src/pages/reportBuilder/types.ts b/app/src/pages/reportBuilder/types.ts index 279246942..6f6faa210 100644 --- a/app/src/pages/reportBuilder/types.ts +++ b/app/src/pages/reportBuilder/types.ts @@ -82,9 +82,16 @@ export interface BrowseModalTemplateProps { headerTitle: string; headerSubtitle: string; colorConfig: IngredientColorConfig; - sidebarSections: BrowseModalSidebarSection[]; + /** Standard sidebar sections - use for simple browse mode sidebars */ + sidebarSections?: BrowseModalSidebarSection[]; + /** Custom sidebar rendering - use when sidebar needs custom layout (e.g., parameter tree) */ + renderSidebar?: () => ReactNode; + /** Sidebar width override (default: 220px) */ + sidebarWidth?: number; renderMainContent: () => ReactNode; + /** Status header shown above main content (e.g., creation mode status bar) */ statusHeader?: ReactNode; + /** Footer shown below main content (e.g., creation mode buttons) */ footer?: ReactNode; } From 170b71ff2d3e77dcc3e2e20546837d0907530f79 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Mon, 5 Jan 2026 17:20:08 +0300 Subject: [PATCH 28/73] fix: Report builder modal improvements --- app/src/CalculatorRouter.tsx | 3 +- app/src/libs/metadataUtils.ts | 33 ++++ .../pages/reportBuilder/ReportBuilderPage.tsx | 39 +++- .../components/SimulationBlock.tsx | 6 +- .../components/SimulationCanvas.tsx | 168 +++++++++++++++++- .../modals/BrowseModalTemplate.tsx | 28 +-- .../modals/IngredientPickerModal.tsx | 6 +- .../modals/PolicyBrowseModal.tsx | 9 +- .../modals/PolicyCreationModal.tsx | 33 ++-- .../modals/PopulationBrowseModal.tsx | 9 +- .../modals/policy/PolicyParameterTree.tsx | 25 +-- 11 files changed, 291 insertions(+), 68 deletions(-) diff --git a/app/src/CalculatorRouter.tsx b/app/src/CalculatorRouter.tsx index 18667a493..d2ef5368c 100644 --- a/app/src/CalculatorRouter.tsx +++ b/app/src/CalculatorRouter.tsx @@ -8,7 +8,8 @@ import StandardLayout from './components/StandardLayout'; import DashboardPage from './pages/Dashboard.page'; import PoliciesPage from './pages/Policies.page'; import PopulationsPage from './pages/Populations.page'; -import ReportBuilderPage from './pages/ReportBuilder.page'; +// Old monolithic file preserved but not used - see ./pages/ReportBuilder.page.tsx +import ReportBuilderPage from './pages/reportBuilder/ReportBuilderPage'; import ReportOutputPage from './pages/ReportOutput.page'; import ReportsPage from './pages/Reports.page'; import SimulationsPage from './pages/Simulations.page'; diff --git a/app/src/libs/metadataUtils.ts b/app/src/libs/metadataUtils.ts index 881a1ec9a..caa390a8a 100644 --- a/app/src/libs/metadataUtils.ts +++ b/app/src/libs/metadataUtils.ts @@ -1,6 +1,15 @@ import { createSelector } from '@reduxjs/toolkit'; import { RootState } from '@/store'; import { MetadataApiPayload, MetadataState } from '@/types/metadata'; +import { ParameterMetadata } from '@/types/metadata/parameterMetadata'; + +/** Parameter paths containing these substrings are excluded from search */ +const EXCLUDED_PARAMETER_PATTERNS = ['pycache'] as const; + +export interface SearchableParameter { + value: string; // Full parameter path (e.g., "gov.irs.credits.eitc.max") + label: string; // Leaf label (e.g., "Maximum amount") +} // Memoized selectors to prevent unnecessary re-renders export const getTaxYears = createSelector( @@ -163,6 +172,30 @@ export const getFieldLabel = (fieldName: string) => { ); }; +/** + * Memoized selector for searchable parameters used in autocomplete components. + * Computed once when metadata loads, shared across all components. + */ +export const selectSearchableParameters = createSelector( + [(state: RootState) => state.metadata.parameters], + (parameters): SearchableParameter[] => { + if (!parameters) return []; + + return Object.values(parameters) + .filter( + (param): param is ParameterMetadata => + param.type === 'parameter' && + !!param.label && + !EXCLUDED_PARAMETER_PATTERNS.some((pattern) => param.parameter.includes(pattern)) + ) + .map((param) => ({ + value: param.parameter, + label: param.label, + })) + .sort((a, b) => a.label.localeCompare(b.label)); + } +); + export function transformMetadataPayload( payload: MetadataApiPayload, country: string diff --git a/app/src/pages/reportBuilder/ReportBuilderPage.tsx b/app/src/pages/reportBuilder/ReportBuilderPage.tsx index fb47c4a03..55ab83dd3 100644 --- a/app/src/pages/reportBuilder/ReportBuilderPage.tsx +++ b/app/src/pages/reportBuilder/ReportBuilderPage.tsx @@ -11,7 +11,7 @@ * - Row view: Stacked horizontal rows */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useLayoutEffect } from 'react'; import { Box, Tabs } from '@mantine/core'; import { IconLayoutColumns, IconRowInsertBottom } from '@tabler/icons-react'; @@ -25,6 +25,20 @@ import { styles } from './styles'; import { SimulationCanvas, ReportMetaPanel } from './components'; export default function ReportBuilderPage() { + const renderCount = useRef(0); + const mountTime = useRef(performance.now()); + renderCount.current++; + + // Reset mount time on first render + if (renderCount.current === 1) { + mountTime.current = performance.now(); + } + + // Debug logging for page render cycle + console.log('[ReportBuilderPage] Render #' + renderCount.current, { + timeSinceMount: (performance.now() - mountTime.current).toFixed(2) + 'ms', + }); + const countryId = useCurrentCountry() as 'us' | 'uk'; const countryConfig = COUNTRY_CONFIG[countryId] || COUNTRY_CONFIG.us; const [activeTab, setActiveTab] = useState('cards'); @@ -51,6 +65,25 @@ export default function ReportBuilderPage() { // Only households allow single-simulation reports const isGeographySelected = !!reportState.simulations[0]?.population?.geography?.id; + // Debug: Track when effects run + useLayoutEffect(() => { + console.log('[ReportBuilderPage] useLayoutEffect START', { + timeSinceMount: (performance.now() - mountTime.current).toFixed(2) + 'ms', + }); + return () => { + console.log('[ReportBuilderPage] useLayoutEffect CLEANUP'); + }; + }); + + useEffect(() => { + console.log('[ReportBuilderPage] useEffect (mount) START', { + timeSinceMount: (performance.now() - mountTime.current).toFixed(2) + 'ms', + }); + return () => { + console.log('[ReportBuilderPage] useEffect (mount) CLEANUP'); + }; + }, []); + useEffect(() => { if (isGeographySelected && reportState.simulations.length === 1) { const newSim = initializeSimulationState(); @@ -66,6 +99,10 @@ export default function ReportBuilderPage() { const viewMode = (activeTab || 'cards') as ViewMode; + console.log('[ReportBuilderPage] About to return JSX', { + timeSinceMount: (performance.now() - mountTime.current).toFixed(2) + 'ms', + }); + return ( diff --git a/app/src/pages/reportBuilder/components/SimulationBlock.tsx b/app/src/pages/reportBuilder/components/SimulationBlock.tsx index 1a7cb87eb..e13b544f9 100644 --- a/app/src/pages/reportBuilder/components/SimulationBlock.tsx +++ b/app/src/pages/reportBuilder/components/SimulationBlock.tsx @@ -2,7 +2,7 @@ * SimulationBlock - A simulation configuration card */ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { Box, Group, @@ -72,6 +72,10 @@ export function SimulationBlock({ recentPopulations, viewMode, }: SimulationBlockProps) { + const renderCount = useRef(0); + renderCount.current++; + console.log('[SimulationBlock #' + index + '] Render #' + renderCount.current); + const [isEditingLabel, setIsEditingLabel] = useState(false); const [labelInput, setLabelInput] = useState(simulation.label || ''); diff --git a/app/src/pages/reportBuilder/components/SimulationCanvas.tsx b/app/src/pages/reportBuilder/components/SimulationCanvas.tsx index bc726f6e4..948bfacc5 100644 --- a/app/src/pages/reportBuilder/components/SimulationCanvas.tsx +++ b/app/src/pages/reportBuilder/components/SimulationCanvas.tsx @@ -2,9 +2,10 @@ * SimulationCanvas - Main orchestrator for simulation blocks */ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useSelector } from 'react-redux'; -import { Box } from '@mantine/core'; +import { Box, Skeleton, Stack, Group, Text } from '@mantine/core'; +import { IconScale, IconUsers } from '@tabler/icons-react'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useUserPolicies } from '@/hooks/useUserPolicy'; @@ -59,14 +60,58 @@ export function SimulationCanvas({ setPickerState, viewMode, }: SimulationCanvasProps) { + const renderCount = useRef(0); + const mountTime = useRef(performance.now()); + renderCount.current++; + + if (renderCount.current === 1) { + mountTime.current = performance.now(); + } + + console.log('[SimulationCanvas] Render #' + renderCount.current + ' START', { + timeSinceMount: (performance.now() - mountTime.current).toFixed(2) + 'ms', + }); + const countryId = useCurrentCountry() as 'us' | 'uk'; const countryConfig = COUNTRY_CONFIG[countryId] || COUNTRY_CONFIG.us; const userId = MOCK_USER_ID.toString(); - const { data: policies } = useUserPolicies(userId); - const { data: households } = useUserHouseholds(userId); + const { data: policies, isLoading: policiesLoading } = useUserPolicies(userId); + const { data: households, isLoading: householdsLoading } = useUserHouseholds(userId); const regionOptions = useSelector((state: RootState) => state.metadata.economyOptions.region); + const metadataLoading = useSelector((state: RootState) => state.metadata.loading); const isGeographySelected = !!reportState.simulations[0]?.population?.geography?.id; + // Show loading skeleton if: + // 1. Policies/households are still loading (isLoading is true) + // 2. Data is still undefined (hasn't resolved yet) + // 3. Metadata is still loading (needed for regions) + const isInitialLoading = policiesLoading || householdsLoading || metadataLoading || + policies === undefined || households === undefined; + + // Debug logging for render cycle analysis + console.log('[SimulationCanvas] Data state:', { + timeSinceMount: (performance.now() - mountTime.current).toFixed(2) + 'ms', + policiesLoading, + householdsLoading, + metadataLoading, + policiesUndefined: policies === undefined, + householdsUndefined: households === undefined, + policiesCount: policies?.length ?? 'undefined', + householdsCount: households?.length ?? 'undefined', + isInitialLoading, + regionOptionsCount: regionOptions?.length ?? 0, + }); + + // Track when effects run + useEffect(() => { + console.log('[SimulationCanvas] useEffect (mount) ran', { + timeSinceMount: (performance.now() - mountTime.current).toFixed(2) + 'ms', + }); + return () => { + console.log('[SimulationCanvas] useEffect (mount) cleanup'); + }; + }, []); + // Suppress unused variable void countryConfig; @@ -88,7 +133,8 @@ export function SimulationCanvas({ // Transform policies data into SavedPolicy format const savedPolicies: SavedPolicy[] = useMemo(() => { - return (policies || []) + const start = performance.now(); + const result = (policies || []) .map((p) => { const policyId = p.association.policyId.toString(); const label = p.association.label || `Policy #${policyId}`; @@ -105,10 +151,13 @@ export function SimulationCanvas({ const bTime = b.updatedAt || b.createdAt || ''; return bTime.localeCompare(aTime); }); + console.log('[SimulationCanvas] useMemo savedPolicies took', (performance.now() - start).toFixed(2) + 'ms'); + return result; }, [policies]); // Build recent populations from usage tracking const recentPopulations: RecentPopulation[] = useMemo(() => { + const start = performance.now(); const results: Array = []; const regions = regionOptions || []; @@ -168,10 +217,12 @@ export function SimulationCanvas({ } } - return results + const result = results .sort((a, b) => b.timestamp.localeCompare(a.timestamp)) .slice(0, 10) .map(({ timestamp: _t, ...rest }) => rest); + console.log('[SimulationCanvas] useMemo recentPopulations took', (performance.now() - start).toFixed(2) + 'ms'); + return result; }, [countryId, households, regionOptions]); const handleAddSimulation = useCallback(() => { @@ -399,6 +450,111 @@ export function SimulationCanvas({ [setReportState] ); + console.log('[SimulationCanvas] All hooks/callbacks defined', { + timeSinceMount: (performance.now() - mountTime.current).toFixed(2) + 'ms', + modalsState: { + policyBrowseOpen: policyBrowseState.isOpen, + policyCreationOpen: policyCreationState.isOpen, + populationBrowseOpen: populationBrowseState.isOpen, + pickerOpen: pickerState.isOpen, + }, + }); + + // Loading skeleton component + const LoadingSkeleton = () => ( + + + + {/* Simulation block skeleton */} + + {/* Header skeleton */} + + + + + + {/* Policy section skeleton */} + + + + + + + + + + + + + {/* Population section skeleton */} + + + + + + + + + + + + + {/* Dynamics section skeleton */} + + + + + + + + + + {/* Add simulation card skeleton */} + + + + + + + + ); + + // Show loading skeleton while fetching initial data + if (isInitialLoading) { + console.log('[SimulationCanvas] Returning LoadingSkeleton', { + timeSinceMount: (performance.now() - mountTime.current).toFixed(2) + 'ms', + }); + return ; + } + + console.log('[SimulationCanvas] About to return full JSX (modals will mount)', { + timeSinceMount: (performance.now() - mountTime.current).toFixed(2) + 'ms', + }); + return ( <> diff --git a/app/src/pages/reportBuilder/modals/BrowseModalTemplate.tsx b/app/src/pages/reportBuilder/modals/BrowseModalTemplate.tsx index b6d7a63b6..7313c2344 100644 --- a/app/src/pages/reportBuilder/modals/BrowseModalTemplate.tsx +++ b/app/src/pages/reportBuilder/modals/BrowseModalTemplate.tsx @@ -139,6 +139,7 @@ export function BrowseModalTemplate({ }, }} > + {/* Main layout: sidebar + content */} {/* Sidebar */} {renderMainContent()} - - {/* Optional Footer */} - {footer && ( - - {footer} - - )} + + {/* Footer spans full width, outside the sidebar/content layout */} + {footer && ( + + {footer} + + )} ); } diff --git a/app/src/pages/reportBuilder/modals/IngredientPickerModal.tsx b/app/src/pages/reportBuilder/modals/IngredientPickerModal.tsx index 43c668e0c..8ebdd84a0 100644 --- a/app/src/pages/reportBuilder/modals/IngredientPickerModal.tsx +++ b/app/src/pages/reportBuilder/modals/IngredientPickerModal.tsx @@ -1,4 +1,4 @@ -import { useState, Fragment } from 'react'; +import { useState, Fragment, useRef } from 'react'; import { Box, Group, @@ -54,6 +54,10 @@ export function IngredientPickerModal({ onSelect, onCreateNew, }: IngredientPickerModalProps) { + const renderCount = useRef(0); + renderCount.current++; + console.log('[IngredientPickerModal] Render #' + renderCount.current + ' (isOpen=' + isOpen + ')'); + const countryId = useCurrentCountry() as 'us' | 'uk'; const countryConfig = COUNTRY_CONFIG[countryId] || COUNTRY_CONFIG.us; const userId = MOCK_USER_ID.toString(); diff --git a/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx b/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx index 964785d9f..5abd8772d 100644 --- a/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx +++ b/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx @@ -5,7 +5,7 @@ * - Browse mode: PolicyBrowseContent for main content * - Creation mode: PolicyCreationContent + PolicyParameterTree */ -import { useState, useCallback, useEffect, useMemo } from 'react'; +import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { Box, Text, UnstyledButton, Divider, ScrollArea, Stack } from '@mantine/core'; import { IconScale, IconUsers, IconPlus, IconFolder } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; @@ -48,6 +48,11 @@ export function PolicyBrowseModal({ onClose, onSelect, }: PolicyBrowseModalProps) { + const renderCount = useRef(0); + renderCount.current++; + const renderStart = performance.now(); + console.log('[PolicyBrowseModal] Render #' + renderCount.current + ' START (isOpen=' + isOpen + ')'); + const countryId = useCurrentCountry() as 'us' | 'uk'; const userId = MOCK_USER_ID.toString(); const { data: policies, isLoading } = useUserPolicies(userId); @@ -408,6 +413,8 @@ export function PolicyBrowseModal({ // ========== Render ========== + console.log('[PolicyBrowseModal] About to return JSX, took', (performance.now() - renderStart).toFixed(2) + 'ms'); + return ( { - if (!parameters) return []; - - return Object.values(parameters) - .filter((param): param is ParameterMetadata => - param.type === 'parameter' && !!param.label && !param.parameter.includes('pycache') - ) - .map(param => { - const hierarchicalLabels = getHierarchicalLabels(param.parameter, parameters); - const fullLabel = hierarchicalLabels.length > 0 - ? formatLabelParts(hierarchicalLabels) - : param.label; - return { - value: param.parameter, - label: fullLabel, - }; - }) - .sort((a, b) => a.label.localeCompare(b.label)); - }, [parameters]); + // Get searchable parameters from memoized selector (computed once when metadata loads) + const searchableParameters = useSelector(selectSearchableParameters); // Handle search selection - expand tree path and select parameter const handleSearchSelect = useCallback((paramName: string) => { @@ -317,6 +304,8 @@ export function PolicyCreationModal({ const ValueSetterToRender = ValueSetterComponents[valueSetterMode]; + console.log('[PolicyCreationModal] About to return JSX, took', (performance.now() - renderStart).toFixed(2) + 'ms'); + // Dock styles matching ReportMetaPanel const dockStyles = { dock: { diff --git a/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx b/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx index 60c8b419c..9bb5b5ac7 100644 --- a/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx +++ b/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx @@ -5,7 +5,7 @@ * - Browse mode: PopulationBrowseContent for main content * - Creation mode: HouseholdCreationContent + PopulationStatusHeader */ -import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useSelector } from 'react-redux'; import { useQueryClient } from '@tanstack/react-query'; import { IconUsers, IconFolder, IconHome, IconPlus } from '@tabler/icons-react'; @@ -57,6 +57,11 @@ export function PopulationBrowseModal({ onSelect, onCreateNew, }: PopulationBrowseModalProps) { + const renderCount = useRef(0); + renderCount.current++; + const renderStart = performance.now(); + console.log('[PopulationBrowseModal] Render #' + renderCount.current + ' START (isOpen=' + isOpen + ')'); + const countryId = useCurrentCountry() as 'us' | 'uk'; const userId = MOCK_USER_ID.toString(); const queryClient = useQueryClient(); @@ -452,6 +457,8 @@ export function PopulationBrowseModal({ // ========== Render ========== + console.log('[PopulationBrowseModal] About to return JSX, took', (performance.now() - renderStart).toFixed(2) + 'ms'); + return ( { - if (!parameters) return []; - return Object.values(parameters) - .filter((param): param is ParameterMetadata => - param.type === 'parameter' && !!param.label && !param.parameter.includes('pycache') - ) - .map(param => { - const hierarchicalLabels = getHierarchicalLabels(param.parameter, parameters); - const fullLabel = hierarchicalLabels.length > 0 - ? formatLabelParts(hierarchicalLabels) - : param.label; - return { - value: param.parameter, - label: fullLabel, - }; - }) - .sort((a, b) => a.label.localeCompare(b.label)); - }, [parameters]); + // Get searchable parameters from memoized selector (computed once when metadata loads) + const searchableParameters = useSelector(selectSearchableParameters); // Render nested menu recursively const renderMenuItems = useCallback((items: ParameterTreeNode[]): React.ReactNode => { @@ -85,7 +69,6 @@ export function PolicyParameterTree({ overflow: 'hidden', display: 'flex', flexDirection: 'column', - background: colors.gray[50], margin: `-${spacing.lg}`, marginRight: 0, }} From 7fe2142ebaaad92ffe7ac9dae4c815d7cd6ae0fe Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Mon, 5 Jan 2026 18:13:45 +0300 Subject: [PATCH 29/73] fix: Improve US Congressional district formatting --- .../modals/PopulationBrowseModal.tsx | 8 +- .../population/PopulationBrowseContent.tsx | 40 ++- .../population/StateDistrictSelector.tsx | 334 ++++++++++++++++++ 3 files changed, 369 insertions(+), 13 deletions(-) create mode 100644 app/src/pages/reportBuilder/modals/population/StateDistrictSelector.tsx diff --git a/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx b/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx index 9bb5b5ac7..98ce6bb1f 100644 --- a/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx +++ b/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx @@ -130,7 +130,7 @@ export function PopulationBrowseModal({ const usStates = getUSStates(regionOptions); const usDistricts = getUSCongressionalDistricts(regionOptions); return [ - { id: 'states' as const, label: 'States', count: usStates.length, regions: usStates }, + { id: 'states' as const, label: 'States and territories', count: usStates.length, regions: usStates }, { id: 'districts' as const, label: 'Congressional districts', count: usDistricts.length, regions: usDistricts }, ]; }, [countryId, regionOptions]); @@ -429,6 +429,11 @@ export function PopulationBrowseModal({ ); } + // Get all congressional districts for StateDistrictSelector (US only) + const allDistricts = countryId === 'us' + ? geographyCategories.find(c => c.id === 'districts')?.regions + : undefined; + return ( ({ id: h.id, label: h.label, diff --git a/app/src/pages/reportBuilder/modals/population/PopulationBrowseContent.tsx b/app/src/pages/reportBuilder/modals/population/PopulationBrowseContent.tsx index 251f9ffc0..985f1281e 100644 --- a/app/src/pages/reportBuilder/modals/population/PopulationBrowseContent.tsx +++ b/app/src/pages/reportBuilder/modals/population/PopulationBrowseContent.tsx @@ -13,6 +13,7 @@ import { RegionOption } from '@/utils/regionStrategies'; import { USOutlineIcon, UKOutlineIcon } from '@/components/icons/CountryOutlineIcons'; import { FONT_SIZES, INGREDIENT_COLORS } from '../../constants'; import { PopulationCategory } from '../../types'; +import { StateDistrictSelector } from './StateDistrictSelector'; interface HouseholdItem { id: string; @@ -26,6 +27,7 @@ interface PopulationBrowseContentProps { searchQuery: string; setSearchQuery: (query: string) => void; filteredRegions: RegionOption[]; + allDistricts?: RegionOption[]; // Full list of congressional districts for StateDistrictSelector filteredHouseholds: HouseholdItem[]; householdsLoading: boolean; getSectionTitle: () => string; @@ -40,6 +42,7 @@ export function PopulationBrowseContent({ searchQuery, setSearchQuery, filteredRegions, + allDistricts, filteredHouseholds, householdsLoading, getSectionTitle, @@ -75,10 +78,13 @@ export function PopulationBrowseContent({ }, }; + // StateDistrictSelector handles its own search and header + const showExternalSearchAndHeader = activeCategory !== 'national' && activeCategory !== 'districts'; + return ( - {/* Search Bar */} - {activeCategory !== 'national' && ( + {/* Search Bar - hidden for national and districts (StateDistrictSelector has its own) */} + {showExternalSearchAndHeader && ( )} - {/* Section Header */} - - - {getSectionTitle()} - - - {getItemCount()} {getItemCount() === 1 ? 'option' : 'options'} - - + {/* Section Header - hidden for national and districts */} + {showExternalSearchAndHeader && ( + + + {getSectionTitle()} + + + {getItemCount()} {getItemCount() === 1 ? 'option' : 'options'} + + + )} {/* Content */} @@ -231,8 +239,16 @@ export function PopulationBrowseContent({ ))} ) + ) : activeCategory === 'districts' && allDistricts ? ( + // Congressional districts - use StateDistrictSelector + ) : ( - // Geography grid + // Standard geography grid (states, countries, constituencies, local authorities) filteredRegions.length === 0 ? ( void; + onSelectDistrict: (district: RegionOption) => void; +} + +interface StateGroup { + stateName: string; + stateAbbreviation: string; + districts: RegionOption[]; +} + +// ============================================================================ +// Pure utility functions +// ============================================================================ + +function formatOrdinal(num: number): string { + const suffixes = ['th', 'st', 'nd', 'rd']; + const v = num % 100; + return num + (suffixes[(v - 20) % 10] || suffixes[v] || suffixes[0]); +} + +function extractDistrictNumber(label: string): number | null { + const match = label.match(/(\d+)/); + return match ? parseInt(match[1], 10) : null; +} + +function sortDistrictsNumerically(districts: RegionOption[]): RegionOption[] { + return [...districts].sort((a, b) => { + const numA = extractDistrictNumber(a.label) || 0; + const numB = extractDistrictNumber(b.label) || 0; + return numA - numB; + }); +} + +function sortGroupsAlphabetically(groups: StateGroup[]): StateGroup[] { + return [...groups].sort((a, b) => a.stateName.localeCompare(b.stateName)); +} + +function groupDistrictsByState(districts: RegionOption[]): StateGroup[] { + const groups: Map = new Map(); + + for (const district of districts) { + const stateName = district.stateName || 'Unknown'; + const stateAbbr = district.stateAbbreviation || ''; + + if (!groups.has(stateName)) { + groups.set(stateName, { + stateName, + stateAbbreviation: stateAbbr, + districts: [], + }); + } + groups.get(stateName)!.districts.push(district); + } + + const sortedGroups = sortGroupsAlphabetically(Array.from(groups.values())); + + return sortedGroups.map((group) => ({ + ...group, + districts: sortDistrictsNumerically(group.districts), + })); +} + +function buildDistrictCountLookup(groups: StateGroup[]): Map { + const counts = new Map(); + for (const group of groups) { + counts.set(group.stateName, group.districts.length); + } + return counts; +} + +function filterGroupsByQuery(groups: StateGroup[], query: string): StateGroup[] { + if (!query.trim()) return groups; + + const normalizedQuery = query.toLowerCase(); + + return groups + .map((group) => { + const stateMatches = group.stateName.toLowerCase().includes(normalizedQuery); + if (stateMatches) return group; + + const matchingDistricts = group.districts.filter((d) => + d.label.toLowerCase().includes(normalizedQuery) + ); + + if (matchingDistricts.length > 0) { + return { ...group, districts: matchingDistricts }; + } + + return null; + }) + .filter((group): group is StateGroup => group !== null); +} + +function getDistrictDisplayLabel( + district: RegionOption, + stateName: string, + originalCounts: Map +): string { + const originalCount = originalCounts.get(stateName) || 0; + if (originalCount === 1) return 'At-large'; + + const num = extractDistrictNumber(district.label); + return num ? formatOrdinal(num) : district.label; +} + +function countTotalDistricts(groups: StateGroup[]): number { + return groups.reduce((sum, group) => sum + group.districts.length, 0); +} + +// ============================================================================ +// Styles +// ============================================================================ + +const colorConfig = INGREDIENT_COLORS.population; + +const styles = { + stateHeader: { + padding: `${spacing.sm} 0`, + borderBottom: `1px solid ${colors.border.light}`, + marginBottom: spacing.sm, + }, + districtGrid: { + display: 'flex', + flexWrap: 'wrap' as const, + gap: spacing.xs, + marginBottom: spacing.lg, + }, + districtChip: { + padding: `${spacing.xs} ${spacing.md}`, + borderRadius: spacing.radius.md, + border: `1px solid ${colors.border.light}`, + background: colors.white, + cursor: 'pointer', + transition: 'all 0.15s ease', + fontSize: FONT_SIZES.small, + minWidth: 60, + textAlign: 'center' as const, + }, + emptyState: { + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + justifyContent: 'center', + padding: spacing['4xl'], + gap: spacing.md, + }, +}; + +// ============================================================================ +// Sub-components +// ============================================================================ + +function SearchBar({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + return ( + } + value={value} + onChange={(e) => onChange(e.target.value)} + size="sm" + styles={{ + input: { + borderRadius: spacing.radius.md, + border: `1px solid ${colors.border.light}`, + fontSize: FONT_SIZES.small, + '&:focus': { + borderColor: colorConfig.accent, + }, + }, + }} + /> + ); +} + +function SectionHeader({ count }: { count: number }) { + return ( + + + Congressional districts + + + {count} {count === 1 ? 'district' : 'districts'} + + + ); +} + +function EmptyState() { + return ( + + + No districts match your search + + + ); +} + +function StateHeader({ stateName, stateAbbreviation }: { stateName: string; stateAbbreviation: string }) { + return ( + + + {stateName} + {stateAbbreviation && ( + + ({stateAbbreviation}) + + )} + + + ); +} + +function DistrictChip({ + label, + onClick, +}: { + label: string; + onClick: () => void; +}) { + return ( + { + e.currentTarget.style.borderColor = colorConfig.border; + e.currentTarget.style.background = colorConfig.bg; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = colors.border.light; + e.currentTarget.style.background = colors.white; + }} + > + {label} + + ); +} + +function StateGroupSection({ + group, + originalCounts, + onSelectDistrict, +}: { + group: StateGroup; + originalCounts: Map; + onSelectDistrict: (district: RegionOption) => void; +}) { + return ( + + + + {group.districts.map((district) => ( + onSelectDistrict(district)} + /> + ))} + + + ); +} + +// ============================================================================ +// Main component +// ============================================================================ + +export function StateDistrictSelector({ + districts, + searchQuery, + setSearchQuery, + onSelectDistrict, +}: StateDistrictSelectorProps) { + const stateGroups = useMemo(() => groupDistrictsByState(districts), [districts]); + + const originalDistrictCounts = useMemo( + () => buildDistrictCountLookup(stateGroups), + [stateGroups] + ); + + const filteredGroups = useMemo( + () => filterGroupsByQuery(stateGroups, searchQuery), + [stateGroups, searchQuery] + ); + + const totalDistrictCount = countTotalDistricts(filteredGroups); + + return ( + + + + + {filteredGroups.length === 0 ? ( + + ) : ( + filteredGroups.map((group) => ( + + )) + )} + + + ); +} From 9c18d91499129556e33a8708cef3b935c71d4d2c Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Mon, 5 Jan 2026 18:45:55 +0300 Subject: [PATCH 30/73] feat: Enable Run button --- .../components/ReportMetaPanel.tsx | 365 +++++++++++++++--- 1 file changed, 312 insertions(+), 53 deletions(-) diff --git a/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx b/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx index 2048504ca..41484ff26 100644 --- a/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx +++ b/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx @@ -2,31 +2,32 @@ * ReportMetaPanel - Floating dock showing report status and configuration */ -import React, { useState } from 'react'; -import { - Box, - Group, - Stack, - Text, - TextInput, - Select, - ActionIcon, -} from '@mantine/core'; +import React, { useCallback, useState } from 'react'; import { + IconCircleCheck, + IconCircleDashed, IconFileDescription, IconPencil, + IconPlayerPlay, IconScale, IconUsers, - IconPlayerPlay, - IconCircleDashed, - IconCircleCheck, } from '@tabler/icons-react'; - -import { colors, spacing, typography } from '@/designTokens'; +import { useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import { ActionIcon, Box, Group, Loader, Select, Stack, Text, TextInput } from '@mantine/core'; +import { ReportAdapter, SimulationAdapter } from '@/adapters'; +import { createSimulation } from '@/api/simulation'; import { CURRENT_YEAR } from '@/constants'; - -import type { ReportBuilderState } from '../types'; +import { colors, spacing, typography } from '@/designTokens'; +import { useCreateReport } from '@/hooks/useCreateReport'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { RootState } from '@/store'; +import { Report } from '@/types/ingredients/Report'; +import { Simulation } from '@/types/ingredients/Simulation'; +import { SimulationStateProps } from '@/types/pathwayState'; +import { getReportOutputPath } from '@/utils/reportRouting'; import { FONT_SIZES } from '../constants'; +import type { ReportBuilderState } from '../types'; import { ProgressDot } from './shared'; interface ReportMetaPanelProps { @@ -35,28 +36,199 @@ interface ReportMetaPanelProps { isReportConfigured: boolean; } -export function ReportMetaPanel({ reportState, setReportState, isReportConfigured }: ReportMetaPanelProps) { +export function ReportMetaPanel({ + reportState, + setReportState, + isReportConfigured, +}: ReportMetaPanelProps) { const [isEditingLabel, setIsEditingLabel] = useState(false); const [labelInput, setLabelInput] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const navigate = useNavigate(); + const countryId = useCurrentCountry(); + const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId); + const { createReport } = useCreateReport(reportState.label || undefined); const handleLabelSubmit = () => { setReportState((prev) => ({ ...prev, label: labelInput || 'Untitled report' })); setIsEditingLabel(false); }; + // Convert SimulationStateProps to API Simulation format for useCreateReport + const convertToSimulation = useCallback( + (simState: SimulationStateProps, simulationId: string): Simulation | null => { + const policyId = simState.policy?.id; + if (!policyId) { + return null; + } + + let populationId: string | undefined; + let populationType: 'household' | 'geography' | undefined; + + if (simState.population?.household?.id) { + populationId = simState.population.household.id; + populationType = 'household'; + } else if (simState.population?.geography?.geographyId) { + populationId = simState.population.geography.geographyId; + populationType = 'geography'; + } + + if (!populationId || !populationType) { + return null; + } + + return { + id: simulationId, + countryId, + apiVersion: undefined, + policyId: policyId === 'current-law' ? currentLawId.toString() : policyId, + populationId, + populationType, + label: simState.label, + isCreated: true, + output: null, + status: 'pending', + }; + }, + [countryId, currentLawId] + ); + + const handleRunReport = useCallback(async () => { + if (!isReportConfigured || isSubmitting) { + return; + } + + setIsSubmitting(true); + + try { + const simulationIds: string[] = []; + const simulations: (Simulation | null)[] = []; + + // Step 1: Create simulations for each simulation in reportState + for (const simState of reportState.simulations) { + // Resolve policy ID (handle 'current-law' placeholder) + const policyId = + simState.policy?.id === 'current-law' ? currentLawId.toString() : simState.policy?.id; + + if (!policyId) { + console.error('[ReportMetaPanel] Simulation missing policy ID'); + continue; + } + + // Determine population ID and type + let populationId: string | undefined; + let populationType: 'household' | 'geography' | undefined; + + if (simState.population?.household?.id) { + populationId = simState.population.household.id; + populationType = 'household'; + } else if (simState.population?.geography?.geographyId) { + populationId = simState.population.geography.geographyId; + populationType = 'geography'; + } + + if (!populationId || !populationType) { + console.error('[ReportMetaPanel] Simulation missing population'); + continue; + } + + // Create simulation payload + const simulationData: Partial = { + populationId, + policyId, + populationType, + }; + + const payload = SimulationAdapter.toCreationPayload(simulationData); + + // Create simulation via API + const result = await createSimulation(countryId, payload); + const simulationId = result.result.simulation_id; + simulationIds.push(simulationId); + + // Convert to Simulation format for useCreateReport + const simulation = convertToSimulation(simState, simulationId); + simulations.push(simulation); + } + + if (simulationIds.length === 0) { + console.error('[ReportMetaPanel] No simulations created'); + setIsSubmitting(false); + return; + } + + // Step 2: Create report with simulation IDs + const reportData: Partial = { + countryId, + year: reportState.year, + simulationIds, + apiVersion: null, + }; + + const serializedPayload = ReportAdapter.toCreationPayload(reportData as Report); + + // Step 3: Call useCreateReport to create report and start calculation + await createReport( + { + countryId, + payload: serializedPayload, + simulations: { + simulation1: simulations[0], + simulation2: simulations[1] || null, + }, + populations: { + household1: reportState.simulations[0]?.population?.household || null, + household2: reportState.simulations[1]?.population?.household || null, + geography1: reportState.simulations[0]?.population?.geography || null, + geography2: reportState.simulations[1]?.population?.geography || null, + }, + }, + { + onSuccess: (data) => { + const outputPath = getReportOutputPath(countryId, data.userReport.id); + navigate(outputPath); + }, + onError: (error) => { + console.error('[ReportMetaPanel] Report creation failed:', error); + setIsSubmitting(false); + }, + } + ); + } catch (error) { + console.error('[ReportMetaPanel] Error running report:', error); + setIsSubmitting(false); + } + }, [ + isReportConfigured, + isSubmitting, + reportState, + countryId, + currentLawId, + createReport, + convertToSimulation, + navigate, + ]); + // Calculate configuration progress const simulations = reportState.simulations; const baselinePolicyConfigured = !!simulations[0]?.policy?.id; - const baselinePopulationConfigured = !!(simulations[0]?.population?.household?.id || simulations[0]?.population?.geography?.id); + const baselinePopulationConfigured = !!( + simulations[0]?.population?.household?.id || simulations[0]?.population?.geography?.id + ); const hasReform = simulations.length > 1; const reformPolicyConfigured = hasReform && !!simulations[1]?.policy?.id; // Get labels for display const baselinePolicyLabel = simulations[0]?.policy?.label || null; - const baselinePopulationLabel = simulations[0]?.population?.label || - (simulations[0]?.population?.household?.id ? 'Household' : - simulations[0]?.population?.geography?.id ? 'Nationwide' : null); - const reformPolicyLabel = hasReform ? (simulations[1]?.policy?.label || null) : null; + const baselinePopulationLabel = + simulations[0]?.population?.label || + (simulations[0]?.population?.household?.id + ? 'Household' + : simulations[0]?.population?.geography?.id + ? 'Nationwide' + : null); + const reformPolicyLabel = hasReform ? simulations[1]?.policy?.label || null : null; // Progress steps const steps = [ @@ -103,24 +275,25 @@ export function ReportMetaPanel({ reportState, setReportState, isReportConfigure margin: `0 ${spacing.xs}`, }, runButton: { - background: isReportConfigured - ? `linear-gradient(135deg, ${colors.primary[500]} 0%, ${colors.primary[600]} 100%)` - : colors.gray[200], - color: isReportConfigured ? 'white' : colors.gray[500], + background: + isReportConfigured && !isSubmitting + ? `linear-gradient(135deg, ${colors.primary[500]} 0%, ${colors.primary[600]} 100%)` + : colors.gray[200], + color: isReportConfigured && !isSubmitting ? 'white' : colors.gray[500], border: 'none', borderRadius: spacing.radius.lg, padding: `${spacing.sm} ${spacing.lg}`, fontFamily: typography.fontFamily.primary, fontWeight: 600, fontSize: FONT_SIZES.normal, - cursor: isReportConfigured ? 'pointer' : 'not-allowed', + cursor: isReportConfigured && !isSubmitting ? 'pointer' : 'not-allowed', display: 'flex', alignItems: 'center', gap: spacing.xs, transition: 'all 0.3s ease', - boxShadow: isReportConfigured - ? `0 4px 12px rgba(44, 122, 123, 0.3)` - : 'none', + boxShadow: + isReportConfigured && !isSubmitting ? `0 4px 12px rgba(44, 122, 123, 0.3)` : 'none', + opacity: isSubmitting ? 0.7 : 1, }, configRow: { display: 'flex', @@ -162,7 +335,9 @@ export function ReportMetaPanel({ reportState, setReportState, isReportConfigure {/* Title with pencil icon - flexible width */} - + {isEditingLabel ? ( ) : ( @@ -223,7 +398,9 @@ export function ReportMetaPanel({ reportState, setReportState, isReportConfigure setReportState((prev) => ({ ...prev, year: value || CURRENT_YEAR }))} + onChange={(value) => + setReportState((prev) => ({ ...prev, year: value || CURRENT_YEAR })) + } data={['2023', '2024', '2025', '2026']} size="xs" w={60} @@ -5495,7 +5934,7 @@ function ReportMetaPanel({ reportState, setReportState, isReportConfigured }: Re cursor: 'pointer', padding: 0, minHeight: 'auto', - } + }, }} /> @@ -5544,31 +5983,77 @@ function ReportMetaPanel({ reportState, setReportState, isReportConfigured }: Re {/* Baseline row */} - Baseline + + Baseline + {baselinePolicyConfigured ? ( <> - {baselinePolicyLabel} + + {baselinePolicyLabel} + ) : ( <> - Select policy + + Select policy + )} - + + + + + {baselinePopulationConfigured ? ( <> - {baselinePopulationLabel} + + {baselinePopulationLabel} + ) : ( <> - Select population + + Select population + )} @@ -5577,21 +6062,51 @@ function ReportMetaPanel({ reportState, setReportState, isReportConfigured }: Re {/* Reform row (if applicable) */} {hasReform && ( - Reform + + Reform + {reformPolicyConfigured ? ( <> - {reformPolicyLabel} + + {reformPolicyLabel} + ) : ( <> - Select policy + + Select policy + )} - (inherits population) + + (inherits population) + )} @@ -5599,7 +6114,13 @@ function ReportMetaPanel({ reportState, setReportState, isReportConfigured }: Re {isReportConfigured && ( - + Ready to run your analysis @@ -5676,8 +6197,12 @@ export default function ReportBuilderPage() { - }>Card view - }>Row view + }> + Card view + + }> + Row view + diff --git a/app/src/pages/reportBuilder/modals/IngredientPickerModal.tsx b/app/src/pages/reportBuilder/modals/IngredientPickerModal.tsx index 8ebdd84a0..ab44fd09a 100644 --- a/app/src/pages/reportBuilder/modals/IngredientPickerModal.tsx +++ b/app/src/pages/reportBuilder/modals/IngredientPickerModal.tsx @@ -1,43 +1,43 @@ -import { useState, Fragment, useRef } from 'react'; +import { Fragment, useRef, useState } from 'react'; import { + IconChartLine, + IconChevronRight, + IconHome, + IconInfoCircle, + IconPlus, + IconScale, + IconSparkles, + IconUsers, +} from '@tabler/icons-react'; +import { useSelector } from 'react-redux'; +import { + ActionIcon, Box, + Button, + Divider, Group, - Text, + Loader, Modal, - Stack, Paper, - Divider, ScrollArea, - Button, - ActionIcon, + Stack, + Text, Tooltip, - Loader, } from '@mantine/core'; -import { - IconPlus, - IconScale, - IconUsers, - IconChartLine, - IconHome, - IconChevronRight, - IconInfoCircle, - IconSparkles, -} from '@tabler/icons-react'; -import { useSelector } from 'react-redux'; +import { MOCK_USER_ID } from '@/constants'; import { colors, spacing } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; -import { PolicyStateProps, PopulationStateProps } from '@/types/pathwayState'; -import { useUserPolicies } from '@/hooks/useUserPolicy'; import { useUserHouseholds } from '@/hooks/useUserHousehold'; -import { MOCK_USER_ID } from '@/constants'; +import { useUserPolicies } from '@/hooks/useUserPolicy'; import { RootState } from '@/store'; -import { getHierarchicalLabels, formatLabelParts } from '@/utils/parameterLabels'; -import { formatParameterValue } from '@/utils/policyTableHelpers'; -import { formatPeriod } from '@/utils/dateUtils'; +import { PolicyStateProps, PopulationStateProps } from '@/types/pathwayState'; import { countPolicyModifications } from '@/utils/countParameterChanges'; -import { FONT_SIZES, INGREDIENT_COLORS, COUNTRY_CONFIG } from '../constants'; -import { IngredientType } from '../types'; +import { formatPeriod } from '@/utils/dateUtils'; +import { formatLabelParts, getHierarchicalLabels } from '@/utils/parameterLabels'; +import { formatParameterValue } from '@/utils/policyTableHelpers'; import { CountryMapIcon } from '../components/shared/CountryMapIcon'; +import { COUNTRY_CONFIG, FONT_SIZES, INGREDIENT_COLORS } from '../constants'; +import { IngredientType } from '../types'; interface IngredientPickerModalProps { isOpen: boolean; @@ -56,7 +56,9 @@ export function IngredientPickerModal({ }: IngredientPickerModalProps) { const renderCount = useRef(0); renderCount.current++; - console.log('[IngredientPickerModal] Render #' + renderCount.current + ' (isOpen=' + isOpen + ')'); + console.log( + '[IngredientPickerModal] Render #' + renderCount.current + ' (isOpen=' + isOpen + ')' + ); const countryId = useCurrentCountry() as 'us' | 'uk'; const countryConfig = COUNTRY_CONFIG[countryId] || COUNTRY_CONFIG.us; @@ -69,18 +71,24 @@ export function IngredientPickerModal({ const getTitle = () => { switch (type) { - case 'policy': return 'Select policy'; - case 'population': return 'Select population'; - case 'dynamics': return 'Configure dynamics'; + case 'policy': + return 'Select policy'; + case 'population': + return 'Select population'; + case 'dynamics': + return 'Configure dynamics'; } }; const getIcon = () => { const iconProps = { size: 20, color: colorConfig.icon }; switch (type) { - case 'policy': return ; - case 'population': return ; - case 'dynamics': return ; + case 'policy': + return ; + case 'population': + return ; + case 'dynamics': + return ; } }; @@ -104,7 +112,11 @@ export function IngredientPickerModal({ onClose(); }; - const handleSelectGeography = (geoId: string, label: string, scope: 'national' | 'subnational') => { + const handleSelectGeography = ( + geoId: string, + label: string, + scope: 'national' | 'subnational' + ) => { onSelect({ label, type: 'geography', @@ -134,7 +146,9 @@ export function IngredientPickerModal({ > {getIcon()} - {getTitle()} + + {getTitle()} + } size="xl" @@ -148,14 +162,35 @@ export function IngredientPickerModal({ {type === 'policy' && ( <> - + - + - Current law - Use existing tax and benefit rules without modifications + + Current law + + + Use existing tax and benefit rules without modifications + @@ -166,260 +201,350 @@ export function IngredientPickerModal({ ) : ( - - {policies?.map((p) => { - // Use association data for display (like Policies page) - const policyId = p.association.policyId.toString(); - const label = p.association.label || `Policy #${policyId}`; - const paramCount = countPolicyModifications(p.policy); // Handles undefined gracefully - const policyParams = p.policy?.parameters || []; - const isExpanded = expandedPolicyId === policyId; + + {policies?.map((p) => { + // Use association data for display (like Policies page) + const policyId = p.association.policyId.toString(); + const label = p.association.label || `Policy #${policyId}`; + const paramCount = countPolicyModifications(p.policy); // Handles undefined gracefully + const policyParams = p.policy?.parameters || []; + const isExpanded = expandedPolicyId === policyId; - return ( - - {/* Main clickable row */} - handleSelectPolicy(policyId, label, paramCount)} - onMouseEnter={(e) => { - e.currentTarget.style.background = colors.gray[50]; - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = 'transparent'; + overflow: 'hidden', + transition: 'all 0.2s ease', + borderColor: isExpanded ? colorConfig.border : undefined, }} > - {/* Policy info - takes remaining space */} - - {label} - - {paramCount} param{paramCount !== 1 ? 's' : ''} changed - - - - {/* Info/expand button - isolated click zone */} - { - e.stopPropagation(); // Prevent selection - setExpandedPolicyId(isExpanded ? null : policyId); + {/* Main clickable row */} + handleSelectPolicy(policyId, label, paramCount)} + onMouseEnter={(e) => { + e.currentTarget.style.background = colors.gray[50]; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = 'transparent'; }} - style={{ marginRight: spacing.sm }} - aria-label={isExpanded ? 'Hide parameter details' : 'Show parameter details'} > - - + {/* Policy info - takes remaining space */} + + + {label} + + + {paramCount} param{paramCount !== 1 ? 's' : ''} changed + + + + {/* Info/expand button - isolated click zone */} + { + e.stopPropagation(); // Prevent selection + setExpandedPolicyId(isExpanded ? null : policyId); + }} + style={{ marginRight: spacing.sm }} + aria-label={ + isExpanded ? 'Hide parameter details' : 'Show parameter details' + } + > + + - {/* Select indicator */} - - + {/* Select indicator */} + + - {/* Expandable parameter details - table-like display */} - - {/* Unified grid for header and data rows */} + {/* Expandable parameter details - table-like display */} - {/* Header row */} - - Parameter - - - Changes - + {/* Header row */} + + Parameter + + + Changes + - {/* Data rows - grouped by parameter */} - {(() => { - // Build grouped list of parameters with their changes - const groupedParams: Array<{ - paramName: string; - label: string; - changes: Array<{ period: string; value: string }>; - }> = []; + {/* Data rows - grouped by parameter */} + {(() => { + // Build grouped list of parameters with their changes + const groupedParams: Array<{ + paramName: string; + label: string; + changes: Array<{ period: string; value: string }>; + }> = []; - policyParams.forEach((param) => { - const paramName = param.name; - const hierarchicalLabels = getHierarchicalLabels(paramName, parameters); - const displayLabel = hierarchicalLabels.length > 0 - ? formatLabelParts(hierarchicalLabels) - : paramName.split('.').pop() || paramName; - const metadata = parameters[paramName]; + policyParams.forEach((param) => { + const paramName = param.name; + const hierarchicalLabels = getHierarchicalLabels( + paramName, + parameters + ); + const displayLabel = + hierarchicalLabels.length > 0 + ? formatLabelParts(hierarchicalLabels) + : paramName.split('.').pop() || paramName; + const metadata = parameters[paramName]; - // Use value intervals directly from the Policy type - const changes = (param.values || []).map((interval) => ({ - period: formatPeriod(interval.startDate, interval.endDate), - value: formatParameterValue(interval.value, metadata?.unit), - })); + // Use value intervals directly from the Policy type + const changes = (param.values || []).map((interval) => ({ + period: formatPeriod(interval.startDate, interval.endDate), + value: formatParameterValue(interval.value, metadata?.unit), + })); - groupedParams.push({ paramName, label: displayLabel, changes }); - }); + groupedParams.push({ paramName, label: displayLabel, changes }); + }); - if (groupedParams.length === 0) { - return ( - <> - - No parameter details available - - - ); - } - - const displayParams = groupedParams.slice(0, 10); - const remainingCount = groupedParams.length - 10; - - return ( - <> - {displayParams.map((param) => ( - - {/* Parameter name cell */} - + - - + + ); + } + + const displayParams = groupedParams.slice(0, 10); + const remainingCount = groupedParams.length - 10; + + return ( + <> + {displayParams.map((param) => ( + + {/* Parameter name cell */} + + - {param.label} - - - - {/* Changes cell - multiple lines */} - + {param.label} + + + + {/* Changes cell - multiple lines */} + + {param.changes.map((change, idx) => ( + + + {change.period}: + {' '} + + {change.value} + + + ))} + + + ))} + {remainingCount > 0 && ( + - {param.changes.map((change, idx) => ( - - - {change.period}: - {' '} - - {change.value} - - - ))} - - - ))} - {remainingCount > 0 && ( - - +{remainingCount} more parameter{remainingCount !== 1 ? 's' : ''} - - )} - - ); - })()} + +{remainingCount} more parameter + {remainingCount !== 1 ? 's' : ''} + + )} + + ); + })()} + - - - ); - })} - {(!policies || policies.length === 0) && No saved policies} - + + ); + })} + {(!policies || policies.length === 0) && ( + + No saved policies + + )} + )} - + )} {type === 'population' && ( <> - handleSelectGeography(countryConfig.nationwideId, countryConfig.nationwideLabel, 'national')}> + + handleSelectGeography( + countryConfig.nationwideId, + countryConfig.nationwideLabel, + 'national' + ) + } + > - + - {countryConfig.nationwideTitle} - {countryConfig.nationwideSubtitle} + + {countryConfig.nationwideTitle} + + + {countryConfig.nationwideSubtitle} + - handleSelectHousehold('sample-household', 'Sample household')}> + handleSelectHousehold('sample-household', 'Sample household')} + > - + - Sample household - Single household simulation + + Sample household + + + Single household simulation + @@ -430,37 +555,75 @@ export function IngredientPickerModal({ ) : ( - - {households?.map((h) => { - // Use association data for display (like Populations page) - const householdId = h.association.householdId.toString(); - const label = h.association.label || `Household #${householdId}`; - return ( - handleSelectHousehold(householdId, label)}> - - {label} - - - - ); - })} - {(!households || households.length === 0) && No saved households} - + + {households?.map((h) => { + // Use association data for display (like Populations page) + const householdId = h.association.householdId.toString(); + const label = h.association.label || `Household #${householdId}`; + return ( + handleSelectHousehold(householdId, label)} + > + + + {label} + + + + + ); + })} + {(!households || households.length === 0) && ( + + No saved households + + )} + )} - + )} {type === 'dynamics' && ( - + - Dynamics coming soon - Dynamic behavioral responses will be available in a future update. + + Dynamics coming soon + + + Dynamic behavioral responses will be available in a future update. + )} diff --git a/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx b/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx index 1bed3a8f8..c81b6a819 100644 --- a/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx +++ b/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx @@ -25,7 +25,7 @@ import { PolicyCreationPayload } from '@/types/payloads'; import { Parameter } from '@/types/subIngredients/parameter'; import { ValueInterval, ValueIntervalCollection } from '@/types/subIngredients/valueInterval'; import { countPolicyModifications } from '@/utils/countParameterChanges'; -import { formatLabelParts, getHierarchicalLabels } from '@/utils/parameterLabels'; +import { formatLabelParts, getHierarchicalLabelsFromTree } from '@/utils/parameterLabels'; import { FONT_SIZES, INGREDIENT_COLORS } from '../constants'; import { modalStyles } from '../styles'; import { BrowseModalTemplate, CreationModeFooter } from './BrowseModalTemplate'; @@ -131,7 +131,7 @@ export function PolicyBrowseModal({ isOpen, onClose, onSelect }: PolicyBrowseMod if (p.label.toLowerCase().includes(query)) return true; const paramDisplayNames = p.parameters .map((param) => { - const hierarchicalLabels = getHierarchicalLabels(param.name, parameters); + const hierarchicalLabels = getHierarchicalLabelsFromTree(param.name, parameterTree); return hierarchicalLabels.length > 0 ? formatLabelParts(hierarchicalLabels) : param.name.split('.').pop() || param.name; @@ -143,7 +143,7 @@ export function PolicyBrowseModal({ isOpen, onClose, onSelect }: PolicyBrowseMod }); } return result; - }, [userPolicies, searchQuery, parameters]); + }, [userPolicies, searchQuery, parameterTree]); // Get policies for current section const displayedPolicies = useMemo(() => { @@ -426,6 +426,7 @@ export function PolicyBrowseModal({ isOpen, onClose, onSelect }: PolicyBrowseMod setDrawerPolicyId(null)} onSelect={() => { if (drawerPolicy) { diff --git a/app/src/pages/reportBuilder/modals/policy/PolicyDetailsDrawer.tsx b/app/src/pages/reportBuilder/modals/policy/PolicyDetailsDrawer.tsx index 45b3c5eea..ad05f724a 100644 --- a/app/src/pages/reportBuilder/modals/policy/PolicyDetailsDrawer.tsx +++ b/app/src/pages/reportBuilder/modals/policy/PolicyDetailsDrawer.tsx @@ -2,14 +2,25 @@ * PolicyDetailsDrawer - Sliding panel showing policy parameter details */ import { Fragment } from 'react'; -import { Box, Group, Stack, Text, ScrollArea, Button, ActionIcon, Tooltip, Transition } from '@mantine/core'; -import { IconX, IconChevronRight } from '@tabler/icons-react'; +import { IconChevronRight, IconX } from '@tabler/icons-react'; +import { + ActionIcon, + Box, + Button, + Group, + ScrollArea, + Stack, + Text, + Tooltip, + Transition, +} from '@mantine/core'; import { colors, spacing } from '@/designTokens'; +import { ParameterTreeNode } from '@/libs/buildParameterTree'; import { ParameterMetadata } from '@/types/metadata/parameterMetadata'; -import { getHierarchicalLabels, formatLabelParts } from '@/utils/parameterLabels'; -import { formatParameterValue } from '@/utils/policyTableHelpers'; -import { formatPeriod } from '@/utils/dateUtils'; import { Parameter } from '@/types/subIngredients/parameter'; +import { formatPeriod } from '@/utils/dateUtils'; +import { formatLabelParts, getHierarchicalLabelsFromTree } from '@/utils/parameterLabels'; +import { formatParameterValue } from '@/utils/policyTableHelpers'; import { FONT_SIZES, INGREDIENT_COLORS } from '../../constants'; interface PolicyDetailsDrawerProps { @@ -21,6 +32,7 @@ interface PolicyDetailsDrawerProps { parameters: Parameter[]; } | null; parameters: Record; + parameterTree: ParameterTreeNode | null | undefined; onClose: () => void; onSelect: () => void; } @@ -28,6 +40,7 @@ interface PolicyDetailsDrawerProps { export function PolicyDetailsDrawer({ policy, parameters, + parameterTree, onClose, onSelect, }: PolicyDetailsDrawerProps) { @@ -79,11 +92,15 @@ export function PolicyDetailsDrawer({ - + {policy.label} - {policy.paramCount} parameter{policy.paramCount !== 1 ? 's' : ''} changed from current law + {policy.paramCount} parameter{policy.paramCount !== 1 ? 's' : ''} changed + from current law @@ -134,36 +151,82 @@ export function PolicyDetailsDrawer({ }> = []; policy.parameters.forEach((param) => { const paramName = param.name; - const hierarchicalLabels = getHierarchicalLabels(paramName, parameters); - const displayLabel = hierarchicalLabels.length > 0 - ? formatLabelParts(hierarchicalLabels) - : paramName.split('.').pop() || paramName; + const hierarchicalLabels = getHierarchicalLabelsFromTree( + paramName, + parameterTree + ); + const displayLabel = + hierarchicalLabels.length > 0 + ? formatLabelParts(hierarchicalLabels) + : paramName.split('.').pop() || paramName; const metadata = parameters[paramName]; const changes = (param.values || []).map((interval) => ({ period: formatPeriod(interval.startDate, interval.endDate), - value: formatParameterValue(interval.value, metadata?.unit ?? undefined), + value: formatParameterValue( + interval.value, + metadata?.unit ?? undefined + ), })); groupedParams.push({ paramName, label: displayLabel, changes }); }); return groupedParams.map((param) => ( - + - + {param.label} - + {param.changes.map((change, idx) => ( - + {change.period} ))} - + {param.changes.map((change, idx) => ( - + {change.value} ))} diff --git a/app/src/tests/unit/utils/policyTableHelpers.test.ts b/app/src/tests/unit/utils/policyTableHelpers.test.ts index f813e90c6..259879e28 100644 --- a/app/src/tests/unit/utils/policyTableHelpers.test.ts +++ b/app/src/tests/unit/utils/policyTableHelpers.test.ts @@ -3,209 +3,203 @@ import { formatParameterValue } from '@/utils/policyTableHelpers'; describe('policyTableHelpers', () => { describe('formatParameterValue', () => { - describe('Integer formatting', () => { - test('given integer with currency-USD unit then formats with one decimal place', () => { - // Given / When - const result = formatParameterValue(1000, 'currency-USD'); - - // Then - expect(result).toBe('$1,000.0'); - }); + describe('default behavior (up to 2 decimals, none for whole numbers)', () => { + describe('Integer formatting', () => { + test('given integer with currency-USD unit then formats with no decimals', () => { + // Given / When + const result = formatParameterValue(1000, 'currency-USD'); - test('given integer with currency-GBP unit then formats with one decimal place', () => { - // Given / When - const result = formatParameterValue(2500, 'currency-GBP'); - - // Then - expect(result).toBe('£2,500.0'); - }); + // Then + expect(result).toBe('$1,000'); + }); - test('given integer with /1 unit then formats as percentage with one decimal place', () => { - // Given / When - const result = formatParameterValue(0.15, '/1'); + test('given integer with currency-GBP unit then formats with no decimals', () => { + // Given / When + const result = formatParameterValue(2500, 'currency-GBP'); - // Then - expect(result).toBe('15.0%'); - }); + // Then + expect(result).toBe('£2,500'); + }); - test('given integer with no unit then formats with one decimal place', () => { - // Given / When - const result = formatParameterValue(5000); + test('given integer with /1 unit then formats as percentage with no decimals', () => { + // Given / When + const result = formatParameterValue(0.15, '/1'); - // Then - expect(result).toBe('5,000.0'); - }); - }); + // Then + expect(result).toBe('15%'); + }); - describe('Decimal formatting', () => { - test('given decimal with currency-USD unit then formats with one decimal place', () => { - // Given / When - const result = formatParameterValue(1234.56, 'currency-USD'); + test('given integer with no unit then formats with no decimals', () => { + // Given / When + const result = formatParameterValue(5000, null); - // Then - expect(result).toBe('$1,234.6'); + // Then + expect(result).toBe('5,000'); + }); }); - test('given decimal with currency-GBP unit then formats with one decimal place', () => { - // Given / When - const result = formatParameterValue(789.45, 'currency-GBP'); + describe('Decimal formatting', () => { + test('given decimal with currency-USD unit then formats with up to 2 decimals', () => { + // Given / When + const result = formatParameterValue(1234.56, 'currency-USD'); - // Then - expect(result).toBe('£789.5'); - }); + // Then + expect(result).toBe('$1,234.56'); + }); - test('given decimal with /1 unit then formats as percentage with one decimal place', () => { - // Given / When - const result = formatParameterValue(0.125, '/1'); + test('given decimal with currency-GBP unit then formats with up to 2 decimals', () => { + // Given / When + const result = formatParameterValue(789.45, 'currency-GBP'); - // Then - expect(result).toBe('12.5%'); - }); + // Then + expect(result).toBe('£789.45'); + }); - test('given decimal with no unit then formats with one decimal place', () => { - // Given / When - const result = formatParameterValue(1234.5); + test('given decimal with /1 unit then formats as percentage with up to 2 decimals', () => { + // Given / When + const result = formatParameterValue(0.125, '/1'); - // Then - expect(result).toBe('1,234.5'); - }); - }); + // Then + expect(result).toBe('12.5%'); + }); - describe('Percentage edge cases', () => { - test('given decimal that becomes integer when multiplied by 100 then formats with one decimal place', () => { - // Given / When - const result = formatParameterValue(0.2, '/1'); // 0.2 * 100 = 20 + test('given decimal with no unit then formats with up to 2 decimals', () => { + // Given / When + const result = formatParameterValue(1234.5, null); - // Then - expect(result).toBe('20.0%'); - }); + // Then + expect(result).toBe('1,234.5'); + }); - test('given decimal that stays decimal when multiplied by 100 then formats with one decimal place', () => { - // Given / When - const result = formatParameterValue(0.125, '/1'); // 0.125 * 100 = 12.5 + test('given value with more than 2 decimals then rounds to 2', () => { + // Given / When + const result = formatParameterValue(1234.567, 'currency-USD'); - // Then - expect(result).toBe('12.5%'); + // Then + expect(result).toBe('$1,234.57'); + }); }); - test('given zero then formats as 0.0%', () => { - // Given / When - const result = formatParameterValue(0, '/1'); + describe('Percentage edge cases', () => { + test('given decimal that becomes integer when multiplied by 100 then formats with no decimals', () => { + // Given / When + const result = formatParameterValue(0.2, '/1'); // 0.2 * 100 = 20 - // Then - expect(result).toBe('0.0%'); - }); + // Then + expect(result).toBe('20%'); + }); - test('given one then formats as 100.0%', () => { - // Given / When - const result = formatParameterValue(1, '/1'); + test('given zero then formats as 0%', () => { + // Given / When + const result = formatParameterValue(0, '/1'); - // Then - expect(result).toBe('100.0%'); - }); - }); + // Then + expect(result).toBe('0%'); + }); - describe('Non-numeric values', () => { - test('given string value then returns string as-is', () => { - // Given / When - const result = formatParameterValue('test'); + test('given one then formats as 100%', () => { + // Given / When + const result = formatParameterValue(1, '/1'); - // Then - expect(result).toBe('test'); + // Then + expect(result).toBe('100%'); + }); }); - test('given boolean value then returns boolean as string', () => { - // Given / When - const result = formatParameterValue(true); + describe('Boolean formatting', () => { + test('given true with bool unit then returns True', () => { + // Given / When + const result = formatParameterValue(true, 'bool'); - // Then - expect(result).toBe('true'); - }); + // Then + expect(result).toBe('True'); + }); - test('given null value then returns null as string', () => { - // Given / When - const result = formatParameterValue(null); + test('given false with bool unit then returns False', () => { + // Given / When + const result = formatParameterValue(false, 'bool'); - // Then - expect(result).toBe('null'); - }); + // Then + expect(result).toBe('False'); + }); - test('given undefined value then returns undefined as string', () => { - // Given / When - const result = formatParameterValue(undefined); + test('given true with abolition unit then returns True', () => { + // Given / When + const result = formatParameterValue(true, 'abolition'); - // Then - expect(result).toBe('undefined'); + // Then + expect(result).toBe('True'); + }); }); - }); - describe('Large numbers', () => { - test('given large integer then formats with commas and one decimal place', () => { - // Given / When - const result = formatParameterValue(1000000, 'currency-USD'); + describe('Null and undefined values', () => { + test('given null value then returns N/A', () => { + // Given / When + const result = formatParameterValue(null, 'currency-USD'); - // Then - expect(result).toBe('$1,000,000.0'); - }); + // Then + expect(result).toBe('N/A'); + }); - test('given large decimal then formats with commas and one decimal place', () => { - // Given / When - const result = formatParameterValue(1234567.89, 'currency-USD'); + test('given undefined value then returns N/A', () => { + // Given / When + const result = formatParameterValue(undefined, 'currency-USD'); - // Then - expect(result).toBe('$1,234,567.9'); + // Then + expect(result).toBe('N/A'); + }); }); - }); - describe('Negative numbers', () => { - test('given negative integer with currency then formats with one decimal place', () => { - // Given / When - const result = formatParameterValue(-1000, 'currency-USD'); + describe('Zero values', () => { + test('given zero with currency-USD then formats as $0', () => { + // Given / When + const result = formatParameterValue(0, 'currency-USD'); - // Then - expect(result).toBe('$-1,000.0'); - }); + // Then + expect(result).toBe('$0'); + }); - test('given negative decimal with currency then formats correctly', () => { - // Given / When - const result = formatParameterValue(-1234.5, 'currency-USD'); + test('given zero with currency-GBP then formats as £0', () => { + // Given / When + const result = formatParameterValue(0, 'currency-GBP'); - // Then - expect(result).toBe('$-1,234.5'); - }); + // Then + expect(result).toBe('£0'); + }); - test('given negative percentage then formats with one decimal place', () => { - // Given / When - const result = formatParameterValue(-0.1, '/1'); + test('given zero with no unit then formats as 0', () => { + // Given / When + const result = formatParameterValue(0, null); - // Then - expect(result).toBe('-10.0%'); + // Then + expect(result).toBe('0'); + }); }); }); - describe('Zero values', () => { - test('given zero with currency-USD then formats as $0.0', () => { + describe('with explicit decimal places', () => { + test('given 2 decimal places then formats with exactly 2 decimals', () => { // Given / When - const result = formatParameterValue(0, 'currency-USD'); + const result = formatParameterValue(1000, 'currency-USD', { decimalPlaces: 2 }); // Then - expect(result).toBe('$0.0'); + expect(result).toBe('$1,000.00'); }); - test('given zero with currency-GBP then formats as £0.0', () => { + test('given 0 decimal places then formats with no decimals', () => { // Given / When - const result = formatParameterValue(0, 'currency-GBP'); + const result = formatParameterValue(1234.56, 'currency-USD', { decimalPlaces: 0 }); // Then - expect(result).toBe('£0.0'); + expect(result).toBe('$1,235'); }); - test('given zero with no unit then formats as 0.0', () => { + test('given 3 decimal places then formats with exactly 3 decimals', () => { // Given / When - const result = formatParameterValue(0); + const result = formatParameterValue(1234.5, 'currency-USD', { decimalPlaces: 3 }); // Then - expect(result).toBe('0.0'); + expect(result).toBe('$1,234.500'); }); }); }); diff --git a/app/src/utils/chartValueUtils.ts b/app/src/utils/chartValueUtils.ts index 9f794d4ea..f59c0021f 100644 --- a/app/src/utils/chartValueUtils.ts +++ b/app/src/utils/chartValueUtils.ts @@ -18,10 +18,11 @@ export interface PlotlyAxisFormat { } /** - * Formats a parameter value for display based on its unit + * Formats a parameter value for display based on its unit. + * By default shows up to 2 decimal places, but none for whole numbers. * @param value - The value to format * @param unit - The unit type (e.g., 'currency-USD', '/1', 'bool') - * @param options - Formatting options (decimal places, include symbol) + * @param options - Formatting options (decimalPlaces forces exact decimals; includeSymbol) * @returns Formatted string representation of the value */ export function formatParameterValue( @@ -29,7 +30,7 @@ export function formatParameterValue( unit: string | null | undefined, options: FormatValueOptions = {} ): string { - const { decimalPlaces = 2, includeSymbol = true } = options; + const { decimalPlaces, includeSymbol = true } = options; // Handle null/undefined if (value === null || value === undefined) { @@ -44,7 +45,15 @@ export function formatParameterValue( // Handle percentage if (unit === '/1') { const percentage = Number(value) * 100; - return `${percentage.toFixed(decimalPlaces)}%`; + if (decimalPlaces !== undefined) { + return `${percentage.toFixed(decimalPlaces)}%`; + } + // Default: up to 2 decimals, none for whole numbers + const formatted = percentage.toLocaleString('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); + return `${formatted}%`; } // Handle currency @@ -53,24 +62,45 @@ export function formatParameterValue( if (currencyUnits.includes(unit || '')) { const symbol = includeSymbol ? '$' : ''; + if (decimalPlaces !== undefined) { + return `${symbol}${Number(value).toLocaleString('en-US', { + minimumFractionDigits: decimalPlaces, + maximumFractionDigits: decimalPlaces, + })}`; + } + // Default: up to 2 decimals, none for whole numbers return `${symbol}${Number(value).toLocaleString('en-US', { - minimumFractionDigits: decimalPlaces, - maximumFractionDigits: decimalPlaces, + minimumFractionDigits: 0, + maximumFractionDigits: 2, })}`; } if (gbpUnits.includes(unit || '')) { const symbol = includeSymbol ? '£' : ''; + if (decimalPlaces !== undefined) { + return `${symbol}${Number(value).toLocaleString('en-GB', { + minimumFractionDigits: decimalPlaces, + maximumFractionDigits: decimalPlaces, + })}`; + } + // Default: up to 2 decimals, none for whole numbers return `${symbol}${Number(value).toLocaleString('en-GB', { - minimumFractionDigits: decimalPlaces, - maximumFractionDigits: decimalPlaces, + minimumFractionDigits: 0, + maximumFractionDigits: 2, })}`; } // Default numeric formatting + if (decimalPlaces !== undefined) { + return Number(value).toLocaleString('en-US', { + minimumFractionDigits: decimalPlaces, + maximumFractionDigits: decimalPlaces, + }); + } + // Default: up to 2 decimals, none for whole numbers return Number(value).toLocaleString('en-US', { minimumFractionDigits: 0, - maximumFractionDigits: decimalPlaces, + maximumFractionDigits: 2, }); } diff --git a/app/src/utils/parameterLabels.ts b/app/src/utils/parameterLabels.ts index 0b174cc4a..8c2cac7a3 100644 --- a/app/src/utils/parameterLabels.ts +++ b/app/src/utils/parameterLabels.ts @@ -1,3 +1,4 @@ +import { ParameterTreeNode } from '@/libs/buildParameterTree'; import { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; /** @@ -71,3 +72,60 @@ export function buildCompactLabel(labels: string[]): { export function formatLabelParts(parts: string[]): string { return parts.join(' → '); } + +/** + * Builds a flat map of parameter path -> label from the parameterTree. + * This includes both intermediate nodes and leaf parameters. + * Used when the parameters collection doesn't include intermediate paths. + */ +export function buildLabelMapFromTree( + tree: ParameterTreeNode | null | undefined +): Record { + const labelMap: Record = {}; + + if (!tree) return labelMap; + + function traverse(node: ParameterTreeNode): void { + // Add this node's path -> label mapping + if (node.name && node.label) { + labelMap[node.name] = node.label; + } + + // Recursively process children + if (node.children) { + for (const child of node.children) { + traverse(child); + } + } + } + + traverse(tree); + return labelMap; +} + +/** + * Gets hierarchical labels for a parameter path using the parameterTree. + * This is used when the parameters collection doesn't contain intermediate paths. + * Starts from the second level (skipping "gov") and collects all available labels. + */ +export function getHierarchicalLabelsFromTree( + paramName: string, + parameterTree: ParameterTreeNode | null | undefined +): string[] { + if (!parameterTree) return []; + + const parts = splitParameterPath(paramName); + const paths = buildCumulativePaths(parts); + + // Build a label map from the tree + const labelMap = buildLabelMapFromTree(parameterTree); + + // Skip the first path ("gov") and collect labels + const labels = paths + .slice(1) + .map((path) => labelMap[path]) + .filter((label): label is string => Boolean(label)); + + // Capitalize the first character of each label, leaving the rest unchanged + return labels.map(capitalizeFirst); +} diff --git a/app/src/utils/policyTableHelpers.ts b/app/src/utils/policyTableHelpers.ts index 1091b6898..bae810a0e 100644 --- a/app/src/utils/policyTableHelpers.ts +++ b/app/src/utils/policyTableHelpers.ts @@ -2,10 +2,14 @@ import { getParamDefinitionDate } from '@/constants'; import { Policy } from '@/types/ingredients/Policy'; import { ParameterMetadata } from '@/types/metadata/parameterMetadata'; import { ValueIntervalCollection } from '@/types/subIngredients/valueInterval'; +import { formatParameterValue } from './chartValueUtils'; export { determinePolicyColumns } from './policyComparison'; export type { PolicyColumn } from './policyComparison'; +// Re-export formatParameterValue from chartValueUtils for backwards compatibility +export { formatParameterValue }; + /** * Extract baseline and reform policies from a policies array * Assumes policies array is [baseline, reform] for both economy and household reports @@ -68,38 +72,6 @@ export function getParameterValueFromPolicy( return formatParameterValue(value, unit); } -/** - * Format a parameter value with appropriate unit formatting - * Always uses 1 decimal place for consistency across all columns - */ -export function formatParameterValue(value: any, unit?: string): string { - if (typeof value === 'number') { - const DECIMAL_PRECISION = 1; - - if (unit === '/1') { - const percentValue = value * 100; - return `${percentValue.toFixed(DECIMAL_PRECISION)}%`; - } - if (unit === 'currency-USD') { - return `$${value.toLocaleString('en-US', { - minimumFractionDigits: DECIMAL_PRECISION, - maximumFractionDigits: DECIMAL_PRECISION, - })}`; - } - if (unit === 'currency-GBP') { - return `£${value.toLocaleString('en-GB', { - minimumFractionDigits: DECIMAL_PRECISION, - maximumFractionDigits: DECIMAL_PRECISION, - })}`; - } - return value.toLocaleString('en-US', { - minimumFractionDigits: DECIMAL_PRECISION, - maximumFractionDigits: DECIMAL_PRECISION, - }); - } - return String(value); -} - /** * Gets the current law parameter value for a given parameter on a specific date * @param paramName - The parameter identifier (e.g., "gov.hmrc.income_tax.rates.uk[0].rate") From a10b646bc0a7d5680c4bd32428be4a42d69a7fc4 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 9 Jan 2026 18:00:16 +0300 Subject: [PATCH 33/73] feat: Modify policy creation modal --- app/src/pages/ReportBuilder.page.tsx | 801 +----------------- .../pages/reportBuilder/ReportBuilderPage.tsx | 52 +- app/src/pages/reportBuilder/constants.ts | 4 +- .../modals/BrowseModalTemplate.tsx | 62 +- .../modals/PolicyBrowseModal.tsx | 59 +- .../modals/PolicyCreationModal.tsx | 736 ++++------------ .../modals/policy/PolicyCreationContent.tsx | 250 +++++- .../modals/policyCreation/ChangesCard.tsx | 78 ++ .../policyCreation/EmptyParameterState.tsx | 48 ++ .../policyCreation/HistoricalValuesCard.tsx | 45 + .../policyCreation/ParameterHeaderCard.tsx | 30 + .../policyCreation/ParameterSidebar.tsx | 119 +++ .../policyCreation/PolicyCreationHeader.tsx | 143 ++++ .../policyCreation/PolicyNameEditor.tsx | 105 +++ .../modals/policyCreation/ValueSetterCard.tsx | 93 ++ .../modals/policyCreation/index.ts | 22 + .../modals/policyCreation/types.ts | 103 +++ .../valueSelectors/DateValueSelectorV6.tsx | 115 +++ .../valueSelectors/DefaultValueSelectorV6.tsx | 97 +++ .../MultiYearValueSelectorV6.tsx | 114 +++ .../valueSelectors/YearlyValueSelectorV6.tsx | 119 +++ .../policyCreation/valueSelectors/index.ts | 21 + app/src/pages/reportBuilder/types.ts | 8 +- .../HistoricalValues.tsx | 5 +- 24 files changed, 1768 insertions(+), 1461 deletions(-) create mode 100644 app/src/pages/reportBuilder/modals/policyCreation/ChangesCard.tsx create mode 100644 app/src/pages/reportBuilder/modals/policyCreation/EmptyParameterState.tsx create mode 100644 app/src/pages/reportBuilder/modals/policyCreation/HistoricalValuesCard.tsx create mode 100644 app/src/pages/reportBuilder/modals/policyCreation/ParameterHeaderCard.tsx create mode 100644 app/src/pages/reportBuilder/modals/policyCreation/ParameterSidebar.tsx create mode 100644 app/src/pages/reportBuilder/modals/policyCreation/PolicyCreationHeader.tsx create mode 100644 app/src/pages/reportBuilder/modals/policyCreation/PolicyNameEditor.tsx create mode 100644 app/src/pages/reportBuilder/modals/policyCreation/ValueSetterCard.tsx create mode 100644 app/src/pages/reportBuilder/modals/policyCreation/index.ts create mode 100644 app/src/pages/reportBuilder/modals/policyCreation/types.ts create mode 100644 app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/DateValueSelectorV6.tsx create mode 100644 app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/DefaultValueSelectorV6.tsx create mode 100644 app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/MultiYearValueSelectorV6.tsx create mode 100644 app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/YearlyValueSelectorV6.tsx create mode 100644 app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/index.ts diff --git a/app/src/pages/ReportBuilder.page.tsx b/app/src/pages/ReportBuilder.page.tsx index 71de5a62a..83b7e77a5 100644 --- a/app/src/pages/ReportBuilder.page.tsx +++ b/app/src/pages/ReportBuilder.page.tsx @@ -117,6 +117,7 @@ import { RegionOption, } from '@/utils/regionStrategies'; import { capitalize } from '@/utils/stringUtils'; +import { PolicyCreationModal } from './reportBuilder/modals'; // ============================================================================ // TYPES @@ -4376,805 +4377,7 @@ function PopulationBrowseModal({ ); } -// ============================================================================ -// POLICY CREATION MODAL - Single-step integrated policy builder -// ============================================================================ - -interface PolicyCreationModalProps { - isOpen: boolean; - onClose: () => void; - onPolicyCreated: (policy: PolicyStateProps) => void; - simulationIndex: number; -} - -function PolicyCreationModal({ - isOpen, - onClose, - onPolicyCreated, - simulationIndex, -}: PolicyCreationModalProps) { - const countryId = useCurrentCountry() as 'us' | 'uk'; - - // Get metadata from Redux state - const { - parameterTree, - parameters, - loading: metadataLoading, - } = useSelector((state: RootState) => state.metadata); - const { minDate, maxDate } = useSelector(getDateRange); - - // Local policy state - const [policyLabel, setPolicyLabel] = useState('New policy'); - const [policyParameters, setPolicyParameters] = useState([]); - const [isEditingLabel, setIsEditingLabel] = useState(false); - - // Parameter selection state - const [selectedParam, setSelectedParam] = useState(null); - const [expandedMenuItems, setExpandedMenuItems] = useState>(new Set()); - - // Value setter state - const [valueSetterMode, setValueSetterMode] = useState(ValueSetterMode.DEFAULT); - const [intervals, setIntervals] = useState([]); - const [startDate, setStartDate] = useState('2025-01-01'); - const [endDate, setEndDate] = useState('2025-12-31'); - - // Changes panel expanded state - const [changesExpanded, setChangesExpanded] = useState(false); - - // Parameter search state - const [parameterSearch, setParameterSearch] = useState(''); - - // API hook for creating policy - const { createPolicy, isPending: isCreating } = useCreatePolicy(policyLabel || undefined); - - // Reset state when modal opens - useEffect(() => { - if (isOpen) { - setPolicyLabel('New policy'); - setPolicyParameters([]); - setSelectedParam(null); - setExpandedMenuItems(new Set()); - setIntervals([]); - setParameterSearch(''); - } - }, [isOpen]); - - // Create local policy state object for components - const localPolicy: PolicyStateProps = useMemo( - () => ({ - label: policyLabel, - parameters: policyParameters, - }), - [policyLabel, policyParameters] - ); - - // Count modifications - const modificationCount = countPolicyModifications(localPolicy); - - // Get modified parameter data for the Changes section - grouped by parameter with multiple changes each - const modifiedParams = useMemo(() => { - return policyParameters.map((p) => { - const metadata = parameters[p.name]; - - // Get full hierarchical label for the parameter (no compacting) - same as report builder - const hierarchicalLabels = getHierarchicalLabels(p.name, parameters); - const displayLabel = - hierarchicalLabels.length > 0 - ? formatLabelParts(hierarchicalLabels) - : p.name.split('.').pop() || p.name; - - // Build changes array for this parameter - const changes = p.values.map((interval) => ({ - period: formatPeriod(interval.startDate, interval.endDate), - value: formatParameterValue(interval.value, metadata?.unit), - })); - - return { - paramName: p.name, - label: displayLabel, - changes, - }; - }); - }, [policyParameters, parameters]); - - // Build flat list of all searchable parameters for autocomplete - const searchableParameters = useMemo(() => { - if (!parameters) return []; - - return Object.values(parameters) - .filter( - (param): param is ParameterMetadata => - param.type === 'parameter' && !!param.label && !param.parameter.includes('pycache') - ) - .map((param) => { - const hierarchicalLabels = getHierarchicalLabels(param.parameter, parameters); - const fullLabel = - hierarchicalLabels.length > 0 ? formatLabelParts(hierarchicalLabels) : param.label; - return { - value: param.parameter, - label: fullLabel, - }; - }) - .sort((a, b) => a.label.localeCompare(b.label)); - }, [parameters]); - - // Handle search selection - expand tree path and select parameter - const handleSearchSelect = useCallback( - (paramName: string) => { - const param = parameters[paramName]; - if (!param || param.type !== 'parameter') return; - - // Expand all parent nodes in the tree path - const pathParts = paramName.split('.'); - const newExpanded = new Set(expandedMenuItems); - let currentPath = ''; - for (let i = 0; i < pathParts.length - 1; i++) { - currentPath = currentPath ? `${currentPath}.${pathParts[i]}` : pathParts[i]; - newExpanded.add(currentPath); - } - setExpandedMenuItems(newExpanded); - - // Select the parameter - setSelectedParam(param); - setIntervals([]); - setValueSetterMode(ValueSetterMode.DEFAULT); - - // Clear search - setParameterSearch(''); - }, - [parameters, expandedMenuItems] - ); - - // Handle policy update from value setter - const handlePolicyUpdate = useCallback((updatedPolicy: PolicyStateProps) => { - setPolicyParameters(updatedPolicy.parameters || []); - }, []); - - // Handle menu item click - const handleMenuItemClick = useCallback( - (paramName: string) => { - const param = parameters[paramName]; - if (param && param.type === 'parameter') { - setSelectedParam(param); - // Reset value setter state when selecting new parameter - setIntervals([]); - setValueSetterMode(ValueSetterMode.DEFAULT); - } - // Toggle expansion for non-leaf nodes - setExpandedMenuItems((prev) => { - const newSet = new Set(prev); - if (newSet.has(paramName)) { - newSet.delete(paramName); - } else { - newSet.add(paramName); - } - return newSet; - }); - }, - [parameters] - ); - - // Handle value submission - const handleValueSubmit = useCallback(() => { - if (!selectedParam || intervals.length === 0) return; - - const updatedParameters = [...policyParameters]; - let existingParam = updatedParameters.find((p) => p.name === selectedParam.parameter); - - if (!existingParam) { - existingParam = { name: selectedParam.parameter, values: [] }; - updatedParameters.push(existingParam); - } - - // Use ValueIntervalCollection to properly merge intervals - const paramCollection = new ValueIntervalCollection(existingParam.values); - intervals.forEach((interval) => { - paramCollection.addInterval(interval); - }); - - existingParam.values = paramCollection.getIntervals(); - setPolicyParameters(updatedParameters); - setIntervals([]); - }, [selectedParam, intervals, policyParameters]); - - // Handle policy creation - const handleCreatePolicy = useCallback(async () => { - if (!policyLabel.trim()) { - return; - } - - const policyData: Partial = { - parameters: policyParameters, - }; - - const payload: PolicyCreationPayload = PolicyAdapter.toCreationPayload(policyData as Policy); - - try { - const result = await createPolicy(payload); - const createdPolicy: PolicyStateProps = { - id: result.result.policy_id, - label: policyLabel, - parameters: policyParameters, - }; - onPolicyCreated(createdPolicy); - onClose(); - } catch (error) { - console.error('Failed to create policy:', error); - } - }, [policyLabel, policyParameters, createPolicy, onPolicyCreated, onClose]); - - // Render nested menu recursively - memoized to prevent expensive re-renders - const renderMenuItems = useCallback( - (items: ParameterTreeNode[]): React.ReactNode => { - return items - .filter((item) => !item.name.includes('pycache')) - .map((item) => ( - handleMenuItemClick(item.name)} - childrenOffset={16} - style={{ - borderRadius: spacing.radius.sm, - }} - > - {item.children && expandedMenuItems.has(item.name) && renderMenuItems(item.children)} - - )); - }, - [selectedParam?.parameter, expandedMenuItems, handleMenuItemClick] - ); - - // Memoize the rendered tree to avoid expensive re-renders on unrelated state changes - const renderedMenuTree = useMemo(() => { - if (metadataLoading || !parameterTree) return null; - return renderMenuItems(parameterTree.children || []); - }, [metadataLoading, parameterTree, renderMenuItems]); - - // Get base and reform values for chart - const getChartValues = () => { - if (!selectedParam) return { baseValues: null, reformValues: null }; - - const baseValues = new ValueIntervalCollection(selectedParam.values as ValuesList); - const reformValues = new ValueIntervalCollection(baseValues); - - const paramToChart = policyParameters.find((p) => p.name === selectedParam.parameter); - if (paramToChart && paramToChart.values && paramToChart.values.length > 0) { - const userIntervals = new ValueIntervalCollection(paramToChart.values as ValuesList); - for (const interval of userIntervals.getIntervals()) { - reformValues.addInterval(interval); - } - } - - return { baseValues, reformValues }; - }; - - const { baseValues, reformValues } = getChartValues(); - const colorConfig = INGREDIENT_COLORS.policy; - - const ValueSetterToRender = ValueSetterComponents[valueSetterMode]; - - // Dock styles matching ReportMetaPanel - const dockStyles = { - dock: { - background: 'rgba(255, 255, 255, 0.95)', - backdropFilter: 'blur(20px) saturate(180%)', - WebkitBackdropFilter: 'blur(20px) saturate(180%)', - borderRadius: spacing.radius.lg, - border: `1px solid ${modificationCount > 0 ? colorConfig.border : colors.border.light}`, - boxShadow: - modificationCount > 0 - ? `0 4px 20px rgba(0, 0, 0, 0.08), 0 0 0 1px ${colorConfig.border}` - : `0 2px 12px ${colors.shadow.light}`, - padding: `${spacing.sm} ${spacing.lg}`, - transition: 'all 0.3s ease', - margin: spacing.md, - marginBottom: 0, - }, - divider: { - width: '1px', - height: '24px', - background: colors.gray[200], - flexShrink: 0, - }, - changesPanel: { - background: colors.white, - borderRadius: spacing.radius.md, - border: `1px solid ${colors.border.light}`, - marginTop: spacing.sm, - overflow: 'hidden', - }, - }; - - return ( - - - {/* Left side: Policy icon and name */} - - {/* Policy icon */} - - - - - {/* Editable policy name */} - - {isEditingLabel ? ( - setPolicyLabel(e.currentTarget.value)} - onBlur={() => setIsEditingLabel(false)} - onKeyDown={(e) => { - if (e.key === 'Enter') setIsEditingLabel(false); - if (e.key === 'Escape') setIsEditingLabel(false); - }} - autoFocus - size="xs" - style={{ width: 250 }} - styles={{ - input: { - fontFamily: typography.fontFamily.primary, - fontWeight: 600, - fontSize: FONT_SIZES.normal, - border: 'none', - background: 'transparent', - padding: 0, - }, - }} - /> - ) : ( - <> - - {policyLabel || 'New policy'} - - setIsEditingLabel(true)} - style={{ flexShrink: 0 }} - > - - - - )} - - - - {/* Right side: Modification count, View changes, Close */} - - {/* Modification count */} - - {modificationCount > 0 ? ( - <> - - - {modificationCount} parameter{modificationCount !== 1 ? 's' : ''} modified - - - ) : ( - - No changes yet - - )} - - - {/* Divider */} - - - {/* View Changes button */} - setChangesExpanded(!changesExpanded)} - style={{ - display: 'flex', - alignItems: 'center', - gap: spacing.xs, - padding: `${spacing.xs} ${spacing.sm}`, - borderRadius: spacing.radius.md, - background: changesExpanded ? colorConfig.bg : 'transparent', - border: `1px solid ${changesExpanded ? colorConfig.border : 'transparent'}`, - transition: 'all 0.1s ease', - }} - > - - View changes - - - - - {/* Close button */} - - - - - - - {/* Expandable changes panel */} - {changesExpanded && ( - - - {modifiedParams.length === 0 ? ( - - - No parameters have been modified yet. Select a parameter from the menu to make - changes. - - - ) : ( - <> - {/* Header row */} - - - Parameter - - - Changes - - - {/* Data rows - one per parameter with multiple change lines */} - - {modifiedParams.map((param) => ( - { - const metadata = parameters[param.paramName]; - if (metadata) { - setSelectedParam(metadata); - setChangesExpanded(false); - } - }} - style={{ - display: 'grid', - gridTemplateColumns: '1fr 180px', - gap: spacing.md, - padding: `${spacing.sm} ${spacing.md}`, - borderBottom: `1px solid ${colors.border.light}`, - background: - selectedParam?.parameter === param.paramName - ? colorConfig.bg - : colors.white, - alignItems: 'start', - }} - > - - {param.label} - - - {param.changes.map((change, idx) => ( - - - {change.period}: - {' '} - - {change.value} - - - ))} - - - ))} - - - )} - - - )} - - } - > - {/* Main content area */} - - {/* Left Sidebar - Parameter Tree */} - - {/* Parameter Tree */} - - - - PARAMETERS - - } - styles={{ - input: { - fontSize: FONT_SIZES.small, - height: 32, - minHeight: 32, - }, - dropdown: { - maxHeight: 300, - }, - option: { - fontSize: FONT_SIZES.small, - padding: `${spacing.xs} ${spacing.sm}`, - }, - }} - size="xs" - /> - - - - {metadataLoading || !parameterTree ? ( - - - - - - ) : ( - renderedMenuTree - )} - - - - - - {/* Main Content - Parameter Editor */} - - {!selectedParam ? ( - - - - - - - Select a parameter from the menu to modify its value for your policy reform. - - - - ) : ( - - - {/* Parameter Header */} - - - {capitalize(selectedParam.label || 'Label unavailable')} - - {selectedParam.description && ( - - {selectedParam.description} - - )} - - - {/* Value Setter */} - - - - Set new value - - - - - - - { - setIntervals([]); - setValueSetterMode(mode); - }} - /> - - - - - - {/* Historical Values Chart */} - {baseValues && reformValues && ( - - - - )} - - - )} - - - - {/* Footer */} - - - - - - - - - - ); -} +// PolicyCreationModal is imported from ./reportBuilder/modals // ============================================================================ // SIMULATION CANVAS diff --git a/app/src/pages/reportBuilder/ReportBuilderPage.tsx b/app/src/pages/reportBuilder/ReportBuilderPage.tsx index 55ab83dd3..194dc5caf 100644 --- a/app/src/pages/reportBuilder/ReportBuilderPage.tsx +++ b/app/src/pages/reportBuilder/ReportBuilderPage.tsx @@ -11,18 +11,17 @@ * - Row view: Stacked horizontal rows */ -import { useState, useEffect, useRef, useLayoutEffect } from 'react'; -import { Box, Tabs } from '@mantine/core'; -import { IconLayoutColumns, IconRowInsertBottom } from '@tabler/icons-react'; - +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { IconLayoutColumns, IconPalette, IconRowInsertBottom } from '@tabler/icons-react'; +import { Link } from 'react-router-dom'; +import { Box, Group, Tabs, Text } from '@mantine/core'; +import { CURRENT_YEAR } from '@/constants'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { initializeSimulationState } from '@/utils/pathwayState/initializeSimulationState'; -import { CURRENT_YEAR } from '@/constants'; - -import type { ReportBuilderState, IngredientPickerState, ViewMode } from './types'; +import { ReportMetaPanel, SimulationCanvas } from './components'; import { COUNTRY_CONFIG } from './constants'; import { styles } from './styles'; -import { SimulationCanvas, ReportMetaPanel } from './components'; +import type { IngredientPickerState, ReportBuilderState, ViewMode } from './types'; export default function ReportBuilderPage() { const renderCount = useRef(0); @@ -117,8 +116,12 @@ export default function ReportBuilderPage() { - }>Card view - }>Row view + }> + Card view + + }> + Row view + @@ -129,6 +132,35 @@ export default function ReportBuilderPage() { setPickerState={setPickerState} viewMode={viewMode} /> + + {/* Dev tools - mockup links */} + + + Development + + + + + Parameter setter mockups + + + ); } diff --git a/app/src/pages/reportBuilder/constants.ts b/app/src/pages/reportBuilder/constants.ts index 62834c4dc..2aa9d0cd4 100644 --- a/app/src/pages/reportBuilder/constants.ts +++ b/app/src/pages/reportBuilder/constants.ts @@ -104,7 +104,7 @@ export const SAMPLE_POPULATIONS = getSamplePopulations('us'); export const BROWSE_MODAL_CONFIG = { width: '90vw', maxWidth: '1400px', - height: '85vh', - maxHeight: '800px', + height: '92vh', + maxHeight: '1300px', sidebarWidth: 220, }; diff --git a/app/src/pages/reportBuilder/modals/BrowseModalTemplate.tsx b/app/src/pages/reportBuilder/modals/BrowseModalTemplate.tsx index 7313c2344..3f60b6be3 100644 --- a/app/src/pages/reportBuilder/modals/BrowseModalTemplate.tsx +++ b/app/src/pages/reportBuilder/modals/BrowseModalTemplate.tsx @@ -36,6 +36,7 @@ export function BrowseModalTemplate({ headerIcon, headerTitle, headerSubtitle, + headerRightContent, colorConfig, sidebarSections, renderSidebar, @@ -43,6 +44,7 @@ export function BrowseModalTemplate({ renderMainContent, statusHeader, footer, + contentPadding = spacing.lg, }: BrowseModalTemplateProps) { // Render standard sidebar sections const renderStandardSidebar = (sections: BrowseModalSidebarSection[]) => ( @@ -89,29 +91,38 @@ export function BrowseModalTemplate({ opened={isOpen} onClose={onClose} title={ - - - {headerIcon} - - - - {headerTitle} - - - {headerSubtitle} - - + + + + {headerIcon} + + + {typeof headerTitle === 'string' ? ( + + {headerTitle} + + ) : ( + headerTitle + )} + {headerSubtitle && ( + + {headerSubtitle} + + )} + + + {headerRightContent} } size={BROWSE_MODAL_CONFIG.width} @@ -130,6 +141,9 @@ export function BrowseModalTemplate({ paddingLeft: spacing.xl, paddingRight: spacing.xl, }, + title: { + flex: 1, + }, body: { padding: 0, flex: 1, @@ -170,7 +184,7 @@ export function BrowseModalTemplate({ {statusHeader} {/* Main Content */} - + {renderMainContent()} diff --git a/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx b/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx index c81b6a819..f85b88395 100644 --- a/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx +++ b/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx @@ -8,7 +8,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { IconFolder, IconPlus, IconScale, IconUsers } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; -import { Box, Divider, ScrollArea, Stack, Text, UnstyledButton } from '@mantine/core'; +import { Box, Divider, Group, ScrollArea, Stack, Text, UnstyledButton } from '@mantine/core'; +import { EditableLabel } from '../components/EditableLabel'; import { PolicyAdapter } from '@/adapters'; import { MOCK_USER_ID } from '@/constants'; import { colors, spacing, typography } from '@/designTokens'; @@ -34,7 +35,6 @@ import { PolicyCreationContent, PolicyDetailsDrawer, PolicyParameterTree, - PolicyStatusHeader, } from './policy'; interface PolicyBrowseModalProps { @@ -391,6 +391,7 @@ export function PolicyBrowseModal({ isOpen, onClose, onSelect }: PolicyBrowseMod localPolicy={localPolicy} policyLabel={policyLabel} policyParameters={policyParameters} + setPolicyParameters={setPolicyParameters} minDate={minDate} maxDate={maxDate} intervals={intervals} @@ -446,31 +447,54 @@ export function PolicyBrowseModal({ isOpen, onClose, onSelect }: PolicyBrowseMod (performance.now() - renderStart).toFixed(2) + 'ms' ); + // Custom header title for creation mode - just the editable label + const creationModeHeaderTitle = ( + + ); + + // Modification count for right side of header + const creationModeHeaderRight = ( + + {modificationCount > 0 ? ( + <> + + + {modificationCount} parameter{modificationCount !== 1 ? 's' : ''} modified + + + ) : ( + + No changes yet + + )} + + ); + return ( } - headerTitle={isCreationMode ? 'Create policy' : 'Select policy'} - headerSubtitle={ - isCreationMode - ? 'Configure parameters for your new policy' - : 'Choose an existing policy or create a new one' - } + headerTitle={isCreationMode ? creationModeHeaderTitle : 'Select policy'} + headerSubtitle={isCreationMode ? undefined : 'Choose an existing policy or create a new one'} + headerRightContent={isCreationMode ? creationModeHeaderRight : undefined} colorConfig={colorConfig} sidebarSections={isCreationMode ? undefined : browseSidebarSections} renderSidebar={isCreationMode ? renderCreationSidebar : undefined} sidebarWidth={isCreationMode ? 280 : undefined} renderMainContent={renderMainContent} - statusHeader={ - isCreationMode ? ( - - ) : undefined - } footer={ isCreationMode ? ( ) : undefined } + contentPadding={isCreationMode ? 0 : undefined} /> ); } diff --git a/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx b/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx index 20b308416..d744dfe40 100644 --- a/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx +++ b/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx @@ -7,55 +7,41 @@ * - Policy creation with API integration */ -import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; -import { - Modal, - Box, - Group, - Stack, - Text, - TextInput, - Button, - UnstyledButton, - Divider, - ScrollArea, - Skeleton, - NavLink, - Title, - ActionIcon, - Autocomplete, -} from '@mantine/core'; -import { - IconScale, - IconPencil, - IconChevronRight, - IconX, - IconSearch, -} from '@tabler/icons-react'; - -import { colors, spacing, typography } from '@/designTokens'; +import { Box, Button, Group, Modal, Stack } from '@mantine/core'; +import { PolicyAdapter } from '@/adapters'; +import { colors, spacing } from '@/designTokens'; +import { useCreatePolicy } from '@/hooks/useCreatePolicy'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { getDateRange, selectSearchableParameters } from '@/libs/metadataUtils'; +import { ValueSetterMode } from '@/pathways/report/components/valueSetters'; import { RootState } from '@/store'; -import { useCreatePolicy } from '@/hooks/useCreatePolicy'; -import { PolicyAdapter } from '@/adapters'; -import { ParameterTreeNode } from '@/types/metadata'; +import { Policy } from '@/types/ingredients/Policy'; import { ParameterMetadata } from '@/types/metadata/parameterMetadata'; import { PolicyStateProps } from '@/types/pathwayState'; -import { Policy } from '@/types/ingredients/Policy'; import { PolicyCreationPayload } from '@/types/payloads'; import { Parameter } from '@/types/subIngredients/parameter'; -import { ValueInterval, ValueIntervalCollection, ValuesList } from '@/types/subIngredients/valueInterval'; -import { getDateRange, selectSearchableParameters } from '@/libs/metadataUtils'; -import { getHierarchicalLabels, formatLabelParts } from '@/utils/parameterLabels'; -import { formatParameterValue } from '@/utils/policyTableHelpers'; -import { formatPeriod } from '@/utils/dateUtils'; +import { + ValueInterval, + ValueIntervalCollection, + ValuesList, +} from '@/types/subIngredients/valueInterval'; import { countPolicyModifications } from '@/utils/countParameterChanges'; -import { capitalize } from '@/utils/stringUtils'; -import { ValueSetterComponents, ValueSetterMode, ModeSelectorButton } from '@/pathways/report/components/valueSetters'; -import HistoricalValues from '@/pathways/report/components/policyParameterSelector/HistoricalValues'; - -import { FONT_SIZES, INGREDIENT_COLORS } from '../constants'; +import { formatPeriod } from '@/utils/dateUtils'; +import { formatLabelParts, getHierarchicalLabels } from '@/utils/parameterLabels'; +import { formatParameterValue } from '@/utils/policyTableHelpers'; +import { + ChangesCard, + EmptyParameterState, + HistoricalValuesCard, + ModifiedParam, + ParameterHeaderCard, + ParameterSidebar, + PolicyCreationHeader, + ValueSetterCard, +} from './policyCreation'; interface PolicyCreationModalProps { isOpen: boolean; @@ -73,14 +59,16 @@ export function PolicyCreationModal({ const renderCount = useRef(0); renderCount.current++; const renderStart = performance.now(); - console.log('[PolicyCreationModal] Render #' + renderCount.current + ' START (isOpen=' + isOpen + ')'); + console.log(`[PolicyCreationModal] Render #${renderCount.current} START (isOpen=${isOpen})`); const countryId = useCurrentCountry() as 'us' | 'uk'; // Get metadata from Redux state - const { parameterTree, parameters, loading: metadataLoading } = useSelector( - (state: RootState) => state.metadata - ); + const { + parameterTree, + parameters, + loading: metadataLoading, + } = useSelector((state: RootState) => state.metadata); const { minDate, maxDate } = useSelector(getDateRange); // Local policy state @@ -98,9 +86,6 @@ export function PolicyCreationModal({ const [startDate, setStartDate] = useState('2025-01-01'); const [endDate, setEndDate] = useState('2025-12-31'); - // Changes panel expanded state - const [changesExpanded, setChangesExpanded] = useState(false); - // Parameter search state const [parameterSearch, setParameterSearch] = useState(''); @@ -124,26 +109,27 @@ export function PolicyCreationModal({ }, [isOpen]); // Create local policy state object for components - const localPolicy: PolicyStateProps = useMemo(() => ({ - label: policyLabel, - parameters: policyParameters, - }), [policyLabel, policyParameters]); + const localPolicy: PolicyStateProps = useMemo( + () => ({ + label: policyLabel, + parameters: policyParameters, + }), + [policyLabel, policyParameters] + ); // Count modifications const modificationCount = countPolicyModifications(localPolicy); - // Get modified parameter data for the Changes section - grouped by parameter with multiple changes each - const modifiedParams = useMemo(() => { - return policyParameters.map(p => { + // Get modified parameter data for the Changes section + const modifiedParams: ModifiedParam[] = useMemo(() => { + return policyParameters.map((p) => { const metadata = parameters[p.name]; - - // Get full hierarchical label for the parameter (no compacting) - same as report builder const hierarchicalLabels = getHierarchicalLabels(p.name, parameters); - const displayLabel = hierarchicalLabels.length > 0 - ? formatLabelParts(hierarchicalLabels) - : p.name.split('.').pop() || p.name; + const displayLabel = + hierarchicalLabels.length > 0 + ? formatLabelParts(hierarchicalLabels) + : p.name.split('.').pop() || p.name; - // Build changes array for this parameter const changes = p.values.map((interval) => ({ period: formatPeriod(interval.startDate, interval.endDate), value: formatParameterValue(interval.value, metadata?.unit), @@ -157,69 +143,82 @@ export function PolicyCreationModal({ }); }, [policyParameters, parameters]); - // Get searchable parameters from memoized selector (computed once when metadata loads) + // Get searchable parameters from memoized selector const searchableParameters = useSelector(selectSearchableParameters); // Handle search selection - expand tree path and select parameter - const handleSearchSelect = useCallback((paramName: string) => { - const param = parameters[paramName]; - if (!param || param.type !== 'parameter') return; - - // Expand all parent nodes in the tree path - const pathParts = paramName.split('.'); - const newExpanded = new Set(expandedMenuItems); - let currentPath = ''; - for (let i = 0; i < pathParts.length - 1; i++) { - currentPath = currentPath ? `${currentPath}.${pathParts[i]}` : pathParts[i]; - newExpanded.add(currentPath); - } - setExpandedMenuItems(newExpanded); - - // Select the parameter - setSelectedParam(param); - setIntervals([]); - setValueSetterMode(ValueSetterMode.DEFAULT); - - // Clear search - setParameterSearch(''); - }, [parameters, expandedMenuItems]); + const handleSearchSelect = useCallback( + (paramName: string) => { + const param = parameters[paramName]; + if (!param || param.type !== 'parameter') { + return; + } - // Handle menu item click - const handleMenuItemClick = useCallback((paramName: string) => { - const param = parameters[paramName]; - if (param && param.type === 'parameter') { + const pathParts = paramName.split('.'); + const newExpanded = new Set(expandedMenuItems); + let currentPath = ''; + for (let i = 0; i < pathParts.length - 1; i++) { + currentPath = currentPath ? `${currentPath}.${pathParts[i]}` : pathParts[i]; + newExpanded.add(currentPath); + } + setExpandedMenuItems(newExpanded); setSelectedParam(param); - // Reset value setter state when selecting new parameter setIntervals([]); setValueSetterMode(ValueSetterMode.DEFAULT); - } - // Toggle expansion for non-leaf nodes - setExpandedMenuItems(prev => { - const newSet = new Set(prev); - if (newSet.has(paramName)) { - newSet.delete(paramName); - } else { - newSet.add(paramName); + setParameterSearch(''); + }, + [parameters, expandedMenuItems] + ); + + // Handle menu item click + const handleMenuItemClick = useCallback( + (paramName: string) => { + const param = parameters[paramName]; + if (param && param.type === 'parameter') { + setSelectedParam(param); + setIntervals([]); + setValueSetterMode(ValueSetterMode.DEFAULT); } - return newSet; - }); - }, [parameters]); + setExpandedMenuItems((prev) => { + const newSet = new Set(prev); + if (newSet.has(paramName)) { + newSet.delete(paramName); + } else { + newSet.add(paramName); + } + return newSet; + }); + }, + [parameters] + ); + + // Handle parameter selection from changes card + const handleSelectParam = useCallback( + (paramName: string) => { + const metadata = parameters[paramName]; + if (metadata) { + setSelectedParam(metadata); + } + }, + [parameters] + ); // Handle value submission const handleValueSubmit = useCallback(() => { - if (!selectedParam || intervals.length === 0) return; + if (!selectedParam || intervals.length === 0) { + return; + } const updatedParameters = [...policyParameters]; - let existingParam = updatedParameters.find(p => p.name === selectedParam.parameter); + let existingParam = updatedParameters.find((p) => p.name === selectedParam.parameter); if (!existingParam) { existingParam = { name: selectedParam.parameter, values: [] }; updatedParameters.push(existingParam); } - // Use ValueIntervalCollection to properly merge intervals const paramCollection = new ValueIntervalCollection(existingParam.values); - intervals.forEach(interval => { + intervals.forEach((interval) => { paramCollection.addInterval(interval); }); @@ -254,41 +253,16 @@ export function PolicyCreationModal({ } }, [policyLabel, policyParameters, createPolicy, onPolicyCreated, onClose]); - // Render nested menu recursively - memoized to prevent expensive re-renders - const renderMenuItems = useCallback((items: ParameterTreeNode[]): React.ReactNode => { - return items - .filter(item => !item.name.includes('pycache')) - .map(item => ( - handleMenuItemClick(item.name)} - childrenOffset={16} - style={{ - borderRadius: spacing.radius.sm, - }} - > - {item.children && expandedMenuItems.has(item.name) && renderMenuItems(item.children)} - - )); - }, [selectedParam?.parameter, expandedMenuItems, handleMenuItemClick]); - - // Memoize the rendered tree to avoid expensive re-renders on unrelated state changes - const renderedMenuTree = useMemo(() => { - if (metadataLoading || !parameterTree) return null; - return renderMenuItems(parameterTree.children || []); - }, [metadataLoading, parameterTree, renderMenuItems]); - // Get base and reform values for chart const getChartValues = () => { - if (!selectedParam) return { baseValues: null, reformValues: null }; + if (!selectedParam) { + return { baseValues: null, reformValues: null }; + } const baseValues = new ValueIntervalCollection(selectedParam.values as ValuesList); const reformValues = new ValueIntervalCollection(baseValues); - const paramToChart = policyParameters.find(p => p.name === selectedParam.parameter); + const paramToChart = policyParameters.find((p) => p.name === selectedParam.parameter); if (paramToChart && paramToChart.values && paramToChart.values.length > 0) { const userIntervals = new ValueIntervalCollection(paramToChart.values as ValuesList); for (const interval of userIntervals.getIntervals()) { @@ -300,42 +274,11 @@ export function PolicyCreationModal({ }; const { baseValues, reformValues } = getChartValues(); - const colorConfig = INGREDIENT_COLORS.policy; - - const ValueSetterToRender = ValueSetterComponents[valueSetterMode]; - - console.log('[PolicyCreationModal] About to return JSX, took', (performance.now() - renderStart).toFixed(2) + 'ms'); - - // Dock styles matching ReportMetaPanel - const dockStyles = { - dock: { - background: 'rgba(255, 255, 255, 0.95)', - backdropFilter: 'blur(20px) saturate(180%)', - WebkitBackdropFilter: 'blur(20px) saturate(180%)', - borderRadius: spacing.radius.lg, - border: `1px solid ${modificationCount > 0 ? colorConfig.border : colors.border.light}`, - boxShadow: modificationCount > 0 - ? `0 4px 20px rgba(0, 0, 0, 0.08), 0 0 0 1px ${colorConfig.border}` - : `0 2px 12px ${colors.shadow.light}`, - padding: `${spacing.sm} ${spacing.lg}`, - transition: 'all 0.3s ease', - margin: spacing.md, - marginBottom: 0, - }, - divider: { - width: '1px', - height: '24px', - background: colors.gray[200], - flexShrink: 0, - }, - changesPanel: { - background: colors.white, - borderRadius: spacing.radius.md, - border: `1px solid ${colors.border.light}`, - marginTop: spacing.sm, - overflow: 'hidden', - }, - }; + + console.log( + '[PolicyCreationModal] About to return JSX, took', + `${(performance.now() - renderStart).toFixed(2)}ms` + ); return ( - - {/* Left side: Policy icon and name */} - - {/* Policy icon */} - - - - - {/* Editable policy name */} - - {isEditingLabel ? ( - setPolicyLabel(e.currentTarget.value)} - onBlur={() => setIsEditingLabel(false)} - onKeyDown={(e) => { - if (e.key === 'Enter') setIsEditingLabel(false); - if (e.key === 'Escape') setIsEditingLabel(false); - }} - autoFocus - size="xs" - style={{ width: 250 }} - styles={{ - input: { - fontFamily: typography.fontFamily.primary, - fontWeight: 600, - fontSize: FONT_SIZES.normal, - border: 'none', - background: 'transparent', - padding: 0, - }, - }} - /> - ) : ( - <> - - {policyLabel || 'New policy'} - - setIsEditingLabel(true)} - style={{ flexShrink: 0 }} - > - - - - )} - - - - {/* Right side: Modification count, View changes, Close */} - - {/* Modification count */} - - {modificationCount > 0 ? ( - <> - - - {modificationCount} parameter{modificationCount !== 1 ? 's' : ''} modified - - - ) : ( - - No changes yet - - )} - - - {/* Divider */} - - - {/* View Changes button */} - setChangesExpanded(!changesExpanded)} - style={{ - display: 'flex', - alignItems: 'center', - gap: spacing.xs, - padding: `${spacing.xs} ${spacing.sm}`, - borderRadius: spacing.radius.md, - background: changesExpanded ? colorConfig.bg : 'transparent', - border: `1px solid ${changesExpanded ? colorConfig.border : 'transparent'}`, - transition: 'all 0.1s ease', - }} - > - - View changes - - - - - {/* Close button */} - - - - - - - {/* Expandable changes panel */} - {changesExpanded && ( - - - {modifiedParams.length === 0 ? ( - - - No parameters have been modified yet. Select a parameter from the menu to make changes. - - - ) : ( - <> - {/* Header row */} - - - Parameter - - - Changes - - - {/* Data rows - one per parameter with multiple change lines */} - - {modifiedParams.map((param) => ( - { - const metadata = parameters[param.paramName]; - if (metadata) { - setSelectedParam(metadata); - setChangesExpanded(false); - } - }} - style={{ - display: 'grid', - gridTemplateColumns: '1fr 180px', - gap: spacing.md, - padding: `${spacing.sm} ${spacing.md}`, - borderBottom: `1px solid ${colors.border.light}`, - background: selectedParam?.parameter === param.paramName ? colorConfig.bg : colors.white, - alignItems: 'start', - }} - > - - {param.label} - - - {param.changes.map((change, idx) => ( - - - {change.period}: - {' '} - - {change.value} - - - ))} - - - ))} - - - )} - - - )} - + } > {/* Main content area */} {/* Left Sidebar - Parameter Tree */} - - {/* Parameter Tree */} - - - - PARAMETERS - - } - styles={{ - input: { - fontSize: FONT_SIZES.small, - height: 32, - minHeight: 32, - }, - dropdown: { - maxHeight: 300, - }, - option: { - fontSize: FONT_SIZES.small, - padding: `${spacing.xs} ${spacing.sm}`, - }, - }} - size="xs" - /> - - - - {metadataLoading || !parameterTree ? ( - - - - - - ) : ( - renderedMenuTree - )} - - - - + {/* Main Content - Parameter Editor */} - + {!selectedParam ? ( - - - - - - - Select a parameter from the menu to modify its value for your policy reform. - - - + ) : ( - {/* Parameter Header */} - - - {capitalize(selectedParam.label || 'Label unavailable')} - - {selectedParam.description && ( - - {selectedParam.description} - - )} - - - {/* Value Setter */} - - - Set new value - - - - - - { - setIntervals([]); - setValueSetterMode(mode); - }} /> - - - - - - {/* Historical Values Chart */} - {baseValues && reformValues && ( - - + + {/* 50/50 Split Content */} + + {/* Left Column: Setter + Changes */} + + {/* Value Setter Card */} + - - )} + + {/* Changes Card */} + + + + {/* Right Column: Chart */} + + )} @@ -788,11 +410,7 @@ export function PolicyCreationModal({ - diff --git a/app/src/pages/reportBuilder/modals/policy/PolicyCreationContent.tsx b/app/src/pages/reportBuilder/modals/policy/PolicyCreationContent.tsx index 782507071..87b80126f 100644 --- a/app/src/pages/reportBuilder/modals/policy/PolicyCreationContent.tsx +++ b/app/src/pages/reportBuilder/modals/policy/PolicyCreationContent.tsx @@ -1,24 +1,34 @@ /** - * PolicyCreationContent - Main content area for policy creation mode + * PolicyCreationContent - Main content area for policy creation mode (V6 styled) */ import { Dispatch, SetStateAction } from 'react'; -import { Box, Stack, Text, Title, Divider, Group, Button } from '@mantine/core'; -import { IconScale } from '@tabler/icons-react'; +import { ActionIcon, Badge, Box, Button, Group, SegmentedControl, Stack, Text, Title, UnstyledButton } from '@mantine/core'; +import { IconScale, IconTrash } from '@tabler/icons-react'; import { colors, spacing } from '@/designTokens'; import { ParameterMetadata } from '@/types/metadata/parameterMetadata'; import { PolicyStateProps } from '@/types/pathwayState'; import { ValueInterval, ValueIntervalCollection, ValuesList } from '@/types/subIngredients/valueInterval'; import { capitalize } from '@/utils/stringUtils'; -import { ValueSetterComponents, ValueSetterMode, ModeSelectorButton } from '@/pathways/report/components/valueSetters'; +import { ValueSetterMode } from '@/pathways/report/components/valueSetters'; import HistoricalValues from '@/pathways/report/components/policyParameterSelector/HistoricalValues'; import { FONT_SIZES } from '../../constants'; import { Parameter } from '@/types/subIngredients/parameter'; +import { ValueSetterComponentsV6 } from '../policyCreation/valueSelectors'; + +// Mode selector options for SegmentedControl +const MODE_OPTIONS = [ + { label: 'Default', value: ValueSetterMode.DEFAULT }, + { label: 'Yearly', value: ValueSetterMode.YEARLY }, + { label: 'Date range', value: ValueSetterMode.DATE }, + { label: 'Multi-year', value: ValueSetterMode.MULTI_YEAR }, +]; interface PolicyCreationContentProps { selectedParam: ParameterMetadata | null; localPolicy: PolicyStateProps; policyLabel: string; policyParameters: Parameter[]; + setPolicyParameters: Dispatch>; minDate: string; maxDate: string; intervals: ValueInterval[]; @@ -37,6 +47,7 @@ export function PolicyCreationContent({ localPolicy, policyLabel, policyParameters, + setPolicyParameters, minDate, maxDate, intervals, @@ -65,7 +76,55 @@ export function PolicyCreationContent({ }; const { baseValues, reformValues } = getChartValues(); - const ValueSetterToRender = ValueSetterComponents[valueSetterMode]; + const ValueSetterToRender = ValueSetterComponentsV6[valueSetterMode]; + + // Get changes for the current parameter + const currentParamChanges = selectedParam + ? policyParameters.find(p => p.name === selectedParam.parameter)?.values || [] + : []; + + // Format a date range for display + const formatPeriod = (interval: ValueInterval): string => { + const start = interval.startDate; + const end = interval.endDate; + if (!end || end === '9999-12-31') { + const year = start.split('-')[0]; + return `${year} onward`; + } + const startYear = start.split('-')[0]; + const endYear = end.split('-')[0]; + if (startYear === endYear) { + return startYear; + } + return `${startYear}-${endYear}`; + }; + + // Format a value for display + const formatValue = (value: number | string | boolean): string => { + if (typeof value === 'boolean') return value ? 'Yes' : 'No'; + if (typeof value === 'number') { + if (selectedParam?.unit === '/1') { + return `${(value * 100).toFixed(1)}%`; + } + return value.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }); + } + return String(value); + }; + + // Remove a change from the current parameter + const handleRemoveChange = (indexToRemove: number) => { + if (!selectedParam) return; + const updatedParameters = policyParameters.map(param => { + if (param.name === selectedParam.parameter) { + return { + ...param, + values: param.values.filter((_, i) => i !== indexToRemove), + }; + } + return param; + }).filter(param => param.values.length > 0); + setPolicyParameters(updatedParameters); + }; if (!selectedParam) { return ( @@ -75,7 +134,8 @@ export function PolicyCreationContent({ display: 'flex', alignItems: 'center', justifyContent: 'center', - padding: spacing.xl, + background: colors.gray[50], + padding: spacing.lg, }} > @@ -101,10 +161,18 @@ export function PolicyCreationContent({ } return ( - + - - + {/* Parameter Header Card */} + <Box + style={{ + background: colors.white, + borderRadius: spacing.radius.lg, + padding: spacing.lg, + border: `1px solid ${colors.border.light}`, + }} + > + <Title order={3} style={{ marginBottom: spacing.xs }}> {capitalize(selectedParam.label || 'Label unavailable')} {selectedParam.description && ( @@ -113,19 +181,46 @@ export function PolicyCreationContent({ )} - - - Set new value - - - + + {/* 50/50 Split Content */} + + {/* Left Column: Setter + Changes */} + + {/* Value Setter Card */} + + + + Set new value + + + {/* Mode selector - SegmentedControl per V6 mockup */} + { + setIntervals([]); + setValueSetterMode(value as ValueSetterMode); + }} + size="xs" + data={MODE_OPTIONS} + styles={{ + root: { + background: colors.gray[100], + borderRadius: spacing.radius.md, + }, + indicator: { + background: colors.white, + boxShadow: '0 1px 3px rgba(0,0,0,0.1)', + }, + }} + /> + - - { - setIntervals([]); - setValueSetterMode(mode); - }} /> - + + + + {/* Changes for this parameter */} + {currentParamChanges.length > 0 && ( + - Add change - - + + + Changes for this parameter + + + {currentParamChanges.length} + + + + {currentParamChanges.map((change, i) => ( + + + {formatPeriod(change)} + + + + {formatValue(change.value)} + + handleRemoveChange(i)} + > + + + + + ))} + + + )} - - {baseValues && reformValues && ( - - + + {/* Right Column: Historical Values Chart */} + + + + Historical values + + {baseValues && reformValues && ( + + )} + - )} + ); diff --git a/app/src/pages/reportBuilder/modals/policyCreation/ChangesCard.tsx b/app/src/pages/reportBuilder/modals/policyCreation/ChangesCard.tsx new file mode 100644 index 000000000..8bdfbadb6 --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policyCreation/ChangesCard.tsx @@ -0,0 +1,78 @@ +/** + * ChangesCard - Displays list of modified parameters with their changes + */ + +import React from 'react'; +import { Box, Group, Stack, Text, UnstyledButton } from '@mantine/core'; +import { colors, spacing } from '@/designTokens'; +import { FONT_SIZES } from '../../constants'; +import { ChangesCardProps } from './types'; + +export function ChangesCard({ + modifiedParams, + modificationCount, + selectedParamName, + onSelectParam, +}: ChangesCardProps) { + if (modifiedParams.length === 0) { + return null; + } + + return ( + + + + Changes for this parameter + + + {modificationCount} + + + + {modifiedParams.map((param) => ( + onSelectParam(param.paramName)} + style={{ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: spacing.sm, + background: + selectedParamName === param.paramName ? colors.primary[50] : colors.gray[50], + borderRadius: spacing.radius.sm, + width: '100%', + }} + > + + {param.label} + + + {param.changes.map((change, idx) => ( + + {change.period}: {change.value} + + ))} + + + ))} + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/policyCreation/EmptyParameterState.tsx b/app/src/pages/reportBuilder/modals/policyCreation/EmptyParameterState.tsx new file mode 100644 index 000000000..d5d7400aa --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policyCreation/EmptyParameterState.tsx @@ -0,0 +1,48 @@ +/** + * EmptyParameterState - Displayed when no parameter is selected + */ + +import React from 'react'; +import { IconScale } from '@tabler/icons-react'; +import { Box, Stack, Text } from '@mantine/core'; +import { colors, spacing } from '@/designTokens'; +import { FONT_SIZES } from '../../constants'; +import { EmptyParameterStateProps } from './types'; + +export function EmptyParameterState({ + message = 'Select a parameter from the menu to modify its value for your policy reform.', +}: EmptyParameterStateProps) { + return ( + + + + + + + {message} + + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/policyCreation/HistoricalValuesCard.tsx b/app/src/pages/reportBuilder/modals/policyCreation/HistoricalValuesCard.tsx new file mode 100644 index 000000000..f6b765fe2 --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policyCreation/HistoricalValuesCard.tsx @@ -0,0 +1,45 @@ +/** + * HistoricalValuesCard - Card wrapper for the historical values chart + */ + +import React from 'react'; +import { Box, Stack, Text } from '@mantine/core'; +import { colors, spacing } from '@/designTokens'; +import HistoricalValues from '@/pathways/report/components/policyParameterSelector/HistoricalValues'; +import { FONT_SIZES } from '../../constants'; +import { HistoricalValuesCardProps } from './types'; + +export function HistoricalValuesCard({ + selectedParam, + baseValues, + reformValues, + policyLabel, +}: HistoricalValuesCardProps) { + return ( + + + + Historical values + + {baseValues && reformValues && ( + + )} + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/policyCreation/ParameterHeaderCard.tsx b/app/src/pages/reportBuilder/modals/policyCreation/ParameterHeaderCard.tsx new file mode 100644 index 000000000..a138097b6 --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policyCreation/ParameterHeaderCard.tsx @@ -0,0 +1,30 @@ +/** + * ParameterHeaderCard - Displays the selected parameter name and description + */ + +import React from 'react'; +import { Box, Text, Title } from '@mantine/core'; +import { colors, spacing } from '@/designTokens'; +import { capitalize } from '@/utils/stringUtils'; +import { FONT_SIZES } from '../../constants'; +import { ParameterHeaderCardProps } from './types'; + +export function ParameterHeaderCard({ label, description }: ParameterHeaderCardProps) { + return ( + + + {capitalize(label || 'Label unavailable')} + + {description && ( + {description} + )} + + ); +} diff --git a/app/src/pages/reportBuilder/modals/policyCreation/ParameterSidebar.tsx b/app/src/pages/reportBuilder/modals/policyCreation/ParameterSidebar.tsx new file mode 100644 index 000000000..e0b34497d --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policyCreation/ParameterSidebar.tsx @@ -0,0 +1,119 @@ +/** + * ParameterSidebar - Left sidebar with parameter search and tree navigation + */ + +import React, { useCallback, useMemo } from 'react'; +import { IconSearch } from '@tabler/icons-react'; +import { Autocomplete, Box, NavLink, ScrollArea, Skeleton, Stack, Text } from '@mantine/core'; +import { colors, spacing } from '@/designTokens'; +import { ParameterTreeNode } from '@/types/metadata'; +import { FONT_SIZES } from '../../constants'; +import { ParameterSidebarProps } from './types'; + +export function ParameterSidebar({ + parameterTree, + metadataLoading, + selectedParam, + expandedMenuItems, + parameterSearch, + searchableParameters, + onSearchChange, + onSearchSelect, + onMenuItemClick, +}: ParameterSidebarProps) { + // Render nested menu recursively + const renderMenuItems = useCallback( + (items: ParameterTreeNode[]): React.ReactNode => { + return items + .filter((item) => !item.name.includes('pycache')) + .map((item) => ( + onMenuItemClick(item.name)} + childrenOffset={16} + style={{ + borderRadius: spacing.radius.sm, + }} + > + {item.children && expandedMenuItems.has(item.name) && renderMenuItems(item.children)} + + )); + }, + [selectedParam?.parameter, expandedMenuItems, onMenuItemClick] + ); + + // Memoize the rendered tree + const renderedMenuTree = useMemo(() => { + if (metadataLoading || !parameterTree) { + return null; + } + return renderMenuItems(parameterTree.children || []); + }, [metadataLoading, parameterTree, renderMenuItems]); + + return ( + + + + + PARAMETERS + + } + styles={{ + input: { + fontSize: FONT_SIZES.small, + height: 32, + minHeight: 32, + }, + dropdown: { + maxHeight: 300, + }, + option: { + fontSize: FONT_SIZES.small, + padding: `${spacing.xs} ${spacing.sm}`, + }, + }} + size="xs" + /> + + + + {metadataLoading || !parameterTree ? ( + + + + + + ) : ( + renderedMenuTree + )} + + + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/policyCreation/PolicyCreationHeader.tsx b/app/src/pages/reportBuilder/modals/policyCreation/PolicyCreationHeader.tsx new file mode 100644 index 000000000..2271dbae1 --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policyCreation/PolicyCreationHeader.tsx @@ -0,0 +1,143 @@ +/** + * PolicyCreationHeader - Modal header with editable policy name, modification count, and close button + */ + +import React from 'react'; +import { IconPencil, IconScale, IconX } from '@tabler/icons-react'; +import { ActionIcon, Box, Group, Text, TextInput } from '@mantine/core'; +import { colors, spacing, typography } from '@/designTokens'; +import { FONT_SIZES, INGREDIENT_COLORS } from '../../constants'; + +export interface PolicyCreationHeaderProps { + policyLabel: string; + isEditingLabel: boolean; + modificationCount: number; + onLabelChange: (label: string) => void; + onEditingChange: (editing: boolean) => void; + onClose: () => void; +} + +export function PolicyCreationHeader({ + policyLabel, + isEditingLabel, + modificationCount, + onLabelChange, + onEditingChange, + onClose, +}: PolicyCreationHeaderProps) { + const colorConfig = INGREDIENT_COLORS.policy; + + return ( + + + {/* Left side: Policy icon and name */} + + {/* Policy icon */} + + + + + {/* Editable policy name */} + + {isEditingLabel ? ( + onLabelChange(e.currentTarget.value)} + onBlur={() => onEditingChange(false)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + onEditingChange(false); + } + if (e.key === 'Escape') { + onEditingChange(false); + } + }} + autoFocus + size="xs" + style={{ width: 250 }} + styles={{ + input: { + fontFamily: typography.fontFamily.primary, + fontWeight: 600, + fontSize: FONT_SIZES.normal, + border: 'none', + background: 'transparent', + padding: 0, + }, + }} + /> + ) : ( + <> + + {policyLabel || 'New policy'} + + onEditingChange(true)} + style={{ flexShrink: 0 }} + > + + + + )} + + + + {/* Right side: Modification count + Close */} + + {/* Modification count */} + + {modificationCount > 0 ? ( + <> + + + {modificationCount} parameter{modificationCount !== 1 ? 's' : ''} modified + + + ) : ( + + No changes yet + + )} + + + {/* Close button */} + + + + + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/policyCreation/PolicyNameEditor.tsx b/app/src/pages/reportBuilder/modals/policyCreation/PolicyNameEditor.tsx new file mode 100644 index 000000000..e25f2d77a --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policyCreation/PolicyNameEditor.tsx @@ -0,0 +1,105 @@ +/** + * PolicyNameEditor - Editable policy name at top of parameter setter pane + */ + +import React from 'react'; +import { IconPencil, IconScale } from '@tabler/icons-react'; +import { ActionIcon, Box, Group, Text, TextInput } from '@mantine/core'; +import { colors, spacing, typography } from '@/designTokens'; +import { FONT_SIZES, INGREDIENT_COLORS } from '../../constants'; + +export interface PolicyNameEditorProps { + policyLabel: string; + isEditingLabel: boolean; + onLabelChange: (label: string) => void; + onEditingChange: (editing: boolean) => void; +} + +export function PolicyNameEditor({ + policyLabel, + isEditingLabel, + onLabelChange, + onEditingChange, +}: PolicyNameEditorProps) { + const colorConfig = INGREDIENT_COLORS.policy; + + return ( + + + {/* Policy icon */} + + + + + {/* Editable policy name */} + + {isEditingLabel ? ( + onLabelChange(e.currentTarget.value)} + onBlur={() => onEditingChange(false)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + onEditingChange(false); + } + if (e.key === 'Escape') { + onEditingChange(false); + } + }} + autoFocus + size="sm" + style={{ flex: 1 }} + styles={{ + input: { + fontFamily: typography.fontFamily.primary, + fontWeight: 600, + fontSize: FONT_SIZES.normal, + }, + }} + /> + ) : ( + <> + + {policyLabel || 'New policy'} + + onEditingChange(true)} + > + + + + )} + + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/policyCreation/ValueSetterCard.tsx b/app/src/pages/reportBuilder/modals/policyCreation/ValueSetterCard.tsx new file mode 100644 index 000000000..6bfa7f8b2 --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policyCreation/ValueSetterCard.tsx @@ -0,0 +1,93 @@ +/** + * ValueSetterCard - Card with mode selector, value inputs, and submit button + * Uses SegmentedControl for mode selection and V6-styled value selectors + */ + +import React from 'react'; +import { Box, Button, SegmentedControl, Stack, Text } from '@mantine/core'; +import { colors, spacing } from '@/designTokens'; +import { ValueSetterMode } from '@/pathways/report/components/valueSetters'; +import { FONT_SIZES } from '../../constants'; +import { ValueSetterCardProps } from './types'; +import { ValueSetterComponentsV6 } from './valueSelectors'; + +// Map enum values to display labels +const MODE_OPTIONS = [ + { label: 'Default', value: ValueSetterMode.DEFAULT }, + { label: 'Yearly', value: ValueSetterMode.YEARLY }, + { label: 'Date range', value: ValueSetterMode.DATE }, + { label: 'Multi-year', value: ValueSetterMode.MULTI_YEAR }, +]; + +export function ValueSetterCard({ + selectedParam, + localPolicy, + minDate, + maxDate, + valueSetterMode, + intervals, + startDate, + endDate, + onModeChange, + onIntervalsChange, + onStartDateChange, + onEndDateChange, + onSubmit, +}: ValueSetterCardProps) { + const ValueSetterToRender = ValueSetterComponentsV6[valueSetterMode]; + + return ( + + + + Set new value + + + {/* Mode selector - SegmentedControl per V6 mockup */} + { + onIntervalsChange([]); + onModeChange(value as ValueSetterMode); + }} + size="xs" + data={MODE_OPTIONS} + styles={{ + root: { + background: colors.gray[100], + borderRadius: spacing.radius.md, + }, + indicator: { + background: colors.white, + boxShadow: '0 1px 3px rgba(0,0,0,0.1)', + }, + }} + /> + + + + + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/policyCreation/index.ts b/app/src/pages/reportBuilder/modals/policyCreation/index.ts new file mode 100644 index 000000000..812f906a0 --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policyCreation/index.ts @@ -0,0 +1,22 @@ +/** + * PolicyCreation components barrel export + */ + +export { ParameterSidebar } from './ParameterSidebar'; +export { PolicyCreationHeader } from './PolicyCreationHeader'; +export { ParameterHeaderCard } from './ParameterHeaderCard'; +export { ValueSetterCard } from './ValueSetterCard'; +export { ChangesCard } from './ChangesCard'; +export { HistoricalValuesCard } from './HistoricalValuesCard'; +export { EmptyParameterState } from './EmptyParameterState'; + +export type { + ModifiedParam, + ParameterSidebarProps, + PolicyCreationHeaderProps, + ParameterHeaderCardProps, + ValueSetterCardProps, + ChangesCardProps, + HistoricalValuesCardProps, + EmptyParameterStateProps, +} from './types'; diff --git a/app/src/pages/reportBuilder/modals/policyCreation/types.ts b/app/src/pages/reportBuilder/modals/policyCreation/types.ts new file mode 100644 index 000000000..cf3d8ce51 --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policyCreation/types.ts @@ -0,0 +1,103 @@ +/** + * Shared types for PolicyCreationModal components + */ + +import { Dispatch, SetStateAction } from 'react'; +import { ValueSetterMode } from '@/pathways/report/components/valueSetters'; +import { ParameterTreeNode } from '@/types/metadata'; +import { ParameterMetadata } from '@/types/metadata/parameterMetadata'; +import { PolicyStateProps } from '@/types/pathwayState'; +import { ValueInterval, ValueIntervalCollection } from '@/types/subIngredients/valueInterval'; + +/** + * Modified parameter with formatted changes for display + */ +export interface ModifiedParam { + paramName: string; + label: string; + changes: Array<{ + period: string; + value: string; + }>; +} + +/** + * Props for ParameterSidebar component + */ +export interface ParameterSidebarProps { + parameterTree: { children?: ParameterTreeNode[] } | null; + metadataLoading: boolean; + selectedParam: ParameterMetadata | null; + expandedMenuItems: Set; + parameterSearch: string; + searchableParameters: Array<{ value: string; label: string }>; + onSearchChange: (value: string) => void; + onSearchSelect: (paramName: string) => void; + onMenuItemClick: (paramName: string) => void; +} + +/** + * Props for PolicyCreationHeader component + */ +export interface PolicyCreationHeaderProps { + policyLabel: string; + isEditingLabel: boolean; + modificationCount: number; + onLabelChange: (label: string) => void; + onEditingChange: (editing: boolean) => void; + onClose: () => void; +} + +/** + * Props for ParameterHeaderCard component + */ +export interface ParameterHeaderCardProps { + label: string; + description?: string; +} + +/** + * Props for ValueSetterCard component + */ +export interface ValueSetterCardProps { + selectedParam: ParameterMetadata; + localPolicy: PolicyStateProps; + minDate: string; + maxDate: string; + valueSetterMode: ValueSetterMode; + intervals: ValueInterval[]; + startDate: string; + endDate: string; + onModeChange: (mode: ValueSetterMode) => void; + onIntervalsChange: Dispatch>; + onStartDateChange: Dispatch>; + onEndDateChange: Dispatch>; + onSubmit: () => void; +} + +/** + * Props for ChangesCard component + */ +export interface ChangesCardProps { + modifiedParams: ModifiedParam[]; + modificationCount: number; + selectedParamName?: string; + onSelectParam: (paramName: string) => void; +} + +/** + * Props for HistoricalValuesCard component + */ +export interface HistoricalValuesCardProps { + selectedParam: ParameterMetadata; + baseValues: ValueIntervalCollection | null; + reformValues: ValueIntervalCollection | null; + policyLabel: string; +} + +/** + * Props for EmptyParameterState component + */ +export interface EmptyParameterStateProps { + message?: string; +} diff --git a/app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/DateValueSelectorV6.tsx b/app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/DateValueSelectorV6.tsx new file mode 100644 index 000000000..65b8377b9 --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/DateValueSelectorV6.tsx @@ -0,0 +1,115 @@ +/** + * DateValueSelectorV6 - V6 styled date range value selector + * Logic copied from original, only layout/styling changed to match V6 mockup + */ + +import dayjs from 'dayjs'; +import { useEffect, useState } from 'react'; +import { Box, Group, Stack, Text } from '@mantine/core'; +import { DatePickerInput } from '@mantine/dates'; +import { colors, spacing } from '@/designTokens'; +import { getDefaultValueForParam } from '@/pathways/report/components/valueSetters/getDefaultValueForParam'; +import { ValueInputBox } from '@/pathways/report/components/valueSetters/ValueInputBox'; +import { ValueSetterProps } from '@/pathways/report/components/valueSetters/ValueSetterProps'; +import { ValueInterval } from '@/types/subIngredients/valueInterval'; +import { fromISODateString, toISODateString } from '@/utils/dateUtils'; + +export function DateValueSelectorV6(props: ValueSetterProps) { + const { + param, + policy, + setIntervals, + minDate, + maxDate, + startDate, + setStartDate, + endDate, + setEndDate, + } = props; + + // Local state for param value + const [paramValue, setParamValue] = useState( + getDefaultValueForParam(param, policy, startDate) + ); + + // Set endDate to end of year of startDate + useEffect(() => { + if (startDate) { + const endOfYearDate = dayjs(startDate).endOf('year').format('YYYY-MM-DD'); + setEndDate(endOfYearDate); + } + }, [startDate, setEndDate]); + + // Update param value when startDate changes + useEffect(() => { + if (startDate) { + const newValue = getDefaultValueForParam(param, policy, startDate); + setParamValue(newValue); + } + }, [startDate, param, policy]); + + // Update intervals whenever local state changes + useEffect(() => { + if (startDate && endDate) { + const newInterval: ValueInterval = { + startDate, + endDate, + value: paramValue, + }; + setIntervals([newInterval]); + } else { + setIntervals([]); + } + }, [startDate, endDate, paramValue, setIntervals]); + + function handleStartDateChange(value: Date | string | null) { + setStartDate(toISODateString(value)); + } + + function handleEndDateChange(value: Date | string | null) { + setEndDate(toISODateString(value)); + } + + // V6 Layout: Two rows - date row, then value row + return ( + + {/* First row: From date + To date */} + + + + From + + + + + + To + + + + + + {/* Second row: Value */} + + + Value + + + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/DefaultValueSelectorV6.tsx b/app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/DefaultValueSelectorV6.tsx new file mode 100644 index 000000000..52fabdb9b --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/DefaultValueSelectorV6.tsx @@ -0,0 +1,97 @@ +/** + * DefaultValueSelectorV6 - V6 styled default value selector + * Logic copied from original, only layout/styling changed to match V6 mockup + */ + +import { useEffect, useState } from 'react'; +import { Box, Group, Stack, Text } from '@mantine/core'; +import { YearPickerInput } from '@mantine/dates'; +import { FOREVER } from '@/constants'; +import { colors, spacing } from '@/designTokens'; +import { getDefaultValueForParam } from '@/pathways/report/components/valueSetters/getDefaultValueForParam'; +import { ValueInputBox } from '@/pathways/report/components/valueSetters/ValueInputBox'; +import { ValueSetterProps } from '@/pathways/report/components/valueSetters/ValueSetterProps'; +import { ValueInterval } from '@/types/subIngredients/valueInterval'; +import { fromISODateString, toISODateString } from '@/utils/dateUtils'; + +export function DefaultValueSelectorV6(props: ValueSetterProps) { + const { + param, + policy, + setIntervals, + minDate, + maxDate, + startDate, + setStartDate, + endDate, + setEndDate, + } = props; + + // Local state for param value + const [paramValue, setParamValue] = useState( + getDefaultValueForParam(param, policy, startDate) + ); + + // Set endDate to 2100-12-31 for default mode + useEffect(() => { + setEndDate(FOREVER); + }, [setEndDate]); + + // Update param value when startDate changes + useEffect(() => { + if (startDate) { + const newValue = getDefaultValueForParam(param, policy, startDate); + setParamValue(newValue); + } + }, [startDate, param, policy]); + + // Update intervals whenever local state changes + useEffect(() => { + if (startDate && endDate) { + const newInterval: ValueInterval = { + startDate, + endDate, + value: paramValue, + }; + setIntervals([newInterval]); + } else { + setIntervals([]); + } + }, [startDate, endDate, paramValue, setIntervals]); + + function handleStartDateChange(value: Date | string | null) { + setStartDate(toISODateString(value)); + } + + // V6 Layout: Two rows - date row, then value row + return ( + + {/* First row: From year + "onward" */} + + + + From + + + + + onward + + + + {/* Second row: Value */} + + + Value + + + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/MultiYearValueSelectorV6.tsx b/app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/MultiYearValueSelectorV6.tsx new file mode 100644 index 000000000..75818e57f --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/MultiYearValueSelectorV6.tsx @@ -0,0 +1,114 @@ +/** + * MultiYearValueSelectorV6 - V6 styled multi-year value selector + * Logic copied from original, only layout/styling changed to match V6 mockup + */ + +import { useEffect, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { Group, Stack, Text } from '@mantine/core'; +import { colors, spacing } from '@/designTokens'; +import { getTaxYears } from '@/libs/metadataUtils'; +import { getDefaultValueForParam } from '@/pathways/report/components/valueSetters/getDefaultValueForParam'; +import { ValueInputBox } from '@/pathways/report/components/valueSetters/ValueInputBox'; +import { ValueSetterProps } from '@/pathways/report/components/valueSetters/ValueSetterProps'; +import { RootState } from '@/store'; +import { ValueInterval } from '@/types/subIngredients/valueInterval'; + +export function MultiYearValueSelectorV6(props: ValueSetterProps) { + const { param, policy, setIntervals } = props; + + // Get available years from metadata + const availableYears = useSelector(getTaxYears); + const countryId = useSelector((state: RootState) => state.metadata.currentCountry); + + // Country-specific max years configuration + const MAX_YEARS_BY_COUNTRY: Record = { + us: 10, + uk: 5, + }; + + // Generate years from metadata, starting from current year + const generateYears = () => { + const currentYear = new Date().getFullYear(); + const maxYears = MAX_YEARS_BY_COUNTRY[countryId || 'us'] || 10; + + // Filter available years from metadata to only include current year onwards + const futureYears = availableYears + .map((option) => parseInt(option.value, 10)) + .filter((year) => year >= currentYear) + .sort((a, b) => a - b); + + // Take only the configured max years for this country + return futureYears.slice(0, maxYears); + }; + + const years = generateYears(); + + // Get values for each year - check reform first, then baseline + const getInitialYearValues = useMemo(() => { + const initialValues: Record = {}; + years.forEach((year) => { + initialValues[year] = getDefaultValueForParam(param, policy, `${year}-01-01`); + }); + return initialValues; + }, [param, policy]); + + const [yearValues, setYearValues] = useState>(getInitialYearValues); + + // Update intervals whenever yearValues changes + useEffect(() => { + const newIntervals: ValueInterval[] = Object.keys(yearValues).map((year: string) => ({ + startDate: `${year}-01-01`, + endDate: `${year}-12-31`, + value: yearValues[year], + })); + + setIntervals(newIntervals); + }, [yearValues, setIntervals]); + + const handleYearValueChange = (year: number, value: any) => { + setYearValues((prev) => ({ + ...prev, + [year]: value, + })); + }; + + // Split years into two columns + const midpoint = Math.ceil(years.length / 2); + const leftColumn = years.slice(0, midpoint); + const rightColumn = years.slice(midpoint); + + // V6 Layout: Two columns with year labels and inputs + return ( + + + {leftColumn.map((year) => ( + + + {year} + + handleYearValueChange(year, value)} + /> + + ))} + + + {rightColumn.map((year) => ( + + + {year} + + handleYearValueChange(year, value)} + /> + + ))} + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/YearlyValueSelectorV6.tsx b/app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/YearlyValueSelectorV6.tsx new file mode 100644 index 000000000..aaa17ac77 --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/YearlyValueSelectorV6.tsx @@ -0,0 +1,119 @@ +/** + * YearlyValueSelectorV6 - V6 styled yearly value selector + * Logic copied from original, only layout/styling changed to match V6 mockup + */ + +import dayjs from 'dayjs'; +import { useEffect, useState } from 'react'; +import { Box, Group, Stack, Text } from '@mantine/core'; +import { YearPickerInput } from '@mantine/dates'; +import { colors, spacing } from '@/designTokens'; +import { getDefaultValueForParam } from '@/pathways/report/components/valueSetters/getDefaultValueForParam'; +import { ValueInputBox } from '@/pathways/report/components/valueSetters/ValueInputBox'; +import { ValueSetterProps } from '@/pathways/report/components/valueSetters/ValueSetterProps'; +import { ValueInterval } from '@/types/subIngredients/valueInterval'; +import { fromISODateString, toISODateString } from '@/utils/dateUtils'; + +export function YearlyValueSelectorV6(props: ValueSetterProps) { + const { + param, + policy, + setIntervals, + minDate, + maxDate, + startDate, + setStartDate, + endDate, + setEndDate, + } = props; + + // Local state for param value + const [paramValue, setParamValue] = useState( + getDefaultValueForParam(param, policy, startDate) + ); + + // Set endDate to end of year of startDate + useEffect(() => { + if (startDate) { + const endOfYearDate = dayjs(startDate).endOf('year').format('YYYY-MM-DD'); + setEndDate(endOfYearDate); + } + }, [startDate, setEndDate]); + + // Update param value when startDate changes + useEffect(() => { + if (startDate) { + const newValue = getDefaultValueForParam(param, policy, startDate); + setParamValue(newValue); + } + }, [startDate, param, policy]); + + // Update intervals whenever local state changes + useEffect(() => { + if (startDate && endDate) { + const newInterval: ValueInterval = { + startDate, + endDate, + value: paramValue, + }; + setIntervals([newInterval]); + } else { + setIntervals([]); + } + }, [startDate, endDate, paramValue, setIntervals]); + + function handleStartDateChange(value: Date | string | null) { + setStartDate(toISODateString(value)); + } + + function handleEndDateChange(value: Date | string | null) { + const isoString = toISODateString(value); + if (isoString) { + const endOfYearDate = dayjs(isoString).endOf('year').format('YYYY-MM-DD'); + setEndDate(endOfYearDate); + } else { + setEndDate(''); + } + } + + // V6 Layout: Two rows - date row, then value row + return ( + + {/* First row: From year + To year */} + + + + From + + + + + + To + + + + + + {/* Second row: Value */} + + + Value + + + + + ); +} diff --git a/app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/index.ts b/app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/index.ts new file mode 100644 index 000000000..80bd9c15a --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policyCreation/valueSelectors/index.ts @@ -0,0 +1,21 @@ +/** + * V6-styled value selector components + */ + +import { ValueSetterMode } from '@/pathways/report/components/valueSetters'; +import { DateValueSelectorV6 } from './DateValueSelectorV6'; +import { DefaultValueSelectorV6 } from './DefaultValueSelectorV6'; +import { MultiYearValueSelectorV6 } from './MultiYearValueSelectorV6'; +import { YearlyValueSelectorV6 } from './YearlyValueSelectorV6'; + +export { DefaultValueSelectorV6 } from './DefaultValueSelectorV6'; +export { YearlyValueSelectorV6 } from './YearlyValueSelectorV6'; +export { DateValueSelectorV6 } from './DateValueSelectorV6'; +export { MultiYearValueSelectorV6 } from './MultiYearValueSelectorV6'; + +export const ValueSetterComponentsV6 = { + [ValueSetterMode.DEFAULT]: DefaultValueSelectorV6, + [ValueSetterMode.YEARLY]: YearlyValueSelectorV6, + [ValueSetterMode.DATE]: DateValueSelectorV6, + [ValueSetterMode.MULTI_YEAR]: MultiYearValueSelectorV6, +} as const; diff --git a/app/src/pages/reportBuilder/types.ts b/app/src/pages/reportBuilder/types.ts index 6f6faa210..db45a4446 100644 --- a/app/src/pages/reportBuilder/types.ts +++ b/app/src/pages/reportBuilder/types.ts @@ -79,8 +79,10 @@ export interface BrowseModalTemplateProps { isOpen: boolean; onClose: () => void; headerIcon: ReactNode; - headerTitle: string; - headerSubtitle: string; + headerTitle: ReactNode; + headerSubtitle?: string; + /** Content to display on the right side of the header (e.g., status indicator) */ + headerRightContent?: ReactNode; colorConfig: IngredientColorConfig; /** Standard sidebar sections - use for simple browse mode sidebars */ sidebarSections?: BrowseModalSidebarSection[]; @@ -93,6 +95,8 @@ export interface BrowseModalTemplateProps { statusHeader?: ReactNode; /** Footer shown below main content (e.g., creation mode buttons) */ footer?: ReactNode; + /** Content area padding override (default: spacing.lg). Set to 0 for full-bleed content. */ + contentPadding?: number | string; } // ============================================================================ diff --git a/app/src/pathways/report/components/policyParameterSelector/HistoricalValues.tsx b/app/src/pathways/report/components/policyParameterSelector/HistoricalValues.tsx index 6d0b04013..779edfa2d 100644 --- a/app/src/pathways/report/components/policyParameterSelector/HistoricalValues.tsx +++ b/app/src/pathways/report/components/policyParameterSelector/HistoricalValues.tsx @@ -44,8 +44,7 @@ export default function PolicyParameterSelectorHistoricalValues( } = props; return ( - - Historical values + {capitalize(param.label)} over time Date: Fri, 13 Feb 2026 01:31:08 +0100 Subject: [PATCH 34/73] fix: Remove param demos --- .../pages/reportBuilder/ReportBuilderPage.tsx | 34 ++----------------- bun.lock | 1 + 2 files changed, 3 insertions(+), 32 deletions(-) diff --git a/app/src/pages/reportBuilder/ReportBuilderPage.tsx b/app/src/pages/reportBuilder/ReportBuilderPage.tsx index 194dc5caf..29fc72856 100644 --- a/app/src/pages/reportBuilder/ReportBuilderPage.tsx +++ b/app/src/pages/reportBuilder/ReportBuilderPage.tsx @@ -12,9 +12,8 @@ */ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { IconLayoutColumns, IconPalette, IconRowInsertBottom } from '@tabler/icons-react'; -import { Link } from 'react-router-dom'; -import { Box, Group, Tabs, Text } from '@mantine/core'; +import { IconLayoutColumns, IconRowInsertBottom } from '@tabler/icons-react'; +import { Box, Tabs } from '@mantine/core'; import { CURRENT_YEAR } from '@/constants'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { initializeSimulationState } from '@/utils/pathwayState/initializeSimulationState'; @@ -132,35 +131,6 @@ export default function ReportBuilderPage() { setPickerState={setPickerState} viewMode={viewMode} /> - - {/* Dev tools - mockup links */} - - - Development - - - - - Parameter setter mockups - - - ); } diff --git a/bun.lock b/bun.lock index e2d910906..64f7ad2ae 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "policyengine-monorepo", From 137cd7adf2258e5c7e118777fea95ca4e5c877fa Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 13 Feb 2026 15:30:07 +0100 Subject: [PATCH 35/73] fix: Allow nameless policies --- .../pages/reportBuilder/modals/PolicyCreationModal.tsx | 10 +++------- .../modals/policyCreation/PolicyCreationHeader.tsx | 5 +++-- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx b/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx index d744dfe40..bcd68ad36 100644 --- a/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx +++ b/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx @@ -72,7 +72,7 @@ export function PolicyCreationModal({ const { minDate, maxDate } = useSelector(getDateRange); // Local policy state - const [policyLabel, setPolicyLabel] = useState('New policy'); + const [policyLabel, setPolicyLabel] = useState(''); const [policyParameters, setPolicyParameters] = useState([]); const [isEditingLabel, setIsEditingLabel] = useState(false); @@ -99,7 +99,7 @@ export function PolicyCreationModal({ // Reset state when modal opens useEffect(() => { if (isOpen) { - setPolicyLabel('New policy'); + setPolicyLabel(''); setPolicyParameters([]); setSelectedParam(null); setExpandedMenuItems(new Set()); @@ -229,10 +229,6 @@ export function PolicyCreationModal({ // Handle policy creation const handleCreatePolicy = useCallback(async () => { - if (!policyLabel.trim()) { - return; - } - const policyData: Partial = { parameters: policyParameters, }; @@ -243,7 +239,7 @@ export function PolicyCreationModal({ const result = await createPolicy(payload); const createdPolicy: PolicyStateProps = { id: result.result.policy_id, - label: policyLabel, + label: policyLabel || null, parameters: policyParameters, }; onPolicyCreated(createdPolicy); diff --git a/app/src/pages/reportBuilder/modals/policyCreation/PolicyCreationHeader.tsx b/app/src/pages/reportBuilder/modals/policyCreation/PolicyCreationHeader.tsx index 2271dbae1..1562e8268 100644 --- a/app/src/pages/reportBuilder/modals/policyCreation/PolicyCreationHeader.tsx +++ b/app/src/pages/reportBuilder/modals/policyCreation/PolicyCreationHeader.tsx @@ -64,6 +64,7 @@ export function PolicyCreationHeader({ onEditingChange(false); } }} + placeholder="Untitled policy" autoFocus size="xs" style={{ width: 250 }} @@ -85,13 +86,13 @@ export function PolicyCreationHeader({ style={{ fontFamily: typography.fontFamily.primary, fontSize: FONT_SIZES.normal, - color: colors.gray[800], + color: policyLabel ? colors.gray[800] : colors.gray[400], overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }} > - {policyLabel || 'New policy'} + {policyLabel || 'Untitled policy'} Date: Fri, 13 Feb 2026 16:04:27 +0100 Subject: [PATCH 36/73] fix: Fix dates, US nationwide populations --- .../components/SimulationBlock.tsx | 3 --- .../modals/PolicyCreationModal.tsx | 12 +--------- .../modals/PopulationBrowseModal.tsx | 23 ++++--------------- .../valueSelectors/DateValueSelectorV6.tsx | 18 +++++++-------- 4 files changed, 15 insertions(+), 41 deletions(-) diff --git a/app/src/pages/reportBuilder/components/SimulationBlock.tsx b/app/src/pages/reportBuilder/components/SimulationBlock.tsx index 5b58704a4..e865dc98f 100644 --- a/app/src/pages/reportBuilder/components/SimulationBlock.tsx +++ b/app/src/pages/reportBuilder/components/SimulationBlock.tsx @@ -58,9 +58,6 @@ export function SimulationBlock({ recentPopulations, viewMode, }: SimulationBlockProps) { - const renderCount = useRef(0); - renderCount.current++; - console.log('[SimulationBlock #' + index + '] Render #' + renderCount.current); const [isEditingLabel, setIsEditingLabel] = useState(false); const [labelInput, setLabelInput] = useState(simulation.label || ''); diff --git a/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx b/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx index bcd68ad36..966d5f29b 100644 --- a/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx +++ b/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx @@ -8,7 +8,7 @@ */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import { Box, Button, Group, Modal, Stack } from '@mantine/core'; import { PolicyAdapter } from '@/adapters'; @@ -56,11 +56,6 @@ export function PolicyCreationModal({ onPolicyCreated, simulationIndex, }: PolicyCreationModalProps) { - const renderCount = useRef(0); - renderCount.current++; - const renderStart = performance.now(); - console.log(`[PolicyCreationModal] Render #${renderCount.current} START (isOpen=${isOpen})`); - const countryId = useCurrentCountry() as 'us' | 'uk'; // Get metadata from Redux state @@ -271,11 +266,6 @@ export function PolicyCreationModal({ const { baseValues, reformValues } = getChartValues(); - console.log( - '[PolicyCreationModal] About to return JSX, took', - `${(performance.now() - renderStart).toFixed(2)}ms` - ); - return ( { + const countryConfig = COUNTRY_CONFIG[countryId]; const geography: Geography = region ? { id: `${countryId}-${region.value}`, @@ -212,10 +206,10 @@ export function PopulationBrowseModal({ geographyId: region.value, } : { - id: countryId, + id: countryConfig.nationwideId, countryId, scope: 'national', - geographyId: countryId, + geographyId: countryConfig.geographyId, }; geographyUsageStore.recordUsage(geography.geographyId); @@ -513,13 +507,6 @@ export function PopulationBrowseModal({ ); }; - // ========== Render ========== - - console.log( - '[PopulationBrowseModal] About to return JSX, took', - (performance.now() - renderStart).toFixed(2) + 'ms' - ); - return ( @@ -94,9 +94,9 @@ export function DateValueSelectorV6(props: ValueSetterProps) { From 4bd365d1085d8105e5f382413cd31996a6d038a6 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 13 Feb 2026 20:16:31 +0100 Subject: [PATCH 37/73] feat: Builder variants --- app/src/CalculatorRouter.tsx | 32 ++ app/src/components/Sidebar.tsx | 2 + .../pathwayVariations/ChecklistVariant.tsx | 379 +++++++++++++++ .../pathwayVariations/FocusedFlowVariant.tsx | 437 ++++++++++++++++++ .../pathwayVariations/GuidedFunnelVariant.tsx | 364 +++++++++++++++ .../NumberedStepsVariant.tsx | 387 ++++++++++++++++ .../PathwayVariationsHub.tsx | 268 +++++++++++ .../pathwayVariations/TimelineVariant.tsx | 379 +++++++++++++++ .../reportBuilder/pathwayVariations/index.ts | 10 + 9 files changed, 2258 insertions(+) create mode 100644 app/src/pages/reportBuilder/pathwayVariations/ChecklistVariant.tsx create mode 100644 app/src/pages/reportBuilder/pathwayVariations/FocusedFlowVariant.tsx create mode 100644 app/src/pages/reportBuilder/pathwayVariations/GuidedFunnelVariant.tsx create mode 100644 app/src/pages/reportBuilder/pathwayVariations/NumberedStepsVariant.tsx create mode 100644 app/src/pages/reportBuilder/pathwayVariations/PathwayVariationsHub.tsx create mode 100644 app/src/pages/reportBuilder/pathwayVariations/TimelineVariant.tsx create mode 100644 app/src/pages/reportBuilder/pathwayVariations/index.ts diff --git a/app/src/CalculatorRouter.tsx b/app/src/CalculatorRouter.tsx index d2ef5368c..ae244fed2 100644 --- a/app/src/CalculatorRouter.tsx +++ b/app/src/CalculatorRouter.tsx @@ -10,6 +10,14 @@ import PoliciesPage from './pages/Policies.page'; import PopulationsPage from './pages/Populations.page'; // Old monolithic file preserved but not used - see ./pages/ReportBuilder.page.tsx import ReportBuilderPage from './pages/reportBuilder/ReportBuilderPage'; +import { + PathwayVariationsHub, + NumberedStepsVariant, + GuidedFunnelVariant, + TimelineVariant, + ChecklistVariant, + FocusedFlowVariant, +} from './pages/reportBuilder/pathwayVariations'; import ReportOutputPage from './pages/ReportOutput.page'; import ReportsPage from './pages/Reports.page'; import SimulationsPage from './pages/Simulations.page'; @@ -125,6 +133,30 @@ const router = createBrowserRouter( path: 'report-builder', element: , }, + { + path: 'report-builder/variants', + element: , + }, + { + path: 'report-builder/variants/numbered-steps', + element: , + }, + { + path: 'report-builder/variants/guided-funnel', + element: , + }, + { + path: 'report-builder/variants/timeline', + element: , + }, + { + path: 'report-builder/variants/checklist', + element: , + }, + { + path: 'report-builder/variants/focused-flow', + element: , + }, { path: 'account', element:
Account settings page
, diff --git a/app/src/components/Sidebar.tsx b/app/src/components/Sidebar.tsx index 4eabc5dd1..cf856b348 100644 --- a/app/src/components/Sidebar.tsx +++ b/app/src/components/Sidebar.tsx @@ -7,6 +7,7 @@ import { IconGitBranch, IconLayoutGrid, IconMail, + IconPalette, IconPlus, IconScale, IconSettings2, @@ -34,6 +35,7 @@ export default function Sidebar({ isOpen = true }: SidebarProps) { const navItems = [ { label: 'Reports', icon: IconFileDescription, path: `/${countryId}/reports` }, { label: 'Report Builder', icon: IconLayoutGrid, path: `/${countryId}/report-builder` }, + { label: 'Builder variants', icon: IconPalette, path: `/${countryId}/report-builder/variants` }, { label: 'Simulations', icon: IconGitBranch, path: `/${countryId}/simulations` }, { label: 'Policies', icon: IconScale, path: `/${countryId}/policies` }, { label: 'Households', icon: IconUsers, path: `/${countryId}/households` }, diff --git a/app/src/pages/reportBuilder/pathwayVariations/ChecklistVariant.tsx b/app/src/pages/reportBuilder/pathwayVariations/ChecklistVariant.tsx new file mode 100644 index 000000000..115f4706d --- /dev/null +++ b/app/src/pages/reportBuilder/pathwayVariations/ChecklistVariant.tsx @@ -0,0 +1,379 @@ +/** + * ChecklistVariant - Clear checklist with completion tracking + * + * Key changes from base: + * - Left sidebar checklist with clear completion states + * - Main content shows only the current step + * - Progress percentage and completion bar + * - Prominent action buttons for each step + */ + +import { Box, Group, Paper, Text, Stack, Progress, Button, Badge } from '@mantine/core'; +import { + IconCheck, + IconCircle, + IconScale, + IconUsers, + IconChartLine, + IconChevronRight, + IconArrowRight, + IconPlayerPlay, +} from '@tabler/icons-react'; +import { colors, spacing, typography } from '@/designTokens'; + +interface ChecklistItemProps { + icon: React.ReactNode; + title: string; + subtitle: string; + status: 'complete' | 'current' | 'pending'; + onClick?: () => void; +} + +function ChecklistItem({ icon, title, subtitle, status, onClick }: ChecklistItemProps) { + const isComplete = status === 'complete'; + const isCurrent = status === 'current'; + + return ( + + {/* Status indicator */} + + {isComplete ? ( + + ) : isCurrent ? ( + + ) : ( + + )} + + + {/* Content */} + + + + {icon} + + + {title} + + + + {isComplete ? subtitle : isCurrent ? 'Click to configure' : 'Pending'} + + + + {/* Arrow for current */} + {isCurrent && ( + + )} + + {/* "NEXT" badge for current */} + {isCurrent && ( + + NEXT + + )} + + ); +} + +interface MainContentPanelProps { + step: { + title: string; + description: string; + icon: React.ReactNode; + colorConfig: { primary: string; light: string; border: string }; + }; + children?: React.ReactNode; +} + +function MainContentPanel({ step, children }: MainContentPanelProps) { + return ( + + {/* Header */} + + + {step.icon} + + + + {step.title} + + + {step.description} + + + + + {/* Content area */} + {children || ( + + + + Make a selection to continue + + + + + + + + )} + + ); +} + +export function ChecklistVariant() { + const completedSteps = 1; + const totalSteps = 3; + const progress = (completedSteps / totalSteps) * 100; + + const steps = [ + { + id: 'policy', + title: 'Select policy', + subtitle: 'Current law', + description: 'Choose a baseline policy for your analysis', + icon: , + colorConfig: { primary: colors.secondary[500], light: colors.secondary[50], border: colors.secondary[200] }, + status: 'complete' as const, + }, + { + id: 'population', + title: 'Choose population', + subtitle: 'Not selected', + description: 'Select who you want to analyze', + icon: , + colorConfig: { primary: colors.primary[500], light: colors.primary[50], border: colors.primary[200] }, + status: 'current' as const, + }, + { + id: 'dynamics', + title: 'Add dynamics', + subtitle: 'Optional', + description: 'Configure behavioral responses', + icon: , + colorConfig: { primary: colors.gray[500], light: colors.gray[50], border: colors.gray[200] }, + status: 'pending' as const, + }, + ]; + + const currentStep = steps.find((s) => s.status === 'current'); + + return ( + + {/* Header */} + + + Report builder + + + Variant 4: Checklist with progress tracking + + + + + {/* Left sidebar - Checklist */} + + {/* Progress header */} + + + + Setup progress + + + {completedSteps}/{totalSteps} complete + + + + + {Math.round(progress)}% complete + + + + {/* Checklist items */} + + {steps.map((step) => ( + + ))} + + + {/* Run button */} + + + + Complete all required steps to run + + + + + {/* Main content - Current step */} + {currentStep && ( + + )} + + + + + ); +} diff --git a/app/src/pages/reportBuilder/pathwayVariations/FocusedFlowVariant.tsx b/app/src/pages/reportBuilder/pathwayVariations/FocusedFlowVariant.tsx new file mode 100644 index 000000000..69be9bdbc --- /dev/null +++ b/app/src/pages/reportBuilder/pathwayVariations/FocusedFlowVariant.tsx @@ -0,0 +1,437 @@ +/** + * FocusedFlowVariant - Progressive disclosure with focused current step + * + * Key changes from base: + * - Only current step is fully visible and interactive + * - Completed steps collapsed to minimal summary + * - Upcoming steps shown as blurred/locked previews + * - Smooth expand/collapse animations + */ + +import { Box, Group, Paper, Text, Stack, Button, Collapse, Badge } from '@mantine/core'; +import { + IconCheck, + IconLock, + IconScale, + IconUsers, + IconChartLine, + IconChevronDown, + IconChevronUp, + IconArrowRight, + IconSparkles, +} from '@tabler/icons-react'; +import { colors, spacing, typography } from '@/designTokens'; +import { useState } from 'react'; + +interface CollapsedStepProps { + icon: React.ReactNode; + title: string; + value: string; + colorConfig: { primary: string; light: string }; + onExpand?: () => void; +} + +function CollapsedStep({ icon, title, value, colorConfig, onExpand }: CollapsedStepProps) { + return ( + + + + + + + {title} + + + {value} + + + {icon} + {onExpand && } + + ); +} + +interface ActiveStepProps { + stepNumber: number; + title: string; + description: string; + icon: React.ReactNode; + colorConfig: { primary: string; light: string; border: string }; + children?: React.ReactNode; +} + +function ActiveStep({ stepNumber, title, description, icon, colorConfig, children }: ActiveStepProps) { + return ( + + {/* Decorative gradient bar */} + + + {/* Header */} + + + + {icon} + + + + Step {stepNumber} — In progress + + + {title} + + + {description} + + + + + + {/* Content */} + {children || ( + + + + + Choose your analysis population + + + Select who you want to include in your policy analysis: the entire nation, a specific region, or a custom household. + + + + + + + + )} + + ); +} + +interface LockedStepProps { + title: string; + description: string; + icon: React.ReactNode; +} + +function LockedStep({ title, description, icon }: LockedStepProps) { + return ( + + {/* Blur overlay */} + + + + + Complete previous step to unlock + + + + + {/* Content (blurred) */} + + + {icon} + + + + {title} + + + {description} + + + + + ); +} + +export function FocusedFlowVariant() { + const [showCompletedDetails, setShowCompletedDetails] = useState(false); + + return ( + + {/* Header */} + + + Build your report + + + Variant 5: Focused flow with progressive disclosure + + + + {/* Content container */} + + + {/* Completed step - Collapsed */} + + + Completed + + } + title="Policy" + value="Current law" + colorConfig={{ primary: colors.secondary[500], light: colors.secondary[50] }} + onExpand={() => setShowCompletedDetails(!showCompletedDetails)} + /> + + + + You selected Current law as your baseline policy. + This represents the existing tax and benefit rules with no changes. + + + + + + + {/* Current step - Expanded */} + + + Current step + + } + colorConfig={{ + primary: colors.primary[500], + light: colors.primary[50], + border: colors.primary[200], + }} + /> + + + {/* Upcoming step - Locked */} + + + Coming up + + } + /> + + + + {/* Skip to run option */} + + + Complete step 2 to continue, or skip optional steps and run your analysis + + + + + + + + + ); +} diff --git a/app/src/pages/reportBuilder/pathwayVariations/GuidedFunnelVariant.tsx b/app/src/pages/reportBuilder/pathwayVariations/GuidedFunnelVariant.tsx new file mode 100644 index 000000000..c58e42e69 --- /dev/null +++ b/app/src/pages/reportBuilder/pathwayVariations/GuidedFunnelVariant.tsx @@ -0,0 +1,364 @@ +/** + * GuidedFunnelVariant - Vertical funnel with connecting arrows + * + * Key changes from base: + * - Vertical funnel layout with clear flow + * - Animated connecting arrows between steps + * - Progress narrows as you complete steps (funnel metaphor) + * - Large "NEXT" indicator on current step + */ + +import { Box, Group, Paper, Text, Button } from '@mantine/core'; +import { + IconCheck, + IconChevronDown, + IconScale, + IconUsers, + IconChartLine, + IconPlayerPlay, +} from '@tabler/icons-react'; +import { colors, spacing, typography } from '@/designTokens'; + +interface FunnelStepProps { + icon: React.ReactNode; + title: string; + description: string; + status: 'complete' | 'current' | 'locked'; + colorConfig: { primary: string; light: string; border: string }; + widthPercent: number; + showArrow?: boolean; +} + +function FunnelStep({ + icon, + title, + description, + status, + colorConfig, + widthPercent, + showArrow = true, +}: FunnelStepProps) { + const isComplete = status === 'complete'; + const isCurrent = status === 'current'; + const isLocked = status === 'locked'; + + return ( + + + {/* Status indicator */} + {isComplete && ( + + + + )} + + {/* NEXT indicator */} + {isCurrent && ( + + NEXT STEP + + )} + + + + + {icon} + + + + {title} + + + {description} + + + + + {isCurrent && ( + + )} + + {isComplete && ( + + Selected: Current law + + )} + + {isLocked && ( + + Complete previous steps first + + )} + + + + {/* Connecting arrow */} + {showArrow && ( + + + + + )} + + ); +} + +export function GuidedFunnelVariant() { + return ( + + + + Build your report + + + Variant 2: Guided funnel with connecting arrows + + + + {/* Progress summary */} + + + + + + + + Step 2 of 3 + + + + {/* Funnel steps */} + + } + title="Select a policy" + description="Choose current law or create a custom reform policy" + status="complete" + colorConfig={{ + primary: colors.secondary[500], + light: colors.secondary[50], + border: colors.secondary[200], + }} + widthPercent={100} + /> + + } + title="Choose your population" + description="Select nationwide, a specific region, or a custom household" + status="current" + colorConfig={{ + primary: colors.primary[500], + light: colors.primary[50], + border: colors.primary[200], + }} + widthPercent={85} + /> + + } + title="Add dynamics (optional)" + description="Configure behavioral responses and economic dynamics" + status="locked" + colorConfig={{ + primary: colors.gray[500], + light: colors.gray[50], + border: colors.gray[200], + }} + widthPercent={70} + showArrow={false} + /> + + + {/* Run button */} + + + + + + + ); +} diff --git a/app/src/pages/reportBuilder/pathwayVariations/NumberedStepsVariant.tsx b/app/src/pages/reportBuilder/pathwayVariations/NumberedStepsVariant.tsx new file mode 100644 index 000000000..b2b06b0d5 --- /dev/null +++ b/app/src/pages/reportBuilder/pathwayVariations/NumberedStepsVariant.tsx @@ -0,0 +1,387 @@ +/** + * NumberedStepsVariant - Adds numbered badges and breadcrumb progress + * + * Key changes from base: + * - Numbered step badges (1, 2, 3) on each ingredient section + * - Horizontal breadcrumb progress bar at top of canvas + * - "Start here" callout on first incomplete step + * - Connecting lines between steps + */ + +import { Box, Group, Paper, Text, Badge } from '@mantine/core'; +import { IconCheck, IconArrowRight, IconPointer } from '@tabler/icons-react'; +import { colors, spacing, typography } from '@/designTokens'; + +// Step configuration +const STEPS = [ + { id: 'policy', label: 'Choose policy', description: 'Select current law or create a reform' }, + { id: 'population', label: 'Choose population', description: 'Pick a geography or household' }, + { id: 'dynamics', label: 'Add dynamics', description: 'Optional: behavioral responses' }, +]; + +interface StepBadgeProps { + number: number; + isComplete: boolean; + isCurrent: boolean; +} + +function StepBadge({ number, isComplete, isCurrent }: StepBadgeProps) { + return ( + + {isComplete ? : number} + {isCurrent && !isComplete && ( + + + + )} + + ); +} + +interface BreadcrumbProgressProps { + completedSteps: number[]; + currentStep: number; +} + +function BreadcrumbProgress({ completedSteps, currentStep }: BreadcrumbProgressProps) { + return ( + + + {STEPS.map((step, index) => { + const isComplete = completedSteps.includes(index); + const isCurrent = currentStep === index; + const isLast = index === STEPS.length - 1; + + return ( + + + + + + {step.label} + + + {step.description} + + + + {!isLast && ( + + {completedSteps.includes(index) && ( + + )} + + )} + + ); + })} + + + ); +} + +interface IngredientSectionWithNumberProps { + stepNumber: number; + title: string; + colorConfig: { icon: string; bg: string; border: string }; + isComplete: boolean; + isCurrent: boolean; + children?: React.ReactNode; +} + +function IngredientSectionWithNumber({ + stepNumber, + title, + colorConfig, + isComplete, + isCurrent, + children, +}: IngredientSectionWithNumberProps) { + return ( + + {/* Step number badge */} + + + {isComplete ? : `Step ${stepNumber}`} + + + + {/* "Start here" callout */} + {isCurrent && !isComplete && ( + + Start here + + )} + + + + {title} + + {children || ( + + + Click to select + + + )} + + + ); +} + +export function NumberedStepsVariant() { + // Simulated state - in real implementation would use actual state + const completedSteps = [0]; // Policy selected + const currentStep = 1; // Population is current + + return ( + + + + Report builder + + + Variant 1: Numbered steps with breadcrumb progress + + + + {/* Breadcrumb progress bar */} + + + {/* Canvas */} + + + {/* Baseline simulation */} + + + Baseline simulation + + + + + + + Current law + + + + + + + + + + + {/* Reform simulation */} + + + Reform simulation (unlocks after baseline) + + + + + + + + + + + + + ); +} diff --git a/app/src/pages/reportBuilder/pathwayVariations/PathwayVariationsHub.tsx b/app/src/pages/reportBuilder/pathwayVariations/PathwayVariationsHub.tsx new file mode 100644 index 000000000..8cbcd3c4a --- /dev/null +++ b/app/src/pages/reportBuilder/pathwayVariations/PathwayVariationsHub.tsx @@ -0,0 +1,268 @@ +/** + * PathwayVariationsHub - Landing page linking to all 5 pathway design variations + */ + +import { Box, Group, Paper, Text, Stack, Badge, SimpleGrid } from '@mantine/core'; +import { Link } from 'react-router-dom'; +import { + IconNumbers, + IconArrowNarrowDown, + IconTimeline, + IconChecklist, + IconFocus2, + IconExternalLink, +} from '@tabler/icons-react'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { colors, spacing, typography } from '@/designTokens'; + +interface VariationCardProps { + icon: React.ReactNode; + title: string; + description: string; + keyChanges: string[]; + path: string; + colorConfig: { primary: string; light: string }; +} + +function VariationCard({ icon, title, description, keyChanges, path, colorConfig }: VariationCardProps) { + const countryId = useCurrentCountry(); + + return ( + + {/* Gradient accent */} + + + + + {icon} + + + + {title} + + + + + + + {description} + + + + + Key changes + + + {keyChanges.map((change, i) => ( + + + + {change} + + + ))} + + + + ); +} + +export function PathwayVariationsHub() { + const variations = [ + { + icon: , + title: 'Numbered steps', + description: 'Adds numbered badges and breadcrumb progress to make the sequential nature clear.', + keyChanges: [ + 'Step badges (1, 2, 3) on each section', + 'Horizontal breadcrumb progress bar', + '"Start here" callout on current step', + ], + path: 'report-builder/variants/numbered-steps', + colorConfig: { primary: colors.secondary[500], light: colors.secondary[50] }, + }, + { + icon: , + title: 'Guided funnel', + description: 'Vertical funnel layout with connecting arrows emphasizing the flow.', + keyChanges: [ + 'Vertical funnel with narrowing steps', + 'Animated connecting arrows', + 'Large "NEXT STEP" indicator', + ], + path: 'report-builder/variants/guided-funnel', + colorConfig: { primary: colors.primary[500], light: colors.primary[50] }, + }, + { + icon: , + title: 'Timeline', + description: 'Horizontal timeline with milestone markers showing where you are in the process.', + keyChanges: [ + 'Prominent horizontal timeline', + 'Milestone markers with icons', + 'Progress line fills as you complete', + ], + path: 'report-builder/variants/timeline', + colorConfig: { primary: '#6366f1', light: '#eef2ff' }, + }, + { + icon: , + title: 'Checklist', + description: 'Clear checklist with sidebar navigation and progress percentage tracking.', + keyChanges: [ + 'Left sidebar checklist', + 'Progress percentage bar', + 'Main content shows current step only', + ], + path: 'report-builder/variants/checklist', + colorConfig: { primary: '#10b981', light: '#d1fae5' }, + }, + { + icon: , + title: 'Focused flow', + description: 'Progressive disclosure showing only the current step prominently.', + keyChanges: [ + 'Only current step fully visible', + 'Completed steps collapsed', + 'Upcoming steps blurred/locked', + ], + path: 'report-builder/variants/focused-flow', + colorConfig: { primary: '#f59e0b', light: '#fef3c7' }, + }, + ]; + + return ( + + {/* Header */} + + + Design exploration + + + Pathway design variations + + + Five visual experiments exploring how to make the report builder setup flow clearer. + Each preserves 75-85% of the current design while adding pathway guidance improvements. + + + + {/* Variation cards */} + + + {variations.map((variation) => ( + + ))} + + + + {/* Footer note */} + + + Note: These are static mockups demonstrating visual approaches. + The actual report builder functionality is available at{' '} + + /report-builder + + + + + ); +} diff --git a/app/src/pages/reportBuilder/pathwayVariations/TimelineVariant.tsx b/app/src/pages/reportBuilder/pathwayVariations/TimelineVariant.tsx new file mode 100644 index 000000000..4cbbbf689 --- /dev/null +++ b/app/src/pages/reportBuilder/pathwayVariations/TimelineVariant.tsx @@ -0,0 +1,379 @@ +/** + * TimelineVariant - Horizontal timeline with milestone markers + * + * Key changes from base: + * - Prominent horizontal timeline at top + * - Milestone markers with icons + * - Progress line fills as you complete steps + * - Current step highlighted with expanded detail + */ + +import { Box, Group, Paper, Text, Stack } from '@mantine/core'; +import { + IconCheck, + IconScale, + IconUsers, + IconChartLine, + IconFlag, + IconPlayerPlay, +} from '@tabler/icons-react'; +import { colors, spacing, typography } from '@/designTokens'; + +interface TimelineMilestoneProps { + icon: React.ReactNode; + label: string; + status: 'complete' | 'current' | 'upcoming'; + isLast?: boolean; +} + +function TimelineMilestone({ icon, label, status, isLast = false }: TimelineMilestoneProps) { + const isComplete = status === 'complete'; + const isCurrent = status === 'current'; + + return ( + + {/* Milestone node */} + + + {isComplete ? ( + + ) : ( + + {icon} + + )} + + + {label} + + + + {/* Connecting line */} + {!isLast && ( + + {/* Animated progress for current step */} + {isCurrent && ( + + )} + + )} + + ); +} + +interface DetailCardProps { + stepNumber: number; + totalSteps: number; + title: string; + description: string; + colorConfig: { primary: string; light: string }; + isActive: boolean; + content?: React.ReactNode; +} + +function DetailCard({ + stepNumber, + totalSteps, + title, + description, + colorConfig, + isActive, + content, +}: DetailCardProps) { + return ( + + {/* Step indicator */} + + + + + + Step {stepNumber} of {totalSteps} + + + {title} + + + {isActive && ( + + IN PROGRESS + + )} + + + + {description} + + + {content || ( + + + {isActive ? 'Click to make a selection' : 'Complete previous steps first'} + + + )} + + ); +} + +export function TimelineVariant() { + return ( + + {/* Header */} + + + Report builder + + + Variant 3: Horizontal timeline with milestone markers + + + + {/* Timeline */} + + + } + label="Select policy" + status="complete" + /> + } + label="Choose population" + status="current" + /> + } + label="Add dynamics" + status="upcoming" + /> + } + label="Review & run" + status="upcoming" + isLast + /> + + + + {/* Detail cards */} + + + + + Current law selected + + + } + /> + + + + + + + {/* Run button */} + + + + Complete all steps to run + + + + + + ); +} diff --git a/app/src/pages/reportBuilder/pathwayVariations/index.ts b/app/src/pages/reportBuilder/pathwayVariations/index.ts new file mode 100644 index 000000000..3e82dca4c --- /dev/null +++ b/app/src/pages/reportBuilder/pathwayVariations/index.ts @@ -0,0 +1,10 @@ +/** + * Pathway Variations - 5 visual experiments for improving user flow clarity + */ + +export { PathwayVariationsHub } from './PathwayVariationsHub'; +export { NumberedStepsVariant } from './NumberedStepsVariant'; +export { GuidedFunnelVariant } from './GuidedFunnelVariant'; +export { TimelineVariant } from './TimelineVariant'; +export { ChecklistVariant } from './ChecklistVariant'; +export { FocusedFlowVariant } from './FocusedFlowVariant'; From 665c516c6d4fff65575eb29bd73ef4ffee23b8e3 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 13 Feb 2026 22:17:59 +0100 Subject: [PATCH 38/73] feat: Add places --- .../components/SimulationCanvas.tsx | 3 +- .../modals/PopulationBrowseModal.tsx | 8 + .../population/PopulationBrowseContent.tsx | 108 ++++--- .../modals/population/StatePlaceSelector.tsx | 288 ++++++++++++++++++ .../reportBuilder/modals/population/index.ts | 1 + app/src/pages/reportBuilder/types.ts | 6 +- app/src/utils/geographyUtils.ts | 8 + app/src/utils/regionStrategies.ts | 14 + 8 files changed, 387 insertions(+), 49 deletions(-) create mode 100644 app/src/pages/reportBuilder/modals/population/StatePlaceSelector.tsx diff --git a/app/src/pages/reportBuilder/components/SimulationCanvas.tsx b/app/src/pages/reportBuilder/components/SimulationCanvas.tsx index 948bfacc5..daa1fe661 100644 --- a/app/src/pages/reportBuilder/components/SimulationCanvas.tsx +++ b/app/src/pages/reportBuilder/components/SimulationCanvas.tsx @@ -15,6 +15,7 @@ import { HouseholdAdapter } from '@/adapters/HouseholdAdapter'; import { getUSStates, getUSCongressionalDistricts, + getUSPlaces, getUKCountries, getUKConstituencies, getUKLocalAuthorities, @@ -162,7 +163,7 @@ export function SimulationCanvas({ const regions = regionOptions || []; const allRegions: RegionOption[] = countryId === 'us' - ? [...getUSStates(regions), ...getUSCongressionalDistricts(regions)] + ? [...getUSStates(regions), ...getUSCongressionalDistricts(regions), ...getUSPlaces()] : [...getUKCountries(regions), ...getUKConstituencies(regions), ...getUKLocalAuthorities(regions)]; const recentGeoIds = geographyUsageStore.getRecentIds(10); diff --git a/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx b/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx index 1ca0ca03b..2a3bc157c 100644 --- a/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx +++ b/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx @@ -30,6 +30,7 @@ import { getUKCountries, getUKLocalAuthorities, getUSCongressionalDistricts, + getUSPlaces, getUSStates, RegionOption, } from '@/utils/regionStrategies'; @@ -137,6 +138,7 @@ export function PopulationBrowseModal({ // US const usStates = getUSStates(regionOptions); const usDistricts = getUSCongressionalDistricts(regionOptions); + const usPlaces = getUSPlaces(); return [ { id: 'states' as const, @@ -150,6 +152,12 @@ export function PopulationBrowseModal({ count: usDistricts.length, regions: usDistricts, }, + { + id: 'places' as const, + label: 'Cities', + count: usPlaces.length, + regions: usPlaces, + }, ]; }, [countryId, regionOptions]); diff --git a/app/src/pages/reportBuilder/modals/population/PopulationBrowseContent.tsx b/app/src/pages/reportBuilder/modals/population/PopulationBrowseContent.tsx index 985f1281e..5824b5527 100644 --- a/app/src/pages/reportBuilder/modals/population/PopulationBrowseContent.tsx +++ b/app/src/pages/reportBuilder/modals/population/PopulationBrowseContent.tsx @@ -6,14 +6,26 @@ * - Region grids (states, districts, etc.) * - Household list */ -import { Box, Group, Text, Stack, TextInput, ScrollArea, Paper, Button, Skeleton, UnstyledButton } from '@mantine/core'; -import { IconSearch, IconHome, IconChevronRight } from '@tabler/icons-react'; +import { IconChevronRight, IconHome, IconSearch } from '@tabler/icons-react'; +import { + Box, + Button, + Group, + Paper, + ScrollArea, + Skeleton, + Stack, + Text, + TextInput, + UnstyledButton, +} from '@mantine/core'; +import { UKOutlineIcon, USOutlineIcon } from '@/components/icons/CountryOutlineIcons'; import { colors, spacing } from '@/designTokens'; import { RegionOption } from '@/utils/regionStrategies'; -import { USOutlineIcon, UKOutlineIcon } from '@/components/icons/CountryOutlineIcons'; import { FONT_SIZES, INGREDIENT_COLORS } from '../../constants'; import { PopulationCategory } from '../../types'; import { StateDistrictSelector } from './StateDistrictSelector'; +import { StatePlaceSelector } from './StatePlaceSelector'; interface HouseholdItem { id: string; @@ -78,8 +90,9 @@ export function PopulationBrowseContent({ }, }; - // StateDistrictSelector handles its own search and header - const showExternalSearchAndHeader = activeCategory !== 'national' && activeCategory !== 'districts'; + // StateDistrictSelector and PlaceSelector handle their own search and header + const showExternalSearchAndHeader = + activeCategory !== 'national' && activeCategory !== 'districts' && activeCategory !== 'places'; return ( @@ -151,7 +164,8 @@ export function PopulationBrowseContent({ {countryId === 'uk' ? 'Households UK-wide' : 'Households nationwide'} - Simulate policy effects across the entire {countryId === 'uk' ? 'United Kingdom' : 'United States'} + Simulate policy effects across the entire{' '} + {countryId === 'uk' ? 'United Kingdom' : 'United States'} - - - - ) : activeCategory === 'my-households' ? ( + {activeCategory === 'my-households' ? ( // Households list householdsLoading ? ( diff --git a/app/src/pages/reportBuilder/pathwayVariations/ChecklistVariant.tsx b/app/src/pages/reportBuilder/pathwayVariations/ChecklistVariant.tsx deleted file mode 100644 index 115f4706d..000000000 --- a/app/src/pages/reportBuilder/pathwayVariations/ChecklistVariant.tsx +++ /dev/null @@ -1,379 +0,0 @@ -/** - * ChecklistVariant - Clear checklist with completion tracking - * - * Key changes from base: - * - Left sidebar checklist with clear completion states - * - Main content shows only the current step - * - Progress percentage and completion bar - * - Prominent action buttons for each step - */ - -import { Box, Group, Paper, Text, Stack, Progress, Button, Badge } from '@mantine/core'; -import { - IconCheck, - IconCircle, - IconScale, - IconUsers, - IconChartLine, - IconChevronRight, - IconArrowRight, - IconPlayerPlay, -} from '@tabler/icons-react'; -import { colors, spacing, typography } from '@/designTokens'; - -interface ChecklistItemProps { - icon: React.ReactNode; - title: string; - subtitle: string; - status: 'complete' | 'current' | 'pending'; - onClick?: () => void; -} - -function ChecklistItem({ icon, title, subtitle, status, onClick }: ChecklistItemProps) { - const isComplete = status === 'complete'; - const isCurrent = status === 'current'; - - return ( - - {/* Status indicator */} - - {isComplete ? ( - - ) : isCurrent ? ( - - ) : ( - - )} - - - {/* Content */} - - - - {icon} - - - {title} - - - - {isComplete ? subtitle : isCurrent ? 'Click to configure' : 'Pending'} - - - - {/* Arrow for current */} - {isCurrent && ( - - )} - - {/* "NEXT" badge for current */} - {isCurrent && ( - - NEXT - - )} - - ); -} - -interface MainContentPanelProps { - step: { - title: string; - description: string; - icon: React.ReactNode; - colorConfig: { primary: string; light: string; border: string }; - }; - children?: React.ReactNode; -} - -function MainContentPanel({ step, children }: MainContentPanelProps) { - return ( - - {/* Header */} - - - {step.icon} - - - - {step.title} - - - {step.description} - - - - - {/* Content area */} - {children || ( - - - - Make a selection to continue - - - - - - - - )} - - ); -} - -export function ChecklistVariant() { - const completedSteps = 1; - const totalSteps = 3; - const progress = (completedSteps / totalSteps) * 100; - - const steps = [ - { - id: 'policy', - title: 'Select policy', - subtitle: 'Current law', - description: 'Choose a baseline policy for your analysis', - icon: , - colorConfig: { primary: colors.secondary[500], light: colors.secondary[50], border: colors.secondary[200] }, - status: 'complete' as const, - }, - { - id: 'population', - title: 'Choose population', - subtitle: 'Not selected', - description: 'Select who you want to analyze', - icon: , - colorConfig: { primary: colors.primary[500], light: colors.primary[50], border: colors.primary[200] }, - status: 'current' as const, - }, - { - id: 'dynamics', - title: 'Add dynamics', - subtitle: 'Optional', - description: 'Configure behavioral responses', - icon: , - colorConfig: { primary: colors.gray[500], light: colors.gray[50], border: colors.gray[200] }, - status: 'pending' as const, - }, - ]; - - const currentStep = steps.find((s) => s.status === 'current'); - - return ( - - {/* Header */} - - - Report builder - - - Variant 4: Checklist with progress tracking - - - - - {/* Left sidebar - Checklist */} - - {/* Progress header */} - - - - Setup progress - - - {completedSteps}/{totalSteps} complete - - - - - {Math.round(progress)}% complete - - - - {/* Checklist items */} - - {steps.map((step) => ( - - ))} - - - {/* Run button */} - - - - Complete all required steps to run - - - - - {/* Main content - Current step */} - {currentStep && ( - - )} - - - - - ); -} diff --git a/app/src/pages/reportBuilder/pathwayVariations/FocusedFlowVariant.tsx b/app/src/pages/reportBuilder/pathwayVariations/FocusedFlowVariant.tsx deleted file mode 100644 index fcc6c91e3..000000000 --- a/app/src/pages/reportBuilder/pathwayVariations/FocusedFlowVariant.tsx +++ /dev/null @@ -1,436 +0,0 @@ -/** - * FocusedFlowVariant - Progressive disclosure with focused current step - * - * Key changes from base: - * - Only current step is fully visible and interactive - * - Completed steps collapsed to minimal summary - * - Upcoming steps shown as blurred/locked previews - * - Smooth expand/collapse animations - */ - -import { Box, Group, Paper, Text, Stack, Button, Collapse, Badge } from '@mantine/core'; -import { - IconCheck, - IconLock, - IconScale, - IconUsers, - IconChartLine, - IconChevronDown, - IconArrowRight, - IconSparkles, -} from '@tabler/icons-react'; -import { colors, spacing, typography } from '@/designTokens'; -import { useState } from 'react'; - -interface CollapsedStepProps { - icon: React.ReactNode; - title: string; - value: string; - colorConfig: { primary: string; light: string }; - onExpand?: () => void; -} - -function CollapsedStep({ icon, title, value, colorConfig, onExpand }: CollapsedStepProps) { - return ( - - - - - - - {title} - - - {value} - - - {icon} - {onExpand && } - - ); -} - -interface ActiveStepProps { - stepNumber: number; - title: string; - description: string; - icon: React.ReactNode; - colorConfig: { primary: string; light: string; border: string }; - children?: React.ReactNode; -} - -function ActiveStep({ stepNumber, title, description, icon, colorConfig, children }: ActiveStepProps) { - return ( - - {/* Decorative gradient bar */} - - - {/* Header */} - - - - {icon} - - - - Step {stepNumber} — In progress - - - {title} - - - {description} - - - - - - {/* Content */} - {children || ( - - - - - Choose your analysis population - - - Select who you want to include in your policy analysis: the entire nation, a specific region, or a custom household. - - - - - - - - )} - - ); -} - -interface LockedStepProps { - title: string; - description: string; - icon: React.ReactNode; -} - -function LockedStep({ title, description, icon }: LockedStepProps) { - return ( - - {/* Blur overlay */} - - - - - Complete previous step to unlock - - - - - {/* Content (blurred) */} - - - {icon} - - - - {title} - - - {description} - - - - - ); -} - -export function FocusedFlowVariant() { - const [showCompletedDetails, setShowCompletedDetails] = useState(false); - - return ( - - {/* Header */} - - - Build your report - - - Variant 5: Focused flow with progressive disclosure - - - - {/* Content container */} - - - {/* Completed step - Collapsed */} - - - Completed - - } - title="Policy" - value="Current law" - colorConfig={{ primary: colors.secondary[500], light: colors.secondary[50] }} - onExpand={() => setShowCompletedDetails(!showCompletedDetails)} - /> - - - - You selected Current law as your baseline policy. - This represents the existing tax and benefit rules with no changes. - - - - - - - {/* Current step - Expanded */} - - - Current step - - } - colorConfig={{ - primary: colors.primary[500], - light: colors.primary[50], - border: colors.primary[200], - }} - /> - - - {/* Upcoming step - Locked */} - - - Coming up - - } - /> - - - - {/* Skip to run option */} - - - Complete step 2 to continue, or skip optional steps and run your analysis - - - - - - - - - ); -} diff --git a/app/src/pages/reportBuilder/pathwayVariations/GuidedFunnelVariant.tsx b/app/src/pages/reportBuilder/pathwayVariations/GuidedFunnelVariant.tsx deleted file mode 100644 index d6c7f1ccb..000000000 --- a/app/src/pages/reportBuilder/pathwayVariations/GuidedFunnelVariant.tsx +++ /dev/null @@ -1,364 +0,0 @@ -/** - * GuidedFunnelVariant - Vertical funnel with connecting arrows - * - * Key changes from base: - * - Vertical funnel layout with clear flow - * - Animated connecting arrows between steps - * - Progress narrows as you complete steps (funnel metaphor) - * - Large "NEXT" indicator on current step - */ - -import { Box, Group, Text, Button } from '@mantine/core'; -import { - IconCheck, - IconChevronDown, - IconScale, - IconUsers, - IconChartLine, - IconPlayerPlay, -} from '@tabler/icons-react'; -import { colors, spacing, typography } from '@/designTokens'; - -interface FunnelStepProps { - icon: React.ReactNode; - title: string; - description: string; - status: 'complete' | 'current' | 'locked'; - colorConfig: { primary: string; light: string; border: string }; - widthPercent: number; - showArrow?: boolean; -} - -function FunnelStep({ - icon, - title, - description, - status, - colorConfig, - widthPercent, - showArrow = true, -}: FunnelStepProps) { - const isComplete = status === 'complete'; - const isCurrent = status === 'current'; - const isLocked = status === 'locked'; - - return ( - - - {/* Status indicator */} - {isComplete && ( - - - - )} - - {/* NEXT indicator */} - {isCurrent && ( - - NEXT STEP - - )} - - - - - {icon} - - - - {title} - - - {description} - - - - - {isCurrent && ( - - )} - - {isComplete && ( - - Selected: Current law - - )} - - {isLocked && ( - - Complete previous steps first - - )} - - - - {/* Connecting arrow */} - {showArrow && ( - - - - - )} - - ); -} - -export function GuidedFunnelVariant() { - return ( - - - - Build your report - - - Variant 2: Guided funnel with connecting arrows - - - - {/* Progress summary */} - - - - - - - - Step 2 of 3 - - - - {/* Funnel steps */} - - } - title="Select a policy" - description="Choose current law or create a custom reform policy" - status="complete" - colorConfig={{ - primary: colors.secondary[500], - light: colors.secondary[50], - border: colors.secondary[200], - }} - widthPercent={100} - /> - - } - title="Choose your population" - description="Select nationwide, a specific region, or a custom household" - status="current" - colorConfig={{ - primary: colors.primary[500], - light: colors.primary[50], - border: colors.primary[200], - }} - widthPercent={85} - /> - - } - title="Add dynamics (optional)" - description="Configure behavioral responses and economic dynamics" - status="locked" - colorConfig={{ - primary: colors.gray[500], - light: colors.gray[50], - border: colors.gray[200], - }} - widthPercent={70} - showArrow={false} - /> - - - {/* Run button */} - - - - - - - ); -} diff --git a/app/src/pages/reportBuilder/pathwayVariations/NumberedStepsVariant.tsx b/app/src/pages/reportBuilder/pathwayVariations/NumberedStepsVariant.tsx deleted file mode 100644 index 385dd84f7..000000000 --- a/app/src/pages/reportBuilder/pathwayVariations/NumberedStepsVariant.tsx +++ /dev/null @@ -1,387 +0,0 @@ -/** - * NumberedStepsVariant - Adds numbered badges and breadcrumb progress - * - * Key changes from base: - * - Numbered step badges (1, 2, 3) on each ingredient section - * - Horizontal breadcrumb progress bar at top of canvas - * - "Start here" callout on first incomplete step - * - Connecting lines between steps - */ - -import { Box, Group, Paper, Text, Badge } from '@mantine/core'; -import { IconCheck, IconArrowRight, IconPointer } from '@tabler/icons-react'; -import { colors, spacing, typography } from '@/designTokens'; - -// Step configuration -const STEPS = [ - { id: 'policy', label: 'Choose policy', description: 'Select current law or create a reform' }, - { id: 'population', label: 'Choose population', description: 'Pick a geography or household' }, - { id: 'dynamics', label: 'Add dynamics', description: 'Optional: behavioral responses' }, -]; - -interface StepBadgeProps { - number: number; - isComplete: boolean; - isCurrent: boolean; -} - -function StepBadge({ number, isComplete, isCurrent }: StepBadgeProps) { - return ( - - {isComplete ? : number} - {isCurrent && !isComplete && ( - - - - )} - - ); -} - -interface BreadcrumbProgressProps { - completedSteps: number[]; - currentStep: number; -} - -function BreadcrumbProgress({ completedSteps, currentStep }: BreadcrumbProgressProps) { - return ( - - - {STEPS.map((step, index) => { - const isComplete = completedSteps.includes(index); - const isCurrent = currentStep === index; - const isLast = index === STEPS.length - 1; - - return ( - - - - - - {step.label} - - - {step.description} - - - - {!isLast && ( - - {completedSteps.includes(index) && ( - - )} - - )} - - ); - })} - - - ); -} - -interface IngredientSectionWithNumberProps { - stepNumber: number; - title: string; - colorConfig: { icon: string; bg: string; border: string }; - isComplete: boolean; - isCurrent: boolean; - children?: React.ReactNode; -} - -function IngredientSectionWithNumber({ - stepNumber, - title, - colorConfig, - isComplete, - isCurrent, - children, -}: IngredientSectionWithNumberProps) { - return ( - - {/* Step number badge */} - - - {isComplete ? : `Step ${stepNumber}`} - - - - {/* "Start here" callout */} - {isCurrent && !isComplete && ( - - Start here - - )} - - - - {title} - - {children || ( - - - Click to select - - - )} - - - ); -} - -export function NumberedStepsVariant() { - // Simulated state - in real implementation would use actual state - const completedSteps = [0]; // Policy selected - const currentStep = 1; // Population is current - - return ( - - - - Report builder - - - Variant 1: Numbered steps with breadcrumb progress - - - - {/* Breadcrumb progress bar */} - - - {/* Canvas */} - - - {/* Baseline simulation */} - - - Baseline simulation - - - - - - - Current law - - - - - - - - - - - {/* Reform simulation */} - - - Reform simulation (unlocks after baseline) - - - - - - - - - - - - - ); -} diff --git a/app/src/pages/reportBuilder/pathwayVariations/PathwayVariationsHub.tsx b/app/src/pages/reportBuilder/pathwayVariations/PathwayVariationsHub.tsx deleted file mode 100644 index e726549fc..000000000 --- a/app/src/pages/reportBuilder/pathwayVariations/PathwayVariationsHub.tsx +++ /dev/null @@ -1,323 +0,0 @@ -/** - * PathwayVariationsHub - Landing page linking to all 5 pathway design variations - */ - -import { Box, Group, Paper, Text, Stack, Badge, SimpleGrid } from '@mantine/core'; -import { Link } from 'react-router-dom'; -import { - IconNumbers, - IconArrowNarrowDown, - IconTimeline, - IconChecklist, - IconFocus2, - IconExternalLink, - IconRefresh, -} from '@tabler/icons-react'; -import { useCurrentCountry } from '@/hooks/useCurrentCountry'; -import { colors, spacing, typography } from '@/designTokens'; -import { TopBarVariants } from './TopBarVariants'; - -interface VariationCardProps { - icon: React.ReactNode; - title: string; - description: string; - keyChanges: string[]; - path: string; - colorConfig: { primary: string; light: string }; -} - -function VariationCard({ icon, title, description, keyChanges, path, colorConfig }: VariationCardProps) { - const countryId = useCurrentCountry(); - - return ( - - {/* Gradient accent */} - - - - - {icon} - - - - {title} - - - - - - - {description} - - - - - Key changes - - - {keyChanges.map((change, i) => ( - - - - {change} - - - ))} - - - - ); -} - -export function PathwayVariationsHub() { - const variations = [ - { - icon: , - title: 'Numbered steps', - description: 'Adds numbered badges and breadcrumb progress to make the sequential nature clear.', - keyChanges: [ - 'Step badges (1, 2, 3) on each section', - 'Horizontal breadcrumb progress bar', - '"Start here" callout on current step', - ], - path: 'report-builder/variants/numbered-steps', - colorConfig: { primary: colors.secondary[500], light: colors.secondary[50] }, - }, - { - icon: , - title: 'Guided funnel', - description: 'Vertical funnel layout with connecting arrows emphasizing the flow.', - keyChanges: [ - 'Vertical funnel with narrowing steps', - 'Animated connecting arrows', - 'Large "NEXT STEP" indicator', - ], - path: 'report-builder/variants/guided-funnel', - colorConfig: { primary: colors.primary[500], light: colors.primary[50] }, - }, - { - icon: , - title: 'Timeline', - description: 'Horizontal timeline with milestone markers showing where you are in the process.', - keyChanges: [ - 'Prominent horizontal timeline', - 'Milestone markers with icons', - 'Progress line fills as you complete', - ], - path: 'report-builder/variants/timeline', - colorConfig: { primary: '#6366f1', light: '#eef2ff' }, - }, - { - icon: , - title: 'Checklist', - description: 'Clear checklist with sidebar navigation and progress percentage tracking.', - keyChanges: [ - 'Left sidebar checklist', - 'Progress percentage bar', - 'Main content shows current step only', - ], - path: 'report-builder/variants/checklist', - colorConfig: { primary: '#10b981', light: '#d1fae5' }, - }, - { - icon: , - title: 'Focused flow', - description: 'Progressive disclosure showing only the current step prominently.', - keyChanges: [ - 'Only current step fully visible', - 'Completed steps collapsed', - 'Upcoming steps blurred/locked', - ], - path: 'report-builder/variants/focused-flow', - colorConfig: { primary: '#f59e0b', light: '#fef3c7' }, - }, - { - icon: , - title: 'Report configuration', - description: 'A re-run oriented view for modifying and re-running existing reports with small tweaks.', - keyChanges: [ - 'Pre-filled from previous report run', - 'Edit icons on each configured ingredient', - '"Re-run" button instead of "Run"', - ], - path: 'report-builder/variants/report-config', - colorConfig: { primary: colors.primary[600], light: colors.primary[50] }, - }, - ]; - - return ( - - {/* Header */} - - - Design exploration - - - Pathway design variations - - - Five visual experiments exploring how to make the report builder setup flow clearer. - Each preserves 75-85% of the current design while adding pathway guidance improvements. - - - - {/* Variation cards */} - - - {variations.map((variation) => ( - - ))} - - - - {/* Top bar redesigns section */} - - - - Component exploration - - - Top bar redesigns - - - Five approaches to button proportions and making report name and year feel clearly editable. - - - - - - - - - {/* Footer note */} - - - Note: These are static mockups demonstrating visual approaches. - The actual report builder functionality is available at{' '} - - /report-builder - - - - - ); -} diff --git a/app/src/pages/reportBuilder/pathwayVariations/ReportConfigVariant.tsx b/app/src/pages/reportBuilder/pathwayVariations/ReportConfigVariant.tsx deleted file mode 100644 index 3ae7f649f..000000000 --- a/app/src/pages/reportBuilder/pathwayVariations/ReportConfigVariant.tsx +++ /dev/null @@ -1,491 +0,0 @@ -/** - * ReportConfigVariant - Report configuration variant for re-running reports - * - * Key changes from base report builder (≤10%): - * - Title: "Report configuration" with "Modify and re-run" subtitle - * - Pre-filled mock data showing a previously-run report - * - Small edit icons on configured ingredients for quick changes - * - "Re-run" button instead of "Run" - * - Subtle "last run" timestamp indicator - */ - -import { Box, Group, Paper, Text } from '@mantine/core'; -import { - IconCheck, - IconChartLine, - IconCopy, - IconFileDescription, - IconHome, - IconPencil, - IconPlayerPlay, - IconRefresh, - IconScale, - IconSelector, - IconSparkles, - IconUsers, -} from '@tabler/icons-react'; -import { colors, spacing, typography } from '@/designTokens'; -import { FONT_SIZES, INGREDIENT_COLORS } from '../constants'; -import { styles } from '../styles'; -import { CountryMapIcon, TopBar } from '../components'; -import type { TopBarAction } from '../types'; - -// Mock pre-filled data representing a previously-run report -const MOCK_REPORT = { - label: 'Child benefit expansion analysis', - year: '2025', - lastRun: '2 hours ago', - simulations: [ - { - label: 'Baseline simulation', - policy: { id: 'current-law', label: 'Current law' }, - population: { id: 'us-nationwide', label: 'US nationwide', type: 'geography' as const }, - }, - { - label: 'Reform simulation', - policy: { id: 'policy-123', label: 'CTC expansion', paramCount: 3 }, - population: { id: 'us-nationwide', label: 'US nationwide', type: 'geography' as const }, - }, - ], -}; - -type IngredientType = 'policy' | 'population' | 'dynamics'; - -interface ConfiguredChipProps { - icon: React.ReactNode; - label: string; - description?: string; - colorConfig: { icon: string; bg: string; border: string }; -} - -function ConfiguredChip({ icon, label, description, colorConfig }: ConfiguredChipProps) { - return ( - - {/* Edit affordance */} - - - - - - {icon} - - - {label} - - {description && ( - - {description} - - )} - - ); -} - -function IngredientSection({ type, config }: { - type: IngredientType; - config?: { label: string; description?: string; populationType?: string }; -}) { - const colorConfig = INGREDIENT_COLORS[type]; - const IconComponent = { - policy: IconScale, - population: IconUsers, - dynamics: IconChartLine, - }[type]; - - const typeLabels = { - policy: 'Policy', - population: 'Household(s)', - dynamics: 'Dynamics', - }; - - return ( - - - - - - - {typeLabels[type]} - - - - - {type === 'policy' && config && ( - - : - } - label={config.label} - description={config.description} - colorConfig={colorConfig} - /> - )} - - {type === 'population' && config && ( - - : - } - label={config.label} - description={config.populationType === 'household' ? 'Household' : 'Nationwide'} - colorConfig={colorConfig} - /> - )} - - {type === 'dynamics' && ( - - - - - Dynamics coming soon - - - - )} - - - ); -} - -function SimulationBlock({ simulation, index }: { - simulation: typeof MOCK_REPORT.simulations[0]; - index: number; -}) { - return ( - - {/* Status indicator - always active since this is a configured report */} - - - {/* Header */} - - - - - {simulation.label} - - - - - - - - - {index === 0 && ( - - Required - - )} - - - - - - - - - - - - - ); -} - -const REPORT_CONFIG_ACTIONS: TopBarAction[] = [ - { - key: 'rerun', - label: 'Re-run', - icon: , - onClick: () => {}, - variant: 'primary', - }, - { - key: 'copy', - label: 'Copy report', - icon: , - onClick: () => {}, - variant: 'secondary', - }, -]; - -export function ReportConfigVariant() { - return ( - - -

Report configuration

- - Modify and re-run your analysis - -
- - - {/* Icon segment */} - - - - - {/* Name segment */} - - - Name - - - {MOCK_REPORT.label} - - - - - {/* Year segment */} - - - Year - - - {MOCK_REPORT.year} - - - - - {/* Last run segment */} - - - - {MOCK_REPORT.lastRun} - - - - - {/* Canvas with simulation blocks */} - - - - {MOCK_REPORT.simulations.map((sim, i) => ( - - ))} - - - - ); -} diff --git a/app/src/pages/reportBuilder/pathwayVariations/TimelineVariant.tsx b/app/src/pages/reportBuilder/pathwayVariations/TimelineVariant.tsx deleted file mode 100644 index abcce1d9c..000000000 --- a/app/src/pages/reportBuilder/pathwayVariations/TimelineVariant.tsx +++ /dev/null @@ -1,379 +0,0 @@ -/** - * TimelineVariant - Horizontal timeline with milestone markers - * - * Key changes from base: - * - Prominent horizontal timeline at top - * - Milestone markers with icons - * - Progress line fills as you complete steps - * - Current step highlighted with expanded detail - */ - -import { Box, Group, Paper, Text } from '@mantine/core'; -import { - IconCheck, - IconScale, - IconUsers, - IconChartLine, - IconFlag, - IconPlayerPlay, -} from '@tabler/icons-react'; -import { colors, spacing, typography } from '@/designTokens'; - -interface TimelineMilestoneProps { - icon: React.ReactNode; - label: string; - status: 'complete' | 'current' | 'upcoming'; - isLast?: boolean; -} - -function TimelineMilestone({ icon, label, status, isLast = false }: TimelineMilestoneProps) { - const isComplete = status === 'complete'; - const isCurrent = status === 'current'; - - return ( - - {/* Milestone node */} - - - {isComplete ? ( - - ) : ( - - {icon} - - )} - - - {label} - - - - {/* Connecting line */} - {!isLast && ( - - {/* Animated progress for current step */} - {isCurrent && ( - - )} - - )} - - ); -} - -interface DetailCardProps { - stepNumber: number; - totalSteps: number; - title: string; - description: string; - colorConfig: { primary: string; light: string }; - isActive: boolean; - content?: React.ReactNode; -} - -function DetailCard({ - stepNumber, - totalSteps, - title, - description, - colorConfig, - isActive, - content, -}: DetailCardProps) { - return ( - - {/* Step indicator */} - - - - - - Step {stepNumber} of {totalSteps} - - - {title} - - - {isActive && ( - - IN PROGRESS - - )} - - - - {description} - - - {content || ( - - - {isActive ? 'Click to make a selection' : 'Complete previous steps first'} - - - )} - - ); -} - -export function TimelineVariant() { - return ( - - {/* Header */} - - - Report builder - - - Variant 3: Horizontal timeline with milestone markers - - - - {/* Timeline */} - - - } - label="Select policy" - status="complete" - /> - } - label="Choose population" - status="current" - /> - } - label="Add dynamics" - status="upcoming" - /> - } - label="Review & run" - status="upcoming" - isLast - /> - - - - {/* Detail cards */} - - - - - Current law selected - - - } - /> - - - - - - - {/* Run button */} - - - - Complete all steps to run - - - - - - ); -} diff --git a/app/src/pages/reportBuilder/pathwayVariations/TopBarVariants.tsx b/app/src/pages/reportBuilder/pathwayVariations/TopBarVariants.tsx deleted file mode 100644 index adbff6b3d..000000000 --- a/app/src/pages/reportBuilder/pathwayVariations/TopBarVariants.tsx +++ /dev/null @@ -1,936 +0,0 @@ -/** - * TopBarVariants - Five redesigns for the report builder top bar - * - * Each variant addresses: - * - Buttons that are proportional (not stretched to bar height) - * - Clear editability affordances for name and year - */ - -import { Box, Group, Paper, Text } from '@mantine/core'; -import { - IconCopy, - IconFileDescription, - IconPencil, - IconPlayerPlay, - IconRefresh, - IconSelector, -} from '@tabler/icons-react'; -import { colors, spacing, typography } from '@/designTokens'; -import { FONT_SIZES } from '../constants'; - -const MOCK = { - label: 'Child benefit expansion analysis', - year: '2025', - lastRun: '2 hours ago', -}; - -// ============================================================================ -// SHARED ELEMENTS -// ============================================================================ - -function PrimaryButton({ label, icon }: { label: string; icon: React.ReactNode }) { - return ( - - {icon} - {label} - - ); -} - -function SecondaryButton({ label, icon }: { label: string; icon: React.ReactNode }) { - return ( - - {icon} - {label} - - ); -} - -function VariantLabel({ number, title, description }: { number: number; title: string; description: string }) { - return ( - - - {number}. {title} - - - {description} - - - ); -} - -// ============================================================================ -// VARIANT A: Centered buttons (fix the stretch, keep the layout) -// ============================================================================ - -function VariantA() { - return ( - - - - {/* Meta panel */} - - - - - - {/* Editable name */} - - - {MOCK.label} - - - - - - - {/* Editable year */} - - - {MOCK.year} - - - - - - {/* Buttons - centered, not stretched */} - } /> - } /> - - - ); -} - -// ============================================================================ -// VARIANT B: Two-row bar (info on top, actions below) -// ============================================================================ - -function VariantB() { - return ( - - - - {/* Top row: meta info */} - - - - - - {/* Editable name with underline */} - - - {MOCK.label} - - - - - - - {/* Editable year with underline */} - - - {MOCK.year} - - - - - {/* Last run */} - - - - Last run {MOCK.lastRun} - - - - - {/* Bottom row: action strip */} - - } /> - } /> - - - - ); -} - -// ============================================================================ -// VARIANT C: Inline editable fields with pill buttons -// ============================================================================ - -function VariantC() { - return ( - - - - {/* Meta panel with input-like fields */} - - - - - - {/* Name field */} - - - Report name - - - - {MOCK.label} - - - - - - {/* Year field */} - - - Year - - - - {MOCK.year} - - - - - - {/* Last run */} - - - - {MOCK.lastRun} - - - - - {/* Pill buttons */} - - - - Re-run - - - - Copy report - - - - - ); -} - -// ============================================================================ -// VARIANT D: Integrated toolbar (everything in one bar, buttons inside) -// ============================================================================ - -function VariantD() { - return ( - - - - - - - - {/* Name chip */} - - - {MOCK.label} - - - - - {/* Year chip */} - - - {MOCK.year} - - - - - {/* Last run */} - - - - {MOCK.lastRun} - - - - {/* Divider */} - - - {/* Compact buttons inside the bar */} - - - Copy - - - - - Re-run - - - - ); -} - -// ============================================================================ -// VARIANT E: Breadcrumb-style editable segments -// ============================================================================ - -function VariantE() { - return ( - - - - {/* Icon segment */} - - - - - {/* Name segment */} - - - Name - - - {MOCK.label} - - - - - {/* Year segment */} - - - Year - - - {MOCK.year} - - - - - {/* Last run segment */} - - - - {MOCK.lastRun} - - - - {/* Button group */} - - - - Copy - - - - Re-run - - - - - ); -} - -// ============================================================================ -// COMBINED EXPORT -// ============================================================================ - -export function TopBarVariants() { - return ( - - - - - - - - ); -} diff --git a/app/src/pages/reportBuilder/pathwayVariations/index.ts b/app/src/pages/reportBuilder/pathwayVariations/index.ts deleted file mode 100644 index cd872660f..000000000 --- a/app/src/pages/reportBuilder/pathwayVariations/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Pathway Variations - 5 visual experiments for improving user flow clarity - */ - -export { PathwayVariationsHub } from './PathwayVariationsHub'; -export { NumberedStepsVariant } from './NumberedStepsVariant'; -export { GuidedFunnelVariant } from './GuidedFunnelVariant'; -export { TimelineVariant } from './TimelineVariant'; -export { ChecklistVariant } from './ChecklistVariant'; -export { FocusedFlowVariant } from './FocusedFlowVariant'; -export { ReportConfigVariant } from './ReportConfigVariant'; -export { TopBarVariants } from './TopBarVariants'; diff --git a/app/src/pages/reportBuilder/types.ts b/app/src/pages/reportBuilder/types.ts index 91574095b..8791c1cf1 100644 --- a/app/src/pages/reportBuilder/types.ts +++ b/app/src/pages/reportBuilder/types.ts @@ -68,7 +68,7 @@ export interface SidebarItem { export interface BrowseModalSidebarSection { id: string; label: string; - items: SidebarItem[]; + items?: SidebarItem[]; } export interface BrowseModalTemplateProps { @@ -274,7 +274,7 @@ export interface ReportMetaPanelProps { // ============================================================================ export type PopulationCategory = - | 'national' + | 'frequently-selected' | 'states' | 'districts' | 'places' From 747dd95fd9eef18410f641a830f1320f680ca810 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 19 Feb 2026 00:17:52 +0100 Subject: [PATCH 43/73] feat: Expand use cases that report builder supports --- .../pages/reportBuilder/ReportBuilderPage.tsx | 251 ++++-------------- .../components/ReportBuilderShell.tsx | 54 ++++ .../pages/reportBuilder/components/index.ts | 1 + .../hooks/useReportSubmission.ts | 187 +++++++++++++ app/src/pages/reportBuilder/types.ts | 1 + 5 files changed, 289 insertions(+), 205 deletions(-) create mode 100644 app/src/pages/reportBuilder/components/ReportBuilderShell.tsx create mode 100644 app/src/pages/reportBuilder/hooks/useReportSubmission.ts diff --git a/app/src/pages/reportBuilder/ReportBuilderPage.tsx b/app/src/pages/reportBuilder/ReportBuilderPage.tsx index d9d2ba5ae..b57b9af01 100644 --- a/app/src/pages/reportBuilder/ReportBuilderPage.tsx +++ b/app/src/pages/reportBuilder/ReportBuilderPage.tsx @@ -1,37 +1,29 @@ /** - * ReportBuilderPage - Main page component for the report builder + * ReportBuilderPage - Setup mode for creating a new report * - * Design Direction: Refined utilitarian with distinct color coding. - * - Policy: Secondary (slate) - authoritative, grounded - * - Population: Primary (teal) - brand-focused, people - * - Dynamics: Blue - forward-looking, data-driven + * Composes ReportBuilderShell with: + * - Blank state initialization + * - useReportSubmission for create-only submission + * - Auto-add second simulation when geography is selected + * - Single "Run" top bar action */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { IconPlayerPlay } from '@tabler/icons-react'; -import { useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; -import { Box } from '@mantine/core'; -import { ReportAdapter, SimulationAdapter } from '@/adapters'; -import { createSimulation } from '@/api/simulation'; import { CURRENT_YEAR } from '@/constants'; -import { useCreateReport } from '@/hooks/useCreateReport'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; -import { RootState } from '@/store'; -import { Report } from '@/types/ingredients/Report'; -import { Simulation } from '@/types/ingredients/Simulation'; -import { SimulationStateProps } from '@/types/pathwayState'; import { initializeSimulationState } from '@/utils/pathwayState/initializeSimulationState'; import { getReportOutputPath } from '@/utils/reportRouting'; -import { ReportMetaPanel, SimulationCanvas, SimulationBlockFull, TopBar } from './components'; -import { styles } from './styles'; +import { ReportBuilderShell, SimulationBlockFull } from './components'; +import { useReportSubmission } from './hooks/useReportSubmission'; import type { IngredientPickerState, ReportBuilderState, TopBarAction } from './types'; export default function ReportBuilderPage() { const countryId = useCurrentCountry() as 'us' | 'uk'; const navigate = useNavigate(); - const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId); + // State initialization (setup mode: blank) const initialSim = initializeSimulationState(); initialSim.label = 'Baseline simulation'; @@ -47,204 +39,53 @@ export default function ReportBuilderPage() { ingredientType: 'policy', }); - const [isSubmitting, setIsSubmitting] = useState(false); - - const { createReport } = useCreateReport(reportState.label || undefined); + // Submission logic (extracted hook) + const { handleSubmit, isSubmitting, isReportConfigured } = useReportSubmission({ + reportState, + countryId, + onSuccess: (userReportId) => { + navigate(getReportOutputPath(countryId, userReportId)); + }, + }); - // Any geography selection (nationwide or subnational) requires dual-simulation + // Auto-add second simulation when geography is selected (setup mode only) const isGeographySelected = !!reportState.simulations[0]?.population?.geography?.id; useEffect(() => { - if (isGeographySelected && reportState.simulations.length === 1) { + if (!reportState.id && isGeographySelected && reportState.simulations.length === 1) { const newSim = initializeSimulationState(); newSim.label = 'Reform simulation'; newSim.population = { ...reportState.simulations[0].population }; setReportState((prev) => ({ ...prev, simulations: [...prev.simulations, newSim] })); } - }, [isGeographySelected, reportState.simulations]); - - const isReportConfigured = reportState.simulations.every( - (sim) => !!sim.policy.id && !!(sim.population.household?.id || sim.population.geography?.id) - ); - - const convertToSimulation = useCallback( - (simState: SimulationStateProps, simulationId: string): Simulation | null => { - const policyId = simState.policy?.id; - if (!policyId) { - return null; - } - - let populationId: string | undefined; - let populationType: 'household' | 'geography' | undefined; - - if (simState.population?.household?.id) { - populationId = simState.population.household.id; - populationType = 'household'; - } else if (simState.population?.geography?.geographyId) { - populationId = simState.population.geography.geographyId; - populationType = 'geography'; - } - - if (!populationId || !populationType) { - return null; - } - - return { - id: simulationId, - countryId, - apiVersion: undefined, - policyId: policyId === 'current-law' ? currentLawId.toString() : policyId, - populationId, - populationType, - label: simState.label, - isCreated: true, - output: null, - status: 'pending', - }; - }, - [countryId, currentLawId] + }, [reportState.id, isGeographySelected, reportState.simulations]); + + // Top bar actions (setup mode: just "Run") + const topBarActions: TopBarAction[] = useMemo( + () => [ + { + key: 'run', + label: 'Run', + icon: , + onClick: handleSubmit, + variant: 'primary', + disabled: !isReportConfigured, + loading: isSubmitting, + loadingLabel: 'Running...', + }, + ], + [handleSubmit, isReportConfigured, isSubmitting] ); - const handleRunReport = useCallback(async () => { - if (!isReportConfigured || isSubmitting) { - return; - } - - setIsSubmitting(true); - - try { - const simulationIds: string[] = []; - const simulations: (Simulation | null)[] = []; - - for (const simState of reportState.simulations) { - const policyId = - simState.policy?.id === 'current-law' ? currentLawId.toString() : simState.policy?.id; - - if (!policyId) { - console.error('[ReportBuilderPage] Simulation missing policy ID'); - continue; - } - - let populationId: string | undefined; - let populationType: 'household' | 'geography' | undefined; - - if (simState.population?.household?.id) { - populationId = simState.population.household.id; - populationType = 'household'; - } else if (simState.population?.geography?.geographyId) { - populationId = simState.population.geography.geographyId; - populationType = 'geography'; - } - - if (!populationId || !populationType) { - console.error('[ReportBuilderPage] Simulation missing population'); - continue; - } - - const simulationData: Partial = { - populationId, - policyId, - populationType, - }; - - const payload = SimulationAdapter.toCreationPayload(simulationData); - const result = await createSimulation(countryId, payload); - const simulationId = result.result.simulation_id; - simulationIds.push(simulationId); - - const simulation = convertToSimulation(simState, simulationId); - simulations.push(simulation); - } - - if (simulationIds.length === 0) { - console.error('[ReportBuilderPage] No simulations created'); - setIsSubmitting(false); - return; - } - - const reportData: Partial = { - countryId, - year: reportState.year, - simulationIds, - apiVersion: null, - }; - - const serializedPayload = ReportAdapter.toCreationPayload(reportData as Report); - - await createReport( - { - countryId, - payload: serializedPayload, - simulations: { - simulation1: simulations[0], - simulation2: simulations[1] || null, - }, - populations: { - household1: reportState.simulations[0]?.population?.household || null, - household2: reportState.simulations[1]?.population?.household || null, - geography1: reportState.simulations[0]?.population?.geography || null, - geography2: reportState.simulations[1]?.population?.geography || null, - }, - }, - { - onSuccess: (data) => { - const outputPath = getReportOutputPath(countryId, data.userReport.id); - navigate(outputPath); - }, - onError: (error) => { - console.error('[ReportBuilderPage] Report creation failed:', error); - setIsSubmitting(false); - }, - } - ); - } catch (error) { - console.error('[ReportBuilderPage] Error running report:', error); - setIsSubmitting(false); - } - }, [ - isReportConfigured, - isSubmitting, - reportState, - countryId, - currentLawId, - createReport, - convertToSimulation, - navigate, - ]); - - const topBarActions: TopBarAction[] = useMemo(() => [ - { - key: 'run', - label: 'Run', - icon: , - onClick: handleRunReport, - variant: 'primary', - disabled: !isReportConfigured, - loading: isSubmitting, - loadingLabel: 'Running...', - }, - ], [handleRunReport, isReportConfigured, isSubmitting]); - return ( - - -

Report builder

-
- - - - - - -
+ ); } diff --git a/app/src/pages/reportBuilder/components/ReportBuilderShell.tsx b/app/src/pages/reportBuilder/components/ReportBuilderShell.tsx new file mode 100644 index 000000000..f09f78dfe --- /dev/null +++ b/app/src/pages/reportBuilder/components/ReportBuilderShell.tsx @@ -0,0 +1,54 @@ +/** + * ReportBuilderShell - Reusable visual shell for the report builder + * + * Renders the page layout: header + TopBar (with ReportMetaPanel + actions) + SimulationCanvas. + * Accepts all logic via props so different modes (setup, modify) can compose it. + */ +import { Box } from '@mantine/core'; +import type { SimulationBlockProps } from './SimulationBlock'; +import type { IngredientPickerState, ReportBuilderState, TopBarAction } from '../types'; +import { styles } from '../styles'; +import { ReportMetaPanel } from './ReportMetaPanel'; +import { SimulationCanvas } from './SimulationCanvas'; +import { SimulationBlockFull } from './SimulationBlockFull'; +import { TopBar } from './TopBar'; + +interface ReportBuilderShellProps { + title: string; + actions: TopBarAction[]; + reportState: ReportBuilderState; + setReportState: React.Dispatch>; + pickerState: IngredientPickerState; + setPickerState: React.Dispatch>; + BlockComponent?: React.ComponentType; +} + +export function ReportBuilderShell({ + title, + actions, + reportState, + setReportState, + pickerState, + setPickerState, + BlockComponent = SimulationBlockFull, +}: ReportBuilderShellProps) { + return ( + + +

{title}

+
+ + + + + + +
+ ); +} diff --git a/app/src/pages/reportBuilder/components/index.ts b/app/src/pages/reportBuilder/components/index.ts index 873ee55fd..d0868c57b 100644 --- a/app/src/pages/reportBuilder/components/index.ts +++ b/app/src/pages/reportBuilder/components/index.ts @@ -22,3 +22,4 @@ export { AddSimulationCard } from './AddSimulationCard'; export { ReportMetaPanel } from './ReportMetaPanel'; export { SimulationCanvas } from './SimulationCanvas'; export { TopBar } from './TopBar'; +export { ReportBuilderShell } from './ReportBuilderShell'; diff --git a/app/src/pages/reportBuilder/hooks/useReportSubmission.ts b/app/src/pages/reportBuilder/hooks/useReportSubmission.ts new file mode 100644 index 000000000..6dff3d73f --- /dev/null +++ b/app/src/pages/reportBuilder/hooks/useReportSubmission.ts @@ -0,0 +1,187 @@ +/** + * useReportSubmission - Extracted submission logic for creating a new report + * + * Handles: + * - Sequential simulation creation via API + * - Report creation with simulation IDs + * - isReportConfigured derivation + * - isSubmitting state + * + * Accepts an onSuccess callback instead of navigating directly, + * so the consuming page controls routing. + */ +import { useCallback, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { ReportAdapter, SimulationAdapter } from '@/adapters'; +import { createSimulation } from '@/api/simulation'; +import { useCreateReport } from '@/hooks/useCreateReport'; +import { RootState } from '@/store'; +import { Report } from '@/types/ingredients/Report'; +import { Simulation } from '@/types/ingredients/Simulation'; +import { SimulationStateProps } from '@/types/pathwayState'; +import { ReportBuilderState } from '../types'; + +interface UseReportSubmissionArgs { + reportState: ReportBuilderState; + countryId: 'us' | 'uk'; + onSuccess: (userReportId: string) => void; +} + +interface UseReportSubmissionReturn { + handleSubmit: () => Promise; + isSubmitting: boolean; + isReportConfigured: boolean; +} + +function convertToSimulation( + simState: SimulationStateProps, + simulationId: string, + countryId: 'us' | 'uk', + currentLawId: number +): Simulation | null { + const policyId = simState.policy?.id; + if (!policyId) { + return null; + } + + let populationId: string | undefined; + let populationType: 'household' | 'geography' | undefined; + + if (simState.population?.household?.id) { + populationId = simState.population.household.id; + populationType = 'household'; + } else if (simState.population?.geography?.geographyId) { + populationId = simState.population.geography.geographyId; + populationType = 'geography'; + } + + if (!populationId || !populationType) { + return null; + } + + return { + id: simulationId, + countryId, + apiVersion: undefined, + policyId: policyId === 'current-law' ? currentLawId.toString() : policyId, + populationId, + populationType, + label: simState.label, + isCreated: true, + output: null, + status: 'pending', + }; +} + +export function useReportSubmission({ + reportState, + countryId, + onSuccess, +}: UseReportSubmissionArgs): UseReportSubmissionReturn { + const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId); + const [isSubmitting, setIsSubmitting] = useState(false); + const { createReport } = useCreateReport(reportState.label || undefined); + + const isReportConfigured = reportState.simulations.every( + (sim) => !!sim.policy.id && !!(sim.population.household?.id || sim.population.geography?.id) + ); + + const handleSubmit = useCallback(async () => { + if (!isReportConfigured || isSubmitting) { + return; + } + + setIsSubmitting(true); + + try { + const simulationIds: string[] = []; + const simulations: (Simulation | null)[] = []; + + for (const simState of reportState.simulations) { + const policyId = + simState.policy?.id === 'current-law' ? currentLawId.toString() : simState.policy?.id; + + if (!policyId) { + console.error('[useReportSubmission] Simulation missing policy ID'); + continue; + } + + let populationId: string | undefined; + let populationType: 'household' | 'geography' | undefined; + + if (simState.population?.household?.id) { + populationId = simState.population.household.id; + populationType = 'household'; + } else if (simState.population?.geography?.geographyId) { + populationId = simState.population.geography.geographyId; + populationType = 'geography'; + } + + if (!populationId || !populationType) { + console.error('[useReportSubmission] Simulation missing population'); + continue; + } + + const simulationData: Partial = { + populationId, + policyId, + populationType, + }; + + const payload = SimulationAdapter.toCreationPayload(simulationData); + const result = await createSimulation(countryId, payload); + const simulationId = result.result.simulation_id; + simulationIds.push(simulationId); + + const simulation = convertToSimulation(simState, simulationId, countryId, currentLawId); + simulations.push(simulation); + } + + if (simulationIds.length === 0) { + console.error('[useReportSubmission] No simulations created'); + setIsSubmitting(false); + return; + } + + const reportData: Partial = { + countryId, + year: reportState.year, + simulationIds, + apiVersion: null, + }; + + const serializedPayload = ReportAdapter.toCreationPayload(reportData as Report); + + await createReport( + { + countryId, + payload: serializedPayload, + simulations: { + simulation1: simulations[0], + simulation2: simulations[1] || null, + }, + populations: { + household1: reportState.simulations[0]?.population?.household || null, + household2: reportState.simulations[1]?.population?.household || null, + geography1: reportState.simulations[0]?.population?.geography || null, + geography2: reportState.simulations[1]?.population?.geography || null, + }, + }, + { + onSuccess: (data) => { + onSuccess(data.userReport.id); + }, + onError: (error) => { + console.error('[useReportSubmission] Report creation failed:', error); + setIsSubmitting(false); + }, + } + ); + } catch (error) { + console.error('[useReportSubmission] Error running report:', error); + setIsSubmitting(false); + } + }, [isReportConfigured, isSubmitting, reportState, countryId, currentLawId, createReport, onSuccess]); + + return { handleSubmit, isSubmitting, isReportConfigured }; +} diff --git a/app/src/pages/reportBuilder/types.ts b/app/src/pages/reportBuilder/types.ts index 8791c1cf1..37e5a2e17 100644 --- a/app/src/pages/reportBuilder/types.ts +++ b/app/src/pages/reportBuilder/types.ts @@ -9,6 +9,7 @@ import { PopulationStateProps, SimulationStateProps } from '@/types/pathwayState // ============================================================================ export interface ReportBuilderState { + id?: string; label: string | null; year: string; simulations: SimulationStateProps[]; From 184433c28a0d3afbf10fde505f8cb5d47373bf0b Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 19 Feb 2026 01:07:19 +0100 Subject: [PATCH 44/73] feat: Report modification screen --- app/src/CalculatorRouter.tsx | 6 +- app/src/components/Sidebar.tsx | 2 - .../components/icons/CountryOutlineIcons.tsx | 60 +- .../components/report/ReportActionButtons.tsx | 24 +- app/src/hooks/useUserReports.ts | 12 +- app/src/pages/ModifyReportDemo.page.tsx | 780 ------------------ app/src/pages/ReportOutput.page.tsx | 8 + app/src/pages/Reports.page.tsx | 8 +- .../report-output/ReportOutputLayout.tsx | 3 + .../pages/reportBuilder/ModifyReportPage.tsx | 157 ++++ .../components/AddSimulationCard.tsx | 12 +- .../components/EditableLabel.tsx | 4 +- .../components/IngredientSection.tsx | 75 +- .../components/IngredientSectionFull.tsx | 87 +- .../components/ReportBuilderShell.tsx | 6 +- .../components/ReportMetaPanel.tsx | 15 +- .../components/SimulationBlock.tsx | 10 +- .../components/SimulationBlockFull.tsx | 7 +- .../components/SimulationCanvas.tsx | 37 +- .../components/SimulationCanvasSkeleton.tsx | 26 +- .../pages/reportBuilder/components/TopBar.tsx | 22 +- .../components/chips/BrowseMoreChip.tsx | 17 +- .../components/chips/CreateCustomChip.tsx | 9 +- .../components/chips/OptionChipRow.tsx | 10 +- .../components/chips/OptionChipSquare.tsx | 10 +- .../pages/reportBuilder/components/index.ts | 13 +- .../components/shared/CountryMapIcon.tsx | 5 +- .../shared/CreationStatusHeader.tsx | 2 +- app/src/pages/reportBuilder/constants.ts | 5 +- .../hooks/useReportSubmission.ts | 10 +- .../hooks/useSimulationCanvas.ts | 170 ++-- .../modals/BrowseModalTemplate.tsx | 68 +- .../modals/PolicyBrowseModal.tsx | 47 +- .../modals/PolicyCreationModal.tsx | 1 - .../modals/PopulationBrowseModal.tsx | 51 +- .../modals/policy/PolicyBrowseContent.tsx | 31 +- .../modals/policy/PolicyCreationContent.tsx | 71 +- .../modals/policy/PolicyParameterTree.tsx | 50 +- .../population/PopulationBrowseContent.tsx | 3 +- .../population/StateDistrictSelector.tsx | 44 +- app/src/pages/reportBuilder/styles.ts | 2 +- .../utils/hydrateReportBuilderState.ts | 101 +++ app/src/utils/geographyUtils.ts | 7 +- 43 files changed, 895 insertions(+), 1193 deletions(-) delete mode 100644 app/src/pages/ModifyReportDemo.page.tsx create mode 100644 app/src/pages/reportBuilder/ModifyReportPage.tsx create mode 100644 app/src/pages/reportBuilder/utils/hydrateReportBuilderState.ts diff --git a/app/src/CalculatorRouter.tsx b/app/src/CalculatorRouter.tsx index a37622fd6..22fb24043 100644 --- a/app/src/CalculatorRouter.tsx +++ b/app/src/CalculatorRouter.tsx @@ -8,9 +8,9 @@ import StandardLayout from './components/StandardLayout'; import DashboardPage from './pages/Dashboard.page'; import PoliciesPage from './pages/Policies.page'; import PopulationsPage from './pages/Populations.page'; +import ModifyReportPage from './pages/reportBuilder/ModifyReportPage'; // Old monolithic file preserved but not used - see ./pages/ReportBuilder.page.tsx import ReportBuilderPage from './pages/reportBuilder/ReportBuilderPage'; -import ModifyReportDemoPage from './pages/ModifyReportDemo.page'; import ReportOutputPage from './pages/ReportOutput.page'; import ReportsPage from './pages/Reports.page'; import SimulationsPage from './pages/Simulations.page'; @@ -127,8 +127,8 @@ const router = createBrowserRouter( element: , }, { - path: 'modify-report-demo', - element: , + path: 'report-builder/:userReportId', + element: , }, { path: 'account', diff --git a/app/src/components/Sidebar.tsx b/app/src/components/Sidebar.tsx index 709bb63d5..27a9beb38 100644 --- a/app/src/components/Sidebar.tsx +++ b/app/src/components/Sidebar.tsx @@ -9,7 +9,6 @@ import { IconMail, IconPlus, IconScale, - IconSettings, IconSettings2, IconUsers, } from '@tabler/icons-react'; @@ -35,7 +34,6 @@ export default function Sidebar({ isOpen = true }: SidebarProps) { const navItems = [ { label: 'Reports', icon: IconFileDescription, path: `/${countryId}/reports` }, { label: 'Report builder', icon: IconLayoutGrid, path: `/${countryId}/report-builder` }, - { label: 'Modify demo', icon: IconSettings, path: `/${countryId}/modify-report-demo` }, { label: 'Simulations', icon: IconGitBranch, path: `/${countryId}/simulations` }, { label: 'Policies', icon: IconScale, path: `/${countryId}/policies` }, { label: 'Households', icon: IconUsers, path: `/${countryId}/households` }, diff --git a/app/src/components/icons/CountryOutlineIcons.tsx b/app/src/components/icons/CountryOutlineIcons.tsx index 0fded0129..053b4c9d8 100644 --- a/app/src/components/icons/CountryOutlineIcons.tsx +++ b/app/src/components/icons/CountryOutlineIcons.tsx @@ -21,12 +21,9 @@ export function USOutlineIcon({ size = 24, color, className, style }: CountryIco className={className} style={{ ...style, display: 'inline-block', verticalAlign: 'middle' }} > - - + - +-33 7 -50 37 -93 162 -32 95 -76 151 -117 151 -17 0 -44 -17 -80 -50z" + /> + ); @@ -148,29 +148,25 @@ export function UKOutlineIcon({ size = 24, color, className, style }: CountryIco className={className} style={{ ...style, display: 'inline-block', verticalAlign: 'middle' }} > - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + ); diff --git a/app/src/components/report/ReportActionButtons.tsx b/app/src/components/report/ReportActionButtons.tsx index c99ddc089..568359ef2 100644 --- a/app/src/components/report/ReportActionButtons.tsx +++ b/app/src/components/report/ReportActionButtons.tsx @@ -1,4 +1,4 @@ -import { IconBookmark, IconPencil, IconShare } from '@tabler/icons-react'; +import { IconBookmark, IconPencil, IconSettings, IconShare } from '@tabler/icons-react'; import { ActionIcon, Tooltip } from '@mantine/core'; import { colors, typography } from '@/designTokens'; @@ -7,6 +7,7 @@ interface ReportActionButtonsProps { onShare?: () => void; onSave?: () => void; onEdit?: () => void; + onModify?: () => void; } /** @@ -21,6 +22,7 @@ export function ReportActionButtons({ onShare, onSave, onEdit, + onModify, }: ReportActionButtonsProps) { if (isSharedView) { return ( @@ -58,6 +60,26 @@ export function ReportActionButtons({ >
+ + + + + s.populationType === 'household'); const householdIds = extractUniqueIds(householdSimulations, 'populationId'); - const householdResults = useParallelQueries(householdIds, { + const householdResults = useParallelQueries(householdIds, { queryKey: householdKeys.byId, - queryFn: async (id) => { - const metadata = await fetchHouseholdById(country, id); - return HouseholdAdapter.fromMetadata(metadata); - }, + queryFn: async (id) => fetchHouseholdById(country, id), enabled: isEnabled && householdIds.length > 0, staleTime: 5 * 60 * 1000, }); - const households = householdResults.queries.map((q) => q.data).filter((h): h is Household => !!h); + const households = householdResults.queries + .map((q) => (q.data ? HouseholdAdapter.fromMetadata(q.data) : undefined)) + .filter((h): h is Household => !!h); const userHouseholds = householdAssociations?.filter((ha) => households.some((h) => h.id === ha.householdId) diff --git a/app/src/pages/ModifyReportDemo.page.tsx b/app/src/pages/ModifyReportDemo.page.tsx deleted file mode 100644 index 3bd091878..000000000 --- a/app/src/pages/ModifyReportDemo.page.tsx +++ /dev/null @@ -1,780 +0,0 @@ -/** - * ModifyReportDemo - Prototype for viewing and modifying a completed report's configuration - * - * Demonstrates the flow: - * 1. User sees the report output overview with a "Modify" button - * 2. Clicking "Modify" swaps to the report builder configuration view - * 3. The configuration shows pre-filled simulation blocks (no empty selection chips) - * 4. User can edit and "Save as new report" - */ - -import { useState } from 'react'; -import { - IconArrowLeft, - IconCalendar, - IconCheck, - IconChartLine, - IconClock, - IconCoin, - IconCopy, - IconFileDescription, - IconHome, - IconPencil, - IconPlayerPlay, - IconScale, - IconSelector, - IconSettings, - IconSparkles, - IconUsers, -} from '@tabler/icons-react'; -import { - ActionIcon, - Box, - Container, - Group, - Paper, - SimpleGrid, - Stack, - Text, - Title, -} from '@mantine/core'; -import { colors, spacing, typography } from '@/designTokens'; -import { FONT_SIZES, INGREDIENT_COLORS } from './reportBuilder/constants'; -import { styles as builderStyles } from './reportBuilder/styles'; -import { TopBar } from './reportBuilder/components'; -import type { TopBarAction } from './reportBuilder/types'; - -// ============================================================================ -// MOCK DATA -// ============================================================================ - -const MOCK_REPORT = { - label: 'Child benefit expansion analysis', - year: '2025', - timestamp: 'Ran today at 14:23', - simulations: [ - { - label: 'Baseline simulation', - policy: { id: 'current-law', label: 'Current law' }, - population: { id: 'us-nationwide', label: 'US nationwide', type: 'geography' as const }, - }, - { - label: 'Reform simulation', - policy: { id: 'policy-123', label: 'CTC expansion', paramCount: 3 }, - population: { id: 'us-nationwide', label: 'US nationwide', type: 'geography' as const }, - }, - ], -}; - -const MOCK_TABS = [ - { value: 'overview', label: 'Overview' }, - { value: 'comparative-analysis', label: 'Comparative analysis' }, - { value: 'policy', label: 'Policy' }, - { value: 'population', label: 'Population' }, - { value: 'dynamics', label: 'Dynamics' }, - { value: 'reproduce', label: 'Reproduce in Python' }, -]; - -// ============================================================================ -// MOCK OVERVIEW CONTENT -// ============================================================================ - -function MockMetricCard({ - icon, - iconBg, - label, - value, - subtext, - trend, -}: { - icon: React.ReactNode; - iconBg: string; - label: string; - value: string; - subtext: string; - trend?: 'positive' | 'negative' | 'neutral'; -}) { - const trendColor = - trend === 'positive' ? colors.primary[600] : trend === 'negative' ? '#e53e3e' : colors.gray[600]; - - return ( - - - - {icon} - - - {label} - - - - {value} - - - {subtext} - - - ); -} - -function MockOverviewContent() { - return ( - - {/* Hero metric */} - - - - - - - - Budgetary impact - - - Annual change in government revenue - - - - - -$12.4 billion - - - in additional government spending - - - - {/* Secondary metrics */} - - } - iconBg={colors.primary[50]} - label="Poverty impact" - value="-8.2%" - subtext="decrease in poverty rate" - trend="positive" - /> - } - iconBg="#eef2ff" - label="Winners" - value="62.3%" - subtext="of households see gains" - trend="positive" - /> - } - iconBg="#fff5f5" - label="Losers" - value="4.1%" - subtext="of households see losses" - trend="negative" - /> - - - ); -} - -// ============================================================================ -// OUTPUT VIEW (mimics ReportOutputLayout) -// ============================================================================ - -function OutputView({ onModify }: { onModify: () => void }) { - const [activeTab, setActiveTab] = useState('overview'); - - return ( - - - {/* Header */} - - - - {MOCK_REPORT.label} - - - - - - {/* Modify button */} - - - Modify - - - - - - - Year: {MOCK_REPORT.year} - - - • - - - - {MOCK_REPORT.timestamp} - - - - - {/* Tab navigation */} - - - {MOCK_TABS.map((tab, i) => ( - setActiveTab(tab.value)} - style={{ - paddingLeft: spacing.sm, - paddingRight: spacing.sm, - paddingBottom: spacing.xs, - paddingTop: spacing.xs, - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - position: 'relative', - borderRight: i < MOCK_TABS.length - 1 ? `1px solid ${colors.border.light}` : 'none', - marginBottom: '-1px', - }} - > - - {tab.label} - - {activeTab === tab.value && ( - - )} - - ))} - - - - {/* Content */} - - - - ); -} - -// ============================================================================ -// MODIFY VIEW (report builder configuration) -// ============================================================================ - -type IngredientType = 'policy' | 'population' | 'dynamics'; - -function ConfiguredIngredient({ - icon, - label, - description, - colorConfig, -}: { - icon: React.ReactNode; - label: string; - description?: string; - colorConfig: { icon: string; bg: string; border: string }; -}) { - return ( - - - - - - - {icon} - - - {label} - - {description && ( - - {description} - - )} - - ); -} - -function IngredientPanel({ type, config }: { - type: IngredientType; - config?: { label: string; description?: string; populationType?: string }; -}) { - const colorConfig = INGREDIENT_COLORS[type]; - const IconComponent = { - policy: IconScale, - population: IconUsers, - dynamics: IconChartLine, - }[type]; - - const typeLabels = { - policy: 'Policy', - population: 'Household(s)', - dynamics: 'Dynamics', - }; - - return ( - - - - - - - {typeLabels[type]} - - - - - {type === 'policy' && config && ( - - : - } - label={config.label} - description={config.description} - colorConfig={colorConfig} - /> - )} - - {type === 'population' && config && ( - } - label={config.label} - description={config.populationType === 'household' ? 'Household' : 'Nationwide'} - colorConfig={colorConfig} - /> - )} - - {type === 'dynamics' && ( - - - - - Dynamics coming soon - - - - )} - - - ); -} - -function SimulationCard({ simulation, index }: { - simulation: typeof MOCK_REPORT.simulations[0]; - index: number; -}) { - return ( - - - - - - - {simulation.label} - - - - - - - - {index === 0 && ( - - Required - - )} - - - - - - - - - - - - - ); -} - -const MODIFY_ACTIONS: TopBarAction[] = [ - { - key: 'save-new', - label: 'Save as new report', - icon: , - onClick: () => {}, - variant: 'primary', - }, - { - key: 'copy', - label: 'Duplicate', - icon: , - onClick: () => {}, - variant: 'secondary', - }, -]; - -function ModifyView({ onBack }: { onBack: () => void }) { - return ( - - - - - - -

Modify report

-
- - Edit configuration and save as a new report - -
- - - {/* Icon segment */} - - - - - {/* Name segment */} - - - Name - - - {MOCK_REPORT.label} - - - - - {/* Year segment */} - - - Year - - - {MOCK_REPORT.year} - - - - - - {/* Simulation blocks */} - - - - {MOCK_REPORT.simulations.map((sim, i) => ( - - ))} - - - - ); -} - -// ============================================================================ -// MAIN PAGE -// ============================================================================ - -export default function ModifyReportDemoPage() { - const [view, setView] = useState<'output' | 'modify'>('output'); - - if (view === 'modify') { - return setView('output')} />; - } - - return setView('modify')} />; -} diff --git a/app/src/pages/ReportOutput.page.tsx b/app/src/pages/ReportOutput.page.tsx index 3b02410ec..aaebebccc 100644 --- a/app/src/pages/ReportOutput.page.tsx +++ b/app/src/pages/ReportOutput.page.tsx @@ -231,6 +231,13 @@ export default function ReportOutputPage() { } }; + // Handle modify button click - navigate to report builder with this report + const handleModify = () => { + if (userReportId) { + navigate(`/${countryId}/report-builder/${userReportId}`); + } + }; + // Show loading state while fetching data if (dataLoading) { return ( @@ -349,6 +356,7 @@ export default function ReportOutputPage() { isSharedView={isSharedView} onShare={handleShare} onSave={handleSave} + onModify={!isSharedView ? handleModify : undefined} > ( diff --git a/app/src/pages/Reports.page.tsx b/app/src/pages/Reports.page.tsx index ccdfe7f80..dc752213a 100644 --- a/app/src/pages/Reports.page.tsx +++ b/app/src/pages/Reports.page.tsx @@ -128,11 +128,17 @@ export default function ReportsPage() { key: 'actions', header: '', type: 'menu', - actions: [{ label: 'Rename', action: 'rename' }], + actions: [ + { label: 'Rename', action: 'rename' }, + { label: 'Modify', action: 'modify' }, + ], onAction: (action: string, recordId: string) => { if (action === 'rename') { handleOpenRename(recordId); } + if (action === 'modify') { + navigate(`/${countryId}/report-builder/${recordId}`); + } }, }, ]; diff --git a/app/src/pages/report-output/ReportOutputLayout.tsx b/app/src/pages/report-output/ReportOutputLayout.tsx index 61e1da9e8..8a6799890 100644 --- a/app/src/pages/report-output/ReportOutputLayout.tsx +++ b/app/src/pages/report-output/ReportOutputLayout.tsx @@ -24,6 +24,7 @@ interface ReportOutputLayoutProps { isSharedView?: boolean; onShare?: () => void; onSave?: () => void; + onModify?: () => void; children: React.ReactNode; } @@ -53,6 +54,7 @@ export default function ReportOutputLayout({ isSharedView = false, onShare, onSave, + onModify, children, }: ReportOutputLayoutProps) { const countryId = useCurrentCountry(); @@ -81,6 +83,7 @@ export default function ReportOutputLayout({ onShare={onShare} onSave={onSave} onEdit={onEditName} + onModify={onModify} /> diff --git a/app/src/pages/reportBuilder/ModifyReportPage.tsx b/app/src/pages/reportBuilder/ModifyReportPage.tsx new file mode 100644 index 000000000..85079c975 --- /dev/null +++ b/app/src/pages/reportBuilder/ModifyReportPage.tsx @@ -0,0 +1,157 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { IconDeviceFloppy, IconPlayerPlay, IconRefresh, IconX } from '@tabler/icons-react'; +import { useSelector } from 'react-redux'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Container, Stack, Text } from '@mantine/core'; +import { spacing } from '@/designTokens'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useUserReportById } from '@/hooks/useUserReports'; +import { RootState } from '@/store'; +import { getReportOutputPath } from '@/utils/reportRouting'; +import { ReportBuilderShell, SimulationBlockFull } from './components'; +import type { IngredientPickerState, ReportBuilderState, TopBarAction } from './types'; +import { hydrateReportBuilderState } from './utils/hydrateReportBuilderState'; + +export default function ModifyReportPage() { + const { userReportId } = useParams<{ userReportId: string }>(); + const countryId = useCurrentCountry() as 'us' | 'uk'; + const navigate = useNavigate(); + const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId); + + const data = useUserReportById(userReportId ?? ''); + + const [reportState, setReportState] = useState(null); + const originalStateRef = useRef(null); + + const [pickerState, setPickerState] = useState({ + isOpen: false, + simulationIndex: 0, + ingredientType: 'policy', + }); + + // Hydrate once data loads + useEffect(() => { + if ( + !data.isLoading && + !data.error && + data.userReport && + data.report && + data.simulations.length > 0 && + reportState === null + ) { + const hydrated = hydrateReportBuilderState({ + userReport: data.userReport, + report: data.report, + simulations: data.simulations, + policies: data.policies, + households: data.households, + geographies: data.geographies, + userSimulations: data.userSimulations, + userPolicies: data.userPolicies, + userHouseholds: data.userHouseholds, + userGeographies: data.userGeographies, + currentLawId, + }); + setReportState(hydrated); + originalStateRef.current = hydrated; + } + }, [ + data.isLoading, + data.error, + data.userReport, + data.report, + data.simulations, + data.policies, + data.households, + data.geographies, + data.userSimulations, + data.userPolicies, + data.userHouseholds, + data.userGeographies, + currentLawId, + reportState, + ]); + + // Change detection (exclude label) + const hasSubstantiveChanges = useMemo(() => { + if (!originalStateRef.current || !reportState) { + return false; + } + const orig = originalStateRef.current; + return ( + orig.year !== reportState.year || + JSON.stringify(orig.simulations) !== JSON.stringify(reportState.simulations) + ); + }, [reportState]); + + // Dynamic toolbar actions + const topBarActions: TopBarAction[] = useMemo(() => { + if (!hasSubstantiveChanges) { + return [ + { + key: 'already-run', + label: 'Already run', + icon: , + onClick: () => {}, + variant: 'primary' as const, + disabled: true, + }, + ]; + } + return [ + { + key: 'save-new', + label: 'Save as new report', + icon: , + onClick: () => console.info('Save as new report'), + variant: 'primary' as const, + }, + { + key: 'replace', + label: 'Replace existing report', + icon: , + onClick: () => console.info('Replace existing report'), + variant: 'secondary' as const, + }, + { + key: 'cancel', + label: 'Cancel', + icon: , + onClick: () => navigate(getReportOutputPath(countryId, userReportId!)), + variant: 'secondary' as const, + }, + ]; + }, [hasSubstantiveChanges, countryId, userReportId, navigate]); + + if (data.isLoading || !reportState) { + return ( + + + Loading report... + + + ); + } + + if (data.error) { + return ( + + + Error loading report: {data.error.message} + + + ); + } + + return ( + >} + pickerState={pickerState} + setPickerState={setPickerState} + BlockComponent={SimulationBlockFull} + /> + ); +} diff --git a/app/src/pages/reportBuilder/components/AddSimulationCard.tsx b/app/src/pages/reportBuilder/components/AddSimulationCard.tsx index 94428eef7..8240fa214 100644 --- a/app/src/pages/reportBuilder/components/AddSimulationCard.tsx +++ b/app/src/pages/reportBuilder/components/AddSimulationCard.tsx @@ -3,14 +3,12 @@ */ import { useState } from 'react'; -import { Box, Text } from '@mantine/core'; import { IconPlus } from '@tabler/icons-react'; - +import { Box, Text } from '@mantine/core'; import { colors } from '@/designTokens'; - -import type { AddSimulationCardProps } from '../types'; import { FONT_SIZES } from '../constants'; import { styles } from '../styles'; +import type { AddSimulationCardProps } from '../types'; export function AddSimulationCard({ onClick, disabled }: AddSimulationCardProps) { const [isHovered, setIsHovered] = useState(false); @@ -52,11 +50,7 @@ export function AddSimulationCard({ onClick, disabled }: AddSimulationCardProps) > Add reform simulation - + Compare policy changes against your baseline
diff --git a/app/src/pages/reportBuilder/components/EditableLabel.tsx b/app/src/pages/reportBuilder/components/EditableLabel.tsx index 75f2207f0..f759e9e6c 100644 --- a/app/src/pages/reportBuilder/components/EditableLabel.tsx +++ b/app/src/pages/reportBuilder/components/EditableLabel.tsx @@ -81,7 +81,9 @@ export function EditableLabel({ value={inputValue} onChange={(e) => setInputValue(e.currentTarget.value)} onKeyDown={(e) => { - if (e.key === 'Enter') {handleSubmit();} + if (e.key === 'Enter') { + handleSubmit(); + } if (e.key === 'Escape') { setInputValue(value); setIsEditing(false); diff --git a/app/src/pages/reportBuilder/components/IngredientSection.tsx b/app/src/pages/reportBuilder/components/IngredientSection.tsx index 685affe77..aef9e760a 100644 --- a/app/src/pages/reportBuilder/components/IngredientSection.tsx +++ b/app/src/pages/reportBuilder/components/IngredientSection.tsx @@ -2,23 +2,21 @@ * IngredientSection - A section displaying policy, population, or dynamics options */ -import { Box, Group, Text } from '@mantine/core'; import { - IconScale, - IconUsers, IconChartLine, - IconHome, - IconFolder, IconFileDescription, + IconFolder, + IconHome, + IconScale, IconSparkles, + IconUsers, } from '@tabler/icons-react'; - +import { Box, Group, Text } from '@mantine/core'; import { colors, spacing } from '@/designTokens'; - -import type { IngredientSectionProps } from '../types'; -import { FONT_SIZES, INGREDIENT_COLORS, COUNTRY_CONFIG } from '../constants'; +import { COUNTRY_CONFIG, FONT_SIZES, INGREDIENT_COLORS } from '../constants'; import { styles } from '../styles'; -import { OptionChipSquare, BrowseMoreChip } from './chips'; +import type { IngredientSectionProps } from '../types'; +import { BrowseMoreChip, OptionChipSquare } from './chips'; import { CountryMapIcon } from './shared'; export function IngredientSection({ @@ -72,12 +70,14 @@ export function IngredientSection({ > - + {typeLabels[type]} - {isInherited && ( - (inherited from baseline) - )} + {isInherited && (inherited from baseline)} {/* Chips container */} @@ -115,14 +115,18 @@ export function IngredientSection({ c={colors.gray[500]} style={{ fontSize: FONT_SIZES.small, lineHeight: 1.2 }} > - {inheritedPopulationType === 'household' ? 'Household' : countryConfig.nationwideTitle} + {inheritedPopulationType === 'household' + ? 'Household' + : countryConfig.nationwideTitle} - {inheritedPopulationType === 'household' ? 'Inherited' : countryConfig.nationwideSubtitle} + {inheritedPopulationType === 'household' + ? 'Inherited' + : countryConfig.nationwideSubtitle} @@ -132,7 +136,12 @@ export function IngredientSection({ <> {/* Current law - always first */} } + icon={ + + } label="Current law" description="No changes" isSelected={currentId === 'current-law'} @@ -149,7 +158,12 @@ export function IngredientSection({ {savedPolicies.slice(0, 3).map((policy) => ( } + icon={ + + } label={policy.label} description={`${policy.paramCount} param${policy.paramCount !== 1 ? 's' : ''} changed`} isSelected={currentId === policy.id} @@ -180,7 +194,15 @@ export function IngredientSection({ <> {/* Nationwide - always first */} } + icon={ + + } label={countryConfig.nationwideTitle} description={countryConfig.nationwideSubtitle} isSelected={currentId === countryConfig.nationwideId} @@ -197,9 +219,18 @@ export function IngredientSection({ {recentPopulations.slice(0, 4).map((pop) => ( - : + icon={ + pop.type === 'household' ? ( + + ) : ( + + ) } label={pop.label} description={pop.type === 'household' ? 'Household' : 'Geography'} diff --git a/app/src/pages/reportBuilder/components/IngredientSectionFull.tsx b/app/src/pages/reportBuilder/components/IngredientSectionFull.tsx index 892de1b1c..79e280ed5 100644 --- a/app/src/pages/reportBuilder/components/IngredientSectionFull.tsx +++ b/app/src/pages/reportBuilder/components/IngredientSectionFull.tsx @@ -8,23 +8,21 @@ * Same props interface as IngredientSection for drop-in replacement. */ -import { Box, Group, Text } from '@mantine/core'; import { - IconScale, - IconUsers, IconChartLine, - IconHome, IconFileDescription, - IconSparkles, + IconHome, IconPlus, + IconScale, + IconSparkles, + IconUsers, IconX, } from '@tabler/icons-react'; - +import { Box, Group, Text } from '@mantine/core'; import { colors, spacing } from '@/designTokens'; - -import type { IngredientSectionProps } from '../types'; -import { FONT_SIZES, INGREDIENT_COLORS, COUNTRY_CONFIG } from '../constants'; +import { COUNTRY_CONFIG, FONT_SIZES, INGREDIENT_COLORS } from '../constants'; import { styles } from '../styles'; +import type { IngredientSectionProps } from '../types'; import { CountryMapIcon } from './shared'; export function IngredientSectionFull({ @@ -59,8 +57,12 @@ export function IngredientSectionFull({ // Determine what's currently selected for policy const selectedPolicyLabel = (() => { - if (type !== 'policy' || !currentId) {return null;} - if (currentId === 'current-law') {return { label: 'Current law', description: 'No changes' };} + if (type !== 'policy' || !currentId) { + return null; + } + if (currentId === 'current-law') { + return { label: 'Current law', description: 'No changes' }; + } const saved = savedPolicies.find((p) => p.id === currentId); if (saved) { return { @@ -73,18 +75,29 @@ export function IngredientSectionFull({ // Determine what's currently selected for population const selectedPopulationLabel = (() => { - if (type !== 'population' || !currentId) {return null;} + if (type !== 'population' || !currentId) { + return null; + } if (currentId === countryConfig.nationwideId) { - return { label: countryConfig.nationwideTitle, description: countryConfig.nationwideSubtitle, populationType: 'geography' }; + return { + label: countryConfig.nationwideTitle, + description: countryConfig.nationwideSubtitle, + populationType: 'geography', + }; } const recent = recentPopulations.find((p) => p.id === currentId); if (recent) { - return { label: recent.label, description: recent.type === 'household' ? 'Household' : 'Geography', populationType: recent.type }; + return { + label: recent.label, + description: recent.type === 'household' ? 'Household' : 'Geography', + populationType: recent.type, + }; } return { label: `Population #${currentId}`, description: '', populationType: 'geography' }; })(); - const hasSelection = type === 'policy' ? !!currentId : type === 'population' ? !!currentId : false; + const hasSelection = + type === 'policy' ? !!currentId : type === 'population' ? !!currentId : false; const handleEmptyClick = () => { if (type === 'policy') { @@ -121,12 +134,14 @@ export function IngredientSectionFull({ > - + {typeLabels[type]} - {isInherited && ( - (inherited from baseline) - )} + {isInherited && (inherited from baseline)} {/* Content area */} @@ -183,7 +198,9 @@ export function IngredientSectionFull({ - {inheritedPopulationType === 'household' ? 'Household' : countryConfig.nationwideTitle} + {inheritedPopulationType === 'household' + ? 'Household' + : countryConfig.nationwideTitle} Inherited from baseline @@ -216,24 +233,30 @@ export function IngredientSectionFull({ flexShrink: 0, }} > - {type === 'policy' && ( - currentId === 'current-law' - ? - : - )} - {type === 'population' && ( - selectedPopulationLabel?.populationType === 'household' - ? - : - )} + {type === 'policy' && + (currentId === 'current-law' ? ( + + ) : ( + + ))} + {type === 'population' && + (selectedPopulationLabel?.populationType === 'household' ? ( + + ) : ( + + ))} {type === 'policy' ? selectedPolicyLabel?.label : selectedPopulationLabel?.label} - {(type === 'policy' ? selectedPolicyLabel?.description : selectedPopulationLabel?.description) && ( + {(type === 'policy' + ? selectedPolicyLabel?.description + : selectedPopulationLabel?.description) && ( - {type === 'policy' ? selectedPolicyLabel?.description : selectedPopulationLabel?.description} + {type === 'policy' + ? selectedPolicyLabel?.description + : selectedPopulationLabel?.description} )} diff --git a/app/src/pages/reportBuilder/components/ReportBuilderShell.tsx b/app/src/pages/reportBuilder/components/ReportBuilderShell.tsx index f09f78dfe..368835337 100644 --- a/app/src/pages/reportBuilder/components/ReportBuilderShell.tsx +++ b/app/src/pages/reportBuilder/components/ReportBuilderShell.tsx @@ -5,12 +5,12 @@ * Accepts all logic via props so different modes (setup, modify) can compose it. */ import { Box } from '@mantine/core'; -import type { SimulationBlockProps } from './SimulationBlock'; -import type { IngredientPickerState, ReportBuilderState, TopBarAction } from '../types'; import { styles } from '../styles'; +import type { IngredientPickerState, ReportBuilderState, TopBarAction } from '../types'; import { ReportMetaPanel } from './ReportMetaPanel'; -import { SimulationCanvas } from './SimulationCanvas'; +import type { SimulationBlockProps } from './SimulationBlock'; import { SimulationBlockFull } from './SimulationBlockFull'; +import { SimulationCanvas } from './SimulationCanvas'; import { TopBar } from './TopBar'; interface ReportBuilderShellProps { diff --git a/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx b/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx index 42b7c7baf..12b0f2e87 100644 --- a/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx +++ b/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx @@ -6,11 +6,7 @@ */ import React, { useLayoutEffect, useState } from 'react'; -import { - IconCheck, - IconFileDescription, - IconPencil, -} from '@tabler/icons-react'; +import { IconCheck, IconFileDescription, IconPencil } from '@tabler/icons-react'; import { ActionIcon, Box, Select, Text, TextInput } from '@mantine/core'; import { CURRENT_YEAR } from '@/constants'; import { colors, spacing, typography } from '@/designTokens'; @@ -34,10 +30,7 @@ interface ReportMetaPanelProps { setReportState: React.Dispatch>; } -export function ReportMetaPanel({ - reportState, - setReportState, -}: ReportMetaPanelProps) { +export function ReportMetaPanel({ reportState, setReportState }: ReportMetaPanelProps) { const [isEditingLabel, setIsEditingLabel] = useState(false); const [labelInput, setLabelInput] = useState(''); const [inputWidth, setInputWidth] = useState(null); @@ -207,9 +200,7 @@ export function ReportMetaPanel({ - - ); - - const primaryAction = { - label: `${initializeText} report`, - onClick: submissionHandler, - }; - - return ( - - ); -} diff --git a/app/src/pathways/report/views/ReportSetupView.tsx b/app/src/pathways/report/views/ReportSetupView.tsx deleted file mode 100644 index ae5bd057b..000000000 --- a/app/src/pathways/report/views/ReportSetupView.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import { useState } from 'react'; -import PathwayView from '@/components/common/PathwayView'; -import { MOCK_USER_ID } from '@/constants'; -import { useUserGeographics } from '@/hooks/useUserGeographic'; -import { useUserHouseholds } from '@/hooks/useUserHousehold'; -import { ReportStateProps, SimulationStateProps } from '@/types/pathwayState'; -import { isSimulationConfigured } from '@/utils/validation/ingredientValidation'; - -type SimulationCard = 'simulation1' | 'simulation2'; - -interface ReportSetupViewProps { - reportState: ReportStateProps; - onNavigateToSimulationSelection: (simulationIndex: 0 | 1) => void; - onNext: () => void; - onPrefillPopulation2: () => void; - onBack?: () => void; - onCancel?: () => void; -} - -export default function ReportSetupView({ - reportState, - onNavigateToSimulationSelection, - onNext, - onPrefillPopulation2, - onBack, - onCancel, -}: ReportSetupViewProps) { - const [selectedCard, setSelectedCard] = useState(null); - - // Get simulation state from report - const simulation1 = reportState.simulations[0]; - const simulation2 = reportState.simulations[1]; - - // Fetch population data for pre-filling simulation 2 - const userId = MOCK_USER_ID.toString(); - const { data: householdData } = useUserHouseholds(userId); - const { data: geographicData } = useUserGeographics(userId); - - // Check if simulations are fully configured - const simulation1Configured = isSimulationConfigured(simulation1); - const simulation2Configured = isSimulationConfigured(simulation2); - - // Check if population data is loaded (needed for simulation2 prefill) - const isPopulationDataLoaded = householdData !== undefined && geographicData !== undefined; - - // Determine if simulation2 is optional based on population type of simulation1 - const isHouseholdReport = simulation1?.population.type === 'household'; - const isSimulation2Optional = simulation1Configured && isHouseholdReport; - - const handleSimulation1Select = () => { - setSelectedCard('simulation1'); - }; - - const handleSimulation2Select = () => { - setSelectedCard('simulation2'); - }; - - const handleNext = () => { - if (selectedCard === 'simulation1') { - onNavigateToSimulationSelection(0); - } else if (selectedCard === 'simulation2') { - // PRE-FILL POPULATION FROM SIMULATION 1 - onPrefillPopulation2(); - onNavigateToSimulationSelection(1); - } else if (canProceed) { - onNext(); - } - }; - - const setupConditionCards = [ - { - title: getBaselineCardTitle(simulation1, simulation1Configured), - description: getBaselineCardDescription(simulation1, simulation1Configured), - onClick: handleSimulation1Select, - isSelected: selectedCard === 'simulation1', - isFulfilled: simulation1Configured, - isDisabled: false, - }, - { - title: getComparisonCardTitle( - simulation2, - simulation2Configured, - simulation1Configured, - isSimulation2Optional - ), - description: getComparisonCardDescription( - simulation2, - simulation2Configured, - simulation1Configured, - isSimulation2Optional, - !isPopulationDataLoaded - ), - onClick: handleSimulation2Select, - isSelected: selectedCard === 'simulation2', - isFulfilled: simulation2Configured, - isDisabled: !simulation1Configured, // Disable until simulation1 is configured - }, - ]; - - // Determine if we can proceed to submission - const canProceed: boolean = - simulation1Configured && (isSimulation2Optional || simulation2Configured); - - // Determine the primary action label and state - const getPrimaryAction = () => { - // Allow setting up simulation1 if selected and not configured - if (selectedCard === 'simulation1' && !simulation1Configured) { - return { - label: 'Configure baseline simulation', - onClick: handleNext, - isDisabled: false, - }; - } - // Allow setting up simulation2 if selected and not configured - else if (selectedCard === 'simulation2' && !simulation2Configured) { - return { - label: 'Configure comparison simulation', - onClick: handleNext, - isDisabled: !isPopulationDataLoaded, // Disable if data not loaded - }; - } - // Allow proceeding if requirements met - else if (canProceed) { - return { - label: 'Review report', - onClick: handleNext, - isDisabled: false, - }; - } - // Disable if requirements not met - show uppermost option (baseline) - return { - label: 'Configure baseline simulation', - onClick: handleNext, - isDisabled: true, - }; - }; - - const primaryAction = getPrimaryAction(); - - return ( - - ); -} - -/** - * Get title for baseline simulation card - */ -function getBaselineCardTitle( - simulation: SimulationStateProps | null, - isConfigured: boolean -): string { - if (isConfigured) { - const label = simulation?.label || simulation?.id || 'Configured'; - return `Baseline: ${label}`; - } - return 'Baseline simulation'; -} - -/** - * Get description for baseline simulation card - */ -function getBaselineCardDescription( - simulation: SimulationStateProps | null, - isConfigured: boolean -): string { - if (isConfigured) { - const policyId = simulation?.policy.id || 'N/A'; - const populationId = - simulation?.population.household?.id || simulation?.population.geography?.id || 'N/A'; - return `Policy #${policyId} • Household(s) #${populationId}`; - } - return 'Select your baseline simulation'; -} - -/** - * Get title for comparison simulation card - */ -function getComparisonCardTitle( - simulation: SimulationStateProps | null, - isConfigured: boolean, - baselineConfigured: boolean, - isOptional: boolean -): string { - // If configured, show simulation name - if (isConfigured) { - const label = simulation?.label || simulation?.id || 'Configured'; - return `Comparison: ${label}`; - } - - // If baseline not configured yet, show waiting message - if (!baselineConfigured) { - return 'Comparison simulation · Waiting for baseline'; - } - - // Baseline configured: show optional or required - if (isOptional) { - return 'Comparison simulation (optional)'; - } - return 'Comparison simulation'; -} - -/** - * Get description for comparison simulation card - */ -function getComparisonCardDescription( - simulation: SimulationStateProps | null, - isConfigured: boolean, - baselineConfigured: boolean, - isOptional: boolean, - dataLoading: boolean -): string { - // If configured, show simulation details - if (isConfigured) { - const policyId = simulation?.policy.id || 'N/A'; - const populationId = - simulation?.population.household?.id || simulation?.population.geography?.id || 'N/A'; - return `Policy #${policyId} • Household(s) #${populationId}`; - } - - // If baseline not configured yet, show waiting message - if (!baselineConfigured) { - return 'Set up your baseline simulation first'; - } - - // If baseline configured but data still loading, show loading message - if (dataLoading && baselineConfigured && !isConfigured) { - return 'Loading household data...'; - } - - // Baseline configured: show optional or required message - if (isOptional) { - return 'Optional: add a second simulation to compare'; - } - return 'Required: add a second simulation to compare'; -} diff --git a/app/src/pathways/report/views/ReportSimulationExistingView.tsx b/app/src/pathways/report/views/ReportSimulationExistingView.tsx deleted file mode 100644 index a3e0c6d46..000000000 --- a/app/src/pathways/report/views/ReportSimulationExistingView.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { useState } from 'react'; -import { Text } from '@mantine/core'; -import PathwayView from '@/components/common/PathwayView'; -import { MOCK_USER_ID } from '@/constants'; -import { EnhancedUserSimulation, useUserSimulations } from '@/hooks/useUserSimulations'; -import { SimulationStateProps } from '@/types/pathwayState'; -import { arePopulationsCompatible } from '@/utils/populationCompatibility'; - -interface ReportSimulationExistingViewProps { - activeSimulationIndex: 0 | 1; - otherSimulation: SimulationStateProps | null; - onSelectSimulation: (enhancedSimulation: EnhancedUserSimulation) => void; - onNext: () => void; - onBack?: () => void; - onCancel?: () => void; -} - -export default function ReportSimulationExistingView({ - activeSimulationIndex: _activeSimulationIndex, - otherSimulation, - onSelectSimulation, - onNext, - onBack, - onCancel, -}: ReportSimulationExistingViewProps) { - const userId = MOCK_USER_ID.toString(); - - const { data, isLoading, isError, error } = useUserSimulations(userId); - const [localSimulation, setLocalSimulation] = useState(null); - - function canProceed() { - if (!localSimulation) { - return false; - } - return localSimulation.simulation?.id !== null && localSimulation.simulation?.id !== undefined; - } - - function handleSimulationSelect(enhancedSimulation: EnhancedUserSimulation) { - if (!enhancedSimulation) { - return; - } - - setLocalSimulation(enhancedSimulation); - } - - function handleSubmit() { - if (!localSimulation || !localSimulation.simulation) { - return; - } - - onSelectSimulation(localSimulation); - onNext(); - } - - const userSimulations = data || []; - - if (isLoading) { - return ( - Loading simulations...} - buttonPreset="none" - /> - ); - } - - if (isError) { - return ( - Error: {(error as Error)?.message || 'Something went wrong.'}} - buttonPreset="none" - /> - ); - } - - if (userSimulations.length === 0) { - return ( - No simulations available. Please create a new simulation.} - primaryAction={{ - label: 'Next', - onClick: () => {}, - isDisabled: true, - }} - backAction={onBack ? { onClick: onBack } : undefined} - cancelAction={onCancel ? { onClick: onCancel } : undefined} - /> - ); - } - - // Filter simulations with loaded data - const filteredSimulations = userSimulations.filter((enhancedSim) => enhancedSim.simulation?.id); - - // Get other simulation's population ID (base ingredient ID) for compatibility check - // For household populations, use household.id - // For geography populations, use geography.geographyId (the base geography identifier) - const otherPopulationId = - otherSimulation?.population.household?.id || - otherSimulation?.population.geography?.geographyId || - otherSimulation?.population.geography?.id; - - // Sort simulations to show compatible first, then incompatible - const sortedSimulations = [...filteredSimulations].sort((a, b) => { - const aCompatible = arePopulationsCompatible(otherPopulationId, a.simulation!.populationId); - const bCompatible = arePopulationsCompatible(otherPopulationId, b.simulation!.populationId); - - return bCompatible === aCompatible ? 0 : aCompatible ? -1 : 1; - }); - - // Build card list items from sorted simulations - const simulationCardItems = sortedSimulations.map((enhancedSim) => { - const simulation = enhancedSim.simulation!; - - // Check compatibility with other simulation - const isCompatible = arePopulationsCompatible(otherPopulationId, simulation.populationId); - - let title = ''; - let subtitle = ''; - - if (enhancedSim.userSimulation?.label) { - title = enhancedSim.userSimulation.label; - subtitle = `Simulation #${simulation.id}`; - } else { - title = `Simulation #${simulation.id}`; - } - - // Add policy and population info to subtitle if available - const policyLabel = - enhancedSim.userPolicy?.label || enhancedSim.policy?.label || enhancedSim.policy?.id; - const populationLabel = - enhancedSim.userHousehold?.label || enhancedSim.geography?.name || simulation.populationId; - - if (policyLabel && populationLabel) { - subtitle = subtitle - ? `${subtitle} • Policy: ${policyLabel} • Population: ${populationLabel}` - : `Policy: ${policyLabel} • Population: ${populationLabel}`; - } - - // If incompatible, add explanation to subtitle - if (!isCompatible) { - subtitle = subtitle - ? `${subtitle} • Incompatible: different population than configured simulation` - : 'Incompatible: different population than configured simulation'; - } - - return { - id: enhancedSim.userSimulation?.id?.toString() || simulation.id, // Use user simulation association ID for unique key - title, - subtitle, - onClick: () => handleSimulationSelect(enhancedSim), - isSelected: localSimulation?.simulation?.id === simulation.id, - isDisabled: !isCompatible, - }; - }); - - const primaryAction = { - label: 'Next', - onClick: handleSubmit, - isDisabled: !canProceed(), - }; - - return ( - - ); -} diff --git a/app/src/pathways/report/views/ReportSimulationSelectionView.tsx b/app/src/pathways/report/views/ReportSimulationSelectionView.tsx deleted file mode 100644 index 925707f93..000000000 --- a/app/src/pathways/report/views/ReportSimulationSelectionView.tsx +++ /dev/null @@ -1,308 +0,0 @@ -import { useState } from 'react'; -import { Stack } from '@mantine/core'; -import { SimulationAdapter } from '@/adapters'; -import PathwayView from '@/components/common/PathwayView'; -import { ButtonPanelVariant } from '@/components/flowView'; -import { MOCK_USER_ID } from '@/constants'; -import { useCreateSimulation } from '@/hooks/useCreateSimulation'; -import { useCreateGeographicAssociation } from '@/hooks/useUserGeographic'; -import { useUserSimulations } from '@/hooks/useUserSimulations'; -import { Simulation } from '@/types/ingredients/Simulation'; -import { PolicyStateProps, PopulationStateProps, SimulationStateProps } from '@/types/pathwayState'; -import { SimulationCreationPayload } from '@/types/payloads'; -import { - countryNames, - getDefaultBaselineLabel, - isDefaultBaselineSimulation, -} from '@/utils/isDefaultBaselineSimulation'; -import DefaultBaselineOption from '../components/DefaultBaselineOption'; - -/** - * Helper functions for creating default baseline simulation - */ - -/** - * Creates a policy state for current law - */ -function createCurrentLawPolicy(currentLawId: number): PolicyStateProps { - return { - id: currentLawId.toString(), - label: 'Current law', - parameters: [], - }; -} - -/** - * Creates a population state for nationwide geography - */ -function createNationwidePopulation( - countryId: string, - geographyId: string, - countryName: string -): PopulationStateProps { - return { - label: `${countryName} nationwide`, - type: 'geography', - household: null, - geography: { - id: geographyId, - countryId: countryId as any, - scope: 'national', - geographyId: countryId, - name: 'National', - }, - }; -} - -/** - * Creates a simulation state from policy and population - */ -function createSimulationState( - simulationId: string, - simulationLabel: string, - countryId: string, - policy: PolicyStateProps, - population: PopulationStateProps -): SimulationStateProps { - return { - id: simulationId, - label: simulationLabel, - countryId, - apiVersion: undefined, - status: undefined, - output: null, - policy, - population, - }; -} - -type SetupAction = 'createNew' | 'loadExisting' | 'defaultBaseline'; - -interface ReportSimulationSelectionViewProps { - simulationIndex: 0 | 1; - countryId: string; - currentLawId: number; - onCreateNew: () => void; - onLoadExisting: () => void; - onSelectDefaultBaseline?: (simulationState: SimulationStateProps, simulationId: string) => void; - onBack?: () => void; - onCancel?: () => void; -} - -export default function ReportSimulationSelectionView({ - simulationIndex, - countryId, - currentLawId, - onCreateNew, - onLoadExisting, - onSelectDefaultBaseline, - onBack, - onCancel, -}: ReportSimulationSelectionViewProps) { - const userId = MOCK_USER_ID.toString(); - const { data: userSimulations } = useUserSimulations(userId); - const hasExistingSimulations = (userSimulations?.length ?? 0) > 0; - - const [selectedAction, setSelectedAction] = useState(null); - const [isCreatingBaseline, setIsCreatingBaseline] = useState(false); - - const { mutateAsync: createGeographicAssociation } = useCreateGeographicAssociation(); - const simulationLabel = getDefaultBaselineLabel(countryId); - const { createSimulation } = useCreateSimulation(simulationLabel); - - // Find existing default baseline simulation for this country - const existingBaseline = userSimulations?.find((sim) => - isDefaultBaselineSimulation(sim, countryId, currentLawId) - ); - const existingSimulationId = existingBaseline?.userSimulation?.simulationId; - - const isBaseline = simulationIndex === 0; - - function handleClickCreateNew() { - setSelectedAction('createNew'); - } - - function handleClickExisting() { - if (hasExistingSimulations) { - setSelectedAction('loadExisting'); - } - } - - function handleClickDefaultBaseline() { - setSelectedAction('defaultBaseline'); - } - - /** - * Reuses an existing default baseline simulation - */ - function reuseExistingBaseline() { - if (!existingBaseline || !existingSimulationId || !onSelectDefaultBaseline) { - return; - } - - const countryName = countryNames[countryId] || countryId.toUpperCase(); - const geographyId = existingBaseline.geography?.geographyId || countryId; - - const policy = createCurrentLawPolicy(currentLawId); - const population = createNationwidePopulation(countryId, geographyId, countryName); - const simulationState = createSimulationState( - existingSimulationId, - simulationLabel, - countryId, - policy, - population - ); - - onSelectDefaultBaseline(simulationState, existingSimulationId); - } - - /** - * Creates a new default baseline simulation - */ - async function createNewBaseline() { - if (!onSelectDefaultBaseline) { - return; - } - - setIsCreatingBaseline(true); - const countryName = countryNames[countryId] || countryId.toUpperCase(); - - try { - // Create geography association - const geographyResult = await createGeographicAssociation({ - id: `${userId}-${Date.now()}`, - userId, - countryId: countryId as any, - geographyId: countryId, - scope: 'national', - label: `${countryName} nationwide`, - }); - - // Create simulation - const simulationData: Partial = { - populationId: geographyResult.geographyId, - policyId: currentLawId.toString(), - populationType: 'geography', - }; - - const serializedPayload: SimulationCreationPayload = - SimulationAdapter.toCreationPayload(simulationData); - - createSimulation(serializedPayload, { - onSuccess: (data) => { - const simulationId = data.result.simulation_id; - - const policy = createCurrentLawPolicy(currentLawId); - const population = createNationwidePopulation( - countryId, - geographyResult.geographyId, - countryName - ); - const simulationState = createSimulationState( - simulationId, - simulationLabel, - countryId, - policy, - population - ); - - if (onSelectDefaultBaseline) { - onSelectDefaultBaseline(simulationState, simulationId); - } - }, - onError: (error) => { - console.error('[ReportSimulationSelectionView] Failed to create simulation:', error); - setIsCreatingBaseline(false); - }, - }); - } catch (error) { - console.error( - '[ReportSimulationSelectionView] Failed to create geographic association:', - error - ); - setIsCreatingBaseline(false); - } - } - - async function handleClickSubmit() { - if (selectedAction === 'createNew') { - onCreateNew(); - } else if (selectedAction === 'loadExisting') { - onLoadExisting(); - } else if (selectedAction === 'defaultBaseline') { - // Reuse existing or create new default baseline simulation - if (existingBaseline && existingSimulationId) { - reuseExistingBaseline(); - } else { - await createNewBaseline(); - } - } - } - - const buttonPanelCards = [ - { - title: 'Create new simulation', - description: 'Build a new simulation', - onClick: handleClickCreateNew, - isSelected: selectedAction === 'createNew', - }, - // Only show "Load existing" if user has existing simulations - ...(hasExistingSimulations - ? [ - { - title: 'Load existing simulation', - description: 'Use a simulation you have already created', - onClick: handleClickExisting, - isSelected: selectedAction === 'loadExisting', - }, - ] - : []), - ]; - - const hasExistingBaselineText = existingBaseline && existingSimulationId; - - const primaryAction = { - label: isCreatingBaseline - ? hasExistingBaselineText - ? 'Applying simulation...' - : 'Creating simulation...' - : 'Next', - onClick: handleClickSubmit, - isLoading: isCreatingBaseline, - isDisabled: !selectedAction || isCreatingBaseline, - }; - - // For baseline simulation, combine default baseline option with other cards - if (isBaseline) { - return ( - - - - - } - primaryAction={primaryAction} - backAction={onBack ? { onClick: onBack } : undefined} - cancelAction={onCancel ? { onClick: onCancel } : undefined} - /> - ); - } - - // For reform simulation, just show the standard button panel - return ( - - ); -} diff --git a/app/src/pathways/report/views/ReportSubmitView.tsx b/app/src/pathways/report/views/ReportSubmitView.tsx deleted file mode 100644 index 7a188922d..000000000 --- a/app/src/pathways/report/views/ReportSubmitView.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import IngredientSubmissionView, { SummaryBoxItem } from '@/components/IngredientSubmissionView'; -import { ReportStateProps } from '@/types/pathwayState'; - -interface ReportSubmitViewProps { - reportState: ReportStateProps; - onSubmit: () => void; - isSubmitting: boolean; - onBack?: () => void; - onCancel?: () => void; -} - -export default function ReportSubmitView({ - reportState, - onSubmit, - isSubmitting, - onBack, - onCancel, -}: ReportSubmitViewProps) { - const simulation1 = reportState.simulations[0]; - const simulation2 = reportState.simulations[1]; - - // Helper to get badge text for a simulation - const getSimulationBadge = (simulation: typeof simulation1) => { - if (!simulation) { - return undefined; - } - - // Get policy label - use label if available, otherwise fall back to ID - const policyLabel = simulation.policy.label || `Policy #${simulation.policy.id}`; - - // Get population label - use label if available, otherwise fall back to ID - const populationLabel = - simulation.population.label || - `Population #${simulation.population.household?.id || simulation.population.geography?.id}`; - - return `${policyLabel} • ${populationLabel}`; - }; - - // Check if simulation is configured (has either ID or configured ingredients) - const isSimulation1Configured = - !!simulation1?.id || - (!!simulation1?.policy?.id && - !!(simulation1?.population?.household?.id || simulation1?.population?.geography?.id)); - const isSimulation2Configured = - !!simulation2?.id || - (!!simulation2?.policy?.id && - !!(simulation2?.population?.household?.id || simulation2?.population?.geography?.id)); - - // Create summary boxes based on the simulations - const summaryBoxes: SummaryBoxItem[] = [ - { - title: 'Baseline simulation', - description: - simulation1?.label || (simulation1?.id ? `Simulation #${simulation1.id}` : 'No simulation'), - isFulfilled: isSimulation1Configured, - badge: isSimulation1Configured ? getSimulationBadge(simulation1) : undefined, - }, - { - title: 'Comparison simulation', - description: - simulation2?.label || (simulation2?.id ? `Simulation #${simulation2.id}` : 'No simulation'), - isFulfilled: isSimulation2Configured, - isDisabled: !isSimulation2Configured, - badge: isSimulation2Configured ? getSimulationBadge(simulation2) : undefined, - }, - ]; - - return ( - - ); -} diff --git a/app/src/tests/fixtures/pathways/report/ReportPathwayWrapperMocks.ts b/app/src/tests/fixtures/pathways/report/ReportPathwayWrapperMocks.ts deleted file mode 100644 index 63a6f50e9..000000000 --- a/app/src/tests/fixtures/pathways/report/ReportPathwayWrapperMocks.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { vi } from 'vitest'; -import { ReportViewMode } from '@/types/pathwayModes/ReportViewMode'; - -// Test constants -export const TEST_COUNTRY_ID = 'us'; -export const TEST_INVALID_COUNTRY_ID = 'invalid'; -export const TEST_USER_ID = 'test-user-123'; -export const TEST_CURRENT_LAW_ID = 1; - -// Mock navigation -export const mockNavigate = vi.fn(); -export const mockOnComplete = vi.fn(); - -// Mock hook return values -export const mockUseParams = { - countryId: TEST_COUNTRY_ID, -}; - -export const mockUseParamsInvalid = { - countryId: TEST_INVALID_COUNTRY_ID, -}; - -export const mockUseParamsMissing = {}; - -export const mockMetadata = { - currentLawId: TEST_CURRENT_LAW_ID, - economyOptions: { - region: [], - }, -}; - -export const mockUseCreateReport = { - createReport: vi.fn(), - isPending: false, - isError: false, - error: null, -} as any; - -export const mockUseUserSimulations = { - data: [], - isLoading: false, - isError: false, - error: null, -} as any; - -export const mockUseUserPolicies = { - data: [], - isLoading: false, - isError: false, - error: null, -} as any; - -export const mockUseUserHouseholds = { - data: [], - isLoading: false, - isError: false, - error: null, -} as any; - -export const mockUseUserGeographics = { - data: [], - isLoading: false, - isError: false, - error: null, -} as any; - -// Helper to reset all mocks -export const resetAllMocks = () => { - mockNavigate.mockClear(); - mockOnComplete.mockClear(); - mockUseCreateReport.createReport.mockClear(); -}; - -// Expected view modes -export const REPORT_VIEW_MODES = { - LABEL: ReportViewMode.REPORT_LABEL, - SETUP: ReportViewMode.REPORT_SETUP, - SIMULATION_SELECTION: ReportViewMode.REPORT_SELECT_SIMULATION, - SIMULATION_EXISTING: ReportViewMode.REPORT_SELECT_EXISTING_SIMULATION, - SUBMIT: ReportViewMode.REPORT_SUBMIT, -} as const; - -/** - * Test constants for simulation indices - */ -export const SIMULATION_INDEX = { - BASELINE: 0 as const, - REFORM: 1 as const, -} as const; - -/** - * Mock user simulations data with existing simulations - */ -export const mockUserSimulationsWithData = { - data: [{ id: 'sim-1', label: 'Test Simulation' }], - isLoading: false, - isError: false, - error: null, -} as any; diff --git a/app/src/tests/fixtures/pathways/report/components/DefaultBaselineOptionMocks.ts b/app/src/tests/fixtures/pathways/report/components/DefaultBaselineOptionMocks.ts deleted file mode 100644 index 70774cacf..000000000 --- a/app/src/tests/fixtures/pathways/report/components/DefaultBaselineOptionMocks.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { vi } from 'vitest'; - -// Test constants -export const TEST_COUNTRIES = { - US: 'us', - UK: 'uk', -} as const; - -export const TEST_USER_ID = 'test-user-123'; -export const TEST_CURRENT_LAW_ID = 1; -export const TEST_SIMULATION_ID = 'sim-123'; -export const TEST_EXISTING_SIMULATION_ID = 'existing-sim-456'; -export const TEST_GEOGRAPHY_ID = 'geo-789'; - -export const DEFAULT_BASELINE_LABELS = { - US: 'United States current law for all households nationwide', - UK: 'United Kingdom current law for all households nationwide', -} as const; - -// Mock existing simulation that matches default baseline criteria -export const mockExistingDefaultBaselineSimulation: any = { - userSimulation: { - id: 'user-sim-1', - userId: TEST_USER_ID, - simulationId: TEST_EXISTING_SIMULATION_ID, - label: DEFAULT_BASELINE_LABELS.US, - countryId: TEST_COUNTRIES.US, - createdAt: '2024-01-15T10:00:00Z', - }, - simulation: { - id: TEST_EXISTING_SIMULATION_ID, - policyId: TEST_CURRENT_LAW_ID.toString(), - populationType: 'geography', - populationId: TEST_COUNTRIES.US, - }, - geography: { - id: 'geo-1', - userId: TEST_USER_ID, - countryId: TEST_COUNTRIES.US, - geographyId: TEST_COUNTRIES.US, - scope: 'national', - label: 'US nationwide', - createdAt: '2024-01-15T10:00:00Z', - }, -}; - -// Mock simulation with different policy (not default baseline) -export const mockNonDefaultSimulation: any = { - userSimulation: { - id: 'user-sim-2', - userId: TEST_USER_ID, - simulationId: 'sim-different', - label: 'Custom reform', - countryId: TEST_COUNTRIES.US, - createdAt: '2024-01-15T11:00:00Z', - }, - simulation: { - id: 'sim-different', - policyId: '999', // Different policy - populationType: 'geography', - populationId: TEST_COUNTRIES.US, - }, - geography: { - id: 'geo-2', - userId: TEST_USER_ID, - countryId: TEST_COUNTRIES.US, - geographyId: TEST_COUNTRIES.US, - scope: 'national', - label: 'US nationwide', - createdAt: '2024-01-15T11:00:00Z', - }, -}; - -// Mock callbacks -export const mockOnSelect = vi.fn(); -export const mockOnClick = vi.fn(); - -// Mock API responses -export const mockGeographyCreationResponse = { - id: TEST_GEOGRAPHY_ID, - userId: TEST_USER_ID, - countryId: TEST_COUNTRIES.US, - geographyId: TEST_COUNTRIES.US, - scope: 'national' as const, - label: 'US nationwide', - createdAt: new Date().toISOString(), -}; - -export const mockSimulationCreationResponse = { - status: 'ok' as const, - result: { - simulation_id: TEST_SIMULATION_ID, - }, -}; - -// Helper to reset all mocks -export const resetAllMocks = () => { - mockOnSelect.mockClear(); - mockOnClick.mockClear(); -}; - -// Mock hook return values -export const mockUseUserSimulationsEmpty = { - data: [], - isLoading: false, - isError: false, - error: null, - associations: { simulations: [], policies: [], households: [] }, - getSimulationWithFullContext: vi.fn(), - getSimulationsByPolicy: vi.fn(() => []), - getSimulationsByHousehold: vi.fn(() => []), - getSimulationsByGeography: vi.fn(() => []), - getNormalizedHousehold: vi.fn(), - getPolicyLabel: vi.fn(), -} as any; - -export const mockUseUserSimulationsWithExisting = { - data: [mockExistingDefaultBaselineSimulation, mockNonDefaultSimulation], - isLoading: false, - isError: false, - error: null, - associations: { simulations: [], policies: [], households: [] }, - getSimulationWithFullContext: vi.fn(), - getSimulationsByPolicy: vi.fn(() => []), - getSimulationsByHousehold: vi.fn(() => []), - getSimulationsByGeography: vi.fn(() => []), - getNormalizedHousehold: vi.fn(), - getPolicyLabel: vi.fn(), -} as any; - -export const mockUseCreateGeographicAssociation = { - mutateAsync: vi.fn().mockResolvedValue(mockGeographyCreationResponse), - isPending: false, - isError: false, - error: null, - mutate: vi.fn(), - reset: vi.fn(), - status: 'idle' as const, -} as any; - -export const mockUseCreateSimulation = { - createSimulation: vi.fn(), - isPending: false, - isError: false, - error: null, -} as any; diff --git a/app/src/tests/fixtures/pathways/report/views/ReportViewMocks.ts b/app/src/tests/fixtures/pathways/report/views/ReportViewMocks.ts deleted file mode 100644 index 038f103a4..000000000 --- a/app/src/tests/fixtures/pathways/report/views/ReportViewMocks.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { ReportStateProps, SimulationStateProps } from '@/types/pathwayState'; - -export const TEST_REPORT_LABEL = 'Test Report 2025'; -export const TEST_SIMULATION_LABEL = 'Test Simulation'; -export const TEST_COUNTRY_ID = 'us'; -export const TEST_CURRENT_LAW_ID = 1; - -export const mockOnUpdateLabel = vi.fn(); -export const mockOnUpdateYear = vi.fn(); -export const mockOnNext = vi.fn(); -export const mockOnBack = vi.fn(); -export const mockOnCancel = vi.fn(); -export const mockOnCreateNew = vi.fn(); -export const mockOnLoadExisting = vi.fn(); -export const mockOnSelectDefaultBaseline = vi.fn(); -export const mockOnNavigateToSimulationSelection = vi.fn(); -export const mockOnPrefillPopulation2 = vi.fn(); -export const mockOnSelectSimulation = vi.fn(); -export const mockOnSubmit = vi.fn(); - -export const mockSimulationState: SimulationStateProps = { - id: undefined, - label: null, - countryId: TEST_COUNTRY_ID, - policy: { - id: undefined, - label: null, - parameters: [], - }, - population: { - label: null, - type: null, - household: null, - geography: null, - }, - apiVersion: undefined, - status: 'pending', -}; - -export const mockConfiguredSimulation: SimulationStateProps = { - id: '123', - label: 'Baseline Simulation', - countryId: TEST_COUNTRY_ID, - policy: { - id: '456', - label: 'Current Law', - parameters: [], - }, - population: { - label: 'My Household', - type: 'household', - household: { - id: '789', - countryId: 'us', - householdData: { - people: {}, - }, - }, - geography: null, - }, - apiVersion: '0.1.0', - status: 'complete', -}; - -export const mockReportState: ReportStateProps = { - id: undefined, - label: null, - year: '2025', - countryId: TEST_COUNTRY_ID, - simulations: [mockSimulationState, mockSimulationState], - apiVersion: null, - status: 'pending', - outputType: undefined, - output: null, -}; - -export const mockReportStateWithConfiguredBaseline: ReportStateProps = { - ...mockReportState, - simulations: [mockConfiguredSimulation, mockSimulationState], -}; - -export const mockReportStateWithBothConfigured: ReportStateProps = { - ...mockReportState, - simulations: [ - mockConfiguredSimulation, - { ...mockConfiguredSimulation, id: '124', label: 'Reform Simulation' }, - ], -}; - -export const mockUseCurrentCountry = vi.fn(() => TEST_COUNTRY_ID); - -export const mockUseUserSimulationsEmpty = { - data: [], - isLoading: false, - isError: false, - error: null, -}; - -export const mockEnhancedUserSimulation = { - userSimulation: { id: 1, label: 'My Simulation', simulation_id: '123', user_id: 1 }, - simulation: { - id: '123', - label: 'Test Simulation', - policyId: '456', - populationId: '789', - countryId: TEST_COUNTRY_ID, - }, - userPolicy: { id: 1, label: 'Test Policy', policy_id: '456', user_id: 1 }, - policy: { id: '456', label: 'Current Law', countryId: TEST_COUNTRY_ID }, - userHousehold: { id: 1, label: 'Test Household', household_id: '789', user_id: 1 }, - household: { id: '789', label: 'My Household', people: {} }, - geography: null, -}; - -export const mockUseUserSimulationsWithData = { - data: [mockEnhancedUserSimulation], - isLoading: false, - isError: false, - error: null, -}; - -export const mockUseUserSimulationsLoading = { - data: undefined, - isLoading: true, - isError: false, - error: null, -}; - -export const mockUseUserSimulationsError = { - data: undefined, - isLoading: false, - isError: true, - error: new Error('Failed to load simulations'), -}; - -export const mockUseUserHouseholdsEmpty = { - data: [], - isLoading: false, - isError: false, - error: null, - associations: [], -}; - -export const mockUseUserGeographicsEmpty = { - data: [], - isLoading: false, - isError: false, - error: null, - associations: [], -}; - -export function resetAllMocks() { - mockOnUpdateLabel.mockClear(); - mockOnUpdateYear.mockClear(); - mockOnNext.mockClear(); - mockOnBack.mockClear(); - mockOnCancel.mockClear(); - mockOnCreateNew.mockClear(); - mockOnLoadExisting.mockClear(); - mockOnSelectDefaultBaseline.mockClear(); - mockOnNavigateToSimulationSelection.mockClear(); - mockOnPrefillPopulation2.mockClear(); - mockOnSelectSimulation.mockClear(); - mockOnSubmit.mockClear(); -} diff --git a/app/src/tests/unit/pathways/report/ReportPathwayWrapper.test.tsx b/app/src/tests/unit/pathways/report/ReportPathwayWrapper.test.tsx deleted file mode 100644 index a4c1f7343..000000000 --- a/app/src/tests/unit/pathways/report/ReportPathwayWrapper.test.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { render, screen } from '@test-utils'; -import { useParams } from 'react-router-dom'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { useCreateReport } from '@/hooks/useCreateReport'; -import { useUserGeographics } from '@/hooks/useUserGeographic'; -import { useUserHouseholds } from '@/hooks/useUserHousehold'; -import { useUserPolicies } from '@/hooks/useUserPolicy'; -import { useUserSimulations } from '@/hooks/useUserSimulations'; -import ReportPathwayWrapper from '@/pathways/report/ReportPathwayWrapper'; -import { - mockMetadata, - mockNavigate, - mockOnComplete, - mockUseCreateReport, - mockUseParams, - mockUseParamsInvalid, - mockUseParamsMissing, - mockUseUserGeographics, - mockUseUserHouseholds, - mockUseUserPolicies, - mockUseUserSimulations, - resetAllMocks, -} from '@/tests/fixtures/pathways/report/ReportPathwayWrapperMocks'; - -// Mock all dependencies -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); - return { - ...actual, - useNavigate: () => mockNavigate, - useParams: vi.fn(), - }; -}); - -vi.mock('react-redux', async () => { - const actual = await vi.importActual('react-redux'); - return { - ...actual, - useSelector: vi.fn((selector) => { - if (selector.toString().includes('currentLawId')) { - return mockMetadata.currentLawId; - } - return mockMetadata; - }), - }; -}); - -vi.mock('@/hooks/useUserSimulations', () => ({ - useUserSimulations: vi.fn(), -})); - -vi.mock('@/hooks/useUserPolicy', () => ({ - useUserPolicies: vi.fn(), -})); - -vi.mock('@/hooks/useUserHousehold', () => ({ - useUserHouseholds: vi.fn(), -})); - -vi.mock('@/hooks/useUserGeographic', () => ({ - useUserGeographics: vi.fn(), -})); - -vi.mock('@/hooks/useCreateReport', () => ({ - useCreateReport: vi.fn(), -})); - -vi.mock('@/hooks/usePathwayNavigation', () => ({ - usePathwayNavigation: vi.fn(() => ({ - mode: 'LABEL', - navigateToMode: vi.fn(), - goBack: vi.fn(), - getBackMode: vi.fn(), - })), -})); - -describe('ReportPathwayWrapper', () => { - beforeEach(() => { - resetAllMocks(); - vi.clearAllMocks(); - - // Default mock implementations - vi.mocked(useParams).mockReturnValue(mockUseParams); - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulations); - vi.mocked(useUserPolicies).mockReturnValue(mockUseUserPolicies); - vi.mocked(useUserHouseholds).mockReturnValue(mockUseUserHouseholds); - vi.mocked(useUserGeographics).mockReturnValue(mockUseUserGeographics); - vi.mocked(useCreateReport).mockReturnValue(mockUseCreateReport); - }); - - describe('Error handling', () => { - test('given missing countryId param then shows error message', () => { - // Given - vi.mocked(useParams).mockReturnValue(mockUseParamsMissing); - - // When - render(); - - // Then - expect(screen.getByText(/Country ID not found/i)).toBeInTheDocument(); - }); - - test('given invalid countryId then shows error message', () => { - // Given - vi.mocked(useParams).mockReturnValue(mockUseParamsInvalid); - - // When - render(); - - // Then - expect(screen.getByText(/Invalid country ID/i)).toBeInTheDocument(); - }); - }); - - describe('Basic rendering', () => { - test('given valid countryId then renders without error', () => { - // When - const { container } = render(); - - // Then - Should render something (not just error message) - expect(container).toBeInTheDocument(); - expect(screen.queryByText(/Country ID not found/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/Invalid country ID/i)).not.toBeInTheDocument(); - }); - - test('given wrapper renders then initializes with hooks', () => { - // When - render(); - - // Then - Hooks should have been called (useUserPolicies is used in child components, not wrapper) - expect(useUserSimulations).toHaveBeenCalled(); - expect(useUserHouseholds).toHaveBeenCalled(); - expect(useUserGeographics).toHaveBeenCalled(); - expect(useCreateReport).toHaveBeenCalled(); - }); - }); - - describe('Props handling', () => { - test('given onComplete callback then accepts prop', () => { - // When - const { container } = render(); - - // Then - Component renders with callback - expect(container).toBeInTheDocument(); - }); - - test('given no onComplete callback then renders without error', () => { - // When - const { container } = render(); - - // Then - expect(container).toBeInTheDocument(); - }); - }); - - describe('State initialization', () => { - test('given wrapper renders then initializes report state with country', () => { - // When - render(); - - // Then - No errors, component initialized successfully - expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); - }); - }); -}); diff --git a/app/src/tests/unit/pathways/report/ReportSimulationSelectionLogic.test.tsx b/app/src/tests/unit/pathways/report/ReportSimulationSelectionLogic.test.tsx deleted file mode 100644 index 5d29ebfa6..000000000 --- a/app/src/tests/unit/pathways/report/ReportSimulationSelectionLogic.test.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Tests for Report pathway simulation selection logic - * - * Tests the fix for the issue where automated simulation setup wasn't working. - * The baseline simulation selection view should always be shown, even when there are - * no existing simulations, because it contains the DefaultBaselineOption component - * for quick setup with "Current law + Nationwide population". - * - * KEY BEHAVIOR: - * - Baseline simulation (index 0): ALWAYS show selection view (even with no existing simulations) - * - Reform simulation (index 1): Skip selection when no existing simulations - */ - -import { describe, expect, test } from 'vitest'; -import { SIMULATION_INDEX } from '@/tests/fixtures/pathways/report/ReportPathwayWrapperMocks'; - -/** - * Helper function that implements the logic from ReportPathwayWrapper.tsx - * for determining whether to show the simulation selection view - */ -function shouldShowSimulationSelectionView( - simulationIndex: 0 | 1, - hasExistingSimulations: boolean -): boolean { - // Always show selection view for baseline (index 0) because it has DefaultBaselineOption - // For reform (index 1), skip if no existing simulations - return simulationIndex === 0 || hasExistingSimulations; -} - -describe('Report pathway simulation selection logic', () => { - describe('Baseline simulation (index 0)', () => { - test('given no existing simulations then should show selection view', () => { - // Given - const simulationIndex = SIMULATION_INDEX.BASELINE; - const hasExistingSimulations = false; - - // When - const result = shouldShowSimulationSelectionView(simulationIndex, hasExistingSimulations); - - // Then - expect(result).toBe(true); - }); - - test('given existing simulations then should show selection view', () => { - // Given - const simulationIndex = SIMULATION_INDEX.BASELINE; - const hasExistingSimulations = true; - - // When - const result = shouldShowSimulationSelectionView(simulationIndex, hasExistingSimulations); - - // Then - expect(result).toBe(true); - }); - }); - - describe('Reform simulation (index 1)', () => { - test('given no existing simulations then should skip selection view', () => { - // Given - const simulationIndex = SIMULATION_INDEX.REFORM; - const hasExistingSimulations = false; - - // When - const result = shouldShowSimulationSelectionView(simulationIndex, hasExistingSimulations); - - // Then - expect(result).toBe(false); - }); - - test('given existing simulations then should show selection view', () => { - // Given - const simulationIndex = SIMULATION_INDEX.REFORM; - const hasExistingSimulations = true; - - // When - const result = shouldShowSimulationSelectionView(simulationIndex, hasExistingSimulations); - - // Then - expect(result).toBe(true); - }); - }); -}); diff --git a/app/src/tests/unit/pathways/report/components/DefaultBaselineOption.test.tsx b/app/src/tests/unit/pathways/report/components/DefaultBaselineOption.test.tsx deleted file mode 100644 index 936e0b57b..000000000 --- a/app/src/tests/unit/pathways/report/components/DefaultBaselineOption.test.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { render, screen, userEvent } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import DefaultBaselineOption from '@/pathways/report/components/DefaultBaselineOption'; -import { - DEFAULT_BASELINE_LABELS, - mockOnClick, - resetAllMocks, - TEST_COUNTRIES, -} from '@/tests/fixtures/pathways/report/components/DefaultBaselineOptionMocks'; - -describe('DefaultBaselineOption', () => { - beforeEach(() => { - resetAllMocks(); - vi.clearAllMocks(); - }); - - describe('Rendering', () => { - test('given component renders then displays default baseline label', () => { - // When - render( - - ); - - // Then - expect(screen.getByText(DEFAULT_BASELINE_LABELS.US)).toBeInTheDocument(); - expect( - screen.getByText('Use current law with all households nationwide as baseline') - ).toBeInTheDocument(); - }); - - test('given UK country then displays UK label', () => { - // When - render( - - ); - - // Then - expect(screen.getByText(DEFAULT_BASELINE_LABELS.UK)).toBeInTheDocument(); - }); - - test('given component renders then displays card as button', () => { - // When - render( - - ); - - // Then - const button = screen.getByRole('button'); - expect(button).toBeInTheDocument(); - expect(button).not.toBeDisabled(); - }); - - test('given component renders then displays chevron icon', () => { - // When - const { container } = render( - - ); - - // Then - const chevronIcon = container.querySelector('svg'); - expect(chevronIcon).toBeInTheDocument(); - }); - }); - - describe('Selection state', () => { - test('given isSelected is false then shows inactive variant', () => { - // When - const { container } = render( - - ); - - // Then - const button = container.querySelector('[data-variant="buttonPanel--inactive"]'); - expect(button).toBeInTheDocument(); - }); - - test('given isSelected is true then shows active variant', () => { - // When - const { container } = render( - - ); - - // Then - const button = container.querySelector('[data-variant="buttonPanel--active"]'); - expect(button).toBeInTheDocument(); - }); - }); - - describe('User interactions', () => { - test('given button is clicked then onClick callback is invoked', async () => { - // Given - const user = userEvent.setup(); - const mockCallback = vi.fn(); - - render( - - ); - - const button = screen.getByRole('button'); - - // When - await user.click(button); - - // Then - expect(mockCallback).toHaveBeenCalledOnce(); - }); - - test('given button is clicked multiple times then onClick is called each time', async () => { - // Given - const user = userEvent.setup(); - const mockCallback = vi.fn(); - - render( - - ); - - const button = screen.getByRole('button'); - - // When - await user.click(button); - await user.click(button); - await user.click(button); - - // Then - expect(mockCallback).toHaveBeenCalledTimes(3); - }); - }); - - describe('Props handling', () => { - test('given different country IDs then generates correct labels', () => { - // Test US - const { rerender } = render( - - ); - expect(screen.getByText(DEFAULT_BASELINE_LABELS.US)).toBeInTheDocument(); - - // Test UK - rerender( - - ); - expect(screen.getByText(DEFAULT_BASELINE_LABELS.UK)).toBeInTheDocument(); - }); - }); -}); diff --git a/app/src/tests/unit/pathways/report/views/ReportLabelView.test.tsx b/app/src/tests/unit/pathways/report/views/ReportLabelView.test.tsx deleted file mode 100644 index 8d5dc3b4c..000000000 --- a/app/src/tests/unit/pathways/report/views/ReportLabelView.test.tsx +++ /dev/null @@ -1,357 +0,0 @@ -import { render, screen, userEvent } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { useCurrentCountry } from '@/hooks/useCurrentCountry'; -import ReportLabelView from '@/pathways/report/views/ReportLabelView'; -import { - mockOnBack, - mockOnCancel, - mockOnNext, - mockOnUpdateLabel, - mockOnUpdateYear, - resetAllMocks, - TEST_COUNTRY_ID, - TEST_REPORT_LABEL, -} from '@/tests/fixtures/pathways/report/views/ReportViewMocks'; - -vi.mock('@/hooks/useCurrentCountry', () => ({ - useCurrentCountry: vi.fn(), -})); - -describe('ReportLabelView', () => { - beforeEach(() => { - resetAllMocks(); - vi.clearAllMocks(); - vi.mocked(useCurrentCountry).mockReturnValue(TEST_COUNTRY_ID); - }); - - describe('Basic rendering', () => { - test('given component renders then displays title', () => { - // When - render( - - ); - - // Then - expect(screen.getByRole('heading', { name: /create report/i })).toBeInTheDocument(); - }); - - test('given component renders then displays report name input', () => { - // When - render( - - ); - - // Then - expect(screen.getByLabelText(/report name/i)).toBeInTheDocument(); - }); - - test('given component renders then displays year select', () => { - // When - const { container } = render( - - ); - - // Then - Year select exists as a searchable input - const yearInput = container.querySelector('input[aria-haspopup="listbox"]'); - expect(yearInput).toBeInTheDocument(); - }); - }); - - describe('US country specific', () => { - test('given US country then displays Initialize button', () => { - // Given - vi.mocked(useCurrentCountry).mockReturnValue('us'); - - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /initialize report/i })).toBeInTheDocument(); - }); - }); - - describe('UK country specific', () => { - test('given UK country then displays Initialise button with British spelling', () => { - // Given - vi.mocked(useCurrentCountry).mockReturnValue('uk'); - - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /initialise report/i })).toBeInTheDocument(); - }); - }); - - describe('Pre-populated label', () => { - test('given existing label then input shows label value', () => { - // When - render( - - ); - - // Then - expect(screen.getByLabelText(/report name/i)).toHaveValue(TEST_REPORT_LABEL); - }); - - test('given null label then input is empty', () => { - // When - render( - - ); - - // Then - expect(screen.getByLabelText(/report name/i)).toHaveValue(''); - }); - }); - - describe('User interactions', () => { - test('given user types in label then input value updates', async () => { - // Given - const user = userEvent.setup(); - render( - - ); - const input = screen.getByLabelText(/report name/i); - - // When - await user.type(input, 'New Report Name'); - - // Then - expect(input).toHaveValue('New Report Name'); - }); - - test('given user clicks submit then calls onUpdateLabel with entered value', async () => { - // Given - const user = userEvent.setup(); - render( - - ); - const input = screen.getByLabelText(/report name/i); - const submitButton = screen.getByRole('button', { name: /initialize report/i }); - - // When - await user.type(input, 'Test Report'); - await user.click(submitButton); - - // Then - expect(mockOnUpdateLabel).toHaveBeenCalledWith('Test Report'); - }); - - test('given user clicks submit then calls onUpdateYear with year value', async () => { - // Given - const user = userEvent.setup(); - render( - - ); - const submitButton = screen.getByRole('button', { name: /initialize report/i }); - - // When - await user.click(submitButton); - - // Then - expect(mockOnUpdateYear).toHaveBeenCalledWith('2025'); - }); - - test('given user clicks submit then calls onNext', async () => { - // Given - const user = userEvent.setup(); - render( - - ); - const submitButton = screen.getByRole('button', { name: /initialize report/i }); - - // When - await user.click(submitButton); - - // Then - expect(mockOnNext).toHaveBeenCalled(); - }); - - test('given user clicks submit with empty label then still submits empty string', async () => { - // Given - const user = userEvent.setup(); - render( - - ); - const submitButton = screen.getByRole('button', { name: /initialize report/i }); - - // When - await user.click(submitButton); - - // Then - expect(mockOnUpdateLabel).toHaveBeenCalledWith(''); - expect(mockOnNext).toHaveBeenCalled(); - }); - }); - - describe('Navigation actions', () => { - test('given onBack provided then renders back button', () => { - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); - }); - - test('given onBack not provided then no back button', () => { - // When - render( - - ); - - // Then - expect(screen.queryByRole('button', { name: /back/i })).not.toBeInTheDocument(); - }); - - test('given onCancel provided then renders cancel button', () => { - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); - }); - - test('given user clicks back then calls onBack', async () => { - // Given - const user = userEvent.setup(); - render( - - ); - - // When - await user.click(screen.getByRole('button', { name: /back/i })); - - // Then - expect(mockOnBack).toHaveBeenCalled(); - }); - - test('given user clicks cancel then calls onCancel', async () => { - // Given - const user = userEvent.setup(); - render( - - ); - - // When - await user.click(screen.getByRole('button', { name: /cancel/i })); - - // Then - expect(mockOnCancel).toHaveBeenCalled(); - }); - }); -}); diff --git a/app/src/tests/unit/pathways/report/views/ReportSetupView.test.tsx b/app/src/tests/unit/pathways/report/views/ReportSetupView.test.tsx deleted file mode 100644 index b5667c30b..000000000 --- a/app/src/tests/unit/pathways/report/views/ReportSetupView.test.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import { render, screen, userEvent } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { useUserGeographics } from '@/hooks/useUserGeographic'; -import { useUserHouseholds } from '@/hooks/useUserHousehold'; -import ReportSetupView from '@/pathways/report/views/ReportSetupView'; -import { - mockOnBack, - mockOnCancel, - mockOnNavigateToSimulationSelection, - mockOnNext, - mockOnPrefillPopulation2, - mockReportState, - mockReportStateWithBothConfigured, - mockReportStateWithConfiguredBaseline, - mockUseUserGeographicsEmpty, - mockUseUserHouseholdsEmpty, - resetAllMocks, -} from '@/tests/fixtures/pathways/report/views/ReportViewMocks'; - -vi.mock('@/hooks/useUserHousehold', () => ({ - useUserHouseholds: vi.fn(), - isHouseholdMetadataWithAssociation: vi.fn(), -})); - -vi.mock('@/hooks/useUserGeographic', () => ({ - useUserGeographics: vi.fn(), - isGeographicMetadataWithAssociation: vi.fn(), -})); - -describe('ReportSetupView', () => { - beforeEach(() => { - resetAllMocks(); - vi.clearAllMocks(); - vi.mocked(useUserHouseholds).mockReturnValue(mockUseUserHouseholdsEmpty); - vi.mocked(useUserGeographics).mockReturnValue(mockUseUserGeographicsEmpty); - }); - - describe('Basic rendering', () => { - test('given component renders then displays title', () => { - // When - render( - - ); - - // Then - expect(screen.getByRole('heading', { name: /configure report/i })).toBeInTheDocument(); - }); - - test('given component renders then displays baseline simulation card', () => { - // When - render( - - ); - - // Then - Multiple "Baseline simulation" texts exist, just verify at least one - expect(screen.getAllByText(/baseline simulation/i).length).toBeGreaterThan(0); - }); - - test('given component renders then displays comparison simulation card', () => { - // When - render( - - ); - - // Then - expect(screen.getByText(/comparison simulation/i)).toBeInTheDocument(); - }); - }); - - describe('Unconfigured simulations', () => { - test('given no simulations configured then comparison card shows waiting message', () => { - // When - render( - - ); - - // Then - expect(screen.getByText(/waiting for baseline/i)).toBeInTheDocument(); - }); - - test('given no simulations configured then comparison card is disabled', () => { - // When - const { container } = render( - - ); - - // Then - Find card by looking for the disabled state in the Card component - const cards = container.querySelectorAll('[data-variant^="setupCondition"]'); - const comparisonCard = Array.from(cards).find((card) => - card.textContent?.includes('Comparison simulation') - ); - // The card should have disabled styling or be marked as disabled - expect(comparisonCard).toBeDefined(); - expect(comparisonCard?.textContent).toContain('Waiting for baseline'); - }); - - test('given no simulations configured then primary button is disabled', () => { - // When - render( - - ); - - // Then - const buttons = screen.getAllByRole('button'); - const primaryButton = buttons.find( - (btn) => - btn.textContent?.includes('Configure baseline simulation') && - btn.className?.includes('Button') - ); - expect(primaryButton).toBeDisabled(); - }); - }); - - describe('Baseline configured', () => { - test('given baseline configured with household then comparison is optional', () => { - // When - render( - - ); - - // Then - expect(screen.getByText(/comparison simulation \(optional\)/i)).toBeInTheDocument(); - }); - - test('given baseline configured then comparison card is enabled', () => { - // When - render( - - ); - - // Then - const cards = screen.getAllByRole('button'); - const comparisonCard = cards.find((card) => - card.textContent?.includes('Comparison simulation') - ); - expect(comparisonCard).not.toHaveAttribute('data-disabled', 'true'); - }); - - test('given baseline configured with household then can proceed without comparison', () => { - // When - render( - - ); - - // Then - const buttons = screen.getAllByRole('button'); - const reviewButton = buttons.find((btn) => btn.textContent?.includes('Review report')); - expect(reviewButton).not.toBeDisabled(); - }); - }); - - describe('Both simulations configured', () => { - test('given both simulations configured then shows Review report button', () => { - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /review report/i })).toBeInTheDocument(); - }); - - test('given both simulations configured then Review button is enabled', () => { - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /review report/i })).not.toBeDisabled(); - }); - }); - - describe('User interactions', () => { - test('given user selects baseline card then calls navigation with index 0', async () => { - // Given - const user = userEvent.setup(); - render( - - ); - const cards = screen.getAllByRole('button'); - const baselineCard = cards.find((card) => card.textContent?.includes('Baseline simulation')); - - // When - await user.click(baselineCard!); - const configureButton = screen.getByRole('button', { - name: /configure baseline simulation/i, - }); - await user.click(configureButton); - - // Then - expect(mockOnNavigateToSimulationSelection).toHaveBeenCalledWith(0); - }); - - test('given user selects comparison card when baseline configured then prefills population', async () => { - // Given - const user = userEvent.setup(); - render( - - ); - const cards = screen.getAllByRole('button'); - const comparisonCard = cards.find((card) => - card.textContent?.includes('Comparison simulation') - ); - - // When - await user.click(comparisonCard!); - const configureButton = screen.getByRole('button', { - name: /configure comparison simulation/i, - }); - await user.click(configureButton); - - // Then - expect(mockOnPrefillPopulation2).toHaveBeenCalled(); - expect(mockOnNavigateToSimulationSelection).toHaveBeenCalledWith(1); - }); - - test('given both configured and review clicked then calls onNext', async () => { - // Given - const user = userEvent.setup(); - render( - - ); - - // When - await user.click(screen.getByRole('button', { name: /review report/i })); - - // Then - expect(mockOnNext).toHaveBeenCalled(); - }); - }); - - describe('Navigation actions', () => { - test('given onBack provided then renders back button', () => { - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); - }); - - test('given onCancel provided then renders cancel button', () => { - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); - }); - }); -}); diff --git a/app/src/tests/unit/pathways/report/views/ReportSimulationExistingView.test.tsx b/app/src/tests/unit/pathways/report/views/ReportSimulationExistingView.test.tsx deleted file mode 100644 index 5eb366cc0..000000000 --- a/app/src/tests/unit/pathways/report/views/ReportSimulationExistingView.test.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import { render, screen, userEvent } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { useUserSimulations } from '@/hooks/useUserSimulations'; -import ReportSimulationExistingView from '@/pathways/report/views/ReportSimulationExistingView'; -import { - mockEnhancedUserSimulation, - mockOnBack, - mockOnCancel, - mockOnNext, - mockOnSelectSimulation, - mockSimulationState, - mockUseUserSimulationsEmpty, - mockUseUserSimulationsError, - mockUseUserSimulationsLoading, - mockUseUserSimulationsWithData, - resetAllMocks, -} from '@/tests/fixtures/pathways/report/views/ReportViewMocks'; - -vi.mock('@/hooks/useUserSimulations', () => ({ - useUserSimulations: vi.fn(), -})); - -describe('ReportSimulationExistingView', () => { - beforeEach(() => { - resetAllMocks(); - vi.clearAllMocks(); - }); - - describe('Loading state', () => { - test('given loading then displays loading message', () => { - // Given - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsLoading as any); - - // When - render( - - ); - - // Then - expect(screen.getByText(/loading simulations/i)).toBeInTheDocument(); - }); - }); - - describe('Error state', () => { - test('given error then displays error message', () => { - // Given - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsError as any); - - // When - render( - - ); - - // Then - expect(screen.getByText(/error/i)).toBeInTheDocument(); - expect(screen.getByText(/failed to load simulations/i)).toBeInTheDocument(); - }); - }); - - describe('Empty state', () => { - test('given no simulations then displays no simulations message', () => { - // Given - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); - - // When - render( - - ); - - // Then - expect(screen.getByText(/no simulations available/i)).toBeInTheDocument(); - }); - - test('given no simulations then next button is disabled', () => { - // Given - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); - - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); - }); - }); - - describe('With simulations', () => { - test('given simulations available then displays simulation cards', () => { - // Given - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsWithData as any); - - // When - render( - - ); - - // Then - expect(screen.getByText(/my simulation/i)).toBeInTheDocument(); - }); - - test('given simulations available then next button initially disabled', () => { - // Given - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsWithData as any); - - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); - }); - }); - - describe('User interactions', () => { - test('given user selects simulation then next button is enabled', async () => { - // Given - const user = userEvent.setup(); - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsWithData as any); - render( - - ); - const simulationCard = screen.getByText(/my simulation/i).closest('button'); - - // When - await user.click(simulationCard!); - - // Then - expect(screen.getByRole('button', { name: /next/i })).not.toBeDisabled(); - }); - - test('given user selects and submits then calls callbacks', async () => { - // Given - const user = userEvent.setup(); - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsWithData as any); - render( - - ); - const simulationCard = screen.getByText(/my simulation/i).closest('button'); - - // When - await user.click(simulationCard!); - await user.click(screen.getByRole('button', { name: /next/i })); - - // Then - expect(mockOnSelectSimulation).toHaveBeenCalledWith(mockEnhancedUserSimulation); - expect(mockOnNext).toHaveBeenCalled(); - }); - }); - - describe('Population compatibility', () => { - test('given incompatible population then simulation is disabled', () => { - // Given - const otherSim = { - ...mockSimulationState, - population: { - ...mockSimulationState.population, - household: { - id: 'different-household-999', - countryId: 'us' as const, - householdData: { people: {} }, - }, - }, - }; - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsWithData as any); - - // When - render( - - ); - - // Then - expect(screen.getByText(/incompatible/i)).toBeInTheDocument(); - }); - }); - - describe('Navigation actions', () => { - test('given onBack provided then renders back button', () => { - // Given - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); - - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); - }); - - test('given onCancel provided then renders cancel button', () => { - // Given - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); - - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); - }); - }); -}); diff --git a/app/src/tests/unit/pathways/report/views/ReportSimulationSelectionView.test.tsx b/app/src/tests/unit/pathways/report/views/ReportSimulationSelectionView.test.tsx deleted file mode 100644 index 10e6ee093..000000000 --- a/app/src/tests/unit/pathways/report/views/ReportSimulationSelectionView.test.tsx +++ /dev/null @@ -1,288 +0,0 @@ -import { render, screen, userEvent } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { useUserSimulations } from '@/hooks/useUserSimulations'; -import ReportSimulationSelectionView from '@/pathways/report/views/ReportSimulationSelectionView'; -import { - mockOnBack, - mockOnCancel, - mockOnCreateNew, - mockOnLoadExisting, - mockOnSelectDefaultBaseline, - mockUseUserSimulationsEmpty, - mockUseUserSimulationsWithData, - resetAllMocks, - TEST_COUNTRY_ID, - TEST_CURRENT_LAW_ID, -} from '@/tests/fixtures/pathways/report/views/ReportViewMocks'; - -vi.mock('@/hooks/useUserSimulations', () => ({ - useUserSimulations: vi.fn(), -})); - -vi.mock('@/hooks/useCreateSimulation', () => ({ - useCreateSimulation: vi.fn(() => ({ - createSimulation: vi.fn(), - isPending: false, - })), -})); - -vi.mock('@/hooks/useUserGeographic', () => ({ - useCreateGeographicAssociation: vi.fn(() => ({ - mutateAsync: vi.fn(), - isPending: false, - })), -})); - -vi.mock('@/hooks/useUserHousehold', () => ({ - useUserHouseholds: vi.fn(() => ({ data: [], isLoading: false })), -})); - -vi.mock('@/hooks/useUserPolicy', () => ({ - useUserPolicies: vi.fn(() => ({ data: [], isLoading: false })), -})); - -vi.mock('react-plotly.js', () => ({ default: vi.fn(() => null) })); - -describe('ReportSimulationSelectionView', () => { - beforeEach(() => { - resetAllMocks(); - vi.clearAllMocks(); - }); - - describe('Baseline simulation (index 0)', () => { - test('given baseline simulation then displays default baseline option', () => { - // Given - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); - - // When - render( - - ); - - // Then - expect(screen.getByText(/current law for all households nationwide/i)).toBeInTheDocument(); - }); - - test('given baseline simulation then displays create new option', () => { - // Given - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); - - // When - render( - - ); - - // Then - expect(screen.getByText(/create new simulation/i)).toBeInTheDocument(); - }); - - test('given user has simulations then displays load existing option', () => { - // Given - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsWithData as any); - - // When - render( - - ); - - // Then - expect(screen.getByText(/load existing simulation/i)).toBeInTheDocument(); - }); - - test('given user has no simulations then load existing not shown', () => { - // Given - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); - - // When - render( - - ); - - // Then - expect(screen.queryByText(/load existing simulation/i)).not.toBeInTheDocument(); - }); - }); - - describe('Comparison simulation (index 1)', () => { - test('given comparison simulation then default baseline option not shown', () => { - // Given - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); - - // When - render( - - ); - - // Then - expect( - screen.queryByText(/current law for all households nationwide/i) - ).not.toBeInTheDocument(); - }); - - test('given comparison simulation then only shows standard options', () => { - // Given - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); - - // When - render( - - ); - - // Then - expect(screen.getByText(/create new simulation/i)).toBeInTheDocument(); - expect(screen.queryByText(/load existing simulation/i)).not.toBeInTheDocument(); - }); - }); - - describe('User interactions', () => { - test('given user clicks create new then calls onCreateNew', async () => { - // Given - const user = userEvent.setup(); - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); - render( - - ); - const cards = screen.getAllByRole('button'); - const createNewCard = cards.find((card) => - card.textContent?.includes('Create new simulation') - ); - - // When - await user.click(createNewCard!); - await user.click(screen.getByRole('button', { name: /next/i })); - - // Then - expect(mockOnCreateNew).toHaveBeenCalled(); - }); - - test('given user clicks load existing then calls onLoadExisting', async () => { - // Given - const user = userEvent.setup(); - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsWithData as any); - render( - - ); - const cards = screen.getAllByRole('button'); - const loadExistingCard = cards.find((card) => - card.textContent?.includes('Load existing simulation') - ); - - // When - await user.click(loadExistingCard!); - await user.click(screen.getByRole('button', { name: /next/i })); - - // Then - expect(mockOnLoadExisting).toHaveBeenCalled(); - }); - - test('given no selection then next button is disabled', () => { - // Given - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); - - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); - }); - }); - - describe('Navigation actions', () => { - test('given onBack provided then renders back button', () => { - // Given - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); - - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); - }); - - test('given onCancel provided then renders cancel button', () => { - // Given - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); - - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); - }); - }); -}); diff --git a/app/src/tests/unit/pathways/report/views/ReportSubmitView.test.tsx b/app/src/tests/unit/pathways/report/views/ReportSubmitView.test.tsx deleted file mode 100644 index 12786b54f..000000000 --- a/app/src/tests/unit/pathways/report/views/ReportSubmitView.test.tsx +++ /dev/null @@ -1,276 +0,0 @@ -import { render, screen, userEvent } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import ReportSubmitView from '@/pathways/report/views/ReportSubmitView'; -import { - mockOnBack, - mockOnCancel, - mockOnSubmit, - mockReportState, - mockReportStateWithBothConfigured, - mockReportStateWithConfiguredBaseline, - resetAllMocks, -} from '@/tests/fixtures/pathways/report/views/ReportViewMocks'; - -describe('ReportSubmitView', () => { - beforeEach(() => { - resetAllMocks(); - vi.clearAllMocks(); - }); - - describe('Basic rendering', () => { - test('given component renders then displays title', () => { - // When - render( - - ); - - // Then - expect( - screen.getByRole('heading', { name: /review report configuration/i }) - ).toBeInTheDocument(); - }); - - test('given component renders then displays subtitle', () => { - // When - render( - - ); - - // Then - expect(screen.getByText(/review your selected simulations/i)).toBeInTheDocument(); - }); - - test('given component renders then displays baseline simulation box', () => { - // When - render( - - ); - - // Then - expect(screen.getByText(/baseline simulation/i)).toBeInTheDocument(); - }); - - test('given component renders then displays comparison simulation box', () => { - // When - render( - - ); - - // Then - expect(screen.getByText(/comparison simulation/i)).toBeInTheDocument(); - }); - - test('given component renders then displays create report button', () => { - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /create report/i })).toBeInTheDocument(); - }); - }); - - describe('Configured baseline simulation', () => { - test('given baseline configured then shows simulation label', () => { - // When - render( - - ); - - // Then - expect(screen.getAllByText(/baseline simulation/i).length).toBeGreaterThan(0); - }); - - test('given baseline configured then shows policy and population info', () => { - // When - render( - - ); - - // Then - expect(screen.getByText(/current law/i)).toBeInTheDocument(); - expect(screen.getByText(/my household/i)).toBeInTheDocument(); - }); - }); - - describe('Both simulations configured', () => { - test('given both configured then shows both simulation labels', () => { - // When - render( - - ); - - // Then - expect(screen.getAllByText(/baseline simulation/i).length).toBeGreaterThan(0); - expect(screen.getByText(/reform simulation/i)).toBeInTheDocument(); - }); - }); - - describe('Unconfigured simulations', () => { - test('given no simulations configured then shows no simulation placeholders', () => { - // When - render( - - ); - - // Then - const noSimulationTexts = screen.getAllByText(/no simulation/i); - expect(noSimulationTexts).toHaveLength(2); - }); - }); - - describe('User interactions', () => { - test('given user clicks submit then calls onSubmit', async () => { - // Given - const user = userEvent.setup(); - render( - - ); - - // When - await user.click(screen.getByRole('button', { name: /create report/i })); - - // Then - expect(mockOnSubmit).toHaveBeenCalled(); - }); - - test('given isSubmitting true then button shows loading state', () => { - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /create report/i })).toBeDisabled(); - }); - - test('given isSubmitting false then button is enabled', () => { - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /create report/i })).not.toBeDisabled(); - }); - }); - - describe('Navigation actions', () => { - test('given onBack provided then renders back button', () => { - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); - }); - - test('given onCancel provided then renders cancel button', () => { - // When - render( - - ); - - // Then - expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); - }); - - test('given user clicks back then calls onBack', async () => { - // Given - const user = userEvent.setup(); - render( - - ); - - // When - await user.click(screen.getByRole('button', { name: /back/i })); - - // Then - expect(mockOnBack).toHaveBeenCalled(); - }); - - test('given user clicks cancel then calls onCancel', async () => { - // Given - const user = userEvent.setup(); - render( - - ); - - // When - await user.click(screen.getByRole('button', { name: /cancel/i })); - - // Then - expect(mockOnCancel).toHaveBeenCalled(); - }); - }); -}); diff --git a/app/src/tests/unit/utils/pathwayState/initializeReportState.test.ts b/app/src/tests/unit/utils/pathwayState/initializeReportState.test.ts deleted file mode 100644 index 2f27dcd7c..000000000 --- a/app/src/tests/unit/utils/pathwayState/initializeReportState.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { - EXPECTED_REPORT_STATE_STRUCTURE, - TEST_COUNTRIES, -} from '@/tests/fixtures/utils/pathwayState/initializeStateMocks'; -import { initializeReportState } from '@/utils/pathwayState/initializeReportState'; - -describe('initializeReportState', () => { - describe('Basic structure', () => { - test('given country ID then returns report state with correct structure', () => { - // When - const result = initializeReportState(TEST_COUNTRIES.US); - - // Then - expect(result).toMatchObject(EXPECTED_REPORT_STATE_STRUCTURE); - expect(result.countryId).toBe(TEST_COUNTRIES.US); - }); - - test('given country ID then initializes with two simulations', () => { - // When - const result = initializeReportState(TEST_COUNTRIES.US); - - // Then - expect(result.simulations).toHaveLength(2); - expect(result.simulations[0]).toBeDefined(); - expect(result.simulations[1]).toBeDefined(); - }); - }); - - describe('Default values', () => { - test('given initialization then id is undefined', () => { - // When - const result = initializeReportState(TEST_COUNTRIES.US); - - // Then - expect(result.id).toBeUndefined(); - }); - - test('given initialization then label is null', () => { - // When - const result = initializeReportState(TEST_COUNTRIES.US); - - // Then - expect(result.label).toBeNull(); - }); - - test('given initialization then apiVersion is null', () => { - // When - const result = initializeReportState(TEST_COUNTRIES.US); - - // Then - expect(result.apiVersion).toBeNull(); - }); - - test('given initialization then status is pending', () => { - // When - const result = initializeReportState(TEST_COUNTRIES.US); - - // Then - expect(result.status).toBe('pending'); - }); - - test('given initialization then outputType is undefined', () => { - // When - const result = initializeReportState(TEST_COUNTRIES.US); - - // Then - expect(result.outputType).toBeUndefined(); - }); - - test('given initialization then output is null', () => { - // When - const result = initializeReportState(TEST_COUNTRIES.US); - - // Then - expect(result.output).toBeNull(); - }); - }); - - describe('Country ID handling', () => { - test('given US country ID then sets correct country', () => { - // When - const result = initializeReportState(TEST_COUNTRIES.US); - - // Then - expect(result.countryId).toBe(TEST_COUNTRIES.US); - }); - - test('given UK country ID then sets correct country', () => { - // When - const result = initializeReportState(TEST_COUNTRIES.UK); - - // Then - expect(result.countryId).toBe(TEST_COUNTRIES.UK); - }); - - test('given CA country ID then sets correct country', () => { - // When - const result = initializeReportState(TEST_COUNTRIES.CA); - - // Then - expect(result.countryId).toBe(TEST_COUNTRIES.CA); - }); - }); - - describe('Nested simulation state', () => { - test('given initialization then simulations have empty policy state', () => { - // When - const result = initializeReportState(TEST_COUNTRIES.US); - - // Then - expect(result.simulations[0].policy).toBeDefined(); - expect(result.simulations[0].policy.id).toBeUndefined(); - expect(result.simulations[0].policy.label).toBeNull(); - expect(result.simulations[0].policy.parameters).toEqual([]); - }); - - test('given initialization then simulations have empty population state', () => { - // When - const result = initializeReportState(TEST_COUNTRIES.US); - - // Then - expect(result.simulations[0].population).toBeDefined(); - expect(result.simulations[0].population.label).toBeNull(); - expect(result.simulations[0].population.type).toBeNull(); - expect(result.simulations[0].population.household).toBeNull(); - expect(result.simulations[0].population.geography).toBeNull(); - }); - - test('given initialization then both simulations are independent objects', () => { - // When - const result = initializeReportState(TEST_COUNTRIES.US); - - // Then - Simulations should be different object references - expect(result.simulations[0]).not.toBe(result.simulations[1]); - expect(result.simulations[0].policy).not.toBe(result.simulations[1].policy); - expect(result.simulations[0].population).not.toBe(result.simulations[1].population); - }); - }); - - describe('Immutability', () => { - test('given multiple initializations then returns new objects each time', () => { - // When - const result1 = initializeReportState(TEST_COUNTRIES.US); - const result2 = initializeReportState(TEST_COUNTRIES.US); - - // Then - expect(result1).not.toBe(result2); - expect(result1.simulations).not.toBe(result2.simulations); - }); - }); -}); diff --git a/app/src/utils/pathwayCallbacks/index.ts b/app/src/utils/pathwayCallbacks/index.ts index 65f9564ed..80e5377d7 100644 --- a/app/src/utils/pathwayCallbacks/index.ts +++ b/app/src/utils/pathwayCallbacks/index.ts @@ -6,4 +6,3 @@ export { createPolicyCallbacks } from './policyCallbacks'; export { createPopulationCallbacks } from './populationCallbacks'; export { createSimulationCallbacks } from './simulationCallbacks'; -export { createReportCallbacks } from './reportCallbacks'; diff --git a/app/src/utils/pathwayCallbacks/reportCallbacks.ts b/app/src/utils/pathwayCallbacks/reportCallbacks.ts deleted file mode 100644 index 3cc1ae088..000000000 --- a/app/src/utils/pathwayCallbacks/reportCallbacks.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { useCallback } from 'react'; -import { EnhancedUserSimulation } from '@/hooks/useUserSimulations'; -import { ReportStateProps, SimulationStateProps } from '@/types/pathwayState'; -import { reconstructSimulationFromEnhanced } from '@/utils/ingredientReconstruction'; - -/** - * Factory for creating reusable report-related callbacks - * Handles report-level operations including label updates, simulation selection, - * and simulation management - * - * @param setState - State setter function for report state - * @param navigateToMode - Navigation function - * @param activeSimulationIndex - Currently active simulation (0 or 1) - * @param simulationSelectionMode - Mode to navigate to for simulation selection - * @param setupMode - Mode to return to after operations (typically REPORT_SETUP) - */ -export function createReportCallbacks( - setState: React.Dispatch>, - navigateToMode: (mode: TMode) => void, - activeSimulationIndex: 0 | 1, - simulationSelectionMode: TMode, - setupMode: TMode -) { - /** - * Updates the report label - */ - const updateLabel = useCallback( - (label: string) => { - setState((prev) => ({ ...prev, label })); - }, - [setState] - ); - - /** - * Updates the report year - */ - const updateYear = useCallback( - (year: string) => { - setState((prev) => ({ ...prev, year })); - }, - [setState] - ); - - /** - * Navigates to simulation selection for a specific simulation slot - */ - const navigateToSimulationSelection = useCallback( - (_simulationIndex: 0 | 1) => { - // Note: activeSimulationIndex must be updated by caller before navigation - navigateToMode(simulationSelectionMode); - }, - [navigateToMode, simulationSelectionMode] - ); - - /** - * Handles selecting an existing simulation - * Reconstructs the simulation from enhanced format and updates state - */ - const handleSelectExistingSimulation = useCallback( - (enhancedSimulation: EnhancedUserSimulation) => { - try { - const reconstructedSimulation = reconstructSimulationFromEnhanced(enhancedSimulation); - - setState((prev) => { - const newSimulations = [...prev.simulations] as [ - SimulationStateProps, - SimulationStateProps, - ]; - newSimulations[activeSimulationIndex] = reconstructedSimulation; - return { ...prev, simulations: newSimulations }; - }); - - navigateToMode(setupMode); - } catch (error) { - console.error('[ReportCallbacks] Error reconstructing simulation:', error); - throw error; - } - }, - [setState, activeSimulationIndex, navigateToMode, setupMode] - ); - - /** - * Copies population from the other simulation to the active simulation - * Report-specific feature for maintaining population consistency - */ - const copyPopulationFromOtherSimulation = useCallback(() => { - const otherIndex = activeSimulationIndex === 0 ? 1 : 0; - - setState((prev) => { - const newSimulations = [...prev.simulations] as [SimulationStateProps, SimulationStateProps]; - newSimulations[activeSimulationIndex].population = { - ...prev.simulations[otherIndex].population, - }; - return { ...prev, simulations: newSimulations }; - }); - - navigateToMode(setupMode); - }, [setState, activeSimulationIndex, navigateToMode, setupMode]); - - /** - * Pre-fills simulation 2's population from simulation 1 - * Used when creating second simulation to maintain population consistency - */ - const prefillPopulation2FromSimulation1 = useCallback(() => { - setState((prev) => { - const sim1Population = prev.simulations[0].population; - const newSimulations = [...prev.simulations] as [SimulationStateProps, SimulationStateProps]; - newSimulations[1] = { - ...newSimulations[1], - population: { ...sim1Population }, - }; - return { - ...prev, - simulations: newSimulations, - }; - }); - }, [setState]); - - return { - updateLabel, - updateYear, - navigateToSimulationSelection, - handleSelectExistingSimulation, - copyPopulationFromOtherSimulation, - prefillPopulation2FromSimulation1, - }; -} diff --git a/app/src/utils/pathwayState/initializeReportState.ts b/app/src/utils/pathwayState/initializeReportState.ts deleted file mode 100644 index 1451a0d4f..000000000 --- a/app/src/utils/pathwayState/initializeReportState.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { CURRENT_YEAR } from '@/constants'; -import { ReportStateProps } from '@/types/pathwayState'; -import { initializeSimulationState } from './initializeSimulationState'; - -/** - * Creates an empty ReportStateProps object with default values - * - * Used to initialize report state in ReportPathwayWrapper. - * Includes nested simulation state (which itself contains nested policy/population). - * Matches the default state from reportReducer.ts but as a plain object - * with nested ingredient state. - * - * @param countryId - Required country ID for the report - * @returns Initialized report state with two empty simulations - */ -export function initializeReportState(countryId: string): ReportStateProps { - return { - id: undefined, - label: null, - year: CURRENT_YEAR, - countryId: countryId as any, // Type assertion for countryIds type - apiVersion: null, - status: 'pending', - outputType: undefined, - output: null, - simulations: [initializeSimulationState(), initializeSimulationState()], - }; -} From 17cc78be0cc69997281fc33915766c60f80bd8c0 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 24 Feb 2026 21:07:02 +0100 Subject: [PATCH 57/73] feat: Implement "Update existing policy" in policy modals Wire up the previously stubbed "Update existing policy" button in both PolicyBrowseModal and PolicyCreationModal. Follows the same pattern as report updates: create a new base policy via API, then update the existing UserPolicy association to point to it. Co-Authored-By: Claude Opus 4.6 --- app/src/pages/Policies.page.tsx | 7 +- .../components/IngredientSectionFull.tsx | 8 ++- .../modals/PolicyBrowseModal.tsx | 69 ++++++++++++++++--- .../modals/PolicyCreationModal.tsx | 57 +++++++++++++-- 4 files changed, 124 insertions(+), 17 deletions(-) diff --git a/app/src/pages/Policies.page.tsx b/app/src/pages/Policies.page.tsx index 567d4aa0a..35e1ad4bc 100644 --- a/app/src/pages/Policies.page.tsx +++ b/app/src/pages/Policies.page.tsx @@ -9,8 +9,8 @@ import IngredientReadView from '@/components/IngredientReadView'; import { MOCK_USER_ID } from '@/constants'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useUpdatePolicyAssociation, useUserPolicies } from '@/hooks/useUserPolicy'; -import { PolicyCreationModal } from '@/pages/reportBuilder/modals/PolicyCreationModal'; import type { EditorMode } from '@/pages/reportBuilder/modals/policyCreation/types'; +import { PolicyCreationModal } from '@/pages/reportBuilder/modals/PolicyCreationModal'; import { PolicyStateProps } from '@/types/pathwayState'; import { countPolicyModifications } from '@/utils/countParameterChanges'; import { formatDate } from '@/utils/dateUtils'; @@ -30,6 +30,7 @@ export default function PoliciesPage() { // Policy editor modal state const [editingPolicy, setEditingPolicy] = useState(null); + const [editingAssociationId, setEditingAssociationId] = useState(null); const [editorMode, setEditorMode] = useState('edit'); const [editorOpened, { open: openEditor, close: closeEditor }] = useDisclosure(false); @@ -48,6 +49,7 @@ export default function PoliciesPage() { label: item.association.label || `Policy #${item.association.policyId}`, parameters: item.policy?.parameters || [], }); + setEditingAssociationId(recordId); setEditorMode(mode); openEditor(); } @@ -182,14 +184,17 @@ export default function PoliciesPage() { onClose={() => { closeEditor(); setEditingPolicy(null); + setEditingAssociationId(null); }} onPolicyCreated={() => { closeEditor(); setEditingPolicy(null); + setEditingAssociationId(null); }} simulationIndex={0} initialPolicy={editingPolicy ?? undefined} initialEditorMode={editorMode} + initialAssociationId={editingAssociationId ?? undefined} /> ); diff --git a/app/src/pages/reportBuilder/components/IngredientSectionFull.tsx b/app/src/pages/reportBuilder/components/IngredientSectionFull.tsx index ef93d3e96..d39097cfb 100644 --- a/app/src/pages/reportBuilder/components/IngredientSectionFull.tsx +++ b/app/src/pages/reportBuilder/components/IngredientSectionFull.tsx @@ -12,10 +12,10 @@ import { IconChartLine, IconFileDescription, IconHome, + IconPencil, IconPlus, IconScale, IconSparkles, - IconPencil, IconTransfer, IconUsers, IconX, @@ -277,7 +277,11 @@ export function IngredientSectionFull({ )} - + { diff --git a/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx b/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx index 66bae8101..080411e8b 100644 --- a/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx +++ b/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx @@ -17,6 +17,7 @@ import { import { useSelector } from 'react-redux'; import { Box, Button, Group, Modal, Paper, Stack, Text } from '@mantine/core'; import { PolicyAdapter } from '@/adapters'; +import { createPolicy as createPolicyApi } from '@/api/policy'; import { EditAndSaveNewButton, EditAndUpdateButton, @@ -25,6 +26,7 @@ import { import { MOCK_USER_ID } from '@/constants'; import { colors, spacing } from '@/designTokens'; import { useCreatePolicy } from '@/hooks/useCreatePolicy'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useUpdatePolicyAssociation, useUserPolicies } from '@/hooks/useUserPolicy'; import { getDateRange } from '@/libs/metadataUtils'; import { ValueSetterMode } from '@/pathways/report/components/valueSetters'; @@ -62,6 +64,7 @@ interface PolicyBrowseModalProps { } export function PolicyBrowseModal({ isOpen, onClose, onSelect }: PolicyBrowseModalProps) { + const countryId = useCurrentCountry(); const userId = MOCK_USER_ID.toString(); const { data: policies, isLoading } = useUserPolicies(userId); const { @@ -83,7 +86,8 @@ export function PolicyBrowseModal({ isOpen, onClose, onSelect }: PolicyBrowseMod // Creation/editor mode state const [isCreationMode, setIsCreationMode] = useState(false); const [editorMode, setEditorMode] = useState('create'); - const [editingPolicyId, setEditingPolicyId] = useState(null); + const [editingAssociationId, setEditingAssociationId] = useState(null); + const [isUpdating, setIsUpdating] = useState(false); const [activeTab, setActiveTab] = useState('overview'); const [policyLabel, setPolicyLabel] = useState(''); const [policyParameters, setPolicyParameters] = useState([]); @@ -104,8 +108,8 @@ export function PolicyBrowseModal({ isOpen, onClose, onSelect }: PolicyBrowseMod const isReadOnly = editorMode === 'display'; - // editingPolicyId tracked for future "Replace existing policy" API integration - void editingPolicyId; + // editingAssociationId tracks the UserPolicy association being edited + // for "Update existing policy" functionality // Reset state on mount useEffect(() => { @@ -116,7 +120,9 @@ export function PolicyBrowseModal({ isOpen, onClose, onSelect }: PolicyBrowseMod setDrawerPolicyId(null); setIsCreationMode(false); setEditorMode('create'); - setEditingPolicyId(null); + + setEditingAssociationId(null); + setIsUpdating(false); setActiveTab('overview'); setPolicyLabel(''); setPolicyParameters([]); @@ -328,13 +334,14 @@ export function PolicyBrowseModal({ isOpen, onClose, onSelect }: PolicyBrowseMod setParameterSearch(''); setActiveTab('overview'); setEditorMode('create'); - setEditingPolicyId(null); + setEditingAssociationId(null); + setIsUpdating(false); setIsCreationMode(true); }, []); // Handle opening an existing policy in the editor (display mode) const handleOpenInEditor = useCallback( - (policy: { id: string; label: string; parameters: Parameter[] }) => { + (policy: { id: string; associationId?: string; label: string; parameters: Parameter[] }) => { setDrawerPolicyId(null); setPolicyLabel(policy.label); setOriginalLabel(policy.label); @@ -345,7 +352,7 @@ export function PolicyBrowseModal({ isOpen, onClose, onSelect }: PolicyBrowseMod setParameterSearch(''); setActiveTab('overview'); setEditorMode('display'); - setEditingPolicyId(policy.id); + setEditingAssociationId(policy.associationId || null); setIsCreationMode(true); }, [] @@ -361,7 +368,9 @@ export function PolicyBrowseModal({ isOpen, onClose, onSelect }: PolicyBrowseMod setIntervals([]); setParameterSearch(''); setEditorMode('create'); - setEditingPolicyId(null); + + setEditingAssociationId(null); + setIsUpdating(false); }, []); // Handle policy creation @@ -396,6 +405,45 @@ export function PolicyBrowseModal({ isOpen, onClose, onSelect }: PolicyBrowseMod } }, [policyLabel, originalLabel, editorMode, handleCreatePolicy]); + // Handle updating an existing policy (create new base policy, update association) + const handleUpdateExistingPolicy = useCallback(async () => { + if (!policyLabel.trim() || !editingAssociationId) { + return; + } + setIsUpdating(true); + + const policyData: Partial = { parameters: policyParameters }; + const payload: PolicyCreationPayload = PolicyAdapter.toCreationPayload(policyData as Policy); + + try { + const result = await createPolicyApi(countryId, payload); + const newPolicyId = result.result.policy_id; + + await updatePolicyAssociation.mutateAsync({ + userPolicyId: editingAssociationId, + updates: { policyId: newPolicyId, label: policyLabel }, + }); + + onSelect({ + id: newPolicyId, + label: policyLabel, + parameters: policyParameters, + }); + onClose(); + } catch (error) { + console.error('Failed to update policy:', error); + setIsUpdating(false); + } + }, [ + policyLabel, + policyParameters, + editingAssociationId, + countryId, + updatePolicyAssociation, + onSelect, + onClose, + ]); + // Policy for drawer preview const drawerPolicy = useMemo(() => { if (!drawerPolicyId) { @@ -890,7 +938,9 @@ export function PolicyBrowseModal({ isOpen, onClose, onSelect }: PolicyBrowseMod console.info('[PolicyBrowseModal] Update existing policy')} + onClick={handleUpdateExistingPolicy} + loading={isUpdating} + disabled={!policyLabel.trim() || isCreating} /> )} diff --git a/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx b/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx index c1a48e95b..33f6d5846 100644 --- a/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx +++ b/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx @@ -13,6 +13,7 @@ import { IconScale, IconX } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; import { ActionIcon, Box, Button, Group, Modal, Stack, Text } from '@mantine/core'; import { PolicyAdapter } from '@/adapters'; +import { createPolicy as createPolicyApi } from '@/api/policy'; import { EditAndSaveNewButton, EditAndUpdateButton, @@ -21,6 +22,7 @@ import { import { colors, spacing } from '@/designTokens'; import { useCreatePolicy } from '@/hooks/useCreatePolicy'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useUpdatePolicyAssociation } from '@/hooks/useUserPolicy'; import { getDateRange, selectSearchableParameters } from '@/libs/metadataUtils'; import { ValueSetterMode } from '@/pathways/report/components/valueSetters'; import { RootState } from '@/store'; @@ -59,6 +61,7 @@ interface PolicyCreationModalProps { simulationIndex: number; initialPolicy?: PolicyStateProps; initialEditorMode?: EditorMode; + initialAssociationId?: string; } export function PolicyCreationModal({ @@ -68,8 +71,9 @@ export function PolicyCreationModal({ simulationIndex, initialPolicy, initialEditorMode, + initialAssociationId, }: PolicyCreationModalProps) { - const countryId = useCurrentCountry() as 'us' | 'uk'; + const countryId = useCurrentCountry(); // Get metadata from Redux state const { @@ -101,11 +105,12 @@ export function PolicyCreationModal({ const [hoveredParamName, setHoveredParamName] = useState(null); const [footerHovered, setFooterHovered] = useState(false); - // API hook for creating policy + // API hooks const { createPolicy, isPending: isCreating } = useCreatePolicy(policyLabel || undefined); + const updatePolicyAssociation = useUpdatePolicyAssociation(); + const [isUpdating, setIsUpdating] = useState(false); - // Suppress unused variable warnings - void countryId; + // Suppress unused variable warning void simulationIndex; // Editor mode: create (new policy), display (read-only existing), edit (modifying existing) @@ -287,6 +292,45 @@ export function PolicyCreationModal({ } }, [policyLabel, initialPolicy?.label, editorMode, handleCreatePolicy]); + // Handle updating an existing policy (create new base policy, update association) + const handleUpdateExistingPolicy = useCallback(async () => { + if (!policyLabel.trim() || !initialAssociationId) { + return; + } + setIsUpdating(true); + + const policyData: Partial = { parameters: policyParameters }; + const payload: PolicyCreationPayload = PolicyAdapter.toCreationPayload(policyData as Policy); + + try { + const result = await createPolicyApi(countryId, payload); + const newPolicyId = result.result.policy_id; + + await updatePolicyAssociation.mutateAsync({ + userPolicyId: initialAssociationId, + updates: { policyId: newPolicyId, label: policyLabel }, + }); + + onPolicyCreated({ + id: newPolicyId, + label: policyLabel || null, + parameters: policyParameters, + }); + onClose(); + } catch (error) { + console.error('Failed to update policy:', error); + setIsUpdating(false); + } + }, [ + policyLabel, + policyParameters, + initialAssociationId, + countryId, + updatePolicyAssociation, + onPolicyCreated, + onClose, + ]); + // Get base and reform values for chart const getChartValues = () => { if (!selectedParam) { @@ -741,7 +785,9 @@ export function PolicyCreationModal({ console.info('[PolicyCreationModal] Update existing policy')} + onClick={handleUpdateExistingPolicy} + loading={isUpdating} + disabled={!policyLabel.trim() || isCreating} /> )} From e8f64078516ee7e00d7250bdffd3c0629125392f Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 24 Feb 2026 21:27:23 +0100 Subject: [PATCH 58/73] fix: Use design tokens, fix title case, extract shared PolicyOverviewContent Replace hardcoded FONT_SIZES values with typography tokens, fix "Date Created" title case violation, replace rgba() values with design token equivalents, and extract duplicated overview content (~400 lines) into shared PolicyOverviewContent component used by both PolicyCreationModal and PolicyBrowseModal. Co-Authored-By: Claude Opus 4.6 --- app/src/pages/Reports.page.tsx | 2 +- app/src/pages/reportBuilder/constants.ts | 10 +- .../modals/PolicyBrowseModal.tsx | 220 +--------------- .../modals/PolicyCreationModal.tsx | 222 +--------------- .../policyCreation/PolicyOverviewContent.tsx | 248 ++++++++++++++++++ .../modals/policyCreation/index.ts | 1 + 6 files changed, 279 insertions(+), 424 deletions(-) create mode 100644 app/src/pages/reportBuilder/modals/policyCreation/PolicyOverviewContent.tsx diff --git a/app/src/pages/Reports.page.tsx b/app/src/pages/Reports.page.tsx index db8649062..61fcc89e5 100644 --- a/app/src/pages/Reports.page.tsx +++ b/app/src/pages/Reports.page.tsx @@ -91,7 +91,7 @@ export default function ReportsPage() { }, { key: 'dateCreated', - header: 'Date Created', + header: 'Date created', type: 'text', }, { diff --git a/app/src/pages/reportBuilder/constants.ts b/app/src/pages/reportBuilder/constants.ts index 248bd83c2..2f4f1e133 100644 --- a/app/src/pages/reportBuilder/constants.ts +++ b/app/src/pages/reportBuilder/constants.ts @@ -1,7 +1,7 @@ /** * Constants for ReportBuilder components */ -import { colors } from '@/designTokens'; +import { colors, typography } from '@/designTokens'; import { IngredientColorConfig } from './types'; // ============================================================================ @@ -9,10 +9,10 @@ import { IngredientColorConfig } from './types'; // ============================================================================ export const FONT_SIZES = { - title: '28px', - normal: '16px', - small: '14px', - tiny: '12px', + title: typography.fontSize['3xl'], // 28px + normal: typography.fontSize.base, // 16px + small: typography.fontSize.sm, // 14px + tiny: typography.fontSize.xs, // 12px }; // ============================================================================ diff --git a/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx b/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx index 080411e8b..ce7ce33f2 100644 --- a/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx +++ b/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx @@ -5,7 +5,7 @@ * - Browse mode: PolicyBrowseContent for main content * - Creation mode: PolicyCreationContent + PolicyParameterTree */ -import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { IconChevronRight, IconFolder, @@ -45,7 +45,6 @@ import { getHierarchicalLabelsFromTree, } from '@/utils/parameterLabels'; import { formatParameterValue } from '@/utils/policyTableHelpers'; -import { EditableLabel } from '../components/EditableLabel'; import { FONT_SIZES, INGREDIENT_COLORS } from '../constants'; import { createCurrentLawPolicy } from '../currentLaw'; import { BrowseModalTemplate } from './BrowseModalTemplate'; @@ -55,6 +54,7 @@ import { PolicyDetailsDrawer, PolicyParameterTree, } from './policy'; +import { PolicyOverviewContent } from './policyCreation'; import type { EditorMode, ModifiedParam, SidebarTab } from './policyCreation/types'; interface PolicyBrowseModalProps { @@ -521,212 +521,16 @@ export function PolicyBrowseModal({ isOpen, onClose, onSelect }: PolicyBrowseMod const renderOverviewContent = () => ( - - {/* Naming card — mirrors PopulationStatusHeader */} - 0 ? colorConfig.border : colors.border.light}`, - boxShadow: - modificationCount > 0 - ? `0 4px 20px rgba(0, 0, 0, 0.08), 0 0 0 1px ${colorConfig.border}` - : `0 2px 12px rgba(0, 0, 0, 0.04)`, - padding: `${spacing.sm} ${spacing.lg}`, - transition: 'all 0.3s ease', - }} - > - - - - - {isReadOnly ? ( - - {policyLabel || 'Untitled policy'} - - ) : ( - - )} - - - - {/* Parameter / Period / Value grid */} - {modifiedParams.length === 0 ? ( - - - No parameter changes{isReadOnly ? '' : ' yet'} - - {!isReadOnly && ( - - Select a parameter from the sidebar to start modifying values. - - )} - - ) : ( - - - - Parameter - - - Period - - - Value - - {modifiedParams.map((param) => { - const isHovered = hoveredParamName === param.paramName; - const rowHandlers = { - onClick: () => handleSearchSelect(param.paramName), - onMouseEnter: () => setHoveredParamName(param.paramName), - onMouseLeave: () => setHoveredParamName(null), - }; - return ( - - - - {param.label} - - - - {param.changes.map((c, i) => ( - - {c.period} - - ))} - - - {param.changes.map((c, i) => ( - - {c.value} - - ))} - - - ); - })} - - - )} - + ); diff --git a/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx b/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx index 33f6d5846..7c8a28489 100644 --- a/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx +++ b/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx @@ -8,7 +8,7 @@ * - Policy creation with API integration */ -import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { IconScale, IconX } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; import { ActionIcon, Box, Button, Group, Modal, Stack, Text } from '@mantine/core'; @@ -40,7 +40,6 @@ import { countPolicyModifications } from '@/utils/countParameterChanges'; import { formatPeriod } from '@/utils/dateUtils'; import { formatLabelParts, getHierarchicalLabels } from '@/utils/parameterLabels'; import { formatParameterValue } from '@/utils/policyTableHelpers'; -import { EditableLabel } from '../components/EditableLabel'; import { FONT_SIZES, INGREDIENT_COLORS } from '../constants'; import { ChangesCard, @@ -50,6 +49,7 @@ import { ModifiedParam, ParameterHeaderCard, ParameterSidebar, + PolicyOverviewContent, SidebarTab, ValueSetterCard, } from './policyCreation'; @@ -360,214 +360,16 @@ export function PolicyCreationModal({ const renderOverviewContent = () => ( - - {/* Naming card — mirrors PopulationStatusHeader */} - 0 ? colorConfig.border : colors.border.light}`, - boxShadow: - modificationCount > 0 - ? `0 4px 20px rgba(0, 0, 0, 0.08), 0 0 0 1px ${colorConfig.border}` - : `0 2px 12px rgba(0, 0, 0, 0.04)`, - padding: `${spacing.sm} ${spacing.lg}`, - transition: 'all 0.3s ease', - }} - > - - - - - {isReadOnly ? ( - - {policyLabel || 'Untitled policy'} - - ) : ( - - )} - - - - {/* Parameter / Period / Value grid */} - {modifiedParams.length === 0 ? ( - - - No parameter changes{isReadOnly ? '' : ' yet'} - - {!isReadOnly && ( - - Select a parameter from the sidebar to start modifying values. - - )} - - ) : ( - - - {/* Column headers */} - - Parameter - - - Period - - - Value - - - {modifiedParams.map((param) => { - const isHovered = hoveredParamName === param.paramName; - const rowHandlers = { - onClick: () => handleSearchSelect(param.paramName), - onMouseEnter: () => setHoveredParamName(param.paramName), - onMouseLeave: () => setHoveredParamName(null), - }; - return ( - - - - {param.label} - - - - {param.changes.map((c, i) => ( - - {c.period} - - ))} - - - {param.changes.map((c, i) => ( - - {c.value} - - ))} - - - ); - })} - - - )} - + ); diff --git a/app/src/pages/reportBuilder/modals/policyCreation/PolicyOverviewContent.tsx b/app/src/pages/reportBuilder/modals/policyCreation/PolicyOverviewContent.tsx new file mode 100644 index 000000000..25ab758f3 --- /dev/null +++ b/app/src/pages/reportBuilder/modals/policyCreation/PolicyOverviewContent.tsx @@ -0,0 +1,248 @@ +/** + * PolicyOverviewContent - Shared overview tab content for policy modals + * + * Renders the policy naming card and parameter modification grid. + * Used by both PolicyCreationModal and PolicyBrowseModal. + */ +import { Fragment } from 'react'; +import { IconScale } from '@tabler/icons-react'; +import { Box, Group, Stack, Text } from '@mantine/core'; +import { colors, spacing } from '@/designTokens'; +import { EditableLabel } from '../../components/EditableLabel'; +import { FONT_SIZES, INGREDIENT_COLORS } from '../../constants'; +import type { ModifiedParam } from './types'; + +interface PolicyOverviewContentProps { + policyLabel: string; + onLabelChange: (label: string) => void; + isReadOnly: boolean; + modificationCount: number; + modifiedParams: ModifiedParam[]; + hoveredParamName: string | null; + onHoverParam: (name: string | null) => void; + onClickParam: (paramName: string) => void; +} + +const colorConfig = INGREDIENT_COLORS.policy; + +export function PolicyOverviewContent({ + policyLabel, + onLabelChange, + isReadOnly, + modificationCount, + modifiedParams, + hoveredParamName, + onHoverParam, + onClickParam, +}: PolicyOverviewContentProps) { + return ( + + {/* Naming card */} + 0 ? colorConfig.border : colors.border.light}`, + boxShadow: + modificationCount > 0 + ? `0 4px 20px ${colorConfig.border}40` + : `0 2px 8px ${colors.shadow.light}`, + padding: `${spacing.sm} ${spacing.lg}`, + transition: 'all 0.3s ease', + }} + > + + + + + {isReadOnly ? ( + + {policyLabel || 'Untitled policy'} + + ) : ( + + )} + + + + {/* Parameter / Period / Value grid */} + {modifiedParams.length === 0 ? ( + + + No parameter changes{isReadOnly ? '' : ' yet'} + + {!isReadOnly && ( + + Select a parameter from the sidebar to start modifying values. + + )} + + ) : ( + + + {/* Column headers */} + + Parameter + + + Period + + + Value + + + {modifiedParams.map((param) => { + const isHovered = hoveredParamName === param.paramName; + const rowHandlers = { + onClick: () => onClickParam(param.paramName), + onMouseEnter: () => onHoverParam(param.paramName), + onMouseLeave: () => onHoverParam(null), + }; + return ( + + + + {param.label} + + + + {param.changes.map((c, i) => ( + + {c.period} + + ))} + + + {param.changes.map((c, i) => ( + + {c.value} + + ))} + + + ); + })} + + + )} + + ); +} diff --git a/app/src/pages/reportBuilder/modals/policyCreation/index.ts b/app/src/pages/reportBuilder/modals/policyCreation/index.ts index 1bb01523e..b1f439caa 100644 --- a/app/src/pages/reportBuilder/modals/policyCreation/index.ts +++ b/app/src/pages/reportBuilder/modals/policyCreation/index.ts @@ -3,6 +3,7 @@ */ export { ParameterSidebar } from './ParameterSidebar'; +export { PolicyOverviewContent } from './PolicyOverviewContent'; export { PolicyCreationHeader } from './PolicyCreationHeader'; export { ParameterHeaderCard } from './ParameterHeaderCard'; export { ValueSetterCard } from './ValueSetterCard'; From 8433121cecd5cd11a844574b7a5f70c34f71eb02 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 25 Feb 2026 18:19:23 +0100 Subject: [PATCH 59/73] fix: Consistent ingredient card padding, remove disabled account settings Unify empty-state and selected-state vertical padding in IngredientSectionFull (both now use spacing.md). Remove disabled "Account settings" sidebar item and unused IconSettings2 import. Co-Authored-By: Claude Opus 4.6 --- app/src/components/Sidebar.tsx | 7 ------- .../reportBuilder/components/IngredientSectionFull.tsx | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/app/src/components/Sidebar.tsx b/app/src/components/Sidebar.tsx index 1c0fc0073..18c300835 100644 --- a/app/src/components/Sidebar.tsx +++ b/app/src/components/Sidebar.tsx @@ -8,7 +8,6 @@ import { IconMail, IconPlus, IconScale, - IconSettings2, IconUsers, } from '@tabler/icons-react'; import { useLocation, useNavigate } from 'react-router-dom'; @@ -74,12 +73,6 @@ export default function Sidebar({ isOpen = true }: SidebarProps) { path: 'mailto:hello@policyengine.org', external: true, }, - { - label: 'Account settings', - icon: IconSettings2, - path: `/${countryId}/account`, - disabled: true, - }, ]; if (!isOpen) { diff --git a/app/src/pages/reportBuilder/components/IngredientSectionFull.tsx b/app/src/pages/reportBuilder/components/IngredientSectionFull.tsx index d39097cfb..dcd9e385d 100644 --- a/app/src/pages/reportBuilder/components/IngredientSectionFull.tsx +++ b/app/src/pages/reportBuilder/components/IngredientSectionFull.tsx @@ -335,7 +335,7 @@ export function IngredientSectionFull({ Date: Thu, 26 Feb 2026 00:15:51 +0100 Subject: [PATCH 60/73] fix: Reuse PolicyOverviewContent in policy details drawer Replace the hand-rolled parameter grid in PolicyDetailsDrawer with the shared PolicyOverviewContent component, making the drawer visually consistent with the policy editor overview tab. Co-Authored-By: Claude Opus 4.6 --- .../modals/policy/PolicyDetailsDrawer.tsx | 199 ++++-------------- 1 file changed, 44 insertions(+), 155 deletions(-) diff --git a/app/src/pages/reportBuilder/modals/policy/PolicyDetailsDrawer.tsx b/app/src/pages/reportBuilder/modals/policy/PolicyDetailsDrawer.tsx index 03fdc8cef..4c507c30a 100644 --- a/app/src/pages/reportBuilder/modals/policy/PolicyDetailsDrawer.tsx +++ b/app/src/pages/reportBuilder/modals/policy/PolicyDetailsDrawer.tsx @@ -1,19 +1,9 @@ /** * PolicyDetailsDrawer - Sliding panel showing policy parameter details */ -import { Fragment } from 'react'; +import { useMemo, useState } from 'react'; import { IconChevronRight, IconPencil, IconX } from '@tabler/icons-react'; -import { - ActionIcon, - Box, - Button, - Group, - ScrollArea, - Stack, - Text, - Tooltip, - Transition, -} from '@mantine/core'; +import { ActionIcon, Box, Button, Group, Stack, Text, Transition } from '@mantine/core'; import { colors, spacing } from '@/designTokens'; import { ParameterTreeNode } from '@/libs/buildParameterTree'; import { ParameterMetadata } from '@/types/metadata/parameterMetadata'; @@ -21,7 +11,8 @@ import { Parameter } from '@/types/subIngredients/parameter'; import { formatPeriod } from '@/utils/dateUtils'; import { formatLabelParts, getHierarchicalLabelsFromTree } from '@/utils/parameterLabels'; import { formatParameterValue } from '@/utils/policyTableHelpers'; -import { FONT_SIZES, INGREDIENT_COLORS } from '../../constants'; +import { FONT_SIZES } from '../../constants'; +import { PolicyOverviewContent } from '../policyCreation'; interface PolicyDetailsDrawerProps { policy: { @@ -46,7 +37,24 @@ export function PolicyDetailsDrawer({ onSelect, onEdit, }: PolicyDetailsDrawerProps) { - const colorConfig = INGREDIENT_COLORS.policy; + const [hoveredParamName, setHoveredParamName] = useState(null); + + const modifiedParams = useMemo(() => { + if (!policy) return []; + return policy.parameters.map((param) => { + const hierarchicalLabels = getHierarchicalLabelsFromTree(param.name, parameterTree); + const displayLabel = + hierarchicalLabels.length > 0 + ? formatLabelParts(hierarchicalLabels) + : param.name.split('.').pop() || param.name; + const metadata = parameters[param.name]; + const changes = (param.values || []).map((interval) => ({ + period: formatPeriod(interval.startDate, interval.endDate), + value: formatParameterValue(interval.value, metadata?.unit ?? undefined), + })); + return { paramName: param.name, label: displayLabel, changes }; + }); + }, [policy, parameters, parameterTree]); return ( <> @@ -92,152 +100,33 @@ export function PolicyDetailsDrawer({ {policy && ( <> - - - - {policy.label} - - - {policy.paramCount} parameter{policy.paramCount !== 1 ? 's' : ''} changed - from current law - - + + + Policy details + - - - - - Parameter - - - Changes - - {(() => { - const groupedParams: Array<{ - paramName: string; - label: string; - changes: Array<{ period: string; value: string }>; - }> = []; - policy.parameters.forEach((param) => { - const paramName = param.name; - const hierarchicalLabels = getHierarchicalLabelsFromTree( - paramName, - parameterTree - ); - const displayLabel = - hierarchicalLabels.length > 0 - ? formatLabelParts(hierarchicalLabels) - : paramName.split('.').pop() || paramName; - const metadata = parameters[paramName]; - const changes = (param.values || []).map((interval) => ({ - period: formatPeriod(interval.startDate, interval.endDate), - value: formatParameterValue( - interval.value, - metadata?.unit ?? undefined - ), - })); - groupedParams.push({ paramName, label: displayLabel, changes }); - }); - return groupedParams.map((param) => ( - - - - - {param.label} - - - - - {param.changes.map((change, idx) => ( - - {change.period} - - ))} - - - {param.changes.map((change, idx) => ( - - {change.value} - - ))} - - - )); - })()} - - + + {}} + isReadOnly={true} + modificationCount={policy.paramCount} + modifiedParams={modifiedParams} + hoveredParamName={hoveredParamName} + onHoverParam={setHoveredParamName} + onClickParam={() => {}} + /> From 11ec2b1355b26137e07d16a1e9a4d638797a0b55 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 26 Feb 2026 00:50:27 +0100 Subject: [PATCH 61/73] feat: Swap ingredient colors, default baseline, remove placeholders Swap policy/population colors so policies use teal and populations use slate. Change checkmark to primary[500]. Default baseline simulation to Current law + nationwide population. Remove dynamics section from report builder and "User-created policies" from policy modal sidebar. Co-Authored-By: Claude Opus 4.6 --- .../pages/reportBuilder/ReportBuilderPage.tsx | 6 +++- .../components/SimulationBlock.tsx | 4 +-- .../components/SimulationBlockFull.tsx | 9 +----- app/src/pages/reportBuilder/constants.ts | 12 ++++---- .../modals/PolicyBrowseModal.tsx | 29 ++++--------------- app/src/pages/reportBuilder/styles.ts | 2 +- 6 files changed, 19 insertions(+), 43 deletions(-) diff --git a/app/src/pages/reportBuilder/ReportBuilderPage.tsx b/app/src/pages/reportBuilder/ReportBuilderPage.tsx index f42f2ad2d..0ae744bc8 100644 --- a/app/src/pages/reportBuilder/ReportBuilderPage.tsx +++ b/app/src/pages/reportBuilder/ReportBuilderPage.tsx @@ -16,6 +16,8 @@ import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { initializeSimulationState } from '@/utils/pathwayState/initializeSimulationState'; import { getReportOutputPath } from '@/utils/reportRouting'; import { ReportBuilderShell, SimulationBlockFull } from './components'; +import { getSamplePopulations } from './constants'; +import { createCurrentLawPolicy } from './currentLaw'; import { useReportSubmission } from './hooks/useReportSubmission'; import type { IngredientPickerState, ReportBuilderState, TopBarAction } from './types'; @@ -23,9 +25,11 @@ export default function ReportBuilderPage() { const countryId = useCurrentCountry() as 'us' | 'uk'; const navigate = useNavigate(); - // State initialization (setup mode: blank) + // State initialization (setup mode: defaults to Current law + nationwide) const initialSim = initializeSimulationState(); initialSim.label = 'Baseline simulation'; + initialSim.policy = createCurrentLawPolicy(); + initialSim.population = getSamplePopulations(countryId).nationwide; const [reportState, setReportState] = useState({ label: null, diff --git a/app/src/pages/reportBuilder/components/SimulationBlock.tsx b/app/src/pages/reportBuilder/components/SimulationBlock.tsx index 8c22c66fa..9ea24e0dd 100644 --- a/app/src/pages/reportBuilder/components/SimulationBlock.tsx +++ b/app/src/pages/reportBuilder/components/SimulationBlock.tsx @@ -134,7 +134,7 @@ export function SimulationBlock({ width: 20, height: 20, borderRadius: '50%', - background: colors.success, + background: colors.primary[500], display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -180,8 +180,6 @@ export function SimulationBlock({ inheritedPopulationLabel={inheritedPopulationLabel} recentPopulations={recentPopulations} /> - - {}} /> ); } diff --git a/app/src/pages/reportBuilder/components/SimulationBlockFull.tsx b/app/src/pages/reportBuilder/components/SimulationBlockFull.tsx index 0bed62d24..00aeba9d3 100644 --- a/app/src/pages/reportBuilder/components/SimulationBlockFull.tsx +++ b/app/src/pages/reportBuilder/components/SimulationBlockFull.tsx @@ -119,7 +119,7 @@ export function SimulationBlockFull({ width: 20, height: 20, borderRadius: '50%', - background: colors.success, + background: colors.primary[500], display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -169,13 +169,6 @@ export function SimulationBlockFull({ recentPopulations={recentPopulations} isReadOnly={isReadOnly} /> - - {}} - isReadOnly={isReadOnly} - /> ); } diff --git a/app/src/pages/reportBuilder/constants.ts b/app/src/pages/reportBuilder/constants.ts index 2f4f1e133..a7d8a824e 100644 --- a/app/src/pages/reportBuilder/constants.ts +++ b/app/src/pages/reportBuilder/constants.ts @@ -24,17 +24,17 @@ export const INGREDIENT_COLORS: Record< IngredientColorConfig > = { policy: { - icon: colors.secondary[600], - bg: colors.secondary[50], - border: colors.secondary[200], - accent: colors.secondary[500], - }, - population: { icon: colors.primary[600], bg: colors.primary[50], border: colors.primary[200], accent: colors.primary[500], }, + population: { + icon: colors.secondary[600], + bg: colors.secondary[50], + border: colors.secondary[200], + accent: colors.secondary[500], + }, dynamics: { // Muted gray-green for dynamics (distinct from teal and slate) icon: colors.gray[500], diff --git a/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx b/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx index ce7ce33f2..fb269017d 100644 --- a/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx +++ b/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx @@ -6,14 +6,7 @@ * - Creation mode: PolicyCreationContent + PolicyParameterTree */ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { - IconChevronRight, - IconFolder, - IconPlus, - IconScale, - IconStar, - IconUsers, -} from '@tabler/icons-react'; +import { IconChevronRight, IconFolder, IconPlus, IconScale, IconStar } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; import { Box, Button, Group, Modal, Paper, Stack, Text } from '@mantine/core'; import { PolicyAdapter } from '@/adapters'; @@ -77,9 +70,9 @@ export function PolicyBrowseModal({ isOpen, onClose, onSelect }: PolicyBrowseMod // Browse mode state const [searchQuery, setSearchQuery] = useState(''); - const [activeSection, setActiveSection] = useState< - 'frequently-selected' | 'my-policies' | 'public' - >('frequently-selected'); + const [activeSection, setActiveSection] = useState<'frequently-selected' | 'my-policies'>( + 'frequently-selected' + ); const [selectedPolicyId, setSelectedPolicyId] = useState(null); const [drawerPolicyId, setDrawerPolicyId] = useState(null); @@ -185,19 +178,14 @@ export function PolicyBrowseModal({ isOpen, onClose, onSelect }: PolicyBrowseMod // Get policies for current section const displayedPolicies = useMemo(() => { - if (activeSection === 'public') { - return []; - } return filteredPolicies; - }, [activeSection, filteredPolicies]); + }, [filteredPolicies]); // Get section title const getSectionTitle = () => { switch (activeSection) { case 'my-policies': return 'My policies'; - case 'public': - return 'User-created policies'; default: return 'Policies'; } @@ -478,13 +466,6 @@ export function PolicyBrowseModal({ isOpen, onClose, onSelect }: PolicyBrowseMod isActive: activeSection === 'my-policies', onClick: () => setActiveSection('my-policies'), }, - { - id: 'public', - label: 'User-created policies', - icon: , - isActive: activeSection === 'public', - onClick: () => setActiveSection('public'), - }, { id: 'create-new', label: 'Create new policy', diff --git a/app/src/pages/reportBuilder/styles.ts b/app/src/pages/reportBuilder/styles.ts index d6d36b928..27d4bb0ea 100644 --- a/app/src/pages/reportBuilder/styles.ts +++ b/app/src/pages/reportBuilder/styles.ts @@ -58,7 +58,7 @@ export const canvasStyles = { simulationsGrid: { display: 'grid', gridTemplateColumns: '1fr 1fr', - gridTemplateRows: 'auto auto auto auto', // header, policy, population, dynamics + gridTemplateRows: 'auto auto auto', // header, policy, population gap: `${spacing.sm} ${spacing['2xl']}`, position: 'relative' as const, zIndex: 1, From 3a1e3d2e1b7f52dec9efb6e4803af2fe95512fe7 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 26 Feb 2026 01:13:11 +0100 Subject: [PATCH 62/73] feat: Enter key submit, unified gear button, back nav, remove loading msg Add Enter key handling in parameter value setter to submit on press. Replace separate view/edit action buttons with single gear icon (View/edit X) on Policies, Reports, and report output pages. Add "Back to reports" navigation above report output header. Remove "Your report is being calculated" info box from loading page. Co-Authored-By: Claude Opus 4.6 --- .../components/report/ReportActionButtons.tsx | 20 +++++++++++++++---- app/src/pages/Policies.page.tsx | 10 ++-------- app/src/pages/Reports.page.tsx | 10 ++-------- app/src/pages/report-output/LoadingPage.tsx | 16 --------------- .../report-output/ReportOutputLayout.tsx | 17 +++++++++++++++- .../modals/policyCreation/ValueSetterCard.tsx | 1 + .../valueSelectors/DateValueSelectorV6.tsx | 7 ++++++- .../valueSelectors/DefaultValueSelectorV6.tsx | 7 ++++++- .../MultiYearValueSelectorV6.tsx | 2 ++ .../valueSelectors/YearlyValueSelectorV6.tsx | 7 ++++++- .../components/valueSetters/ValueInputBox.tsx | 6 +++++- .../valueSetters/ValueSetterProps.ts | 1 + 12 files changed, 63 insertions(+), 41 deletions(-) diff --git a/app/src/components/report/ReportActionButtons.tsx b/app/src/components/report/ReportActionButtons.tsx index 94a0d7e5b..82f9c8d5e 100644 --- a/app/src/components/report/ReportActionButtons.tsx +++ b/app/src/components/report/ReportActionButtons.tsx @@ -1,6 +1,6 @@ -import { IconBookmark } from '@tabler/icons-react'; +import { IconBookmark, IconSettings2 } from '@tabler/icons-react'; import { ActionIcon, Group, Tooltip } from '@mantine/core'; -import { EditDefaultButton, ShareButton, ViewButton } from '@/components/common/ActionButtons'; +import { ShareButton } from '@/components/common/ActionButtons'; import { colors, typography } from '@/designTokens'; interface ReportActionButtonsProps { @@ -52,8 +52,20 @@ export function ReportActionButtons({ return ( - - + + + + + ); diff --git a/app/src/pages/Policies.page.tsx b/app/src/pages/Policies.page.tsx index 35e1ad4bc..4f1a40065 100644 --- a/app/src/pages/Policies.page.tsx +++ b/app/src/pages/Policies.page.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { IconInfoCircle, IconPencil } from '@tabler/icons-react'; +import { IconSettings2 } from '@tabler/icons-react'; import { useNavigate } from 'react-router-dom'; import { Stack } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; @@ -110,14 +110,8 @@ export default function PoliciesPage() { key: 'actions', header: '', type: 'actions', - actions: [ - { action: 'view', tooltip: 'View policy setup', icon: }, - { action: 'edit', tooltip: 'Edit policy', icon: }, - ], + actions: [{ action: 'edit', tooltip: 'View/edit policy', icon: }], onAction: (action: string, recordId: string) => { - if (action === 'view') { - handleOpenEditor(recordId, 'display'); - } if (action === 'edit') { handleOpenEditor(recordId, 'edit'); } diff --git a/app/src/pages/Reports.page.tsx b/app/src/pages/Reports.page.tsx index 61fcc89e5..1e7e7db41 100644 --- a/app/src/pages/Reports.page.tsx +++ b/app/src/pages/Reports.page.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { IconInfoCircle, IconPencil } from '@tabler/icons-react'; +import { IconSettings2 } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import { Stack } from '@mantine/core'; @@ -124,14 +124,8 @@ export default function ReportsPage() { key: 'actions', header: '', type: 'actions', - actions: [ - { action: 'view', tooltip: 'View report setup', icon: }, - { action: 'edit', tooltip: 'Edit report', icon: }, - ], + actions: [{ action: 'edit', tooltip: 'View/edit report', icon: }], onAction: (action: string, recordId: string) => { - if (action === 'view') { - navigate(`/${countryId}/reports/create/${recordId}`); - } if (action === 'edit') { navigate(`/${countryId}/reports/create/${recordId}`, { state: { edit: true } }); } diff --git a/app/src/pages/report-output/LoadingPage.tsx b/app/src/pages/report-output/LoadingPage.tsx index 27c8547bb..7f79a4aff 100644 --- a/app/src/pages/report-output/LoadingPage.tsx +++ b/app/src/pages/report-output/LoadingPage.tsx @@ -109,22 +109,6 @@ export default function LoadingPage({ - - {/* Info Message */} - - - {queuePosition - ? `Your report is queued at position ${queuePosition}. The page will automatically update when ready.` - : 'Your report is being calculated. This may take a few moments for complex analyses. The page will automatically update when ready.'} - - ); } diff --git a/app/src/pages/report-output/ReportOutputLayout.tsx b/app/src/pages/report-output/ReportOutputLayout.tsx index f9dd25567..9118f0b0a 100644 --- a/app/src/pages/report-output/ReportOutputLayout.tsx +++ b/app/src/pages/report-output/ReportOutputLayout.tsx @@ -1,4 +1,5 @@ -import { IconCalendar, IconClock } from '@tabler/icons-react'; +import { IconCalendar, IconChevronLeft, IconClock } from '@tabler/icons-react'; +import { useNavigate } from 'react-router-dom'; import { Box, Container, Group, Stack, Text, Title } from '@mantine/core'; import { ReportActionButtons } from '@/components/report/ReportActionButtons'; import { SharedReportTag } from '@/components/report/SharedReportTag'; @@ -58,6 +59,7 @@ export default function ReportOutputLayout({ children, }: ReportOutputLayoutProps) { const countryId = useCurrentCountry(); + const navigate = useNavigate(); // Get the appropriate tree based on output type const sidebarTree = @@ -65,6 +67,19 @@ export default function ReportOutputLayout({ return ( + {/* Back navigation */} + navigate(`/${countryId}/reports`)} + > + + + Back to reports + + + {/* Header Section */} {/* Title row with actions */} diff --git a/app/src/pages/reportBuilder/modals/policyCreation/ValueSetterCard.tsx b/app/src/pages/reportBuilder/modals/policyCreation/ValueSetterCard.tsx index 6bfa7f8b2..5ecf23474 100644 --- a/app/src/pages/reportBuilder/modals/policyCreation/ValueSetterCard.tsx +++ b/app/src/pages/reportBuilder/modals/policyCreation/ValueSetterCard.tsx @@ -82,6 +82,7 @@ export function ValueSetterCard({ setStartDate={onStartDateChange} endDate={endDate} setEndDate={onEndDateChange} + onSubmit={onSubmit} /> + )} + + } + > + {progressMessage && ( + + {progressMessage} + + )} + {isLoading && } + + + ); +} + +export default function MigrationSubPage({ + output, + report, + simulations, + geographies, +}: MigrationSubPageProps) { + const countryId = useCurrentCountry(); + const [distributionalMode, setDistributionalMode] = useState('absolute'); + const [povertyDepth, setPovertyDepth] = useState('regular'); + const [povertyBreakdown, setPovertyBreakdown] = useState('by-age'); + const breakdownOptions = getBreakdownOptions(povertyDepth, countryId); + + const handleDepthChange = (value: string) => { + const newDepth = value as PovertyDepth; + setPovertyDepth(newDepth); + // Reset breakdown if current selection is disabled in the new depth + const options = getBreakdownOptions(newDepth, countryId); + const currentOption = options.find((o) => o.value === povertyBreakdown); + if (!currentOption || currentOption.disabled) { + setPovertyBreakdown(options[0].value); + } + }; + + // UK constituency/local authority sections: only for national or country-level reports + const hasLocalLevelGeography = geographies?.some((g) => isUKLocalLevelGeography(g)); + const showUKGeographySections = countryId === 'uk' && !hasLocalLevelGeography; + + // Congressional district provider props + const reformPolicyId = simulations?.[1]?.policyId; + const baselinePolicyId = simulations?.[0]?.policyId; + const year = report?.year; + const region = simulations?.[0]?.populationId; + const canShowCongressional = + countryId === 'us' && !!reformPolicyId && !!baselinePolicyId && !!year; + + return ( + + + + + + + + setDistributionalMode(value as DistributionalMode)} + size="xs" + data={DISTRIBUTIONAL_MODE_OPTIONS} + styles={segmentedControlStyles} + /> + } + > + {distributionalMode === 'absolute' && ( + + )} + {distributionalMode === 'relative' && ( + + )} + {distributionalMode === 'intra-decile' && ( + + )} + + + + + setPovertyBreakdown(value as PovertyBreakdown)} + size="xs" + data={breakdownOptions} + styles={segmentedControlStyles} + /> + + } + > + + + + + + + + {canShowCongressional && ( + + + + )} + + {showUKGeographySections && ( + <> + + + + + + + + + )} + + ); +} diff --git a/app/src/pages/report-output/ReportOutputLayout.tsx b/app/src/pages/report-output/ReportOutputLayout.tsx index 9118f0b0a..e4d66d120 100644 --- a/app/src/pages/report-output/ReportOutputLayout.tsx +++ b/app/src/pages/report-output/ReportOutputLayout.tsx @@ -1,4 +1,4 @@ -import { IconCalendar, IconChevronLeft, IconClock } from '@tabler/icons-react'; +import { IconCalendar, IconChevronRight, IconClock } from '@tabler/icons-react'; import { useNavigate } from 'react-router-dom'; import { Box, Container, Group, Stack, Text, Title } from '@mantine/core'; import { ReportActionButtons } from '@/components/report/ReportActionButtons'; @@ -25,7 +25,7 @@ interface ReportOutputLayoutProps { onShare?: () => void; onSave?: () => void; onView?: () => void; - onEdit?: () => void; + onReproduce?: () => void; children: React.ReactNode; } @@ -55,7 +55,7 @@ export default function ReportOutputLayout({ onShare, onSave, onView, - onEdit, + onReproduce, children, }: ReportOutputLayoutProps) { const countryId = useCurrentCountry(); @@ -67,38 +67,50 @@ export default function ReportOutputLayout({ return ( - {/* Back navigation */} - navigate(`/${countryId}/reports`)} - > - - - Back to reports + {/* Breadcrumb */} + + navigate(`/${countryId}/reports`)} + > + Reports + + + + {reportLabel || reportId} {/* Header Section */} {/* Title row with actions */} - - - {reportLabel || reportId} - - {isSharedView && } + + + + {reportLabel || reportId} + + {isSharedView && } + diff --git a/app/src/pages/report-output/SocietyWideReportOutput.tsx b/app/src/pages/report-output/SocietyWideReportOutput.tsx index ed2138796..51dd46bcc 100644 --- a/app/src/pages/report-output/SocietyWideReportOutput.tsx +++ b/app/src/pages/report-output/SocietyWideReportOutput.tsx @@ -18,6 +18,7 @@ import DynamicsSubPage from './DynamicsSubPage'; import ErrorPage from './ErrorPage'; import LoadingPage from './LoadingPage'; import { LocalAuthoritySubPage } from './LocalAuthoritySubPage'; +import MigrationSubPage from './MigrationSubPage'; import NotFoundSubPage from './NotFoundSubPage'; import OverviewSubPage from './OverviewSubPage'; import PolicySubPage from './PolicySubPage'; @@ -82,6 +83,15 @@ const INPUT_ONLY_TABS: Record React.ReactEleme * These tabs need the OUTPUT data (calculated society-wide impacts) */ const OUTPUT_TABS: Record React.ReactElement> = { + migration: ({ output, report, simulations, geographies }) => ( + + ), + overview: ({ output }) => , 'comparative-analysis': ({ output, simulations, report, activeView }) => ( diff --git a/app/src/pages/report-output/comparativeAnalysisTree.ts b/app/src/pages/report-output/comparativeAnalysisTree.ts index ce5e55b8d..96f301a1f 100644 --- a/app/src/pages/report-output/comparativeAnalysisTree.ts +++ b/app/src/pages/report-output/comparativeAnalysisTree.ts @@ -8,47 +8,40 @@ export interface TreeNode { /** * Get the tree structure for Comparative Analysis submenu * Based on V1 tree structure but adapted for V2 + * + * Charts migrated to the Migration tab are excluded: + * - budgetary-impact-overall + * - distributional-impact-income-relative + * - distributional-impact-income-average + * - winners-losers-income-decile + * - poverty-impact-age, poverty-impact-gender, poverty-impact-race + * - deep-poverty-impact-age, deep-poverty-impact-gender + * - inequality-impact */ export function getComparativeAnalysisTree(countryId: string): TreeNode[] { return [ - { - name: 'budgetaryImpact', - label: 'Budgetary impact', - children: [ - { - name: 'budgetary-impact-overall', - label: 'Overall', - }, - ...(countryId === 'uk' - ? [ + // Budgetary impact: only UK has remaining children (by-program) + ...(countryId === 'uk' + ? [ + { + name: 'budgetaryImpact', + label: 'Budgetary impact', + children: [ { name: 'budgetary-impact-by-program', label: 'By program', }, - ] - : []), - ], - }, - { - name: 'distributionalImpact', - label: 'Distributional impact', - children: [ - { - name: 'distributionalImpact.incomeDecile', - label: 'By income decile', - children: [ - { - name: 'distributional-impact-income-relative', - label: 'Relative', - }, - { - name: 'distributional-impact-income-average', - label: 'Absolute', - }, - ], - }, - ...(countryId === 'uk' - ? [ + ], + }, + ] + : []), + // Distributional impact: only UK has remaining children (wealth-decile) + ...(countryId === 'uk' + ? [ + { + name: 'distributionalImpact', + label: 'Distributional impact', + children: [ { name: 'distributionalImpact.wealthDecile', label: 'By wealth decile', @@ -63,74 +56,26 @@ export function getComparativeAnalysisTree(countryId: string): TreeNode[] { }, ], }, - ] - : []), - ], - }, - { - name: 'winnersAndLosers', - label: 'Winners and losers', - children: [ - { - name: 'winners-losers-income-decile', - label: 'By income decile', - }, - ...(countryId === 'uk' - ? [ + ], + }, + ] + : []), + // Winners and losers: only UK has remaining children (wealth-decile) + ...(countryId === 'uk' + ? [ + { + name: 'winnersAndLosers', + label: 'Winners and losers', + children: [ { name: 'winners-losers-wealth-decile', label: 'By wealth decile', }, - ] - : []), - ], - }, - { - name: 'povertyImpact', - label: 'Poverty impact', - children: [ - { - name: 'povertyImpact.regular', - label: 'Regular poverty', - children: [ - { - name: 'poverty-impact-age', - label: 'By age', - }, - { - name: 'poverty-impact-gender', - label: 'By gender', - }, - ...(countryId === 'us' - ? [ - { - name: 'poverty-impact-race', - label: 'By race', - }, - ] - : []), - ], - }, - { - name: 'povertyImpact.deep', - label: 'Deep poverty', - children: [ - { - name: 'deep-poverty-impact-age', - label: 'By age', - }, - { - name: 'deep-poverty-impact-gender', - label: 'By gender', - }, - ], - }, - ], - }, - { - name: 'inequality-impact', - label: 'Inequality impact', - }, + ], + }, + ] + : []), + // Poverty impact: all charts migrated to Migration tab ...(countryId === 'us' ? [ { diff --git a/app/src/pages/reportBuilder/ModifyReportPage.tsx b/app/src/pages/reportBuilder/ModifyReportPage.tsx index 0d1aa19cf..431eb43ce 100644 --- a/app/src/pages/reportBuilder/ModifyReportPage.tsx +++ b/app/src/pages/reportBuilder/ModifyReportPage.tsx @@ -1,11 +1,5 @@ import { useCallback, useMemo, useState } from 'react'; -import { - IconChevronLeft, - IconNewSection, - IconPencil, - IconStatusChange, - IconX, -} from '@tabler/icons-react'; +import { IconNewSection, IconPencil, IconStatusChange, IconX } from '@tabler/icons-react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { Button, Container, Group, Modal, Stack, Text } from '@mantine/core'; import { spacing } from '@/designTokens'; @@ -21,7 +15,14 @@ export default function ModifyReportPage() { const countryId = useCurrentCountry() as 'us' | 'uk'; const navigate = useNavigate(); const location = useLocation(); - const startInEditMode = (location.state as { edit?: boolean } | null)?.edit === true; + const locationState = location.state as { + edit?: boolean; + from?: string; + reportPath?: string; + } | null; + const startInEditMode = locationState?.edit === true; + const cameFromReportOutput = locationState?.from === 'report-output'; + const reportOutputPath = locationState?.reportPath; const { reportState, setReportState, originalState, isLoading, error } = useReportBuilderState( userReportId ?? '' @@ -63,13 +64,6 @@ export default function ModifyReportPage() { const topBarActions: TopBarAction[] = useMemo(() => { if (!isEditing) { return [ - { - key: 'back', - label: 'Back', - icon: , - onClick: () => navigate(getReportOutputPath(countryId, userReportId!)), - variant: 'secondary' as const, - }, { key: 'edit', label: 'Edit report', @@ -150,6 +144,9 @@ export default function ModifyReportPage() { <> >} diff --git a/app/src/pages/reportBuilder/ReportBuilderPage.tsx b/app/src/pages/reportBuilder/ReportBuilderPage.tsx index 0ae744bc8..1a7a3afbe 100644 --- a/app/src/pages/reportBuilder/ReportBuilderPage.tsx +++ b/app/src/pages/reportBuilder/ReportBuilderPage.tsx @@ -84,6 +84,7 @@ export default function ReportBuilderPage() { return ( >; BlockComponent?: React.ComponentType; isReadOnly?: boolean; + breadcrumbLabel?: string; + backPath?: string; + backLabel?: string; } export function ReportBuilderShell({ @@ -33,9 +40,31 @@ export function ReportBuilderShell({ setPickerState, BlockComponent = SimulationBlockFull, isReadOnly, + breadcrumbLabel, + backPath, + backLabel, }: ReportBuilderShellProps) { + const navigate = useNavigate(); + const countryId = useCurrentCountry(); + return ( + {/* Breadcrumb */} + + navigate(backPath || `/${countryId}/reports`)} + > + {backLabel || 'Reports'} + + + + {breadcrumbLabel || title} + + +

{title}

diff --git a/app/src/tests/unit/components/report/ReportActionButtons.test.tsx b/app/src/tests/unit/components/report/ReportActionButtons.test.tsx index 4a0769941..93e54f072 100644 --- a/app/src/tests/unit/components/report/ReportActionButtons.test.tsx +++ b/app/src/tests/unit/components/report/ReportActionButtons.test.tsx @@ -13,21 +13,21 @@ describe('ReportActionButtons', () => { expect(screen.queryByRole('button', { name: /view/i })).not.toBeInTheDocument(); }); - test('given isSharedView=false then renders view, edit, and share buttons', () => { + test('given isSharedView=false then renders reproduce, view, and share buttons', () => { // Given render( ); // Then + expect(screen.getByRole('button', { name: /reproduce in python/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /share/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /view/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /save report/i })).not.toBeInTheDocument(); }); @@ -57,29 +57,29 @@ describe('ReportActionButtons', () => { expect(handleShare).toHaveBeenCalledOnce(); }); - test('given onView callback then calls it when view clicked', async () => { + test('given onReproduce callback then calls it when reproduce clicked', async () => { // Given const user = userEvent.setup(); - const handleView = vi.fn(); - render(); + const handleReproduce = vi.fn(); + render(); // When - await user.click(screen.getByRole('button', { name: /view/i })); + await user.click(screen.getByRole('button', { name: /reproduce in python/i })); // Then - expect(handleView).toHaveBeenCalledOnce(); + expect(handleReproduce).toHaveBeenCalledOnce(); }); - test('given onEdit callback then calls it when edit clicked', async () => { + test('given onView callback then calls it when view clicked', async () => { // Given const user = userEvent.setup(); - const handleEdit = vi.fn(); - render(); + const handleView = vi.fn(); + render(); // When - await user.click(screen.getByRole('button', { name: /edit/i })); + await user.click(screen.getByRole('button', { name: /view/i })); // Then - expect(handleEdit).toHaveBeenCalledOnce(); + expect(handleView).toHaveBeenCalledOnce(); }); }); diff --git a/app/src/tests/unit/pages/report-output/ReportOutputLayout.test.tsx b/app/src/tests/unit/pages/report-output/ReportOutputLayout.test.tsx index 6001ce358..1596d7286 100644 --- a/app/src/tests/unit/pages/report-output/ReportOutputLayout.test.tsx +++ b/app/src/tests/unit/pages/report-output/ReportOutputLayout.test.tsx @@ -239,7 +239,7 @@ describe('ReportOutputLayout', () => { isSharedView={false} onShare={vi.fn()} onView={vi.fn()} - onEdit={vi.fn()} + onReproduce={vi.fn()} >
Content
@@ -247,8 +247,8 @@ describe('ReportOutputLayout', () => { // Then expect(screen.queryByTestId('shared-report-tag')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /reproduce in python/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /share/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /view/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument(); }); }); From 26759f8c56cb861108e9d027bb5deaf7ff3ed92f Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 26 Feb 2026 22:12:37 +0100 Subject: [PATCH 64/73] feat: Migrate remaining UK charts, remove mockup page - Add UK-only "Budgetary impact by program" section after budgetary impact - Add UK-only "Wealth distributional analysis" section with same absolute/relative/intra-decile segmented control as income section - Remove all items from Comparative Analysis sidebar tree (all charts now live on the Migration tab) - Remove report mockup page, route, and sidebar entry Co-Authored-By: Claude Opus 4.6 --- app/src/CalculatorRouter.tsx | 5 - app/src/components/Sidebar.tsx | 2 - app/src/pages/ReportOutputMockup.page.tsx | 333 ------------------ .../pages/report-output/MigrationSubPage.tsx | 36 ++ .../report-output/comparativeAnalysisTree.ts | 91 +---- 5 files changed, 44 insertions(+), 423 deletions(-) delete mode 100644 app/src/pages/ReportOutputMockup.page.tsx diff --git a/app/src/CalculatorRouter.tsx b/app/src/CalculatorRouter.tsx index 797682539..41d460b8a 100644 --- a/app/src/CalculatorRouter.tsx +++ b/app/src/CalculatorRouter.tsx @@ -14,7 +14,6 @@ import ModifyReportPage from './pages/reportBuilder/ModifyReportPage'; // Old monolithic file preserved but not used - see ./pages/ReportBuilder.page.tsx import ReportBuilderPage from './pages/reportBuilder/ReportBuilderPage'; import ReportOutputPage from './pages/ReportOutput.page'; -import ReportOutputMockupPage from './pages/ReportOutputMockup.page'; import ReportsPage from './pages/Reports.page'; import SimulationsPage from './pages/Simulations.page'; import PolicyPathwayWrapper from './pathways/policy/PolicyPathwayWrapper'; @@ -128,10 +127,6 @@ const router = createBrowserRouter( path: 'reports/create/:userReportId', element: , }, - { - path: 'report-mockups', - element: , - }, { path: 'policy-editing-concepts', element: , diff --git a/app/src/components/Sidebar.tsx b/app/src/components/Sidebar.tsx index 258092c37..18c300835 100644 --- a/app/src/components/Sidebar.tsx +++ b/app/src/components/Sidebar.tsx @@ -2,7 +2,6 @@ import { IconBook, IconBrandGithub, IconBrandSlack, - IconChartBar, IconCpu, IconFileDescription, IconGitBranch, @@ -35,7 +34,6 @@ export default function Sidebar({ isOpen = true }: SidebarProps) { { label: 'Simulations', icon: IconGitBranch, path: `/${countryId}/simulations` }, { label: 'Policies', icon: IconScale, path: `/${countryId}/policies` }, { label: 'Households', icon: IconUsers, path: `/${countryId}/households` }, - { label: 'Report mock-ups', icon: IconChartBar, path: `/${countryId}/report-mockups` }, ]; const resourceItems = [ diff --git a/app/src/pages/ReportOutputMockup.page.tsx b/app/src/pages/ReportOutputMockup.page.tsx deleted file mode 100644 index 2a978ad0e..000000000 --- a/app/src/pages/ReportOutputMockup.page.tsx +++ /dev/null @@ -1,333 +0,0 @@ -import { - IconCalendar, - IconChevronRight, - IconClock, - IconCode, - IconSettings, -} from '@tabler/icons-react'; -import { useNavigate } from 'react-router-dom'; -import { - ActionIcon, - Box, - Container, - Group, - SimpleGrid, - Stack, - Text, - Title, - Tooltip, -} from '@mantine/core'; -import { ShareButton } from '@/components/common/ActionButtons'; -import { colors, spacing, typography } from '@/designTokens'; -import { useCurrentCountry } from '@/hooks/useCurrentCountry'; -import type { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS'; -import BudgetaryImpactSubPage from './report-output/budgetary-impact/BudgetaryImpactSubPage'; -import DistributionalImpactIncomeRelativeSubPage from './report-output/distributional-impact/DistributionalImpactIncomeRelativeSubPage'; -import WinnersLosersIncomeDecileSubPage from './report-output/distributional-impact/WinnersLosersIncomeDecileSubPage'; -import InequalityImpactSubPage from './report-output/inequality-impact/InequalityImpactSubPage'; -import PovertyImpactByAgeSubPage from './report-output/poverty-impact/PovertyImpactByAgeSubPage'; -import SocietyWideOverview from './report-output/SocietyWideOverview'; - -/** - * Hardcoded mock data representing a typical US CTC-expansion reform. - * This matches the ReportOutputSocietyWideUS interface exactly. - */ -const MOCK_OUTPUT: ReportOutputSocietyWideUS = { - budget: { - baseline_net_income: 15_000_000_000_000, - benefit_spending_impact: 52_500_000_000, - budgetary_impact: -50_000_000_000, - households: 130_000_000, - state_tax_revenue_impact: 500_000_000, - tax_revenue_impact: 2_500_000_000, - }, - cliff_impact: null, - congressional_district_impact: null, - constituency_impact: null, - data_version: 'mock-2025', - decile: { - relative: { - '1': 0.035, - '2': 0.028, - '3': 0.022, - '4': 0.018, - '5': 0.012, - '6': 0.008, - '7': 0.005, - '8': 0.003, - '9': 0.001, - '10': -0.001, - }, - average: { - '1': 450, - '2': 520, - '3': 580, - '4': 550, - '5': 420, - '6': 350, - '7': 250, - '8': 180, - '9': 70, - '10': -100, - }, - }, - detailed_budget: {}, - inequality: { - gini: { baseline: 0.407, reform: 0.401 }, - top_10_pct_share: { baseline: 0.295, reform: 0.291 }, - top_1_pct_share: { baseline: 0.098, reform: 0.097 }, - }, - intra_decile: { - all: { - 'Gain more than 5%': 0.15, - 'Gain less than 5%': 0.25, - 'No change': 0.35, - 'Lose less than 5%': 0.15, - 'Lose more than 5%': 0.1, - }, - deciles: { - 'Gain more than 5%': [0.45, 0.35, 0.25, 0.2, 0.15, 0.1, 0.08, 0.05, 0.03, 0.01], - 'Gain less than 5%': [0.3, 0.35, 0.35, 0.3, 0.28, 0.25, 0.2, 0.18, 0.15, 0.05], - 'No change': [0.1, 0.12, 0.18, 0.25, 0.3, 0.35, 0.4, 0.42, 0.45, 0.5], - 'Lose less than 5%': [0.1, 0.12, 0.14, 0.15, 0.16, 0.18, 0.18, 0.2, 0.22, 0.24], - 'Lose more than 5%': [0.05, 0.06, 0.08, 0.1, 0.11, 0.12, 0.14, 0.15, 0.15, 0.2], - }, - }, - intra_wealth_decile: null, - labor_supply_response: { - decile: { - average: { - income: { - '1': 0, - '2': 0, - '3': 0, - '4': 0, - '5': 0, - '6': 0, - '7': 0, - '8': 0, - '9': 0, - '10': 0, - }, - substitution: { - '1': 0, - '2': 0, - '3': 0, - '4': 0, - '5': 0, - '6': 0, - '7': 0, - '8': 0, - '9': 0, - '10': 0, - }, - }, - relative: { - income: { - '1': 0, - '2': 0, - '3': 0, - '4': 0, - '5': 0, - '6': 0, - '7': 0, - '8': 0, - '9': 0, - '10': 0, - }, - substitution: { - '1': 0, - '2': 0, - '3': 0, - '4': 0, - '5': 0, - '6': 0, - '7': 0, - '8': 0, - '9': 0, - '10': 0, - }, - }, - }, - hours: { - baseline: 0, - change: 0, - income_effect: 0, - reform: 0, - substitution_effect: 0, - }, - income_lsr: 0, - relative_lsr: { income: 0, substitution: 0 }, - revenue_change: 0, - substitution_lsr: 0, - total_change: 0, - }, - model_version: 'mock-v1', - poverty: { - poverty: { - child: { baseline: 0.12, reform: 0.105 }, - adult: { baseline: 0.095, reform: 0.09 }, - senior: { baseline: 0.085, reform: 0.082 }, - all: { baseline: 0.1, reform: 0.094 }, - }, - deep_poverty: { - child: { baseline: 0.05, reform: 0.042 }, - adult: { baseline: 0.04, reform: 0.038 }, - senior: { baseline: 0.035, reform: 0.034 }, - all: { baseline: 0.042, reform: 0.039 }, - }, - }, - poverty_by_gender: { - poverty: { - female: { baseline: 0.11, reform: 0.103 }, - male: { baseline: 0.09, reform: 0.085 }, - }, - deep_poverty: { - female: { baseline: 0.045, reform: 0.04 }, - male: { baseline: 0.038, reform: 0.035 }, - }, - }, - poverty_by_race: { - poverty: { - black: { baseline: 0.18, reform: 0.158 }, - hispanic: { baseline: 0.16, reform: 0.143 }, - white: { baseline: 0.075, reform: 0.072 }, - other: { baseline: 0.1, reform: 0.093 }, - }, - }, - wealth_decile: null, -}; - -const tooltipStyles = { - tooltip: { - backgroundColor: colors.gray[700], - fontSize: typography.fontSize.xs, - }, -}; - -/** - * Section header with a top divider line — groups related charts visually. - */ -function SectionHeader({ label }: { label: string }) { - return ( - - - {label} - - - ); -} - -/** - * Mock-up page combining Overview + Comparative Analysis on a single screen. - * Uses hardcoded data so the layout can be evaluated without a real report. - */ -export default function ReportOutputMockupPage() { - const navigate = useNavigate(); - const countryId = useCurrentCountry(); - - return ( - - - {/* Breadcrumb */} - - navigate(`/${countryId}/reports`)} - > - Reports - - - - Sample policy reform report - - - - {/* Header */} - - - - Sample policy reform report - - - - - - - - - - - - - - - - - - - - Year: 2025 - - - • - - - - Ran today at 10:30:00 - - - - - {/* === Overview === */} - - - {/* === Budgetary impact === */} - - - - {/* === Distributional analysis === */} - - - - - {/* === Social impact — poverty & inequality side by side === */} - - - - - - - - ); -} diff --git a/app/src/pages/report-output/MigrationSubPage.tsx b/app/src/pages/report-output/MigrationSubPage.tsx index a84a4386b..b88ed1a9a 100644 --- a/app/src/pages/report-output/MigrationSubPage.tsx +++ b/app/src/pages/report-output/MigrationSubPage.tsx @@ -24,10 +24,14 @@ import { formatParameterValue } from '@/utils/chartValueUtils'; import { isUKLocalLevelGeography } from '@/utils/geographyUtils'; import { DIVERGING_GRAY_TEAL } from '@/utils/visualization/colorScales'; import BudgetaryImpactSubPage from './budgetary-impact/BudgetaryImpactSubPage'; +import BudgetaryImpactByProgramSubPage from './budgetary-impact/BudgetaryImpactByProgramSubPage'; import { ConstituencySubPage } from './ConstituencySubPage'; import DistributionalImpactIncomeAverageSubPage from './distributional-impact/DistributionalImpactIncomeAverageSubPage'; import DistributionalImpactIncomeRelativeSubPage from './distributional-impact/DistributionalImpactIncomeRelativeSubPage'; +import DistributionalImpactWealthAverageSubPage from './distributional-impact/DistributionalImpactWealthAverageSubPage'; +import DistributionalImpactWealthRelativeSubPage from './distributional-impact/DistributionalImpactWealthRelativeSubPage'; import WinnersLosersIncomeDecileSubPage from './distributional-impact/WinnersLosersIncomeDecileSubPage'; +import WinnersLosersWealthDecileSubPage from './distributional-impact/WinnersLosersWealthDecileSubPage'; import InequalityImpactSubPage from './inequality-impact/InequalityImpactSubPage'; import { LocalAuthoritySubPage } from './LocalAuthoritySubPage'; import DeepPovertyImpactByAgeSubPage from './poverty-impact/DeepPovertyImpactByAgeSubPage'; @@ -310,6 +314,7 @@ export default function MigrationSubPage({ }: MigrationSubPageProps) { const countryId = useCurrentCountry(); const [distributionalMode, setDistributionalMode] = useState('absolute'); + const [wealthMode, setWealthMode] = useState('absolute'); const [povertyDepth, setPovertyDepth] = useState('regular'); const [povertyBreakdown, setPovertyBreakdown] = useState('by-age'); const breakdownOptions = getBreakdownOptions(povertyDepth, countryId); @@ -345,6 +350,12 @@ export default function MigrationSubPage({ + {countryId === 'uk' && ( + + + + )} + + {countryId === 'uk' && ( + setWealthMode(value as DistributionalMode)} + size="xs" + data={DISTRIBUTIONAL_MODE_OPTIONS} + styles={segmentedControlStyles} + /> + } + > + {wealthMode === 'absolute' && ( + + )} + {wealthMode === 'relative' && ( + + )} + {wealthMode === 'intra-decile' && ( + + )} + + )} + Date: Thu, 26 Feb 2026 22:23:28 +0100 Subject: [PATCH 65/73] feat: Remove tab ribbon, move compute button left of mode selector - Remove entire tab navigation ribbon from ReportOutputLayout (tabs, sidebar, comparative analysis tree references all removed) - Migration content is now the full report output page for society-wide - Move "Compute congressional impacts" button to left of Absolute/ Relative segmented control - Clean up ReportOutputLayout props, tests, and fixtures - Remove mockup page route and sidebar entry Co-Authored-By: Claude Opus 4.6 --- app/src/pages/ReportOutput.page.tsx | 66 ----------- .../pages/report-output/MigrationSubPage.tsx | 14 +-- .../report-output/ReportOutputLayout.tsx | 103 +----------------- .../report-output/ReportOutputLayoutMocks.ts | 13 --- .../report-output/ReportOutputLayout.test.tsx | 72 ------------ 5 files changed, 11 insertions(+), 257 deletions(-) diff --git a/app/src/pages/ReportOutput.page.tsx b/app/src/pages/ReportOutput.page.tsx index da1f161c0..78057b9e8 100644 --- a/app/src/pages/ReportOutput.page.tsx +++ b/app/src/pages/ReportOutput.page.tsx @@ -137,20 +137,6 @@ export default function ReportOutputPage() { shareDataUserReportId, ]); - // Determine which tabs to show based on output type, country, and geography scope - const tabs = outputType ? getTabsForOutputType(outputType) : []; - - // Handle tab navigation (absolute path, preserve search params for shared views) - const handleTabClick = (tabValue: string) => { - if (isSharedView && shareDataUserReportId) { - navigate( - `/${countryId}/report-output/${shareDataUserReportId}/${tabValue}?${searchParams.toString()}` - ); - } else { - navigate(`/${countryId}/report-output/${userReportId}/${tabValue}`); - } - }; - // Format the report creation timestamp using the current country's locale const timestamp = formatReportTimestamp(userReport?.createdAt, countryId); @@ -253,20 +239,6 @@ export default function ReportOutputPage() { ); } - // Determine if sidebar should be shown - const showSidebar = activeTab === 'comparative-analysis'; - - // Handle sidebar navigation (absolute path, preserve search params for shared views) - const handleSidebarNavigate = (viewName: string) => { - if (isSharedView && shareDataUserReportId) { - navigate( - `/${countryId}/report-output/${shareDataUserReportId}/comparative-analysis/${viewName}?${searchParams.toString()}` - ); - } else { - navigate(`/${countryId}/report-output/${userReportId}/comparative-analysis/${viewName}`); - } - }; - // Determine the display label and ID for the report const displayLabel = userReport?.label; const displayReportId = isSharedView ? shareDataUserReportId : userReportId; @@ -337,13 +309,6 @@ export default function ReportOutputPage() { reportLabel={displayLabel ?? undefined} reportYear={report?.year} timestamp={timestamp} - tabs={tabs} - activeTab={activeTab} - onTabChange={handleTabClick} - showSidebar={showSidebar} - outputType={outputType} - activeView={activeView} - onSidebarNavigate={handleSidebarNavigate} isSharedView={isSharedView} onShare={handleShare} onSave={handleSave} @@ -362,37 +327,6 @@ export default function ReportOutputPage() { ); } -/** - * Determine which tabs to display based on output type and content - * - * Uses a common tabs structure that can be easily extended with - * type-specific tabs in the future (e.g., regional breakdown for - * society-wide, family structure for household). - */ -function getTabsForOutputType( - outputType: ReportOutputType -): Array<{ value: string; label: string }> { - if (outputType === 'societyWide') { - return [ - { value: 'migration', label: 'Migration' }, - { value: 'comparative-analysis', label: 'Comparative analysis' }, - { value: 'policy', label: 'Policy' }, - { value: 'population', label: 'Population' }, - ]; - } - - if (outputType === 'household') { - return [ - { value: 'overview', label: 'Overview' }, - { value: 'comparative-analysis', label: 'Comparative analysis' }, - { value: 'policy', label: 'Policy' }, - { value: 'population', label: 'Population' }, - ]; - } - - return [{ value: 'overview', label: 'Overview' }]; -} - /** * Type guard to check if society-wide output is US-specific */ diff --git a/app/src/pages/report-output/MigrationSubPage.tsx b/app/src/pages/report-output/MigrationSubPage.tsx index b88ed1a9a..9e2b0a3f4 100644 --- a/app/src/pages/report-output/MigrationSubPage.tsx +++ b/app/src/pages/report-output/MigrationSubPage.tsx @@ -270,13 +270,6 @@ function CongressionalDistrictSection({ label="Congressional district impact" right={ - setMode(value as CongressionalMode)} - size="xs" - data={CONGRESSIONAL_MODE_OPTIONS} - styles={segmentedControlStyles} - /> {!hasStarted && !existingDistricts && ( )} @@ -345,9 +333,7 @@ export default function MigrationSubPage({ return ( - - - + @@ -403,9 +389,7 @@ export default function MigrationSubPage({ {wealthMode === 'relative' && ( )} - {wealthMode === 'intra-decile' && ( - - )} + {wealthMode === 'intra-decile' && } )} diff --git a/app/src/pages/report-output/SocietyWideOverview.tsx b/app/src/pages/report-output/SocietyWideOverview.tsx index f3babd6cb..5c2589be2 100644 --- a/app/src/pages/report-output/SocietyWideOverview.tsx +++ b/app/src/pages/report-output/SocietyWideOverview.tsx @@ -1,11 +1,16 @@ +import { useState } from 'react'; import { IconCoin, IconHome, IconUsers } from '@tabler/icons-react'; -import { Box, Group, SimpleGrid, Stack, Text } from '@mantine/core'; +import { Box, Group, SimpleGrid, Text } from '@mantine/core'; import { SocietyWideReportOutput } from '@/api/societyWideCalculation'; +import DashboardCard from '@/components/report/DashboardCard'; import MetricCard from '@/components/report/MetricCard'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { formatBudgetaryImpact } from '@/utils/formatPowers'; import { currencySymbol } from '@/utils/formatters'; +import BudgetaryImpactSubPage from './budgetary-impact/BudgetaryImpactSubPage'; +import WinnersLosersIncomeDecileSubPage from './distributional-impact/WinnersLosersIncomeDecileSubPage'; +import PovertyImpactByAgeSubPage from './poverty-impact/PovertyImpactByAgeSubPage'; interface SocietyWideOverviewProps { output: SocietyWideReportOutput; @@ -15,18 +20,21 @@ interface SocietyWideOverviewProps { const HERO_ICON_SIZE = 48; const SECONDARY_ICON_SIZE = 36; -/** - * Overview page for society-wide reports - * - * Features: - * - Hero metric for budgetary impact (most important number) - * - Secondary metrics for poverty and income distribution - * - Clean visual hierarchy with trend indicators - * - Progressive disclosure for details - */ +const GRID_GAP = 16; + +type CardKey = 'budget' | 'poverty' | 'winners'; + export default function SocietyWideOverview({ output }: SocietyWideOverviewProps) { const countryId = useCurrentCountry(); const symbol = currencySymbol(countryId); + const [expandedCard, setExpandedCard] = useState(null); + + const toggle = (key: CardKey) => { + setExpandedCard((prev) => (prev === key ? null : key)); + }; + + const modeOf = (key: CardKey) => (expandedCard === key ? 'expanded' : 'shrunken'); + const zOf = (key: CardKey) => (expandedCard === key ? 10 : 1); // Calculate budgetary impact const budgetaryImpact = output.budget.budgetary_impact; @@ -51,8 +59,6 @@ export default function SocietyWideOverview({ output }: SocietyWideOverviewProps : (povertyOverview.reform - povertyOverview.baseline) / povertyOverview.baseline; const povertyAbsChange = Math.abs(povertyRateChange) * 100; const povertyValue = povertyRateChange === 0 ? 'No change' : `${povertyAbsChange.toFixed(1)}%`; - // For poverty: decrease is good (positive), increase is bad (negative) - // Arrow direction should match the actual change direction for clarity const povertyTrend = povertyRateChange === 0 ? 'neutral' : povertyRateChange < 0 ? 'positive' : 'negative'; const povertySubtext = @@ -69,54 +75,57 @@ export default function SocietyWideOverview({ output }: SocietyWideOverviewProps const unchangedPercent = decileOverview['No change']; return ( - - {/* Hero Section - Budgetary Impact */} - - - - - - - - - - + + {/* Budgetary Impact — full width hero */} + + + + + + + + + } + expandedContent={} + onToggleMode={() => toggle('budget')} + /> - {/* Secondary Metrics Grid */} - - {/* Poverty Impact */} - + {/* Poverty Impact */} +
-
+ } + expandedContent={} + onToggleMode={() => toggle('poverty')} + /> - {/* Winners and Losers */} - + {/* Winners and Losers */} +
-
- -
+ } + expandedContent={} + onToggleMode={() => toggle('winners')} + /> + ); } diff --git a/app/src/pages/report-output/SocietyWideReportOutput.tsx b/app/src/pages/report-output/SocietyWideReportOutput.tsx index 59fdd097d..d791c9b09 100644 --- a/app/src/pages/report-output/SocietyWideReportOutput.tsx +++ b/app/src/pages/report-output/SocietyWideReportOutput.tsx @@ -20,7 +20,6 @@ import LoadingPage from './LoadingPage'; import { LocalAuthoritySubPage } from './LocalAuthoritySubPage'; import MigrationSubPage from './MigrationSubPage'; import NotFoundSubPage from './NotFoundSubPage'; -import OverviewSubPage from './OverviewSubPage'; import PolicySubPage from './PolicySubPage'; import PopulationSubPage from './PopulationSubPage'; import PolicyReproducibility from './reproduce-in-python/PolicyReproducibility'; @@ -92,8 +91,6 @@ const OUTPUT_TABS: Record React.ReactElement> /> ), - overview: ({ output }) => , - 'comparative-analysis': ({ output, simulations, report, activeView }) => ( { - if (!reportState.id && isGeographySelected && reportState.simulations.length === 1) { - const newSim = initializeSimulationState(); - newSim.label = 'Reform simulation'; - newSim.population = { ...reportState.simulations[0].population }; - setReportState((prev) => ({ ...prev, simulations: [...prev.simulations, newSim] })); + if (!reportState.id && isGeographySelected) { + setReportState((prev) => { + if (prev.simulations.length !== 1) { + return prev; + } + const newSim = initializeSimulationState(); + newSim.label = 'Reform simulation'; + newSim.population = { ...prev.simulations[0].population }; + return { ...prev, simulations: [...prev.simulations, newSim] }; + }); } - }, [reportState.id, isGeographySelected, reportState.simulations]); + }, [reportState.id, isGeographySelected, setReportState]); // Top bar actions (setup mode: just "Run") const topBarActions: TopBarAction[] = useMemo( diff --git a/app/src/pages/reportBuilder/hooks/useSimulationCanvas.ts b/app/src/pages/reportBuilder/hooks/useSimulationCanvas.ts index cb8140d5e..5e345be52 100644 --- a/app/src/pages/reportBuilder/hooks/useSimulationCanvas.ts +++ b/app/src/pages/reportBuilder/hooks/useSimulationCanvas.ts @@ -238,14 +238,16 @@ export function useSimulationCanvas({ // --------------------------------------------------------------------------- const handleAddSimulation = useCallback(() => { - if (reportState.simulations.length >= 2) { - return; - } - const newSim = initializeSimulationState(); - newSim.label = 'Reform simulation'; - newSim.population = { ...reportState.simulations[0].population }; - setReportState((prev) => ({ ...prev, simulations: [...prev.simulations, newSim] })); - }, [reportState.simulations, setReportState]); + setReportState((prev) => { + if (prev.simulations.length >= 2) { + return prev; + } + const newSim = initializeSimulationState(); + newSim.label = 'Reform simulation'; + newSim.population = { ...prev.simulations[0].population }; + return { ...prev, simulations: [...prev.simulations, newSim] }; + }); + }, [setReportState]); const handleRemoveSimulation = useCallback( (index: number) => { diff --git a/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx b/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx index 7c8a28489..5652c771c 100644 --- a/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx +++ b/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx @@ -282,6 +282,9 @@ export function PolicyCreationModal({ // Same-name warning for "Save as new" when name matches original const [showSameNameWarning, setShowSameNameWarning] = useState(false); + // Unnamed-policy warning for creating/saving without a name + const [showUnnamedWarning, setShowUnnamedWarning] = useState(false); + const handleSaveAsNewPolicy = useCallback(() => { const currentName = (policyLabel || '').trim(); const originalName = (initialPolicy?.label || '').trim(); @@ -570,7 +573,17 @@ export function PolicyCreationModal({
{editorMode === 'create' && ( - )} @@ -595,7 +608,13 @@ export function PolicyCreationModal({ label="Save as new policy" color="teal" variant="filled" - onClick={handleSaveAsNewPolicy} + onClick={() => { + if (!policyLabel.trim()) { + setShowUnnamedWarning(true); + } else { + handleSaveAsNewPolicy(); + } + }} loading={isCreating} disabled={isUpdating} /> @@ -634,6 +653,35 @@ export function PolicyCreationModal({ + + {/* Unnamed policy warning modal */} + setShowUnnamedWarning(false)} + title={Unnamed policy} + centered + size="sm" + > + + + This policy has no name. Are you sure you want to save it without a name? + + + + + + + ); } From 4edd7752a6eb62b6050214e6907492c939ddffca Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Mon, 2 Mar 2026 21:48:03 +0100 Subject: [PATCH 70/73] feat: Fix DashboardCard sizing, add chart height props, and budgetary breakdowns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix initial card height bug caused by useLayoutEffect clearing styles on mount - Reduce SHRUNKEN_CARD_HEIGHT from 260 to 200px, center content vertically - Add chartHeight prop to BudgetaryImpact, PovertyImpactByAge, and WinnersLosersIncomeDecile subpages for use in constrained containers - Calculate precise expanded chart heights from card layout dimensions - Add federal tax, state tax, and benefit spending breakdowns to budgetary impact card (US only) using MetricCard components - Format values <$1M as "<$1 million" (or £ equivalent for UK) Co-Authored-By: Claude Opus 4.6 --- .../report-output/SocietyWideOverview.tsx | 79 ++++++++++++++++--- .../BudgetaryImpactSubPage.tsx | 5 +- .../WinnersLosersIncomeDecileSubPage.tsx | 8 +- .../PovertyImpactByAgeSubPage.tsx | 5 +- 4 files changed, 81 insertions(+), 16 deletions(-) diff --git a/app/src/pages/report-output/SocietyWideOverview.tsx b/app/src/pages/report-output/SocietyWideOverview.tsx index 5c2589be2..b71469836 100644 --- a/app/src/pages/report-output/SocietyWideOverview.tsx +++ b/app/src/pages/report-output/SocietyWideOverview.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { IconCoin, IconHome, IconUsers } from '@tabler/icons-react'; import { Box, Group, SimpleGrid, Text } from '@mantine/core'; import { SocietyWideReportOutput } from '@/api/societyWideCalculation'; -import DashboardCard from '@/components/report/DashboardCard'; +import DashboardCard, { SHRUNKEN_CARD_HEIGHT } from '@/components/report/DashboardCard'; import MetricCard from '@/components/report/MetricCard'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; @@ -22,6 +22,19 @@ const SECONDARY_ICON_SIZE = 36; const GRID_GAP = 16; +// Expanded card chart heights, calculated from the layout: +// Expanded card outer: SHRUNKEN_CARD_HEIGHT * 2 + GRID_GAP = 416px +// Card border: 2px (1px each side) +// Card padding (spacing.lg = 16px): 32px → content area = 382px +// ChartContainer chrome (title ~28px + gap 8px + box border 2px + box padding 24px) = 62px +// Description text (2 lines ~40px + gap 8px) = 48px (poverty & winners only) +// +// Budget (spacing.xl = 20px padding, no description): 416 - 42 - 62 = 312px +// Poverty / Winners (spacing.lg, with description): 416 - 34 - 62 - 48 = 272px +const EXPANDED_H = SHRUNKEN_CARD_HEIGHT * 2 + GRID_GAP; // 416 +const BUDGET_CHART_H = EXPANDED_H - 2 - 40 - 62 - 2; // 310 +const SECONDARY_CHART_H = EXPANDED_H - 2 - 32 - 62 - 48 - 2; // 270 + type CardKey = 'budget' | 'poverty' | 'winners'; export default function SocietyWideOverview({ output }: SocietyWideOverviewProps) { @@ -38,12 +51,22 @@ export default function SocietyWideOverview({ output }: SocietyWideOverviewProps // Calculate budgetary impact const budgetaryImpact = output.budget.budgetary_impact; - const budgetFormatted = formatBudgetaryImpact(Math.abs(budgetaryImpact)); + + // Federal and state breakdowns (US only) + const stateTaxImpact = output.budget.state_tax_revenue_impact; + const federalTaxImpact = output.budget.tax_revenue_impact - stateTaxImpact; + const spendingImpact = output.budget.benefit_spending_impact; + + const formatImpact = (value: number) => { + if (value === 0) return 'No change'; + const abs = Math.abs(value); + if (abs < 1e6) return `<${symbol}1 million`; + const formatted = formatBudgetaryImpact(abs); + return `${symbol}${formatted.display}${formatted.label ? ` ${formatted.label}` : ''}`; + }; + const budgetIsPositive = budgetaryImpact > 0; - const budgetValue = - budgetaryImpact === 0 - ? 'No change' - : `${symbol}${budgetFormatted.display}${budgetFormatted.label ? ` ${budgetFormatted.label}` : ''}`; + const budgetValue = formatImpact(budgetaryImpact); const budgetSubtext = budgetaryImpact === 0 ? 'This policy has no impact on the budget' @@ -51,6 +74,16 @@ export default function SocietyWideOverview({ output }: SocietyWideOverviewProps ? 'in additional government revenue' : 'in additional government spending'; + const trendOf = (value: number): 'positive' | 'negative' | 'neutral' => + value === 0 ? 'neutral' : value > 0 ? 'positive' : 'negative'; + + const subtextOf = (value: number, category: string) => + value === 0 + ? `No change in ${category}` + : value > 0 + ? `in additional ${category}` + : `in reduced ${category}`; + // Calculate poverty rate change const povertyOverview = output.poverty.poverty.all; const povertyRateChange = @@ -87,7 +120,7 @@ export default function SocietyWideOverview({ output }: SocietyWideOverviewProps shrunkenBorderColor={colors.primary[100]} padding={spacing.xl} shrunkenContent={ - + + {countryId === 'us' && ( + + + + + + )} } - expandedContent={} + expandedContent={} onToggleMode={() => toggle('budget')} /> @@ -152,7 +207,9 @@ export default function SocietyWideOverview({ output }: SocietyWideOverviewProps
} - expandedContent={} + expandedContent={ + + } onToggleMode={() => toggle('poverty')} /> @@ -274,7 +331,9 @@ export default function SocietyWideOverview({ output }: SocietyWideOverviewProps } - expandedContent={} + expandedContent={ + + } onToggleMode={() => toggle('winners')} /> diff --git a/app/src/pages/report-output/budgetary-impact/BudgetaryImpactSubPage.tsx b/app/src/pages/report-output/budgetary-impact/BudgetaryImpactSubPage.tsx index f420adc8f..91944dd38 100644 --- a/app/src/pages/report-output/budgetary-impact/BudgetaryImpactSubPage.tsx +++ b/app/src/pages/report-output/budgetary-impact/BudgetaryImpactSubPage.tsx @@ -21,14 +21,15 @@ import { regionName } from '@/utils/impactChartUtils'; interface Props { output: SocietyWideReportOutput; + chartHeight?: number; } -export default function BudgetaryImpactSubPage({ output }: Props) { +export default function BudgetaryImpactSubPage({ output, chartHeight: chartHeightProp }: Props) { const mobile = useMediaQuery('(max-width: 768px)'); const { height: viewportHeight } = useViewportSize(); const countryId = useCurrentCountry(); const metadata = useSelector((state: RootState) => state.metadata); - const chartHeight = getClampedChartHeight(viewportHeight, mobile); + const chartHeight = chartHeightProp ?? getClampedChartHeight(viewportHeight, mobile); // Extract data const budgetaryImpact = output.budget.budgetary_impact; diff --git a/app/src/pages/report-output/distributional-impact/WinnersLosersIncomeDecileSubPage.tsx b/app/src/pages/report-output/distributional-impact/WinnersLosersIncomeDecileSubPage.tsx index 65edd29f9..dadf95dd4 100644 --- a/app/src/pages/report-output/distributional-impact/WinnersLosersIncomeDecileSubPage.tsx +++ b/app/src/pages/report-output/distributional-impact/WinnersLosersIncomeDecileSubPage.tsx @@ -21,6 +21,7 @@ import { regionName } from '@/utils/impactChartUtils'; interface Props { output: SocietyWideReportOutput; + chartHeight?: number; } // Category definitions and styling @@ -56,12 +57,15 @@ const LEGEND_TEXT_MAP: Record = { 'Lose more than 5%': 'Loss more than 5%', }; -export default function WinnersLosersIncomeDecileSubPage({ output }: Props) { +export default function WinnersLosersIncomeDecileSubPage({ + output, + chartHeight: chartHeightProp, +}: Props) { const mobile = useMediaQuery('(max-width: 768px)'); const countryId = useCurrentCountry(); const metadata = useSelector((state: RootState) => state.metadata); const { height: viewportHeight } = useViewportSize(); - const chartHeight = getClampedChartHeight(viewportHeight, mobile); + const chartHeight = chartHeightProp ?? getClampedChartHeight(viewportHeight, mobile); // Extract data const deciles = output.intra_decile.deciles; diff --git a/app/src/pages/report-output/poverty-impact/PovertyImpactByAgeSubPage.tsx b/app/src/pages/report-output/poverty-impact/PovertyImpactByAgeSubPage.tsx index 191251080..bbbbbf7c5 100644 --- a/app/src/pages/report-output/poverty-impact/PovertyImpactByAgeSubPage.tsx +++ b/app/src/pages/report-output/poverty-impact/PovertyImpactByAgeSubPage.tsx @@ -21,14 +21,15 @@ import { regionName } from '@/utils/impactChartUtils'; interface Props { output: SocietyWideReportOutput; + chartHeight?: number; } -export default function PovertyImpactByAgeSubPage({ output }: Props) { +export default function PovertyImpactByAgeSubPage({ output, chartHeight: chartHeightProp }: Props) { const mobile = useMediaQuery('(max-width: 768px)'); const countryId = useCurrentCountry(); const metadata = useSelector((state: RootState) => state.metadata); const { height: viewportHeight } = useViewportSize(); - const chartHeight = getClampedChartHeight(viewportHeight, mobile); + const chartHeight = chartHeightProp ?? getClampedChartHeight(viewportHeight, mobile); // Extract data const povertyImpact = output.poverty.poverty; From 29337504c935a73a4bfc4a6c9177cbb67ebd0054 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 3 Mar 2026 20:03:34 +0100 Subject: [PATCH 71/73] feat: Add congressional district card, expandable controls row, and fix chart heights - Add CongressionalDistrictCard to SocietyWideOverview with auto-start fetch, shrunken loading/gain-lose counts, and expanded choropleth map - Lift CongressionalDistrictDataProvider in MigrationSubPage to share context between overview and dropdown section - Add expandedRows prop to DashboardCard for variable expanded height - Add expandedControls prop to render SegmentedControls alongside the minimize button in a fixed-height controls row (31px = SC xs height) - Add shrunkenHeader/shrunkenBody two-part layout to DashboardCard - Fix chart heights: derive from card dimensions with controls row overhead (SECONDARY_CHART_H=252, BUDGET_CHART_H=279) so Box content height is ~279px consistently across all chart cards Co-Authored-By: Claude Opus 4.6 --- app/src/components/report/DashboardCard.tsx | 73 +- app/src/components/report/MetricCard.tsx | 24 +- .../pages/report-output/MigrationSubPage.tsx | 174 +--- .../report-output/SocietyWideOverview.tsx | 848 ++++++++++++++---- ...stributionalImpactIncomeAverageSubPage.tsx | 5 +- ...tributionalImpactIncomeRelativeSubPage.tsx | 5 +- .../InequalityImpactSubPage.tsx | 5 +- .../DeepPovertyImpactByAgeSubPage.tsx | 5 +- .../DeepPovertyImpactByGenderSubPage.tsx | 5 +- .../PovertyImpactByGenderSubPage.tsx | 5 +- .../PovertyImpactByRaceSubPage.tsx | 5 +- 11 files changed, 825 insertions(+), 329 deletions(-) diff --git a/app/src/components/report/DashboardCard.tsx b/app/src/components/report/DashboardCard.tsx index 8b0e701a4..54cf5b18a 100644 --- a/app/src/components/report/DashboardCard.tsx +++ b/app/src/components/report/DashboardCard.tsx @@ -27,8 +27,17 @@ interface DashboardCardProps { // Custom shrunken content (replaces header + slides entirely) shrunkenContent?: React.ReactNode; + // Two-part shrunken layout: header at top, body centered in remaining space + shrunkenHeader?: React.ReactNode; + shrunkenBody?: React.ReactNode; + // Layout colSpan?: number; + /** Number of grid rows the card occupies when expanded (default 2) */ + expandedRows?: number; + + /** Controls (e.g. SegmentedControl) rendered in a row with the minimize button when expanded */ + expandedControls?: React.ReactNode; // Style overrides (apply only when shrunken/idle) shrunkenBackground?: string; @@ -67,7 +76,11 @@ export default function DashboardCard({ label, slides, shrunkenContent, + shrunkenHeader, + shrunkenBody, colSpan = 1, + expandedRows = 2, + expandedControls, shrunkenBackground, shrunkenBorderColor, padding: paddingProp, @@ -76,7 +89,7 @@ export default function DashboardCard({ const cardRef = useRef(null); const safeIndex = slides ? Math.min(activeSlideIndex, slides.length - 1) : 0; const isExpanded = mode === 'expanded'; - const useCustomContent = !!shrunkenContent; + const useCustomContent = !!shrunkenContent || !!shrunkenHeader; const cardPadding = paddingProp ?? spacing.lg; const [phase, setPhase] = useState('idle'); @@ -163,7 +176,7 @@ export default function DashboardCard({ // --- Derived values --- const isLifted = phase !== 'idle'; const expandedW = colSpan >= 2 ? (cell?.w ?? 0) : cell ? cell.w * 2 + gridGap : 0; - const expandedH = cell ? cell.h * 2 + gridGap : 0; + const expandedH = cell ? cell.h * expandedRows + gridGap * (expandedRows - 1) : 0; // Background/border: use overrides only when idle (shrunken) const cardBackground = @@ -276,7 +289,7 @@ export default function DashboardCard({ {/* Content area */}
- {/* Shrunken layer — always mounted, opacity-controlled, vertically centered */} + {/* Shrunken layer — always mounted, opacity-controlled */}
- {useCustomContent ? ( + {shrunkenHeader ? ( +
+
{shrunkenHeader}
+
+
{shrunkenBody}
+
+
+ ) : useCustomContent ? (
{shrunkenContent}
) : ( @@ -327,16 +361,37 @@ export default function DashboardCard({ opacity: expandedContentOpacity, transition: `opacity ${FADE_MS}ms ease`, pointerEvents: phase === 'expanded' ? 'auto' : 'none', - overflow: 'auto', + display: 'flex', + flexDirection: 'column', }} > - {expandedContent} + {/* Controls row: expandedControls on left, minimize button on right */} + {onToggleMode && ( +
+
+ {expandedControls} +
+ {expandButton} +
+ )} +
+ {expandedContent} +
)}
- {/* Expand button for custom content mode — absolutely positioned */} - {useCustomContent && expandButton && ( + {/* Expand button for custom content mode — absolutely positioned (hidden when expanded) */} + {useCustomContent && expandButton && !mountExpanded && (
{expandButton}
)} diff --git a/app/src/components/report/MetricCard.tsx b/app/src/components/report/MetricCard.tsx index c8f358088..f0cba23b8 100644 --- a/app/src/components/report/MetricCard.tsx +++ b/app/src/components/report/MetricCard.tsx @@ -5,8 +5,8 @@ import { colors, spacing, typography } from '@/designTokens'; type MetricTrend = 'positive' | 'negative' | 'neutral'; interface MetricCardProps { - /** Label describing the metric */ - label: string; + /** Label describing the metric (omit to hide) */ + label?: string; /** The main value to display */ value: string; /** Optional secondary value or context */ @@ -67,15 +67,17 @@ export default function MetricCard({ return ( {/* Label */} - - {label} - + {label && ( + + {label} + + )} {/* Value with trend indicator */} diff --git a/app/src/pages/report-output/MigrationSubPage.tsx b/app/src/pages/report-output/MigrationSubPage.tsx index b5bb428f7..089465d45 100644 --- a/app/src/pages/report-output/MigrationSubPage.tsx +++ b/app/src/pages/report-output/MigrationSubPage.tsx @@ -28,21 +28,11 @@ import { formatParameterValue } from '@/utils/chartValueUtils'; import { isUKLocalLevelGeography } from '@/utils/geographyUtils'; import { DIVERGING_GRAY_TEAL } from '@/utils/visualization/colorScales'; import BudgetaryImpactByProgramSubPage from './budgetary-impact/BudgetaryImpactByProgramSubPage'; -import BudgetaryImpactSubPage from './budgetary-impact/BudgetaryImpactSubPage'; import { ConstituencySubPage } from './ConstituencySubPage'; -import DistributionalImpactIncomeAverageSubPage from './distributional-impact/DistributionalImpactIncomeAverageSubPage'; -import DistributionalImpactIncomeRelativeSubPage from './distributional-impact/DistributionalImpactIncomeRelativeSubPage'; import DistributionalImpactWealthAverageSubPage from './distributional-impact/DistributionalImpactWealthAverageSubPage'; import DistributionalImpactWealthRelativeSubPage from './distributional-impact/DistributionalImpactWealthRelativeSubPage'; -import WinnersLosersIncomeDecileSubPage from './distributional-impact/WinnersLosersIncomeDecileSubPage'; import WinnersLosersWealthDecileSubPage from './distributional-impact/WinnersLosersWealthDecileSubPage'; -import InequalityImpactSubPage from './inequality-impact/InequalityImpactSubPage'; import { LocalAuthoritySubPage } from './LocalAuthoritySubPage'; -import DeepPovertyImpactByAgeSubPage from './poverty-impact/DeepPovertyImpactByAgeSubPage'; -import DeepPovertyImpactByGenderSubPage from './poverty-impact/DeepPovertyImpactByGenderSubPage'; -import PovertyImpactByAgeSubPage from './poverty-impact/PovertyImpactByAgeSubPage'; -import PovertyImpactByGenderSubPage from './poverty-impact/PovertyImpactByGenderSubPage'; -import PovertyImpactByRaceSubPage from './poverty-impact/PovertyImpactByRaceSubPage'; import SocietyWideOverview from './SocietyWideOverview'; interface MigrationSubPageProps { @@ -109,14 +99,6 @@ const DISTRIBUTIONAL_MODE_OPTIONS = [ { label: 'Intra-decile impacts', value: 'intra-decile' as DistributionalMode }, ]; -type PovertyDepth = 'regular' | 'deep'; -type PovertyBreakdown = 'by-age' | 'by-gender' | 'by-race'; - -const POVERTY_DEPTH_OPTIONS = [ - { label: 'Regular poverty', value: 'regular' as PovertyDepth }, - { label: 'Deep poverty', value: 'deep' as PovertyDepth }, -]; - type CongressionalMode = 'absolute' | 'relative'; const CONGRESSIONAL_MODE_OPTIONS = [ @@ -135,44 +117,6 @@ const segmentedControlStyles = { }, }; -function getBreakdownOptions( - depth: PovertyDepth, - countryId: string -): Array<{ label: string; value: PovertyBreakdown; disabled?: boolean }> { - const options: Array<{ label: string; value: PovertyBreakdown; disabled?: boolean }> = [ - { label: 'By age', value: 'by-age' }, - { label: 'By gender', value: 'by-gender' }, - ]; - if (countryId === 'us') { - options.push({ - label: 'By race', - value: 'by-race', - disabled: depth === 'deep', - }); - } - return options; -} - -function PovertyChart({ - output, - depth, - breakdown, -}: { - output: SocietyWideReportOutput; - depth: PovertyDepth; - breakdown: PovertyBreakdown; -}) { - if (depth === 'regular') { - if (breakdown === 'by-age') return ; - if (breakdown === 'by-gender') return ; - if (breakdown === 'by-race') return ; - } - // Deep poverty: by age and by gender only (no by-race data available) - if (breakdown === 'by-age') return ; - if (breakdown === 'by-gender') return ; - return null; -} - /** * Congressional district collapsible section. * Renders the map immediately (all outlines visible, white fill). @@ -194,15 +138,21 @@ function CongressionalDistrictSection({ output }: { output: SocietyWideReportOut // Check if output already has district data (from nationwide calculation) const existingDistricts = useMemo(() => { - if (!('congressional_district_impact' in output)) return null; + if (!('congressional_district_impact' in output)) { + return null; + } const districtData = (output as ReportOutputSocietyWideUS).congressional_district_impact; - if (!districtData?.districts) return null; + if (!districtData?.districts) { + return null; + } return districtData.districts; }, [output]); // Build map data from context (progressive fill as states complete) const contextMapData = useMemo(() => { - if (stateResponses.size === 0) return []; + if (stateResponses.size === 0) { + return []; + } const points: Array<{ geoId: string; label: string; value: number }> = []; stateResponses.forEach((stateData) => { stateData.districts.forEach((district) => { @@ -302,22 +252,7 @@ export default function MigrationSubPage({ geographies, }: MigrationSubPageProps) { const countryId = useCurrentCountry(); - const [distributionalMode, setDistributionalMode] = useState('absolute'); const [wealthMode, setWealthMode] = useState('absolute'); - const [povertyDepth, setPovertyDepth] = useState('regular'); - const [povertyBreakdown, setPovertyBreakdown] = useState('by-age'); - const breakdownOptions = getBreakdownOptions(povertyDepth, countryId); - - const handleDepthChange = (value: string) => { - const newDepth = value as PovertyDepth; - setPovertyDepth(newDepth); - // Reset breakdown if current selection is disabled in the new depth - const options = getBreakdownOptions(newDepth, countryId); - const currentOption = options.find((o) => o.value === povertyBreakdown); - if (!currentOption || currentOption.disabled) { - setPovertyBreakdown(options[0].value); - } - }; // UK constituency/local authority sections: only for national or country-level reports const hasLocalLevelGeography = geographies?.some((g) => isUKLocalLevelGeography(g)); @@ -331,13 +266,9 @@ export default function MigrationSubPage({ const canShowCongressional = countryId === 'us' && !!reformPolicyId && !!baselinePolicyId && !!year; - return ( - - - - - - + const stackChildren = ( + <> + {countryId === 'uk' && ( @@ -345,30 +276,6 @@ export default function MigrationSubPage({ )} - setDistributionalMode(value as DistributionalMode)} - size="xs" - data={DISTRIBUTIONAL_MODE_OPTIONS} - styles={segmentedControlStyles} - /> - } - > - {distributionalMode === 'absolute' && ( - - )} - {distributionalMode === 'relative' && ( - - )} - {distributionalMode === 'intra-decile' && ( - - )} - - {countryId === 'uk' && ( )} - - - setPovertyBreakdown(value as PovertyBreakdown)} - size="xs" - data={breakdownOptions} - styles={segmentedControlStyles} - /> - - } - > - - - - - - - - {canShowCongressional && ( - - - - )} + {canShowCongressional && } {showUKGeographySections && ( <> @@ -444,6 +313,23 @@ export default function MigrationSubPage({ )} + + ); + + return ( + + {canShowCongressional ? ( + + {stackChildren} + + ) : ( + stackChildren + )} ); } diff --git a/app/src/pages/report-output/SocietyWideOverview.tsx b/app/src/pages/report-output/SocietyWideOverview.tsx index b71469836..bc05bab1c 100644 --- a/app/src/pages/report-output/SocietyWideOverview.tsx +++ b/app/src/pages/report-output/SocietyWideOverview.tsx @@ -1,19 +1,40 @@ -import { useState } from 'react'; -import { IconCoin, IconHome, IconUsers } from '@tabler/icons-react'; -import { Box, Group, SimpleGrid, Text } from '@mantine/core'; +import { useEffect, useMemo, useState } from 'react'; +import { + IconChartBar, + IconCoin, + IconHome, + IconMap, + IconScale, + IconUsers, +} from '@tabler/icons-react'; +import Plot from 'react-plotly.js'; +import { Box, Group, Progress, SegmentedControl, SimpleGrid, Stack, Text } from '@mantine/core'; import { SocietyWideReportOutput } from '@/api/societyWideCalculation'; -import DashboardCard, { SHRUNKEN_CARD_HEIGHT } from '@/components/report/DashboardCard'; +import DashboardCard from '@/components/report/DashboardCard'; import MetricCard from '@/components/report/MetricCard'; +import { USDistrictChoroplethMap } from '@/components/visualization/USDistrictChoroplethMap'; +import { useCongressionalDistrictData } from '@/contexts/CongressionalDistrictDataContext'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import type { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS'; +import { formatParameterValue } from '@/utils/chartValueUtils'; import { formatBudgetaryImpact } from '@/utils/formatPowers'; import { currencySymbol } from '@/utils/formatters'; +import { DIVERGING_GRAY_TEAL } from '@/utils/visualization/colorScales'; import BudgetaryImpactSubPage from './budgetary-impact/BudgetaryImpactSubPage'; +import DistributionalImpactIncomeAverageSubPage from './distributional-impact/DistributionalImpactIncomeAverageSubPage'; +import DistributionalImpactIncomeRelativeSubPage from './distributional-impact/DistributionalImpactIncomeRelativeSubPage'; import WinnersLosersIncomeDecileSubPage from './distributional-impact/WinnersLosersIncomeDecileSubPage'; +import InequalityImpactSubPage from './inequality-impact/InequalityImpactSubPage'; +import DeepPovertyImpactByAgeSubPage from './poverty-impact/DeepPovertyImpactByAgeSubPage'; +import DeepPovertyImpactByGenderSubPage from './poverty-impact/DeepPovertyImpactByGenderSubPage'; import PovertyImpactByAgeSubPage from './poverty-impact/PovertyImpactByAgeSubPage'; +import PovertyImpactByGenderSubPage from './poverty-impact/PovertyImpactByGenderSubPage'; +import PovertyImpactByRaceSubPage from './poverty-impact/PovertyImpactByRaceSubPage'; interface SocietyWideOverviewProps { output: SocietyWideReportOutput; + showCongressionalCard?: boolean; } // Fixed size for icon containers to ensure square aspect ratio @@ -22,25 +43,322 @@ const SECONDARY_ICON_SIZE = 36; const GRID_GAP = 16; -// Expanded card chart heights, calculated from the layout: -// Expanded card outer: SHRUNKEN_CARD_HEIGHT * 2 + GRID_GAP = 416px +// Expanded card chart heights — derived from the layout: +// +// Expanded card outer: SHRUNKEN_CARD_HEIGHT × 2 + GRID_GAP = 416px // Card border: 2px (1px each side) // Card padding (spacing.lg = 16px): 32px → content area = 382px -// ChartContainer chrome (title ~28px + gap 8px + box border 2px + box padding 24px) = 62px -// Description text (2 lines ~40px + gap 8px) = 48px (poverty & winners only) // -// Budget (spacing.xl = 20px padding, no description): 416 - 42 - 62 = 312px -// Poverty / Winners (spacing.lg, with description): 416 - 34 - 62 - 48 = 272px -const EXPANDED_H = SHRUNKEN_CARD_HEIGHT * 2 + GRID_GAP; // 416 -const BUDGET_CHART_H = EXPANDED_H - 2 - 40 - 62 - 2; // 310 -const SECONDARY_CHART_H = EXPANDED_H - 2 - 32 - 62 - 48 - 2; // 270 +// Controls row (height: 31px, matching SegmentedControl xs rendered height +// of ~30.6px — see SegmentedControl.css: root padding 4px + label padding +// 2px + font 12px × 1.55 line-height + label padding 2px + root padding 4px) +// + marginBottom 8px = 39px total +// +// → expandedContent area = 382 − 39 = 343px (secondary cards) +// → expandedContent area = 374 − 39 = 335px (budget card, spacing.xl = 20px) +// +// ChartContainer chrome: title row (~28px) + gap (8px) + Box padding (24px) +// + Box border (2px) = 62px. Secondary charts also have a description line +// (~19px) and an inner gap (8px) = 27px extra inside the Box. +// +// Target Box content height ≈ 279 for all chart cards: +// Budget (no description): chartHeight = 279 +// Secondary (with desc+gap): chartHeight = 279 − 27 = 252 +// +const CONTROLS_ROW_H = 39; // 31 (SC xs height) + 8 (marginBottom) +const BUDGET_CHART_H = 279; +const SECONDARY_CHART_H = 252; + +// Poverty segmented control types and options +type PovertyDepth = 'regular' | 'deep'; +type PovertyBreakdown = 'by-age' | 'by-gender' | 'by-race'; + +const POVERTY_DEPTH_OPTIONS = [ + { label: 'Regular poverty', value: 'regular' as PovertyDepth }, + { label: 'Deep poverty', value: 'deep' as PovertyDepth }, +]; + +function getBreakdownOptions( + depth: PovertyDepth, + countryId: string +): Array<{ label: string; value: PovertyBreakdown; disabled?: boolean }> { + const options: Array<{ label: string; value: PovertyBreakdown; disabled?: boolean }> = [ + { label: 'By age', value: 'by-age' }, + { label: 'By gender', value: 'by-gender' }, + ]; + if (countryId === 'us') { + options.push({ + label: 'By race', + value: 'by-race', + disabled: depth === 'deep', + }); + } + return options; +} + +const segmentedControlStyles = { + root: { + background: colors.gray[100], + borderRadius: spacing.radius.md, + }, + indicator: { + background: colors.white, + boxShadow: '0 1px 3px rgba(0,0,0,0.1)', + }, +}; + + +type DecileMode = 'absolute' | 'relative'; +const DECILE_MODE_OPTIONS = [ + { label: 'Absolute', value: 'absolute' as DecileMode }, + { label: 'Relative', value: 'relative' as DecileMode }, +]; + +// Mini chart config for shrunken decile card +const MINI_CHART_HEIGHT = 90; +const MINI_CHART_CONFIG = { displayModeBar: false, responsive: true, staticPlot: true }; + +type CongressionalMode = 'absolute' | 'relative'; +const CONGRESSIONAL_MODE_OPTIONS = [ + { label: 'Absolute', value: 'absolute' as CongressionalMode }, + { label: 'Relative', value: 'relative' as CongressionalMode }, +]; + +type CardKey = 'budget' | 'decile' | 'poverty' | 'winners' | 'inequality' | 'congressional'; + +/** + * Congressional district card content — must be rendered inside a + * CongressionalDistrictDataProvider. Auto-starts fetching on mount. + */ +function CongressionalDistrictCard({ + output, + mode, + zIndex, + gridGap, + header, + onToggleMode, +}: { + output: SocietyWideReportOutput; + mode: 'expanded' | 'shrunken'; + zIndex: number; + gridGap: number; + header: React.ReactNode; + onToggleMode: () => void; +}) { + const { + stateResponses, + completedCount, + totalStates, + hasStarted, + isLoading, + labelLookup, + stateCode, + startFetch, + } = useCongressionalDistrictData(); + const [congressionalMode, setCongressionalMode] = useState('absolute'); + + // Auto-start fetch on mount + useEffect(() => { + if (!hasStarted) { + startFetch(); + } + }, [hasStarted, startFetch]); + + // Check if output already has district data (from nationwide calculation) + const existingDistricts = useMemo(() => { + if (!('congressional_district_impact' in output)) { + return null; + } + const districtData = (output as ReportOutputSocietyWideUS).congressional_district_impact; + if (!districtData?.districts) { + return null; + } + return districtData.districts; + }, [output]); + + // Build map data from context (progressive fill as states complete) + const contextMapData = useMemo(() => { + if (stateResponses.size === 0) { + return []; + } + const points: Array<{ geoId: string; label: string; value: number }> = []; + stateResponses.forEach((stateData) => { + stateData.districts.forEach((district) => { + points.push({ + geoId: district.district, + label: labelLookup.get(district.district) ?? `District ${district.district}`, + value: + congressionalMode === 'absolute' + ? district.average_household_income_change + : district.relative_household_income_change, + }); + }); + }); + return points; + }, [stateResponses, labelLookup, congressionalMode]); + + // Use pre-computed data if available, otherwise progressive context data + const mapData = useMemo(() => { + if (existingDistricts) { + return existingDistricts.map((item) => ({ + geoId: item.district, + label: labelLookup.get(item.district) ?? `District ${item.district}`, + value: + congressionalMode === 'absolute' + ? item.average_household_income_change + : item.relative_household_income_change, + })); + } + return contextMapData; + }, [existingDistricts, contextMapData, labelLookup, congressionalMode]); + + // Map config based on absolute vs relative mode + const mapConfig = useMemo( + () => ({ + colorScale: { + colors: DIVERGING_GRAY_TEAL.colors, + tickFormat: congressionalMode === 'absolute' ? '$,.0f' : '.1%', + symmetric: true, + }, + formatValue: (value: number) => + congressionalMode === 'absolute' + ? formatParameterValue(value, 'currency-USD', { + decimalPlaces: 0, + includeSymbol: true, + }) + : formatParameterValue(value, '/1', { decimalPlaces: 1 }), + }), + [congressionalMode] + ); + + // Count districts gaining and losing (from whichever data source is available) + const { gainingCount, losingCount } = useMemo(() => { + let gaining = 0; + let losing = 0; + if (existingDistricts) { + for (const d of existingDistricts) { + if (d.average_household_income_change > 0) { + gaining++; + } else if (d.average_household_income_change < 0) { + losing++; + } + } + } else { + stateResponses.forEach((stateData) => { + for (const d of stateData.districts) { + if (d.average_household_income_change > 0) { + gaining++; + } else if (d.average_household_income_change < 0) { + losing++; + } + } + }); + } + return { gainingCount: gaining, losingCount: losing }; + }, [existingDistricts, stateResponses]); + + const dataReady = existingDistricts || (!isLoading && hasStarted); + const progressPercent = totalStates > 0 ? Math.round((completedCount / totalStates) * 100) : 0; + + return ( + + {/* Left half: placeholder for hex choropleth map */} + + {/* TODO: Add small hexagonal choropleth map component */} + + + Map placeholder + + + -type CardKey = 'budget' | 'poverty' | 'winners'; + {/* Right half: loading state or gain/lose counts */} + + {!dataReady ? ( + + + Loading ({completedCount} of {totalStates} states)... + + + + ) : ( + + + + + + + + + )} + + + } + expandedControls={ + setCongressionalMode(value as CongressionalMode)} + size="xs" + data={CONGRESSIONAL_MODE_OPTIONS} + styles={segmentedControlStyles} + /> + } + expandedContent={ + + } + onToggleMode={onToggleMode} + /> + ); +} -export default function SocietyWideOverview({ output }: SocietyWideOverviewProps) { +export default function SocietyWideOverview({ output, showCongressionalCard }: SocietyWideOverviewProps) { const countryId = useCurrentCountry(); const symbol = currencySymbol(countryId); const [expandedCard, setExpandedCard] = useState(null); + const [decileMode, setDecileMode] = useState('absolute'); + const [povertyDepth, setPovertyDepth] = useState('regular'); + const [povertyBreakdown, setPovertyBreakdown] = useState('by-age'); + const breakdownOptions = getBreakdownOptions(povertyDepth, countryId); + + const handleDepthChange = (value: string) => { + const newDepth = value as PovertyDepth; + setPovertyDepth(newDepth); + const options = getBreakdownOptions(newDepth, countryId); + const currentOption = options.find((o) => o.value === povertyBreakdown); + if (!currentOption || currentOption.disabled) { + setPovertyBreakdown(options[0].value); + } + }; const toggle = (key: CardKey) => { setExpandedCard((prev) => (prev === key ? null : key)); @@ -58,9 +376,13 @@ export default function SocietyWideOverview({ output }: SocietyWideOverviewProps const spendingImpact = output.budget.benefit_spending_impact; const formatImpact = (value: number) => { - if (value === 0) return 'No change'; + if (value === 0) { + return 'No change'; + } const abs = Math.abs(value); - if (abs < 1e6) return `<${symbol}1 million`; + if (abs < 1e6) { + return `<${symbol}1 million`; + } const formatted = formatBudgetaryImpact(abs); return `${symbol}${formatted.display}${formatted.label ? ` ${formatted.label}` : ''}`; }; @@ -101,12 +423,132 @@ export default function SocietyWideOverview({ output }: SocietyWideOverviewProps ? 'decrease in poverty rate' : 'increase in poverty rate'; + // Calculate child poverty rate change + const childPovertyOverview = output.poverty.poverty.child; + const childPovertyRateChange = + childPovertyOverview.baseline === 0 + ? 0 + : (childPovertyOverview.reform - childPovertyOverview.baseline) / + childPovertyOverview.baseline; + const childPovertyAbsChange = Math.abs(childPovertyRateChange) * 100; + const childPovertyValue = + childPovertyRateChange === 0 ? 'No change' : `${childPovertyAbsChange.toFixed(1)}%`; + const childPovertyTrend: 'positive' | 'negative' | 'neutral' = + childPovertyRateChange === 0 + ? 'neutral' + : childPovertyRateChange < 0 + ? 'positive' + : 'negative'; + const childPovertySubtext = + childPovertyRateChange === 0 + ? 'Child poverty rate unchanged' + : childPovertyRateChange < 0 + ? 'decrease in child poverty rate' + : 'increase in child poverty rate'; + + // Calculate Gini index change + const giniOverview = output.inequality.gini; + const giniRateChange = + giniOverview.baseline === 0 + ? 0 + : (giniOverview.reform - giniOverview.baseline) / giniOverview.baseline; + const giniAbsChange = Math.abs(giniRateChange) * 100; + const giniValue = giniRateChange === 0 ? 'No change' : `${giniAbsChange.toFixed(1)}%`; + const giniTrend: 'positive' | 'negative' | 'neutral' = + giniRateChange === 0 ? 'neutral' : giniRateChange < 0 ? 'positive' : 'negative'; + const giniSubtext = + giniRateChange === 0 + ? 'Gini index unchanged' + : giniRateChange < 0 + ? 'decrease in Gini index' + : 'increase in Gini index'; + + // Calculate Top 1% share change + const top1Overview = output.inequality.top_1_pct_share; + const top1RateChange = + top1Overview.baseline === 0 + ? 0 + : (top1Overview.reform - top1Overview.baseline) / top1Overview.baseline; + const top1AbsChange = Math.abs(top1RateChange) * 100; + const top1Value = top1RateChange === 0 ? 'No change' : `${top1AbsChange.toFixed(1)}%`; + const top1Trend: 'positive' | 'negative' | 'neutral' = + top1RateChange === 0 ? 'neutral' : top1RateChange < 0 ? 'positive' : 'negative'; + const top1Subtext = + top1RateChange === 0 + ? 'Top 1% share unchanged' + : top1RateChange < 0 + ? 'decrease in top 1% share' + : 'increase in top 1% share'; + + // Poverty chart switcher for expanded mode + const povertyChart = (() => { + if (povertyDepth === 'regular') { + if (povertyBreakdown === 'by-age') { + return ; + } + if (povertyBreakdown === 'by-gender') { + return ; + } + if (povertyBreakdown === 'by-race') { + return ; + } + } + if (povertyBreakdown === 'by-age') { + return ; + } + if (povertyBreakdown === 'by-gender') { + return ; + } + return null; + })(); + + // Decile impact mini chart data (absolute) + const decileKeys = Object.keys(output.decile.average).sort((a, b) => Number(a) - Number(b)); + const decileAbsValues = decileKeys.map((d) => output.decile.average[d]); + // Calculate winners and losers const decileOverview = output.intra_decile.all; const winnersPercent = decileOverview['Gain more than 5%'] + decileOverview['Gain less than 5%']; const losersPercent = decileOverview['Lose more than 5%'] + decileOverview['Lose less than 5%']; const unchangedPercent = decileOverview['No change']; + // Reusable card header: icon + uppercase label + const cardHeader = ( + IconComponent: React.ComponentType<{ size: number; color: string; stroke: number }>, + labelText: string, + hero = false + ) => ( + + + + + + {labelText} + + + ); + return ( {/* Budgetary Impact — full width hero */} @@ -119,25 +561,11 @@ export default function SocietyWideOverview({ output }: SocietyWideOverviewProps shrunkenBackground={`linear-gradient(135deg, ${colors.primary[50]} 0%, ${colors.background.primary} 100%)`} shrunkenBorderColor={colors.primary[100]} padding={spacing.xl} - shrunkenContent={ + shrunkenHeader={cardHeader(IconCoin, 'Budgetary impact', true)} + shrunkenBody={ - - - toggle('budget')} /> - {/* Poverty Impact */} + {/* Decile Impacts */} - + + v >= 0 ? colors.primary[500] : colors.gray[600] + ), + }, + }, + ]} + layout={{ + margin: { t: 5, b: 20, l: 35, r: 5 }, + showlegend: false, + paper_bgcolor: 'transparent', + plot_bgcolor: 'transparent', + xaxis: { + fixedrange: true, + tickvals: decileKeys, + ticktext: decileKeys, + dtick: 1, + tickfont: { color: colors.text.secondary }, + }, + yaxis: { + fixedrange: true, + tickprefix: symbol, + tickformat: ',.0f', + tickfont: { color: colors.text.secondary }, + }, }} - > - - - - + + - - + + Absolute impacts by decile + + + + } + expandedControls={ + setDecileMode(value as DecileMode)} + size="xs" + data={DECILE_MODE_OPTIONS} + styles={segmentedControlStyles} + /> } expandedContent={ - + decileMode === 'absolute' ? ( + + ) : ( + + ) } - onToggleMode={() => toggle('poverty')} + onToggleMode={() => toggle('decile')} /> {/* Winners and Losers */} + shrunkenHeader={cardHeader(IconUsers, 'Winners and losers')} + shrunkenBody={ + + {/* Distribution Bar */} - - - - - Winners and losers - - - {/* Distribution Bar */} + /> + + + + + {/* Legend */} + + + + Gain: {(winnersPercent * 100).toFixed(1)}% + + + + + No change: {(unchangedPercent * 100).toFixed(1)}% + + + - - - {/* Legend */} - - - - - Gain: {(winnersPercent * 100).toFixed(1)}% - - - - - - No change: {(unchangedPercent * 100).toFixed(1)}% - - - - - - Lose: {(losersPercent * 100).toFixed(1)}% - - + + Lose: {(losersPercent * 100).toFixed(1)}% + - - + + } expandedContent={ } onToggleMode={() => toggle('winners')} /> + + {/* Poverty Impact */} + + + + + + + + + } + expandedControls={ + <> + + setPovertyBreakdown(value as PovertyBreakdown)} + size="xs" + data={breakdownOptions} + styles={segmentedControlStyles} + /> + + } + expandedContent={povertyChart} + onToggleMode={() => toggle('poverty')} + /> + + {/* Inequality Impact */} + + + + + + + + + } + expandedContent={ + + } + onToggleMode={() => toggle('inequality')} + /> + + {/* Congressional District Impact — US only, full width */} + {showCongressionalCard && ( + toggle('congressional')} + /> + )} ); } diff --git a/app/src/pages/report-output/distributional-impact/DistributionalImpactIncomeAverageSubPage.tsx b/app/src/pages/report-output/distributional-impact/DistributionalImpactIncomeAverageSubPage.tsx index 9b9f1fa41..2dc4fed9d 100644 --- a/app/src/pages/report-output/distributional-impact/DistributionalImpactIncomeAverageSubPage.tsx +++ b/app/src/pages/report-output/distributional-impact/DistributionalImpactIncomeAverageSubPage.tsx @@ -21,14 +21,15 @@ import { regionName } from '@/utils/impactChartUtils'; interface Props { output: SocietyWideReportOutput; + chartHeight?: number; } -export default function DistributionalImpactIncomeAverageSubPage({ output }: Props) { +export default function DistributionalImpactIncomeAverageSubPage({ output, chartHeight: chartHeightProp }: Props) { const mobile = useMediaQuery('(max-width: 768px)'); const countryId = useCurrentCountry(); const metadata = useSelector((state: RootState) => state.metadata); const { height: viewportHeight } = useViewportSize(); - const chartHeight = getClampedChartHeight(viewportHeight, mobile); + const chartHeight = chartHeightProp ?? getClampedChartHeight(viewportHeight, mobile); // Extract data - object with keys "1", "2", ..., "10" const decileAverage = output.decile.average; diff --git a/app/src/pages/report-output/distributional-impact/DistributionalImpactIncomeRelativeSubPage.tsx b/app/src/pages/report-output/distributional-impact/DistributionalImpactIncomeRelativeSubPage.tsx index 2814797bd..321095475 100644 --- a/app/src/pages/report-output/distributional-impact/DistributionalImpactIncomeRelativeSubPage.tsx +++ b/app/src/pages/report-output/distributional-impact/DistributionalImpactIncomeRelativeSubPage.tsx @@ -21,14 +21,15 @@ import { regionName } from '@/utils/impactChartUtils'; interface Props { output: SocietyWideReportOutput; + chartHeight?: number; } -export default function DistributionalImpactIncomeRelativeSubPage({ output }: Props) { +export default function DistributionalImpactIncomeRelativeSubPage({ output, chartHeight: chartHeightProp }: Props) { const mobile = useMediaQuery('(max-width: 768px)'); const countryId = useCurrentCountry(); const metadata = useSelector((state: RootState) => state.metadata); const { height: viewportHeight } = useViewportSize(); - const chartHeight = getClampedChartHeight(viewportHeight, mobile); + const chartHeight = chartHeightProp ?? getClampedChartHeight(viewportHeight, mobile); // Extract data - object with keys "1", "2", ..., "10" const decileRelative = output.decile.relative; diff --git a/app/src/pages/report-output/inequality-impact/InequalityImpactSubPage.tsx b/app/src/pages/report-output/inequality-impact/InequalityImpactSubPage.tsx index 731d698e0..f08559d1b 100644 --- a/app/src/pages/report-output/inequality-impact/InequalityImpactSubPage.tsx +++ b/app/src/pages/report-output/inequality-impact/InequalityImpactSubPage.tsx @@ -21,14 +21,15 @@ import { regionName } from '@/utils/impactChartUtils'; interface Props { output: SocietyWideReportOutput; + chartHeight?: number; } -export default function InequalityImpactSubPage({ output }: Props) { +export default function InequalityImpactSubPage({ output, chartHeight: chartHeightProp }: Props) { const mobile = useMediaQuery('(max-width: 768px)'); const countryId = useCurrentCountry(); const metadata = useSelector((state: RootState) => state.metadata); const { height: viewportHeight } = useViewportSize(); - const chartHeight = getClampedChartHeight(viewportHeight, mobile); + const chartHeight = chartHeightProp ?? getClampedChartHeight(viewportHeight, mobile); // Extract data const giniImpact = output.inequality.gini; diff --git a/app/src/pages/report-output/poverty-impact/DeepPovertyImpactByAgeSubPage.tsx b/app/src/pages/report-output/poverty-impact/DeepPovertyImpactByAgeSubPage.tsx index b1129828a..ed9f757bd 100644 --- a/app/src/pages/report-output/poverty-impact/DeepPovertyImpactByAgeSubPage.tsx +++ b/app/src/pages/report-output/poverty-impact/DeepPovertyImpactByAgeSubPage.tsx @@ -21,14 +21,15 @@ import { regionName } from '@/utils/impactChartUtils'; interface Props { output: SocietyWideReportOutput; + chartHeight?: number; } -export default function DeepPovertyImpactByAgeSubPage({ output }: Props) { +export default function DeepPovertyImpactByAgeSubPage({ output, chartHeight: chartHeightProp }: Props) { const mobile = useMediaQuery('(max-width: 768px)'); const countryId = useCurrentCountry(); const metadata = useSelector((state: RootState) => state.metadata); const { height: viewportHeight } = useViewportSize(); - const chartHeight = getClampedChartHeight(viewportHeight, mobile); + const chartHeight = chartHeightProp ?? getClampedChartHeight(viewportHeight, mobile); // Extract data const deepPovertyImpact = output.poverty.deep_poverty; diff --git a/app/src/pages/report-output/poverty-impact/DeepPovertyImpactByGenderSubPage.tsx b/app/src/pages/report-output/poverty-impact/DeepPovertyImpactByGenderSubPage.tsx index 02ef2f2e1..5e9208b0a 100644 --- a/app/src/pages/report-output/poverty-impact/DeepPovertyImpactByGenderSubPage.tsx +++ b/app/src/pages/report-output/poverty-impact/DeepPovertyImpactByGenderSubPage.tsx @@ -21,14 +21,15 @@ import { regionName } from '@/utils/impactChartUtils'; interface Props { output: SocietyWideReportOutput; + chartHeight?: number; } -export default function DeepPovertyImpactByGenderSubPage({ output }: Props) { +export default function DeepPovertyImpactByGenderSubPage({ output, chartHeight: chartHeightProp }: Props) { const mobile = useMediaQuery('(max-width: 768px)'); const countryId = useCurrentCountry(); const metadata = useSelector((state: RootState) => state.metadata); const { height: viewportHeight } = useViewportSize(); - const chartHeight = getClampedChartHeight(viewportHeight, mobile); + const chartHeight = chartHeightProp ?? getClampedChartHeight(viewportHeight, mobile); // Extract data const genderImpact = output.poverty_by_gender?.deep_poverty || { diff --git a/app/src/pages/report-output/poverty-impact/PovertyImpactByGenderSubPage.tsx b/app/src/pages/report-output/poverty-impact/PovertyImpactByGenderSubPage.tsx index fe12cb266..33f7b84cf 100644 --- a/app/src/pages/report-output/poverty-impact/PovertyImpactByGenderSubPage.tsx +++ b/app/src/pages/report-output/poverty-impact/PovertyImpactByGenderSubPage.tsx @@ -21,14 +21,15 @@ import { regionName } from '@/utils/impactChartUtils'; interface Props { output: SocietyWideReportOutput; + chartHeight?: number; } -export default function PovertyImpactByGenderSubPage({ output }: Props) { +export default function PovertyImpactByGenderSubPage({ output, chartHeight: chartHeightProp }: Props) { const mobile = useMediaQuery('(max-width: 768px)'); const countryId = useCurrentCountry(); const metadata = useSelector((state: RootState) => state.metadata); const { height: viewportHeight } = useViewportSize(); - const chartHeight = getClampedChartHeight(viewportHeight, mobile); + const chartHeight = chartHeightProp ?? getClampedChartHeight(viewportHeight, mobile); // Extract data const genderImpact = output.poverty_by_gender?.poverty || { diff --git a/app/src/pages/report-output/poverty-impact/PovertyImpactByRaceSubPage.tsx b/app/src/pages/report-output/poverty-impact/PovertyImpactByRaceSubPage.tsx index 37f140865..cb13d1e79 100644 --- a/app/src/pages/report-output/poverty-impact/PovertyImpactByRaceSubPage.tsx +++ b/app/src/pages/report-output/poverty-impact/PovertyImpactByRaceSubPage.tsx @@ -21,14 +21,15 @@ import { regionName } from '@/utils/impactChartUtils'; interface Props { output: SocietyWideReportOutput; + chartHeight?: number; } -export default function PovertyImpactByRaceSubPage({ output }: Props) { +export default function PovertyImpactByRaceSubPage({ output, chartHeight: chartHeightProp }: Props) { const mobile = useMediaQuery('(max-width: 768px)'); const countryId = useCurrentCountry(); const metadata = useSelector((state: RootState) => state.metadata); const { height: viewportHeight } = useViewportSize(); - const chartHeight = getClampedChartHeight(viewportHeight, mobile); + const chartHeight = chartHeightProp ?? getClampedChartHeight(viewportHeight, mobile); // Extract data type RaceData = Record; From 807f6296a65cb6019f8105e9cc0dc2b329e2e0d0 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 3 Mar 2026 20:35:09 +0100 Subject: [PATCH 72/73] feat: Remove congressional dropdown, fill map to expanded card height Remove CongressionalDistrictSection from MigrationSubPage since the DashboardCard now handles congressional district display. Pass computed height (557px) to the choropleth map so it fills the 3-row expanded card without blank space. Co-Authored-By: Claude Opus 4.6 --- .../choropleth/USDistrictChoroplethMap.tsx | 1 + .../pages/report-output/MigrationSubPage.tsx | 150 +----------------- .../report-output/SocietyWideOverview.tsx | 7 +- 3 files changed, 9 insertions(+), 149 deletions(-) diff --git a/app/src/components/visualization/choropleth/USDistrictChoroplethMap.tsx b/app/src/components/visualization/choropleth/USDistrictChoroplethMap.tsx index 041af0586..1db6362ea 100644 --- a/app/src/components/visualization/choropleth/USDistrictChoroplethMap.tsx +++ b/app/src/components/visualization/choropleth/USDistrictChoroplethMap.tsx @@ -161,6 +161,7 @@ export function USDistrictChoroplethMap({ return ( ('absolute'); - - // Check if output already has district data (from nationwide calculation) - const existingDistricts = useMemo(() => { - if (!('congressional_district_impact' in output)) { - return null; - } - const districtData = (output as ReportOutputSocietyWideUS).congressional_district_impact; - if (!districtData?.districts) { - return null; - } - return districtData.districts; - }, [output]); - - // Build map data from context (progressive fill as states complete) - const contextMapData = useMemo(() => { - if (stateResponses.size === 0) { - return []; - } - const points: Array<{ geoId: string; label: string; value: number }> = []; - stateResponses.forEach((stateData) => { - stateData.districts.forEach((district) => { - points.push({ - geoId: district.district, - label: labelLookup.get(district.district) ?? `District ${district.district}`, - value: - mode === 'absolute' - ? district.average_household_income_change - : district.relative_household_income_change, - }); - }); - }); - return points; - }, [stateResponses, labelLookup, mode]); - - // Use pre-computed data if available, otherwise progressive context data - const mapData = useMemo(() => { - if (existingDistricts) { - return existingDistricts.map((item) => ({ - geoId: item.district, - label: labelLookup.get(item.district) ?? `District ${item.district}`, - value: - mode === 'absolute' - ? item.average_household_income_change - : item.relative_household_income_change, - })); - } - return contextMapData; - }, [existingDistricts, contextMapData, labelLookup, mode]); - - // Map config based on absolute vs relative mode - const mapConfig = useMemo( - () => ({ - colorScale: { - colors: DIVERGING_GRAY_TEAL.colors, - tickFormat: mode === 'absolute' ? '$,.0f' : '.1%', - symmetric: true, - }, - formatValue: (value: number) => - mode === 'absolute' - ? formatParameterValue(value, 'currency-USD', { - decimalPlaces: 0, - includeSymbol: true, - }) - : formatParameterValue(value, '/1', { decimalPlaces: 1 }), - }), - [mode] - ); - - // Progress - const progressPercent = totalStates > 0 ? Math.round((completedCount / totalStates) * 100) : 0; - const progressMessage = isLoading - ? `Computing district impacts (${completedCount} of ${totalStates} states)...` - : undefined; - - return ( - - {!hasStarted && !existingDistricts && ( - - )} - setMode(value as CongressionalMode)} - size="xs" - data={CONGRESSIONAL_MODE_OPTIONS} - styles={segmentedControlStyles} - /> - - } - > - {progressMessage && ( - - {progressMessage} - - )} - {isLoading && } - - - ); -} - export default function MigrationSubPage({ output, report, @@ -300,8 +156,6 @@ export default function MigrationSubPage({ )} - {canShowCongressional && } - {showUKGeographySections && ( <> diff --git a/app/src/pages/report-output/SocietyWideOverview.tsx b/app/src/pages/report-output/SocietyWideOverview.tsx index bc05bab1c..819508bd0 100644 --- a/app/src/pages/report-output/SocietyWideOverview.tsx +++ b/app/src/pages/report-output/SocietyWideOverview.tsx @@ -69,6 +69,11 @@ const CONTROLS_ROW_H = 39; // 31 (SC xs height) + 8 (marginBottom) const BUDGET_CHART_H = 279; const SECONDARY_CHART_H = 252; +// Congressional map height (3 rows): +// outer: 200×3 + 16×2 = 632, minus border(2) + padding(32) + controls(39) = 559 +// minus map Box border (2px) = 557 +const CONGRESSIONAL_MAP_H = 557; + // Poverty segmented control types and options type PovertyDepth = 'regular' | 'deep'; type PovertyBreakdown = 'by-age' | 'by-gender' | 'by-race'; @@ -332,7 +337,7 @@ function CongressionalDistrictCard({ expandedContent={ } From 0bd5b6b337b26d279494bd83b581b6b1c0e9fd68 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 3 Mar 2026 23:16:56 +0100 Subject: [PATCH 73/73] feat: Add error state visualization, improve error logging, and UI fixes - Add red error trace overlay on choropleth map for errored states with red hover card background - Detect missing districts from both pre-computed and progressive fetch paths to show error counts on shrunken card - Add 'error' trend to MetricCard with red label, icon, and value - Improve fetchSocietyWideCalculation error logging with response body - Increase mini chart left margin for currency symbol visibility - Remove "Absolute impacts by decile" legend from decile card - Expose erroredStates from congressional district context Co-Authored-By: Claude Opus 4.6 --- app/src/api/societyWideCalculation.ts | 14 ++- app/src/components/report/MetricCard.tsx | 14 ++- .../choropleth/USDistrictChoroplethMap.tsx | 5 +- .../visualization/choropleth/types.ts | 2 + .../visualization/choropleth/utils.ts | 87 ++++++++++++++++++- .../CongressionalDistrictDataContext.tsx | 2 + .../contexts/congressional-district/types.ts | 2 + .../report-output/SocietyWideOverview.tsx | 85 +++++++++++++----- .../congressionalDistrictMocks.ts | 1 + 9 files changed, 177 insertions(+), 35 deletions(-) diff --git a/app/src/api/societyWideCalculation.ts b/app/src/api/societyWideCalculation.ts index 61656b6b3..94018bc36 100644 --- a/app/src/api/societyWideCalculation.ts +++ b/app/src/api/societyWideCalculation.ts @@ -43,12 +43,18 @@ export async function fetchSocietyWideCalculation( }); if (!response.ok) { + let body = ''; + try { + body = await response.text(); + } catch { + // ignore + } console.error( - '[fetchSocietyWideCalculation] Failed with status:', - response.status, - response.statusText + `[fetchSocietyWideCalculation] ${response.status} ${response.statusText}`, + url, + body ); - throw new Error(`Society-wide calculation failed: ${response.statusText}`); + throw new Error(`Society-wide calculation failed (${response.status}): ${body || response.statusText}`); } const data = await response.json(); diff --git a/app/src/components/report/MetricCard.tsx b/app/src/components/report/MetricCard.tsx index f0cba23b8..6ff77cbe7 100644 --- a/app/src/components/report/MetricCard.tsx +++ b/app/src/components/report/MetricCard.tsx @@ -1,8 +1,8 @@ -import { IconArrowDown, IconArrowUp, IconMinus } from '@tabler/icons-react'; +import { IconAlertTriangle, IconArrowDown, IconArrowUp, IconMinus } from '@tabler/icons-react'; import { Box, Group, Text, ThemeIcon } from '@mantine/core'; import { colors, spacing, typography } from '@/designTokens'; -type MetricTrend = 'positive' | 'negative' | 'neutral'; +type MetricTrend = 'positive' | 'negative' | 'neutral' | 'error'; interface MetricCardProps { /** Label describing the metric (omit to hide) */ @@ -42,12 +42,18 @@ export default function MetricCard({ return colors.primary[600]; case 'negative': return colors.gray[600]; + case 'error': + return 'rgb(220, 53, 69)'; default: return colors.gray[500]; } }; const getTrendIcon = () => { + if (trend === 'error') { + return ; + } + // When invertArrow is true, flip the arrow direction // (useful for metrics like poverty where decrease is good) const showUpArrow = invertArrow ? trend === 'negative' : trend === 'positive'; @@ -71,7 +77,7 @@ export default function MetricCard({ @@ -86,7 +92,7 @@ export default function MetricCard({ size={hero ? 32 : 24} radius="xl" variant="light" - color={trend === 'positive' ? 'teal' : 'gray'} + color={trend === 'positive' ? 'teal' : trend === 'error' ? 'red' : 'gray'} > {getTrendIcon()} diff --git a/app/src/components/visualization/choropleth/USDistrictChoroplethMap.tsx b/app/src/components/visualization/choropleth/USDistrictChoroplethMap.tsx index 1db6362ea..e1ef34626 100644 --- a/app/src/components/visualization/choropleth/USDistrictChoroplethMap.tsx +++ b/app/src/components/visualization/choropleth/USDistrictChoroplethMap.tsx @@ -108,6 +108,7 @@ export function USDistrictChoroplethMap({ config = {}, geoDataPath = DEFAULT_GEOJSON_PATH, focusState, + errorStates, }: USDistrictChoroplethMapProps) { // Load GeoJSON data const { geoJSON, loading, error } = useGeoJSONLoader(geoDataPath); @@ -129,8 +130,8 @@ export function USDistrictChoroplethMap({ if (!geoJSON) { return { plotData: [], plotLayout: {} }; } - return buildPlotDataAndLayout(geoJSON, dataMap, colorRange, fullConfig, focusState); - }, [geoJSON, dataMap, colorRange, fullConfig, focusState]); + return buildPlotDataAndLayout(geoJSON, dataMap, colorRange, fullConfig, focusState, errorStates); + }, [geoJSON, dataMap, colorRange, fullConfig, focusState, errorStates]); // Build Plotly config const plotConfig = useMemo(() => buildPlotConfig(), []); diff --git a/app/src/components/visualization/choropleth/types.ts b/app/src/components/visualization/choropleth/types.ts index a35926e65..34c1583f6 100644 --- a/app/src/components/visualization/choropleth/types.ts +++ b/app/src/components/visualization/choropleth/types.ts @@ -86,6 +86,8 @@ export interface USDistrictChoroplethMapProps { geoDataPath?: string; /** State code to focus/zoom on (e.g., 'ca', 'ny'). If provided, map will zoom to fit that state's districts. */ focusState?: string; + /** Uppercase 2-letter state abbreviations whose fetches errored (e.g., ['CO']). Districts in these states are colored red with error hover text. */ + errorStates?: string[]; } /** diff --git a/app/src/components/visualization/choropleth/utils.ts b/app/src/components/visualization/choropleth/utils.ts index 3fabb1d61..3847f6770 100644 --- a/app/src/components/visualization/choropleth/utils.ts +++ b/app/src/components/visualization/choropleth/utils.ts @@ -185,6 +185,79 @@ export function buildBackgroundTrace(geoJSON: GeoJSONFeatureCollection): Partial } as Partial; } +/** + * Build a red error trace for districts belonging to states that failed to load. + * Shows a solid red fill with an error message on hover. + */ +export function buildErrorTrace( + geoJSON: GeoJSONFeatureCollection, + errorStates: string[] +): Partial | null { + if (errorStates.length === 0) { + return null; + } + + const errorSet = new Set(errorStates); + const locations: string[] = []; + const hoverText: string[] = []; + const features: GeoJSONFeature[] = []; + + geoJSON.features.forEach((feature: GeoJSONFeature) => { + const districtId = feature.properties?.DISTRICT_ID as string; + if (!districtId) { + return; + } + // DISTRICT_ID format: "CO-01" — extract 2-letter state abbreviation + const stateAbbr = districtId.split('-')[0]; + if (!errorSet.has(stateAbbr)) { + return; + } + locations.push(districtId); + hoverText.push( + `${feature.properties?.NAMELSAD ?? districtId}
Error loading data` + ); + features.push(feature); + }); + + if (locations.length === 0) { + return null; + } + + const errorGeoJSON: GeoJSONFeatureCollection = { + ...geoJSON, + features: features.map((f, i) => ({ ...f, id: locations[i] })), + }; + + return { + type: 'choropleth', + geojson: errorGeoJSON, + locations, + z: locations.map(() => 1), + text: hoverText, + featureidkey: 'id', + locationmode: 'geojson-id', + colorscale: [ + [0, 'rgba(220, 53, 69, 0.5)'], + [1, 'rgba(220, 53, 69, 0.5)'], + ] as ColorscaleEntry[], + zmin: 0, + zmax: 1, + showscale: false, + hovertemplate: '%{text}', + hoverlabel: { + bgcolor: 'rgba(220, 53, 69, 0.9)', + font: { color: 'white' }, + bordercolor: 'rgba(220, 53, 69, 1)', + }, + marker: { + line: { + color: 'rgba(220, 53, 69, 0.8)', + width: 1.0, + }, + }, + } as Partial; +} + /** * Build the geo configuration for Plotly. * @@ -319,7 +392,8 @@ export function buildPlotDataAndLayout( dataMap: Map, colorRange: ColorRange, config: ChoroplethMapConfig, - focusState?: string + focusState?: string, + errorStates?: string[] ): PlotDataAndLayout { // Background trace: all district outlines (white fill, gray borders) const backgroundTrace = buildBackgroundTrace(geoJSON); @@ -338,7 +412,16 @@ export function buildPlotDataAndLayout( // Build data trace (colored fills for districts with data) const dataTraces = buildPlotData(geoJSONWithIds, processedData, colorscale, colorRange, config); + + // Build error trace (red fill for districts in errored states) + const errorTrace = errorStates ? buildErrorTrace(geoJSON, errorStates) : null; + const plotLayout = buildPlotLayout(geoConfig, config.height); - return { plotData: [backgroundTrace, ...dataTraces], plotLayout }; + const plotData = [backgroundTrace, ...dataTraces]; + if (errorTrace) { + plotData.push(errorTrace); + } + + return { plotData, plotLayout }; } diff --git a/app/src/contexts/congressional-district/CongressionalDistrictDataContext.tsx b/app/src/contexts/congressional-district/CongressionalDistrictDataContext.tsx index dab06efb4..fc05e7389 100644 --- a/app/src/contexts/congressional-district/CongressionalDistrictDataContext.tsx +++ b/app/src/contexts/congressional-district/CongressionalDistrictDataContext.tsx @@ -226,6 +226,7 @@ export function CongressionalDistrictDataProvider({ isLoading, hasStarted: state.hasStarted, errorCount, + erroredStates: state.erroredStates, labelLookup, isStateLevelReport, stateCode: stateCodeValue, @@ -237,6 +238,7 @@ export function CongressionalDistrictDataProvider({ [ state.stateResponses, state.hasStarted, + state.erroredStates, completedCount, loadingCount, totalDistrictsLoaded, diff --git a/app/src/contexts/congressional-district/types.ts b/app/src/contexts/congressional-district/types.ts index bec1b5c98..e5fcfd79b 100644 --- a/app/src/contexts/congressional-district/types.ts +++ b/app/src/contexts/congressional-district/types.ts @@ -66,6 +66,8 @@ export interface CongressionalDistrictDataContextValue { hasStarted: boolean; /** Number of states that errored */ errorCount: number; + /** Set of state codes that errored (e.g., 'state/co') */ + erroredStates: Set; /** Label lookup for district display names */ labelLookup: DistrictLabelLookup; /** Whether this is a state-level report (single state) vs national */ diff --git a/app/src/pages/report-output/SocietyWideOverview.tsx b/app/src/pages/report-output/SocietyWideOverview.tsx index 819508bd0..09a475f01 100644 --- a/app/src/pages/report-output/SocietyWideOverview.tsx +++ b/app/src/pages/report-output/SocietyWideOverview.tsx @@ -133,7 +133,8 @@ type CardKey = 'budget' | 'decile' | 'poverty' | 'winners' | 'inequality' | 'con /** * Congressional district card content — must be rendered inside a - * CongressionalDistrictDataProvider. Auto-starts fetching on mount. + * CongressionalDistrictDataProvider. Starts fetching once the report + * output is available and no pre-computed district data exists. */ function CongressionalDistrictCard({ output, @@ -159,16 +160,10 @@ function CongressionalDistrictCard({ labelLookup, stateCode, startFetch, + erroredStates, } = useCongressionalDistrictData(); const [congressionalMode, setCongressionalMode] = useState('absolute'); - // Auto-start fetch on mount - useEffect(() => { - if (!hasStarted) { - startFetch(); - } - }, [hasStarted, startFetch]); - // Check if output already has district data (from nationwide calculation) const existingDistricts = useMemo(() => { if (!('congressional_district_impact' in output)) { @@ -181,6 +176,14 @@ function CongressionalDistrictCard({ return districtData.districts; }, [output]); + // Auto-start fetch only when the report output is ready and no + // pre-computed district data exists (avoids 51 redundant requests) + useEffect(() => { + if (!existingDistricts && !hasStarted) { + startFetch(); + } + }, [existingDistricts, hasStarted, startFetch]); + // Build map data from context (progressive fill as states complete) const contextMapData = useMemo(() => { if (stateResponses.size === 0) { @@ -262,6 +265,39 @@ function CongressionalDistrictCard({ return { gainingCount: gaining, losingCount: losing }; }, [existingDistricts, stateResponses]); + // Detect errored districts from EITHER source: + // 1. Pre-computed data: districts in labelLookup but missing from existingDistricts + // 2. Progressive fetching: districts belonging to states in erroredStates + const { errorDistrictCount, errorStateAbbrs } = useMemo(() => { + if (existingDistricts) { + const existingSet = new Set(existingDistricts.map((d) => d.district)); + const missingStates = new Set(); + let count = 0; + labelLookup.forEach((_label, districtId) => { + if (!existingSet.has(districtId)) { + count++; + missingStates.add(districtId.split('-')[0]); + } + }); + return { errorDistrictCount: count, errorStateAbbrs: Array.from(missingStates) }; + } + + const abbrs = Array.from(erroredStates).map((code) => + code.replace(/^state\//, '').toUpperCase() + ); + if (abbrs.length === 0) { + return { errorDistrictCount: 0, errorStateAbbrs: abbrs }; + } + const errorSet = new Set(abbrs); + let count = 0; + labelLookup.forEach((_label, districtId) => { + if (errorSet.has(districtId.split('-')[0])) { + count++; + } + }); + return { errorDistrictCount: count, errorStateAbbrs: abbrs }; + }, [existingDistricts, erroredStates, labelLookup]); + const dataReady = existingDistricts || (!isLoading && hasStarted); const progressPercent = totalStates > 0 ? Math.round((completedCount / totalStates) * 100) : 0; @@ -303,6 +339,13 @@ function CongressionalDistrictCard({ Loading ({completedCount} of {totalStates} states)...
+ {errorDistrictCount > 0 && ( + + )} ) : ( @@ -320,6 +363,15 @@ function CongressionalDistrictCard({ trend="negative" />
+ {errorDistrictCount > 0 && ( + + + + )} )}
@@ -339,6 +391,7 @@ function CongressionalDistrictCard({ data={mapData} config={{ ...mapConfig, height: CONGRESSIONAL_MAP_H }} focusState={stateCode ?? undefined} + errorStates={errorStateAbbrs} /> } onToggleMode={onToggleMode} @@ -630,7 +683,7 @@ export default function SocietyWideOverview({ output, showCongressionalCard }: S }, ]} layout={{ - margin: { t: 5, b: 20, l: 35, r: 5 }, + margin: { t: 5, b: 20, l: 50, r: 5 }, showlegend: false, paper_bgcolor: 'transparent', plot_bgcolor: 'transparent', @@ -651,20 +704,6 @@ export default function SocietyWideOverview({ output, showCongressionalCard }: S config={MINI_CHART_CONFIG} style={{ width: '100%', height: MINI_CHART_HEIGHT }} /> - - - - Absolute impacts by decile - -
} expandedControls={ diff --git a/app/src/tests/fixtures/contexts/congressional-district/congressionalDistrictMocks.ts b/app/src/tests/fixtures/contexts/congressional-district/congressionalDistrictMocks.ts index f3099c093..8aac7aa50 100644 --- a/app/src/tests/fixtures/contexts/congressional-district/congressionalDistrictMocks.ts +++ b/app/src/tests/fixtures/contexts/congressional-district/congressionalDistrictMocks.ts @@ -260,6 +260,7 @@ export function createMockContextValue( isLoading: false, hasStarted: false, errorCount: 0, + erroredStates: new Set(), labelLookup: new Map(), isStateLevelReport: false, stateCode: null,