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 { Button } from './ui/button';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Card, CardContent } from './ui/card';
import { Badge } from './ui/badge';
import { FormTimeInput } from './ui/form-time-input';
import { FormNumericInput } from './ui/form-numeric-input';
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';
interface DayScheduleProps {
@@ -46,6 +47,23 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
// Track collapsed state for each day (by day ID)
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) => {
setCollapsedDays(prev => {
const newSet = new Set(prev);
@@ -54,6 +72,7 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
} else {
newSet.add(dayId);
}
saveCollapsedDays(newSet);
return newSet;
});
};
@@ -89,89 +108,13 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
return (
<Card key={day.id}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-wrap">
<Button
type="button"
size="sm"
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">
<CollapsibleCardHeader
title={day.isTemplate ? t('regularPlan') : t('alternativePlan')}
isCollapsed={collapsedDays.has(day.id)}
onToggle={() => toggleDayCollapse(day.id)}
toggleLabel={collapsedDays.has(day.id) ? t('expandDay') : t('collapseDay')}
rightSection={
<>
{canAddDay && (
<Button
onClick={() => onAddDay(day.id)}
@@ -193,9 +136,71 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
<Trash2 className="h-4 w-4" />
</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) && (
<CardContent className="space-y-3">
{/* Dose table header */}

View File

@@ -10,15 +10,16 @@
*/
import React from 'react';
import { FormNumericInput } from './ui/form-numeric-input';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Button } from './ui/button';
import { Switch } from './ui/switch';
import { Card, CardContent } from './ui/card';
import { Label } from './ui/label';
import { Switch } from './ui/switch';
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 { 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';
/**
@@ -124,6 +125,46 @@ const Settings = ({
const [isPharmacokineticExpanded, setIsPharmacokineticExpanded] = React.useState(true);
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
const defaultsForT = getDefaultsForTranslation(pkParams, therapeuticRange, uiSettings);
@@ -158,12 +199,11 @@ const Settings = ({
<div className="space-y-4">
{/* Diagram Settings Card */}
<Card>
<CardHeader className="cursor-pointer pb-3" onClick={() => setIsDiagramExpanded(!isDiagramExpanded)}>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{t('diagramSettings')}</CardTitle>
{isDiagramExpanded ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />}
</div>
</CardHeader>
<CollapsibleCardHeader
title={t('diagramSettings')}
isCollapsed={!isDiagramExpanded}
onToggle={() => updateDiagramExpanded(!isDiagramExpanded)}
/>
{isDiagramExpanded && (
<CardContent className="space-y-4">
<div className="space-y-3">
@@ -431,12 +471,11 @@ const Settings = ({
{/* Simulation Settings Card */}
<Card>
<CardHeader className="cursor-pointer pb-3" onClick={() => setIsSimulationExpanded(!isSimulationExpanded)}>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{t('simulationSettings')}</CardTitle>
{isSimulationExpanded ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />}
</div>
</CardHeader>
<CollapsibleCardHeader
title={t('simulationSettings')}
isCollapsed={!isSimulationExpanded}
onToggle={() => updateSimulationExpanded(!isSimulationExpanded)}
/>
{isSimulationExpanded && (
<CardContent className="space-y-4">
<div className="space-y-2">
@@ -507,12 +546,11 @@ const Settings = ({
{/* Pharmacokinetic Settings Card */}
<Card>
<CardHeader className="cursor-pointer pb-3" onClick={() => setIsPharmacokineticExpanded(!isPharmacokineticExpanded)}>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{t('pharmacokineticsSettings')}</CardTitle>
{isPharmacokineticExpanded ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />}
</div>
</CardHeader>
<CollapsibleCardHeader
title={t('pharmacokineticsSettings')}
isCollapsed={!isPharmacokineticExpanded}
onToggle={() => updatePharmacokineticExpanded(!isPharmacokineticExpanded)}
/>
{isPharmacokineticExpanded && (
<CardContent className="space-y-4">
<h3 className="text-lg font-semibold">{t('dAmphetamineParameters')}</h3>
@@ -627,12 +665,11 @@ const Settings = ({
{/* Advanced Settings Card */}
<Card>
<CardHeader className="cursor-pointer pb-3" onClick={() => setIsAdvancedExpanded(!isAdvancedExpanded)}>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{t('advancedSettings')}</CardTitle>
{isAdvancedExpanded ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />}
</div>
</CardHeader>
<CollapsibleCardHeader
title={t('advancedSettings')}
isCollapsed={!isAdvancedExpanded}
onToggle={() => updateAdvancedExpanded(!isAdvancedExpanded)}
/>
{isAdvancedExpanded && (
<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">

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;