From b9a248922514019439f1c98f0ac31c1f640100f5 Mon Sep 17 00:00:00 2001 From: Andreas Weyer Date: Wed, 21 Jan 2026 17:12:47 +0000 Subject: [PATCH] Add new data manager modal with clipboard support and basic json editor --- src/App.tsx | 21 + src/components/data-management-modal.tsx | 799 +++++++++++++++++++++++ src/components/settings.tsx | 335 +--------- src/components/ui/textarea.tsx | 21 + src/locales/de.ts | 27 + src/locales/en.ts | 27 + src/utils/exportImport.ts | 2 +- 7 files changed, 911 insertions(+), 321 deletions(-) create mode 100644 src/components/data-management-modal.tsx create mode 100644 src/components/ui/textarea.tsx diff --git a/src/App.tsx b/src/App.tsx index 9491b00..05e2777 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 */} + 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)} + /> +
@@ -229,6 +249,7 @@ const MedPlanAssistant = () => { onUpdateUiSetting={updateUiSetting} onReset={handleReset} onImportDays={(importedDays: any) => updateState('days', importedDays)} + onOpenDataManagement={() => setShowDataManagement(true)} t={t} />
diff --git a/src/components/data-management-modal.tsx b/src/components/data-management-modal.tsx new file mode 100644 index 0000000..89eed93 --- /dev/null +++ b/src/components/data-management-modal.tsx @@ -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 = ({ + isOpen, + onClose, + t, + pkParams, + days, + therapeuticRange, + doseIncrement, + uiSettings, + onUpdatePkParams, + onUpdateTherapeuticRange, + onUpdateUiSetting, + onImportDays, +}) => { + // Export/Import options + const [exportOptions, setExportOptions] = useState({ + includeSchedules: true, + includeDiagramSettings: true, + includeSimulationSettings: true, + includePharmacoSettings: true, + includeAdvancedSettings: true, + }); + + const [importOptions, setImportOptions] = useState({ + includeSchedules: true, + includeDiagramSettings: true, + includeSimulationSettings: true, + includePharmacoSettings: true, + includeAdvancedSettings: true, + }); + + // File upload state + const [selectedFile, setSelectedFile] = useState(null); + const fileInputRef = useRef(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) => { + 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) => { + 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 ( +
+
+ + + + {t('dataManagementTitle')} + +

+ {t('dataManagementSubtitle')} +

+
+ + + {/* Export Section */} +
+
+ +

{t('exportSettings')}

+ + + + + +

{t('exportImportTooltip')}

+
+
+
+ +
+ +
+
+ + setExportOptions({ ...exportOptions, includeSchedules: checked }) + } + /> + +
+
+ + setExportOptions({ ...exportOptions, includeDiagramSettings: checked }) + } + /> + +
+
+ + setExportOptions({ ...exportOptions, includeSimulationSettings: checked }) + } + /> + +
+
+ + setExportOptions({ ...exportOptions, includePharmacoSettings: checked }) + } + /> + +
+
+ + setExportOptions({ ...exportOptions, includeAdvancedSettings: checked }) + } + /> + +
+
+
+ + {/* Export Actions - Mobile-friendly button group */} +
+ + +
+
+ + + + {/* Import Section */} +
+
+ +

{t('importSettings')}

+
+ +
+ +
+
+ + setImportOptions({ ...importOptions, includeSchedules: checked }) + } + /> + +
+
+ + setImportOptions({ ...importOptions, includeDiagramSettings: checked }) + } + /> + +
+
+ + setImportOptions({ ...importOptions, includeSimulationSettings: checked }) + } + /> + +
+
+ + setImportOptions({ ...importOptions, includePharmacoSettings: checked }) + } + /> + +
+
+ + setImportOptions({ ...importOptions, includeAdvancedSettings: checked }) + } + /> + +
+
+
+ + {/* Import Actions - Mobile-friendly button group */} +
+ + +
+ + {/* Hidden file input */} + + + {selectedFile && ( +
+ {t('importFileSelected')} {selectedFile.name} +
+ )} +
+ + + + {/* JSON Editor Section */} +
+ + + {jsonEditorExpanded && ( +
+
+
+ + + + + + +

{t('jsonEditorTooltip')}

+
+
+
+ +