Add collapsible-card-header component to consolidate and improve folding

This commit is contained in:
2026-01-16 13:26:30 +00:00
parent 6f6e5d9696
commit e1aaa24186
3 changed files with 219 additions and 118 deletions

View File

@@ -10,12 +10,13 @@
import React from 'react'; import React from 'react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Card, CardContent } from './ui/card';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { FormTimeInput } from './ui/form-time-input'; import { FormTimeInput } from './ui/form-time-input';
import { FormNumericInput } from './ui/form-numeric-input'; import { FormNumericInput } from './ui/form-numeric-input';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { Plus, Copy, Trash2, ArrowDownAZ, ChevronDown, ChevronUp, TrendingUp, TrendingDown } from 'lucide-react'; import CollapsibleCardHeader from './ui/collapsible-card-header';
import { Plus, Copy, Trash2, ArrowDownAZ, TrendingUp, TrendingDown } from 'lucide-react';
import type { DayGroup } from '../constants/defaults'; import type { DayGroup } from '../constants/defaults';
interface DayScheduleProps { interface DayScheduleProps {
@@ -46,6 +47,23 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
// Track collapsed state for each day (by day ID) // Track collapsed state for each day (by day ID)
const [collapsedDays, setCollapsedDays] = React.useState<Set<string>>(new Set()); const [collapsedDays, setCollapsedDays] = React.useState<Set<string>>(new Set());
// Load and persist collapsed days state
React.useEffect(() => {
const savedCollapsed = localStorage.getItem('dayScheduleCollapsedDays_v1');
if (savedCollapsed) {
try {
const collapsedArray = JSON.parse(savedCollapsed);
setCollapsedDays(new Set(collapsedArray));
} catch (e) {
console.warn('Failed to load collapsed days state:', e);
}
}
}, []);
const saveCollapsedDays = (newCollapsedDays: Set<string>) => {
localStorage.setItem('dayScheduleCollapsedDays_v1', JSON.stringify(Array.from(newCollapsedDays)));
};
const toggleDayCollapse = (dayId: string) => { const toggleDayCollapse = (dayId: string) => {
setCollapsedDays(prev => { setCollapsedDays(prev => {
const newSet = new Set(prev); const newSet = new Set(prev);
@@ -54,6 +72,7 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
} else { } else {
newSet.add(dayId); newSet.add(dayId);
} }
saveCollapsedDays(newSet);
return newSet; return newSet;
}); });
}; };
@@ -89,89 +108,13 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
return ( return (
<Card key={day.id}> <Card key={day.id}>
<CardHeader className="pb-3"> <CollapsibleCardHeader
<div className="flex items-center justify-between"> title={day.isTemplate ? t('regularPlan') : t('alternativePlan')}
<div className="flex items-center gap-2 flex-wrap"> isCollapsed={collapsedDays.has(day.id)}
<Button onToggle={() => toggleDayCollapse(day.id)}
type="button" toggleLabel={collapsedDays.has(day.id) ? t('expandDay') : t('collapseDay')}
size="sm" rightSection={
variant="ghost" <>
className="h-6 w-6 p-0"
onClick={() => toggleDayCollapse(day.id)}
title={collapsedDays.has(day.id) ? t('expandDay') : t('collapseDay')}
>
{collapsedDays.has(day.id) ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronUp className="h-4 w-4" />
)}
</Button>
<CardTitle className="text-lg">
{day.isTemplate ? t('regularPlan') : t('alternativePlan')}
</CardTitle>
<Badge variant="secondary" className="text-xs">
{t('day')} {dayIndex + 1}
</Badge>
{!day.isTemplate && doseCountDiff !== 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center cursor-help focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded-md"
>
<Badge
variant="outline"
className={`text-xs ${doseCountDiff > 0 ? 'bg-blue-50' : 'bg-orange-50'}`}
>
{doseCountDiff > 0 ? <TrendingUp className="h-3 w-3 inline mr-1" /> : <TrendingDown className="h-3 w-3 inline mr-1" />}
{day.doses.length} {day.doses.length === 1 ? t('dose') : t('doses')}
</Badge>
</button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{doseCountDiff > 0 ? '+' : ''}{doseCountDiff} {Math.abs(doseCountDiff) === 1 ? t('dose') : t('doses')} {t('comparedToRegularPlan')}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Badge variant="outline" className="text-xs">
{day.doses.length} {day.doses.length === 1 ? t('dose') : t('doses')}
</Badge>
)}
{!day.isTemplate && Math.abs(totalMgDiff) > 0.1 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center cursor-help focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded-md"
>
<Badge
variant="outline"
className={`text-xs ${totalMgDiff > 0 ? 'bg-blue-50' : 'bg-orange-50'}`}
>
{totalMgDiff > 0 ? <TrendingUp className="h-3 w-3 inline mr-1" /> : <TrendingDown className="h-3 w-3 inline mr-1" />}
{day.doses.reduce((sum, dose) => sum + (parseFloat(dose.ldx) || 0), 0).toFixed(1)} mg
</Badge>
</button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{totalMgDiff > 0 ? '+' : ''}{totalMgDiff.toFixed(1)} mg {t('comparedToRegularPlan')}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Badge variant="outline" className="text-xs">
{day.doses.reduce((sum, dose) => sum + (parseFloat(dose.ldx) || 0), 0).toFixed(1)} mg
</Badge>
)}
</div>
<div className="flex gap-2">
{canAddDay && ( {canAddDay && (
<Button <Button
onClick={() => onAddDay(day.id)} onClick={() => onAddDay(day.id)}
@@ -193,9 +136,71 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
)} )}
</div> </>
</div> }
</CardHeader> >
<Badge variant="secondary" className="text-xs">
{t('day')} {dayIndex + 1}
</Badge>
{!day.isTemplate && doseCountDiff !== 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center cursor-help focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded-md"
>
<Badge
variant="outline"
className={`text-xs ${doseCountDiff > 0 ? 'bg-blue-50' : 'bg-orange-50'}`}
>
{doseCountDiff > 0 ? <TrendingUp className="h-3 w-3 inline mr-1" /> : <TrendingDown className="h-3 w-3 inline mr-1" />}
{day.doses.length} {day.doses.length === 1 ? t('dose') : t('doses')}
</Badge>
</button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{doseCountDiff > 0 ? '+' : ''}{doseCountDiff} {Math.abs(doseCountDiff) === 1 ? t('dose') : t('doses')} {t('comparedToRegularPlan')}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Badge variant="outline" className="text-xs">
{day.doses.length} {day.doses.length === 1 ? t('dose') : t('doses')}
</Badge>
)}
{!day.isTemplate && Math.abs(totalMgDiff) > 0.1 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center cursor-help focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded-md"
>
<Badge
variant="outline"
className={`text-xs ${totalMgDiff > 0 ? 'bg-blue-50' : 'bg-orange-50'}`}
>
{totalMgDiff > 0 ? <TrendingUp className="h-3 w-3 inline mr-1" /> : <TrendingDown className="h-3 w-3 inline mr-1" />}
{day.doses.reduce((sum, dose) => sum + (parseFloat(dose.ldx) || 0), 0).toFixed(1)} mg
</Badge>
</button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{totalMgDiff > 0 ? '+' : ''}{totalMgDiff.toFixed(1)} mg {t('comparedToRegularPlan')}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Badge variant="outline" className="text-xs">
{day.doses.reduce((sum, dose) => sum + (parseFloat(dose.ldx) || 0), 0).toFixed(1)} mg
</Badge>
)}
</CollapsibleCardHeader>
{!collapsedDays.has(day.id) && ( {!collapsedDays.has(day.id) && (
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{/* Dose table header */} {/* Dose table header */}

View File

@@ -10,15 +10,16 @@
*/ */
import React from 'react'; import React from 'react';
import { FormNumericInput } from './ui/form-numeric-input'; import { Card, CardContent } from './ui/card';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Button } from './ui/button';
import { Switch } from './ui/switch';
import { Label } from './ui/label'; import { Label } from './ui/label';
import { Switch } from './ui/switch';
import { Separator } from './ui/separator'; import { Separator } from './ui/separator';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { Button } from './ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { ChevronDown, ChevronUp, Info } from 'lucide-react'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { FormNumericInput } from './ui/form-numeric-input';
import CollapsibleCardHeader from './ui/collapsible-card-header';
import { Info } from 'lucide-react';
import { getDefaultState } from '../constants/defaults'; import { getDefaultState } from '../constants/defaults';
/** /**
@@ -124,6 +125,46 @@ const Settings = ({
const [isPharmacokineticExpanded, setIsPharmacokineticExpanded] = React.useState(true); const [isPharmacokineticExpanded, setIsPharmacokineticExpanded] = React.useState(true);
const [isAdvancedExpanded, setIsAdvancedExpanded] = React.useState(false); const [isAdvancedExpanded, setIsAdvancedExpanded] = React.useState(false);
// Load and persist settings card expansion states
React.useEffect(() => {
const savedStates = localStorage.getItem('settingsCardStates_v1');
if (savedStates) {
try {
const states = JSON.parse(savedStates);
if (states.diagram !== undefined) setIsDiagramExpanded(states.diagram);
if (states.simulation !== undefined) setIsSimulationExpanded(states.simulation);
if (states.pharmacokinetic !== undefined) setIsPharmacokineticExpanded(states.pharmacokinetic);
if (states.advanced !== undefined) setIsAdvancedExpanded(states.advanced);
} catch (e) {
console.warn('Failed to load settings card states:', e);
}
}
}, []);
const updateDiagramExpanded = (value: boolean) => {
setIsDiagramExpanded(value);
saveCardStates({ diagram: value, simulation: isSimulationExpanded, pharmacokinetic: isPharmacokineticExpanded, advanced: isAdvancedExpanded });
};
const updateSimulationExpanded = (value: boolean) => {
setIsSimulationExpanded(value);
saveCardStates({ diagram: isDiagramExpanded, simulation: value, pharmacokinetic: isPharmacokineticExpanded, advanced: isAdvancedExpanded });
};
const updatePharmacokineticExpanded = (value: boolean) => {
setIsPharmacokineticExpanded(value);
saveCardStates({ diagram: isDiagramExpanded, simulation: isSimulationExpanded, pharmacokinetic: value, advanced: isAdvancedExpanded });
};
const updateAdvancedExpanded = (value: boolean) => {
setIsAdvancedExpanded(value);
saveCardStates({ diagram: isDiagramExpanded, simulation: isSimulationExpanded, pharmacokinetic: isPharmacokineticExpanded, advanced: value });
};
const saveCardStates = (states: any) => {
localStorage.setItem('settingsCardStates_v1', JSON.stringify(states));
};
// Create defaults object for translation interpolation // Create defaults object for translation interpolation
const defaultsForT = getDefaultsForTranslation(pkParams, therapeuticRange, uiSettings); const defaultsForT = getDefaultsForTranslation(pkParams, therapeuticRange, uiSettings);
@@ -158,12 +199,11 @@ const Settings = ({
<div className="space-y-4"> <div className="space-y-4">
{/* Diagram Settings Card */} {/* Diagram Settings Card */}
<Card> <Card>
<CardHeader className="cursor-pointer pb-3" onClick={() => setIsDiagramExpanded(!isDiagramExpanded)}> <CollapsibleCardHeader
<div className="flex items-center justify-between"> title={t('diagramSettings')}
<CardTitle className="text-lg">{t('diagramSettings')}</CardTitle> isCollapsed={!isDiagramExpanded}
{isDiagramExpanded ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />} onToggle={() => updateDiagramExpanded(!isDiagramExpanded)}
</div> />
</CardHeader>
{isDiagramExpanded && ( {isDiagramExpanded && (
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-3"> <div className="space-y-3">
@@ -431,12 +471,11 @@ const Settings = ({
{/* Simulation Settings Card */} {/* Simulation Settings Card */}
<Card> <Card>
<CardHeader className="cursor-pointer pb-3" onClick={() => setIsSimulationExpanded(!isSimulationExpanded)}> <CollapsibleCardHeader
<div className="flex items-center justify-between"> title={t('simulationSettings')}
<CardTitle className="text-lg">{t('simulationSettings')}</CardTitle> isCollapsed={!isSimulationExpanded}
{isSimulationExpanded ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />} onToggle={() => updateSimulationExpanded(!isSimulationExpanded)}
</div> />
</CardHeader>
{isSimulationExpanded && ( {isSimulationExpanded && (
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -507,12 +546,11 @@ const Settings = ({
{/* Pharmacokinetic Settings Card */} {/* Pharmacokinetic Settings Card */}
<Card> <Card>
<CardHeader className="cursor-pointer pb-3" onClick={() => setIsPharmacokineticExpanded(!isPharmacokineticExpanded)}> <CollapsibleCardHeader
<div className="flex items-center justify-between"> title={t('pharmacokineticsSettings')}
<CardTitle className="text-lg">{t('pharmacokineticsSettings')}</CardTitle> isCollapsed={!isPharmacokineticExpanded}
{isPharmacokineticExpanded ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />} onToggle={() => updatePharmacokineticExpanded(!isPharmacokineticExpanded)}
</div> />
</CardHeader>
{isPharmacokineticExpanded && ( {isPharmacokineticExpanded && (
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<h3 className="text-lg font-semibold">{t('dAmphetamineParameters')}</h3> <h3 className="text-lg font-semibold">{t('dAmphetamineParameters')}</h3>
@@ -627,12 +665,11 @@ const Settings = ({
{/* Advanced Settings Card */} {/* Advanced Settings Card */}
<Card> <Card>
<CardHeader className="cursor-pointer pb-3" onClick={() => setIsAdvancedExpanded(!isAdvancedExpanded)}> <CollapsibleCardHeader
<div className="flex items-center justify-between"> title={t('advancedSettings')}
<CardTitle className="text-lg">{t('advancedSettings')}</CardTitle> isCollapsed={!isAdvancedExpanded}
{isAdvancedExpanded ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />} onToggle={() => updateAdvancedExpanded(!isAdvancedExpanded)}
</div> />
</CardHeader>
{isAdvancedExpanded && ( {isAdvancedExpanded && (
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3 text-sm"> <div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3 text-sm">

View File

@@ -0,0 +1,59 @@
/**
* CollapsibleCardHeader
*
* Shared header row with a title + chevron toggle, optional children after title/chevron,
* and an optional right section for action buttons.
*/
import React from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { CardHeader, CardTitle } from './card';
import { cn } from '../../lib/utils';
interface CollapsibleCardHeaderProps {
title: React.ReactNode;
isCollapsed: boolean;
onToggle: () => void;
children?: React.ReactNode;
rightSection?: React.ReactNode;
className?: string;
titleClassName?: string;
toggleLabel?: string;
}
const CollapsibleCardHeader: React.FC<CollapsibleCardHeaderProps> = ({
title,
isCollapsed,
onToggle,
children,
rightSection,
className,
titleClassName,
toggleLabel
}) => {
const accessibilityProps = toggleLabel ? { title: toggleLabel, 'aria-label': toggleLabel } : {};
return (
<CardHeader className={cn('pb-3', className)}>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap flex-1">
<button
type="button"
onClick={onToggle}
className="inline-flex items-center gap-2 rounded-md px-1 py-1 -ml-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:bg-muted/60 cursor-pointer"
aria-expanded={!isCollapsed}
{...accessibilityProps}
>
<CardTitle className={cn('text-lg', titleClassName)}>
{title}
</CardTitle>
{isCollapsed ? <ChevronDown className="h-5 w-5 flex-shrink-0" /> : <ChevronUp className="h-5 w-5 flex-shrink-0" />}
</button>
{children}
</div>
{rightSection && <div className="flex items-center gap-2">{rightSection}</div>}
</div>
</CardHeader>
);
};
export default CollapsibleCardHeader;