Add import/export feature
This commit is contained in:
@@ -20,7 +20,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
|
||||
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, APP_VERSION } from '../constants/defaults';
|
||||
import { exportSettings, downloadExport, parseImportFile, validateImportData, importSettings } from '../utils/exportImport';
|
||||
|
||||
/**
|
||||
* Helper function to create translation interpolation values for defaults.
|
||||
@@ -110,10 +111,13 @@ const Settings = ({
|
||||
pkParams,
|
||||
therapeuticRange,
|
||||
uiSettings,
|
||||
days,
|
||||
doseIncrement,
|
||||
onUpdatePkParams,
|
||||
onUpdateTherapeuticRange,
|
||||
onUpdateUiSetting,
|
||||
onReset,
|
||||
onImportDays,
|
||||
t
|
||||
}: any) => {
|
||||
const { showDayTimeOnXAxis, yAxisMin, yAxisMax, showTemplateDay, simulationDays, displayedDays } = uiSettings;
|
||||
@@ -125,6 +129,24 @@ const Settings = ({
|
||||
const [isSimulationExpanded, setIsSimulationExpanded] = React.useState(true);
|
||||
const [isPharmacokineticExpanded, setIsPharmacokineticExpanded] = React.useState(true);
|
||||
const [isAdvancedExpanded, setIsAdvancedExpanded] = React.useState(false);
|
||||
const [isDataManagementExpanded, setIsDataManagementExpanded] = React.useState(false);
|
||||
|
||||
const [exportOptions, setExportOptions] = React.useState({
|
||||
includeSchedules: true,
|
||||
includeDiagramSettings: true,
|
||||
includeSimulationSettings: true,
|
||||
includePharmacoSettings: true,
|
||||
includeAdvancedSettings: false,
|
||||
});
|
||||
const [importOptions, setImportOptions] = React.useState({
|
||||
includeSchedules: true,
|
||||
includeDiagramSettings: true,
|
||||
includeSimulationSettings: true,
|
||||
includePharmacoSettings: true,
|
||||
includeAdvancedSettings: false,
|
||||
});
|
||||
const [selectedFile, setSelectedFile] = React.useState<File | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Track which tooltip is currently open (for mobile touch interaction)
|
||||
const [openTooltipId, setOpenTooltipId] = React.useState<string | null>(null);
|
||||
@@ -157,6 +179,7 @@ const Settings = ({
|
||||
if (states.simulation !== undefined) setIsSimulationExpanded(states.simulation);
|
||||
if (states.pharmacokinetic !== undefined) setIsPharmacokineticExpanded(states.pharmacokinetic);
|
||||
if (states.advanced !== undefined) setIsAdvancedExpanded(states.advanced);
|
||||
if (states.dataManagement !== undefined) setIsDataManagementExpanded(states.dataManagement);
|
||||
} catch (e) {
|
||||
console.warn('Failed to load settings card states:', e);
|
||||
}
|
||||
@@ -196,22 +219,27 @@ const Settings = ({
|
||||
|
||||
const updateDiagramExpanded = (value: boolean) => {
|
||||
setIsDiagramExpanded(value);
|
||||
saveCardStates({ diagram: value, simulation: isSimulationExpanded, pharmacokinetic: isPharmacokineticExpanded, advanced: isAdvancedExpanded });
|
||||
saveCardStates({ diagram: value, simulation: isSimulationExpanded, pharmacokinetic: isPharmacokineticExpanded, advanced: isAdvancedExpanded, dataManagement: isDataManagementExpanded });
|
||||
};
|
||||
|
||||
const updateSimulationExpanded = (value: boolean) => {
|
||||
setIsSimulationExpanded(value);
|
||||
saveCardStates({ diagram: isDiagramExpanded, simulation: value, pharmacokinetic: isPharmacokineticExpanded, advanced: isAdvancedExpanded });
|
||||
saveCardStates({ diagram: isDiagramExpanded, simulation: value, pharmacokinetic: isPharmacokineticExpanded, advanced: isAdvancedExpanded, dataManagement: isDataManagementExpanded });
|
||||
};
|
||||
|
||||
const updatePharmacokineticExpanded = (value: boolean) => {
|
||||
setIsPharmacokineticExpanded(value);
|
||||
saveCardStates({ diagram: isDiagramExpanded, simulation: isSimulationExpanded, pharmacokinetic: value, advanced: isAdvancedExpanded });
|
||||
saveCardStates({ diagram: isDiagramExpanded, simulation: isSimulationExpanded, pharmacokinetic: value, advanced: isAdvancedExpanded, dataManagement: isDataManagementExpanded });
|
||||
};
|
||||
|
||||
const updateAdvancedExpanded = (value: boolean) => {
|
||||
setIsAdvancedExpanded(value);
|
||||
saveCardStates({ diagram: isDiagramExpanded, simulation: isSimulationExpanded, pharmacokinetic: isPharmacokineticExpanded, advanced: value });
|
||||
saveCardStates({ diagram: isDiagramExpanded, simulation: isSimulationExpanded, pharmacokinetic: isPharmacokineticExpanded, advanced: value, dataManagement: isDataManagementExpanded });
|
||||
};
|
||||
|
||||
const updateDataManagementExpanded = (value: boolean) => {
|
||||
setIsDataManagementExpanded(value);
|
||||
saveCardStates({ diagram: isDiagramExpanded, simulation: isSimulationExpanded, pharmacokinetic: isPharmacokineticExpanded, advanced: isAdvancedExpanded, dataManagement: value });
|
||||
};
|
||||
|
||||
const saveCardStates = (states: any) => {
|
||||
@@ -239,6 +267,115 @@ const Settings = ({
|
||||
});
|
||||
};
|
||||
|
||||
// Export/Import handlers
|
||||
const handleExport = () => {
|
||||
const hasAnySelected = Object.values(exportOptions).some(v => v);
|
||||
if (!hasAnySelected) {
|
||||
alert(t('exportNoOptionsSelected'));
|
||||
return;
|
||||
}
|
||||
|
||||
const appState = {
|
||||
pkParams,
|
||||
days,
|
||||
therapeuticRange,
|
||||
doseIncrement,
|
||||
uiSettings,
|
||||
steadyStateConfig: { daysOnMedication: pkParams.advanced.steadyStateDays }
|
||||
};
|
||||
const exportData = exportSettings(appState, exportOptions, APP_VERSION);
|
||||
downloadExport(exportData);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
setSelectedFile(file || null);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!selectedFile) {
|
||||
alert(t('importFileNotSelected'));
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAnySelected = Object.values(importOptions).some(v => v);
|
||||
if (!hasAnySelected) {
|
||||
alert(t('importNoOptionsSelected'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileContent = await selectedFile.text();
|
||||
const importData = parseImportFile(fileContent);
|
||||
|
||||
if (!importData) {
|
||||
alert(t('importParseError'));
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateImportData(importData);
|
||||
|
||||
if (!validation.isValid) {
|
||||
alert(t('importError') + '\n\n' + validation.errors.join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (validation.warnings.length > 0) {
|
||||
const warningMessage = t('importValidationTitle') + '\n\n' +
|
||||
t('importValidationWarnings') + '\n' +
|
||||
validation.warnings.join('\n') + '\n\n' +
|
||||
t('importValidationContinue');
|
||||
|
||||
if (!window.confirm(warningMessage)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply import
|
||||
const currentState = { pkParams, days, therapeuticRange, doseIncrement, uiSettings, steadyStateConfig: { daysOnMedication: pkParams.advanced.steadyStateDays } };
|
||||
const newState = importSettings(currentState, importData.data, importOptions);
|
||||
|
||||
// Apply schedules
|
||||
if (newState.days && importOptions.includeSchedules && onImportDays) {
|
||||
onImportDays(newState.days);
|
||||
}
|
||||
|
||||
// Apply PK params
|
||||
if (newState.pkParams && importOptions.includePharmacoSettings) {
|
||||
Object.entries(newState.pkParams).forEach(([key, value]) => {
|
||||
if (key !== 'advanced') {
|
||||
onUpdatePkParams(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (newState.pkParams?.advanced && importOptions.includeAdvancedSettings) {
|
||||
onUpdatePkParams('advanced', newState.pkParams.advanced);
|
||||
}
|
||||
|
||||
if (newState.therapeuticRange && importOptions.includePharmacoSettings) {
|
||||
Object.entries(newState.therapeuticRange).forEach(([key, value]) => {
|
||||
onUpdateTherapeuticRange(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
if (newState.uiSettings) {
|
||||
Object.entries(newState.uiSettings).forEach(([key, value]) => {
|
||||
onUpdateUiSetting(key as any, value);
|
||||
});
|
||||
}
|
||||
|
||||
alert(t('importSuccess'));
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
alert(t('importError'));
|
||||
}
|
||||
};
|
||||
|
||||
// Check for out-of-range warnings
|
||||
const absorptionHL = parseFloat(pkParams.ldx.absorptionHalfLife);
|
||||
const conversionHL = parseFloat(pkParams.ldx.halfLife);
|
||||
@@ -975,6 +1112,188 @@ const Settings = ({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Export/Import Settings Card */}
|
||||
<Card>
|
||||
<CollapsibleCardHeader
|
||||
title={t('dataManagement')}
|
||||
isCollapsed={!isDataManagementExpanded}
|
||||
onToggle={() => updateDataManagementExpanded(!isDataManagementExpanded)}
|
||||
/>
|
||||
{isDataManagementExpanded && (
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
<Info className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p>{t('exportImportTooltip')}</p>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
{/* Export Section */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-sm">{t('exportSettings')}</h4>
|
||||
<p className="text-xs text-muted-foreground">{t('exportSelectWhat')}</p>
|
||||
|
||||
<div className="space-y-2 ml-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="export-schedules"
|
||||
checked={exportOptions.includeSchedules}
|
||||
onCheckedChange={checked => setExportOptions({...exportOptions, includeSchedules: checked})}
|
||||
/>
|
||||
<Label htmlFor="export-schedules" className="text-sm">
|
||||
{t('exportOptionSchedules')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="export-diagram"
|
||||
checked={exportOptions.includeDiagramSettings}
|
||||
onCheckedChange={checked => setExportOptions({...exportOptions, includeDiagramSettings: checked})}
|
||||
/>
|
||||
<Label htmlFor="export-diagram" className="text-sm">
|
||||
{t('exportOptionDiagram')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="export-simulation"
|
||||
checked={exportOptions.includeSimulationSettings}
|
||||
onCheckedChange={checked => setExportOptions({...exportOptions, includeSimulationSettings: checked})}
|
||||
/>
|
||||
<Label htmlFor="export-simulation" className="text-sm">
|
||||
{t('exportOptionSimulation')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="export-pharmaco"
|
||||
checked={exportOptions.includePharmacoSettings}
|
||||
onCheckedChange={checked => setExportOptions({...exportOptions, includePharmacoSettings: checked})}
|
||||
/>
|
||||
<Label htmlFor="export-pharmaco" className="text-sm">
|
||||
{t('exportOptionPharmaco')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="export-advanced"
|
||||
checked={exportOptions.includeAdvancedSettings}
|
||||
onCheckedChange={checked => setExportOptions({...exportOptions, includeAdvancedSettings: checked})}
|
||||
/>
|
||||
<Label htmlFor="export-advanced" className="text-sm">
|
||||
{t('exportOptionAdvanced')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
variant="default"
|
||||
className="w-full"
|
||||
>
|
||||
{t('exportButton')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
{/* Import Section */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-sm">{t('importSettings')}</h4>
|
||||
<p className="text-xs text-muted-foreground">{t('importSelectWhat')}</p>
|
||||
|
||||
<div className="space-y-2 ml-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="import-schedules"
|
||||
checked={importOptions.includeSchedules}
|
||||
onCheckedChange={checked => setImportOptions({...importOptions, includeSchedules: checked})}
|
||||
/>
|
||||
<Label htmlFor="import-schedules" className="text-sm">
|
||||
{t('exportOptionSchedules')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="import-diagram"
|
||||
checked={importOptions.includeDiagramSettings}
|
||||
onCheckedChange={checked => setImportOptions({...importOptions, includeDiagramSettings: checked})}
|
||||
/>
|
||||
<Label htmlFor="import-diagram" className="text-sm">
|
||||
{t('exportOptionDiagram')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="import-simulation"
|
||||
checked={importOptions.includeSimulationSettings}
|
||||
onCheckedChange={checked => setImportOptions({...importOptions, includeSimulationSettings: checked})}
|
||||
/>
|
||||
<Label htmlFor="import-simulation" className="text-sm">
|
||||
{t('exportOptionSimulation')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="import-pharmaco"
|
||||
checked={importOptions.includePharmacoSettings}
|
||||
onCheckedChange={checked => setImportOptions({...importOptions, includePharmacoSettings: checked})}
|
||||
/>
|
||||
<Label htmlFor="import-pharmaco" className="text-sm">
|
||||
{t('exportOptionPharmaco')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="import-advanced"
|
||||
checked={importOptions.includeAdvancedSettings}
|
||||
onCheckedChange={checked => setImportOptions({...importOptions, includeAdvancedSettings: checked})}
|
||||
/>
|
||||
<Label htmlFor="import-advanced" className="text-sm">
|
||||
{t('exportOptionAdvanced')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
id="import-file-input"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
{t('importButton')}
|
||||
</Button>
|
||||
{selectedFile && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('importFileSelected')} <span className="font-mono">{selectedFile.name}</span>
|
||||
</p>
|
||||
)}
|
||||
{selectedFile && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
variant="default"
|
||||
className="w-full"
|
||||
>
|
||||
{t('importApplyButton')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Reset Button - Always Visible */}
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user