Add new data manager modal with clipboard support and basic json editor

This commit is contained in:
2026-01-21 17:12:47 +00:00
parent 6983ce3853
commit b9a2489225
7 changed files with 911 additions and 321 deletions

View File

@@ -18,6 +18,7 @@ import SimulationChart from './components/simulation-chart';
import Settings from './components/settings';
import LanguageSelector from './components/language-selector';
import DisclaimerModal from './components/disclaimer-modal';
import DataManagementModal from './components/data-management-modal';
import { Button } from './components/ui/button';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from './components/ui/tooltip';
import { IconButtonWithTooltip } from './components/ui/icon-button-with-tooltip';
@@ -35,6 +36,9 @@ const MedPlanAssistant = () => {
// Disclaimer modal state
const [showDisclaimer, setShowDisclaimer] = React.useState(false);
// Data management modal state
const [showDataManagement, setShowDataManagement] = React.useState(false);
React.useEffect(() => {
const hasAccepted = localStorage.getItem('medPlanDisclaimerAccepted_v1');
if (!hasAccepted) {
@@ -115,6 +119,22 @@ const MedPlanAssistant = () => {
t={t}
/>
{/* Data Management Modal */}
<DataManagementModal
isOpen={showDataManagement}
onClose={() => setShowDataManagement(false)}
t={t}
pkParams={pkParams}
days={days}
therapeuticRange={therapeuticRange}
doseIncrement={doseIncrement}
uiSettings={uiSettings}
onUpdatePkParams={(key: any, value: any) => updateNestedState('pkParams', key, value)}
onUpdateTherapeuticRange={(key: any, value: any) => updateNestedState('therapeuticRange', key, value)}
onUpdateUiSetting={(key: any, value: any) => updateUiSetting(key as any, value)}
onImportDays={(importedDays: any) => updateState('days', importedDays)}
/>
<div className="max-w-7xl mx-auto">
<header className="mb-8">
<div className="flex justify-between items-start">
@@ -229,6 +249,7 @@ const MedPlanAssistant = () => {
onUpdateUiSetting={updateUiSetting}
onReset={handleReset}
onImportDays={(importedDays: any) => updateState('days', importedDays)}
onOpenDataManagement={() => setShowDataManagement(true)}
t={t}
/>
</div>

View File

@@ -0,0 +1,799 @@
/**
* Data Management Modal Component
*
* Provides a comprehensive interface for exporting and importing application data.
* Features include:
* - File-based download/upload
* - Clipboard copy/paste functionality
* - JSON editor for manual editing
* - Validation and error handling
* - Category selection for partial exports/imports
*
* @author Andreas Weyer
* @license MIT
*/
import React, { useState, useRef } from 'react';
import { Button } from './ui/button';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Label } from './ui/label';
import { Switch } from './ui/switch';
import { Separator } from './ui/separator';
import { Textarea } from './ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from './ui/popover';
import {
Download,
Upload,
Copy,
ClipboardPaste,
Check,
X,
ChevronDown,
FileJson,
Info,
} from 'lucide-react';
import {
exportSettings,
downloadExport,
parseImportFile,
validateImportData,
importSettings,
} from '../utils/exportImport';
import { APP_VERSION } from '../constants/defaults';
interface ExportImportOptions {
includeSchedules: boolean;
includeDiagramSettings: boolean;
includeSimulationSettings: boolean;
includePharmacoSettings: boolean;
includeAdvancedSettings: boolean;
}
interface DataManagementModalProps {
isOpen: boolean;
onClose: () => void;
t: (key: string) => string;
// App state
pkParams: any;
days: any;
therapeuticRange: any;
doseIncrement: any;
uiSettings: any;
// Callbacks
onUpdatePkParams: (key: string, value: any) => void;
onUpdateTherapeuticRange: (key: string, value: any) => void;
onUpdateUiSetting: (key: string, value: any) => void;
onImportDays?: (days: any) => void;
}
const DataManagementModal: React.FC<DataManagementModalProps> = ({
isOpen,
onClose,
t,
pkParams,
days,
therapeuticRange,
doseIncrement,
uiSettings,
onUpdatePkParams,
onUpdateTherapeuticRange,
onUpdateUiSetting,
onImportDays,
}) => {
// Export/Import options
const [exportOptions, setExportOptions] = useState<ExportImportOptions>({
includeSchedules: true,
includeDiagramSettings: true,
includeSimulationSettings: true,
includePharmacoSettings: true,
includeAdvancedSettings: true,
});
const [importOptions, setImportOptions] = useState<ExportImportOptions>({
includeSchedules: true,
includeDiagramSettings: true,
includeSimulationSettings: true,
includePharmacoSettings: true,
includeAdvancedSettings: true,
});
// File upload state
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// JSON editor state
const [jsonEditorExpanded, setJsonEditorExpanded] = useState(false);
const [jsonEditorContent, setJsonEditorContent] = useState('');
const [jsonValidationMessage, setJsonValidationMessage] = useState<{
type: 'success' | 'error' | null;
message: string;
}>({ type: null, message: '' });
// Clipboard feedback
const [copySuccess, setCopySuccess] = useState(false);
// Reset editor when modal opens/closes
React.useEffect(() => {
if (isOpen) {
// Load current app data into editor when opening
const appState = {
pkParams,
days,
therapeuticRange,
doseIncrement,
uiSettings,
steadyStateConfig: { daysOnMedication: pkParams.advanced.steadyStateDays },
};
const exportData = exportSettings(appState, exportOptions, APP_VERSION);
const jsonString = JSON.stringify(exportData, null, 2);
setJsonEditorContent(jsonString);
setJsonEditorExpanded(true);
validateJsonContent(jsonString);
} else {
// Clear editor when closing
setJsonEditorContent('');
setJsonEditorExpanded(false);
setJsonValidationMessage({ type: null, message: '' });
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
}, [isOpen]);
if (!isOpen) return null;
// Handle export to file
const handleExportToFile = () => {
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);
};
// Handle copy to clipboard
const handleCopyToClipboard = async () => {
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);
const jsonString = JSON.stringify(exportData, null, 2);
try {
// Try modern Clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(jsonString);
setCopySuccess(true);
setJsonEditorContent(jsonString);
setJsonEditorExpanded(true);
validateJsonContent(jsonString);
setTimeout(() => setCopySuccess(false), 2000);
} else {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = jsonString;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
setCopySuccess(true);
setJsonEditorContent(jsonString);
setJsonEditorExpanded(true);
validateJsonContent(jsonString);
setTimeout(() => setCopySuccess(false), 2000);
} catch (err) {
console.error('Fallback copy failed:', err);
alert(t('copyFailed'));
}
document.body.removeChild(textArea);
}
} catch (error) {
console.error('Copy to clipboard failed:', error);
alert(t('copyFailed'));
}
};
// Handle paste from clipboard
const handlePasteFromClipboard = async () => {
try {
let text = '';
// Try modern Clipboard API first
if (navigator.clipboard && navigator.clipboard.readText) {
text = await navigator.clipboard.readText();
} else {
// Fallback: show message and open editor for manual paste
alert(t('pasteNoClipboardApi'));
setJsonEditorExpanded(true);
return;
}
// Validate content size (max 5000 characters)
if (text.length > 5000) {
alert(t('pasteContentTooLarge'));
return;
}
// Update editor and validate
setJsonEditorContent(text);
setJsonEditorExpanded(true);
validateJsonContent(text);
} catch (error) {
console.error('Paste from clipboard failed:', error);
alert(t('pasteFailed'));
// Still show editor for manual paste
setJsonEditorExpanded(true);
}
};
// Handle file selection
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
setSelectedFile(file || null);
if (file) {
// Automatically read and show in JSON editor
file.text().then(content => {
setJsonEditorContent(content);
setJsonEditorExpanded(true);
validateJsonContent(content);
});
}
};
// Validate JSON content
const validateJsonContent = (content: string) => {
if (!content.trim()) {
setJsonValidationMessage({ type: null, message: '' });
return;
}
try {
const parsed = JSON.parse(content);
const importData = parseImportFile(content);
if (!importData) {
setJsonValidationMessage({
type: 'error',
message: t('importParseError'),
});
return;
}
const validation = validateImportData(importData);
if (!validation.isValid) {
setJsonValidationMessage({
type: 'error',
message: validation.errors.join(', '),
});
return;
}
if (validation.warnings.length > 0) {
setJsonValidationMessage({
type: 'success',
message: t('jsonValidationSuccess') + ' ⚠️ ' + validation.warnings.length + ' warnings',
});
return;
}
setJsonValidationMessage({
type: 'success',
message: t('jsonValidationSuccess'),
});
} catch (error) {
setJsonValidationMessage({
type: 'error',
message: t('pasteInvalidJson'),
});
}
};
// Handle JSON editor content change
const handleJsonEditorChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const content = e.target.value;
setJsonEditorContent(content);
validateJsonContent(content);
};
// Handle import from JSON editor or file
const handleImport = async () => {
const hasAnySelected = Object.values(importOptions).some(v => v);
if (!hasAnySelected) {
alert(t('importNoOptionsSelected'));
return;
}
let fileContent = jsonEditorContent;
// If no JSON in editor but file selected, read file
if (!fileContent && selectedFile) {
fileContent = await selectedFile.text();
}
if (!fileContent) {
alert(t('importFileNotSelected'));
return;
}
try {
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);
setJsonEditorContent('');
setJsonValidationMessage({ type: null, message: '' });
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
onClose();
} catch (error) {
console.error('Import error:', error);
alert(t('importError'));
}
};
// Clear JSON editor
const handleClearJson = () => {
setJsonEditorContent('');
setJsonValidationMessage({ type: null, message: '' });
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="max-w-4xl w-full max-h-[90vh] overflow-y-auto bg-background rounded-lg shadow-xl">
<Card className="border-0">
<CardHeader className="bg-primary/10 border-b">
<CardTitle className="text-2xl font-bold">
{t('dataManagementTitle')}
</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
{t('dataManagementSubtitle')}
</p>
</CardHeader>
<CardContent className="space-y-6 pt-6">
{/* Export Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<FileJson className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">{t('exportSettings')}</h3>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs max-w-xs">{t('exportImportTooltip')}</p>
</TooltipContent>
</Tooltip>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">{t('exportSelectWhat')}</Label>
<div className="space-y-2 pl-4">
<div className="flex items-center gap-3">
<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-3">
<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-3">
<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-3">
<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-3">
<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>
</div>
{/* Export Actions - Mobile-friendly button group */}
<div className="flex flex-col sm:flex-row gap-2">
<Button
onClick={handleExportToFile}
className="flex-1 gap-2"
variant="default"
>
<Download className="h-4 w-4" />
{t('exportButton')}
</Button>
<Button
onClick={handleCopyToClipboard}
className="flex-1 gap-2"
variant="secondary"
>
{copySuccess ? (
<>
<Check className="h-4 w-4" />
{t('copiedToClipboard')}
</>
) : (
<>
<Copy className="h-4 w-4" />
{t('copyToClipboard')}
</>
)}
</Button>
</div>
</div>
<Separator />
{/* Import Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<FileJson className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">{t('importSettings')}</h3>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">{t('importSelectWhat')}</Label>
<div className="space-y-2 pl-4">
<div className="flex items-center gap-3">
<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-3">
<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-3">
<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-3">
<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-3">
<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>
{/* Import Actions - Mobile-friendly button group */}
<div className="flex flex-col sm:flex-row gap-2">
<Button
onClick={() => fileInputRef.current?.click()}
className="flex-1 gap-2"
variant="default"
>
<Upload className="h-4 w-4" />
{t('importButton')}
</Button>
<Button
onClick={handlePasteFromClipboard}
className="flex-1 gap-2"
variant="secondary"
>
<ClipboardPaste className="h-4 w-4" />
{t('pasteFromClipboard')}
</Button>
</div>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileSelect}
className="hidden"
/>
{selectedFile && (
<div className="text-sm text-muted-foreground">
{t('importFileSelected')} <span className="font-medium">{selectedFile.name}</span>
</div>
)}
</div>
<Separator />
{/* JSON Editor Section */}
<div className="space-y-4">
<Button
onClick={() => setJsonEditorExpanded(!jsonEditorExpanded)}
variant="outline"
className="w-full justify-between"
>
<span className="flex items-center gap-2">
<FileJson className="h-4 w-4" />
{jsonEditorExpanded ? t('hideJsonEditor') : t('showJsonEditor')}
</span>
<ChevronDown
className={`h-4 w-4 transition-transform ${
jsonEditorExpanded ? 'rotate-180' : ''
}`}
/>
</Button>
{jsonEditorExpanded && (
<div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="json-editor" className="text-sm font-medium">
{t('jsonEditorLabel')}
</Label>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs max-w-xs">{t('jsonEditorTooltip')}</p>
</TooltipContent>
</Tooltip>
</div>
<Textarea
id="json-editor"
value={jsonEditorContent}
onChange={handleJsonEditorChange}
placeholder={t('jsonEditorPlaceholder')}
className="font-mono text-xs min-h-[200px] max-h-[400px]"
spellCheck={false}
/>
{jsonValidationMessage.type && (
<div
className={`flex items-center gap-2 text-sm ${
jsonValidationMessage.type === 'success'
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
>
{jsonValidationMessage.type === 'success' ? (
<Check className="h-4 w-4" />
) : (
<X className="h-4 w-4" />
)}
<span>{jsonValidationMessage.message}</span>
</div>
)}
<div className="flex gap-2">
<Button
onClick={() => validateJsonContent(jsonEditorContent)}
variant="outline"
size="sm"
className="flex-1"
>
{t('validateJson')}
</Button>
<Button
onClick={handleClearJson}
variant="outline"
size="sm"
className="flex-1"
>
{t('clearJson')}
</Button>
</div>
</div>
</div>
)}
</div>
<Separator />
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-3">
<Button
onClick={handleImport}
className="flex-1"
size="lg"
disabled={!jsonEditorContent && !selectedFile}
>
{t('importApplyButton')}
</Button>
<Button onClick={onClose} variant="outline" className="flex-1" size="lg">
{t('closeDataManagement')}
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
);
};
export default DataManagementModal;

View File

@@ -20,8 +20,7 @@ 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, APP_VERSION } from '../constants/defaults';
import { exportSettings, downloadExport, parseImportFile, validateImportData, importSettings } from '../utils/exportImport';
import { getDefaultState } from '../constants/defaults';
/**
* Helper function to create translation interpolation values for defaults.
@@ -120,6 +119,7 @@ const Settings = ({
onUpdateUiSetting,
onReset,
onImportDays,
onOpenDataManagement,
t
}: any) => {
const { showDayTimeOnXAxis, yAxisMin, yAxisMax, showTemplateDay, simulationDays, displayedDays } = uiSettings;
@@ -131,24 +131,6 @@ 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);
@@ -181,7 +163,6 @@ 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);
}
@@ -221,27 +202,22 @@ const Settings = ({
const updateDiagramExpanded = (value: boolean) => {
setIsDiagramExpanded(value);
saveCardStates({ diagram: value, simulation: isSimulationExpanded, pharmacokinetic: isPharmacokineticExpanded, advanced: isAdvancedExpanded, dataManagement: isDataManagementExpanded });
saveCardStates({ diagram: value, simulation: isSimulationExpanded, pharmacokinetic: isPharmacokineticExpanded, advanced: isAdvancedExpanded });
};
const updateSimulationExpanded = (value: boolean) => {
setIsSimulationExpanded(value);
saveCardStates({ diagram: isDiagramExpanded, simulation: value, pharmacokinetic: isPharmacokineticExpanded, advanced: isAdvancedExpanded, dataManagement: isDataManagementExpanded });
saveCardStates({ diagram: isDiagramExpanded, simulation: value, pharmacokinetic: isPharmacokineticExpanded, advanced: isAdvancedExpanded });
};
const updatePharmacokineticExpanded = (value: boolean) => {
setIsPharmacokineticExpanded(value);
saveCardStates({ diagram: isDiagramExpanded, simulation: isSimulationExpanded, pharmacokinetic: value, advanced: isAdvancedExpanded, dataManagement: isDataManagementExpanded });
saveCardStates({ diagram: isDiagramExpanded, simulation: isSimulationExpanded, pharmacokinetic: value, advanced: isAdvancedExpanded });
};
const updateAdvancedExpanded = (value: boolean) => {
setIsAdvancedExpanded(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 });
saveCardStates({ diagram: isDiagramExpanded, simulation: isSimulationExpanded, pharmacokinetic: isPharmacokineticExpanded, advanced: value });
};
const saveCardStates = (states: any) => {
@@ -269,115 +245,6 @@ 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);
@@ -1254,187 +1121,15 @@ 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>
{/* Data Management Button */}
<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()}
onClick={onOpenDataManagement}
variant="outline"
className="w-full"
>
{t('importButton')}
{t('openDataManagement')}
</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

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "../../lib/utils"
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -218,6 +218,33 @@ export const de = {
importFileNotSelected: "Keine Datei ausgewählt",
exportImportTooltip: "Exportiere deine Einstellungen als Backup oder zum Teilen. Importiere zuvor exportierte Einstellungen. Wähle individuell, welche Teile exportiert/importiert werden sollen.",
// Data Management Modal
dataManagementTitle: "Datenverwaltung",
dataManagementSubtitle: "Exportieren, importieren und verwalten Sie Ihre Anwendungsdaten",
openDataManagement: "Daten verwalten...",
copyToClipboard: "In Zwischenablage kopieren",
pasteFromClipboard: "Aus Zwischenablage einfügen",
exportActions: "Export-Aktionen",
importActions: "Import-Aktionen",
showJsonEditor: "JSON-Editor anzeigen",
hideJsonEditor: "JSON-Editor ausblenden",
jsonEditorLabel: "JSON-Editor",
jsonEditorPlaceholder: "Fügen Sie hier Ihr JSON-Backup ein oder bearbeiten Sie die exportierten Daten...",
jsonEditorTooltip: "Bearbeiten Sie exportierte Daten direkt oder fügen Sie Backup-JSON ein. Manuelle Bearbeitung erfordert JSON-Kenntnisse.",
copiedToClipboard: "In Zwischenablage kopiert!",
copyFailed: "Kopieren in Zwischenablage fehlgeschlagen",
pasteSuccess: "JSON erfolgreich eingefügt",
pasteFailed: "Einfügen aus Zwischenablage fehlgeschlagen",
pasteNoClipboardApi: "Zwischenablage-Zugriff nicht verfügbar. Bitte manuell einfügen.",
pasteInvalidJson: "Ungültiges JSON-Format. Bitte überprüfen Sie Ihre Daten.",
jsonEditWarning: "⚠️ Manuelle Bearbeitung erfordert JSON-Kenntnisse. Ungültige Daten können Fehler verursachen.",
validateJson: "JSON validieren",
clearJson: "Löschen",
jsonValidationSuccess: "JSON ist gültig",
jsonValidationError: "✗ Ungültiges JSON",
closeDataManagement: "Schließen",
pasteContentTooLarge: "Inhalt zu groß (max. 5000 Zeichen)",
// Footer disclaimer
importantNote: "Wichtiger Hinweis",
disclaimer: "Dieses Tool dient ausschließlich zu Illustrations- und Informationszwecken. Es ist kein medizinisches Gerät und ersetzt nicht die Beratung durch einen Arzt oder Apotheker. Alle Berechnungen sind Simulationen, die auf allgemeinen pharmakokinetischen Modellen basieren und von individuellen Faktoren erheblich abweichen können. Bitte konsultiere deinen behandelnden Arzt, bevor du Anpassungen an deiner Medikation vornimmst.",

View File

@@ -216,6 +216,33 @@ export const en = {
importFileNotSelected: "No file selected",
exportImportTooltip: "Export your settings as backup or share with others. Import previously exported settings. Choose which parts to export/import individually.",
// Data Management Modal
dataManagementTitle: "Data Management",
dataManagementSubtitle: "Export, import, and manage your application data",
openDataManagement: "Manage Data...",
copyToClipboard: "Copy to Clipboard",
pasteFromClipboard: "Paste from Clipboard",
exportActions: "Export Actions",
importActions: "Import Actions",
showJsonEditor: "Show JSON Editor",
hideJsonEditor: "Hide JSON Editor",
jsonEditorLabel: "JSON Editor",
jsonEditorPlaceholder: "Paste your JSON backup here or edit the exported data...",
jsonEditorTooltip: "Edit exported data directly or paste backup JSON. Manual editing requires JSON knowledge.",
copiedToClipboard: "Copied to clipboard!",
copyFailed: "Failed to copy to clipboard",
pasteSuccess: "JSON pasted successfully",
pasteFailed: "Failed to paste from clipboard",
pasteNoClipboardApi: "Clipboard access not available. Please paste manually.",
pasteInvalidJson: "Invalid JSON format. Please check your data.",
jsonEditWarning: "⚠️ Manual editing requires JSON knowledge. Invalid data may cause errors.",
validateJson: "Validate JSON",
clearJson: "Clear",
jsonValidationSuccess: "JSON is valid",
jsonValidationError: "✗ Invalid JSON",
closeDataManagement: "Close",
pasteContentTooLarge: "Content too large (max. 5000 characters)",
// Footer disclaimer
importantNote: "Important Notice",
disclaimer: "This tool is for illustration and information purposes only. It is not a medical device and does not replace consultation with a doctor or pharmacist. All calculations are simulations based on general pharmacokinetic models and may differ significantly from individual factors. Please consult your treating physician before making adjustments to your medication.",

View File

@@ -219,7 +219,7 @@ export const validateImportData = (data: any): ImportValidationResult => {
// Validate advanced settings
if (importData.advancedSettings !== undefined) {
const validCategories = ['weightBasedVd', 'foodEffect', 'urinePh', 'fOral', 'steadyStateDays'];
const validCategories = ['standardVd', 'weightBasedVd', 'foodEffect', 'urinePh', 'fOral', 'steadyStateDays'];
const importedCategories = Object.keys(importData.advancedSettings);
const unknownCategories = importedCategories.filter(c => !validCategories.includes(c));
if (unknownCategories.length > 0) {