Update migrated js to ts and shadcn
This commit is contained in:
22
components.json
Normal file
22
components.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "./src/components",
|
||||||
|
"utils": "./src/lib/utils",
|
||||||
|
"ui": "./src/components/ui",
|
||||||
|
"lib": "./src/lib",
|
||||||
|
"hooks": "./src/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
28
package.json
28
package.json
@@ -3,16 +3,34 @@
|
|||||||
"version": "0.1.1",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.8.0",
|
"@testing-library/jest-dom": "^6.8.0",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.554.0",
|
||||||
"npx": "^10.2.2",
|
"npx": "^10.2.2",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-hook-form": "^7.66.1",
|
||||||
|
"react-is": "^19.2.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"recharts": "^3.3.0",
|
"recharts": "^3.3.0",
|
||||||
"web-vitals": "^2.1.4"
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"web-vitals": "^2.1.4",
|
||||||
|
"zod": "^4.1.13"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "cross-env HOST=0.0.0.0 BROWSER=none CHOKIDAR_USEPOLLING=true FAST_REFRESH=false react-scripts start",
|
"start": "cross-env HOST=0.0.0.0 BROWSER=none CHOKIDAR_USEPOLLING=true FAST_REFRESH=false react-scripts start",
|
||||||
@@ -41,11 +59,15 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hint/configuration-web-recommended": "^8.2.24",
|
"@hint/configuration-web-recommended": "^8.2.24",
|
||||||
"autoprefixer": "^10.4.21",
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"autoprefixer": "^10.4.22",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"hint": "^7.1.13",
|
"hint": "^7.1.13",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"puppeteer": "^24.27.0",
|
"puppeteer": "^24.27.0",
|
||||||
"tailwindcss": "^3.4.18"
|
"tailwindcss": "^3.4.18",
|
||||||
|
"typescript": "^4.9.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
test('renders learn react link', () => {
|
test('renders learn react link', () => {
|
||||||
render(<App />);
|
render(<App />);
|
||||||
const linkElement = screen.getByText(/learn react/i);
|
const linkElement = screen.getByText(/learn react/i);
|
||||||
|
// @ts-ignore
|
||||||
expect(linkElement).toBeInTheDocument();
|
expect(linkElement).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import DoseSchedule from './components/DoseSchedule.js';
|
import DoseSchedule from './components/dose-schedule';
|
||||||
import DeviationList from './components/DeviationList.js';
|
import DeviationList from './components/deviation-list';
|
||||||
import SuggestionPanel from './components/SuggestionPanel.js';
|
import SuggestionPanel from './components/suggestion-panel';
|
||||||
import SimulationChart from './components/SimulationChart.js';
|
import SimulationChart from './components/simulation-chart';
|
||||||
import Settings from './components/Settings.js';
|
import Settings from './components/settings';
|
||||||
import LanguageSelector from './components/LanguageSelector.js';
|
import LanguageSelector from './components/language-selector';
|
||||||
|
import { Button } from './components/ui/button';
|
||||||
|
|
||||||
// Custom Hooks
|
// Custom Hooks
|
||||||
import { useAppState } from './hooks/useAppState.js';
|
import { useAppState } from './hooks/useAppState';
|
||||||
import { useSimulation } from './hooks/useSimulation.js';
|
import { useSimulation } from './hooks/useSimulation';
|
||||||
import { useLanguage } from './hooks/useLanguage.js';
|
import { useLanguage } from './hooks/useLanguage';
|
||||||
|
|
||||||
// --- Main Component ---
|
// --- Main Component ---
|
||||||
const MedPlanAssistant = () => {
|
const MedPlanAssistant = () => {
|
||||||
@@ -55,13 +56,13 @@ const MedPlanAssistant = () => {
|
|||||||
} = useSimulation(appState);
|
} = useSimulation(appState);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-100 font-sans p-4 sm:p-6 lg:p-8">
|
<div className="min-h-screen bg-background p-4 sm:p-6 lg:p-8">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<header className="mb-8">
|
<header className="mb-8">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl md:text-4xl font-bold text-gray-800">{t.appTitle}</h1>
|
<h1 className="text-3xl md:text-4xl font-bold tracking-tight">{t.appTitle}</h1>
|
||||||
<p className="text-gray-600 mt-1">{t.appSubtitle}</p>
|
<p className="text-muted-foreground mt-1">{t.appSubtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<LanguageSelector currentLanguage={currentLanguage} onLanguageChange={changeLanguage} t={t} />
|
<LanguageSelector currentLanguage={currentLanguage} onLanguageChange={changeLanguage} t={t} />
|
||||||
</div>
|
</div>
|
||||||
@@ -70,26 +71,26 @@ const MedPlanAssistant = () => {
|
|||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||||
|
|
||||||
{/* Both Columns - Chart */}
|
{/* Both Columns - Chart */}
|
||||||
<div className="xl:col-span-2 bg-white p-5 rounded-lg shadow-sm border min-h-[600px] flex flex-col">
|
<div className="xl:col-span-2 bg-card p-6 rounded-lg border min-h-[600px] flex flex-col">
|
||||||
<div className="flex justify-center space-x-2 mb-4">
|
<div className="flex justify-center gap-2 mb-4">
|
||||||
<button
|
<Button
|
||||||
onClick={() => updateUiSetting('chartView', 'damph')}
|
onClick={() => updateUiSetting('chartView', 'damph')}
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-md ${chartView === 'damph' ? 'bg-sky-600 text-white' : 'bg-gray-200 text-gray-700'}`}
|
variant={chartView === 'damph' ? 'default' : 'secondary'}
|
||||||
>
|
>
|
||||||
{t.dAmphetamine}
|
{t.dAmphetamine}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
onClick={() => updateUiSetting('chartView', 'ldx')}
|
onClick={() => updateUiSetting('chartView', 'ldx')}
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-md ${chartView === 'ldx' ? 'bg-sky-600 text-white' : 'bg-gray-200 text-gray-700'}`}
|
variant={chartView === 'ldx' ? 'default' : 'secondary'}
|
||||||
>
|
>
|
||||||
{t.lisdexamfetamine}
|
{t.lisdexamfetamine}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
onClick={() => updateUiSetting('chartView', 'both')}
|
onClick={() => updateUiSetting('chartView', 'both')}
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-md ${chartView === 'both' ? 'bg-sky-600 text-white' : 'bg-gray-200 text-gray-700'}`}
|
variant={chartView === 'both' ? 'default' : 'secondary'}
|
||||||
>
|
>
|
||||||
{t.both}
|
{t.both}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SimulationChart
|
<SimulationChart
|
||||||
@@ -112,7 +113,7 @@ const MedPlanAssistant = () => {
|
|||||||
<DoseSchedule
|
<DoseSchedule
|
||||||
doses={doses}
|
doses={doses}
|
||||||
doseIncrement={doseIncrement}
|
doseIncrement={doseIncrement}
|
||||||
onUpdateDoses={(newDoses) => updateState('doses', newDoses)}
|
onUpdateDoses={(newDoses: any) => updateState('doses', newDoses)}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -139,8 +140,8 @@ const MedPlanAssistant = () => {
|
|||||||
pkParams={pkParams}
|
pkParams={pkParams}
|
||||||
therapeuticRange={therapeuticRange}
|
therapeuticRange={therapeuticRange}
|
||||||
uiSettings={uiSettings}
|
uiSettings={uiSettings}
|
||||||
onUpdatePkParams={(key, value) => updateNestedState('pkParams', key, value)}
|
onUpdatePkParams={(key: any, value: any) => updateNestedState('pkParams', key, value)}
|
||||||
onUpdateTherapeuticRange={(key, value) => updateNestedState('therapeuticRange', key, value)}
|
onUpdateTherapeuticRange={(key: any, value: any) => updateNestedState('therapeuticRange', key, value)}
|
||||||
onUpdateUiSetting={updateUiSetting}
|
onUpdateUiSetting={updateUiSetting}
|
||||||
onReset={handleReset}
|
onReset={handleReset}
|
||||||
t={t}
|
t={t}
|
||||||
@@ -149,8 +150,8 @@ const MedPlanAssistant = () => {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="mt-8 p-4 bg-gray-100 rounded-lg text-sm text-gray-700 border">
|
<footer className="mt-8 p-4 bg-muted rounded-lg text-sm text-muted-foreground border">
|
||||||
<h3 className="font-semibold mb-2">{t.importantNote}</h3>
|
<h3 className="font-semibold mb-2 text-foreground">{t.importantNote}</h3>
|
||||||
<p>{t.disclaimer}</p>
|
<p>{t.disclaimer}</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import TimeInput from './TimeInput.js';
|
|
||||||
import NumericInput from './NumericInput.js';
|
|
||||||
|
|
||||||
const DeviationList = ({
|
|
||||||
deviations,
|
|
||||||
doseIncrement,
|
|
||||||
simulationDays,
|
|
||||||
onAddDeviation,
|
|
||||||
onRemoveDeviation,
|
|
||||||
onDeviationChange,
|
|
||||||
t
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="bg-amber-50 p-5 rounded-lg shadow-sm border border-amber-200">
|
|
||||||
<h2 className="text-xl font-semibold mb-4 text-gray-700">{t.deviationsFromPlan}</h2>
|
|
||||||
{deviations.map((dev, index) => (
|
|
||||||
<div key={index} className="flex items-center gap-3 mb-2 p-2 bg-white rounded flex-wrap">
|
|
||||||
<select
|
|
||||||
value={dev.dayOffset || 0}
|
|
||||||
onChange={e => onDeviationChange(index, 'dayOffset', parseInt(e.target.value, 10))}
|
|
||||||
className="p-2 border rounded-md text-sm"
|
|
||||||
>
|
|
||||||
{[...Array(parseInt(simulationDays, 10) || 1).keys()].map(day => (
|
|
||||||
<option key={day} value={day}>{t.day} {day + 1}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<TimeInput
|
|
||||||
value={dev.time}
|
|
||||||
onChange={newTime => onDeviationChange(index, 'time', newTime)}
|
|
||||||
errorMessage={t.errorTimeRequired}
|
|
||||||
/>
|
|
||||||
<div className="w-32">
|
|
||||||
<NumericInput
|
|
||||||
value={dev.dose}
|
|
||||||
onChange={newDose => onDeviationChange(index, 'dose', newDose)}
|
|
||||||
increment={doseIncrement}
|
|
||||||
min={0}
|
|
||||||
unit={t.mg}
|
|
||||||
errorMessage={t.fieldRequired}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onRemoveDeviation(index)}
|
|
||||||
className="text-red-500 hover:text-red-700 font-bold text-lg px-1"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
<div className="flex items-center" title={t.additionalTooltip}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id={`add_dose_${index}`}
|
|
||||||
checked={dev.isAdditional}
|
|
||||||
onChange={e => onDeviationChange(index, 'isAdditional', e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500"
|
|
||||||
/>
|
|
||||||
<label htmlFor={`add_dose_${index}`} className="ml-2 text-xs text-gray-600 whitespace-nowrap">
|
|
||||||
{t.additional}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
onClick={onAddDeviation}
|
|
||||||
className="mt-2 w-full bg-amber-500 text-white py-2 rounded-md hover:bg-amber-600 text-sm"
|
|
||||||
>
|
|
||||||
{t.addDeviation}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};export default DeviationList;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import TimeInput from './TimeInput.js';
|
|
||||||
import NumericInput from './NumericInput.js';
|
|
||||||
|
|
||||||
const DoseSchedule = ({ doses, doseIncrement, onUpdateDoses, t }) => {
|
|
||||||
return (
|
|
||||||
<div className="bg-white p-5 rounded-lg shadow-sm border">
|
|
||||||
<h2 className="text-xl font-semibold mb-4 text-gray-700">{t.myPlan}</h2>
|
|
||||||
{doses.map((dose, index) => (
|
|
||||||
<div key={index} className="flex items-center space-x-3 mb-3">
|
|
||||||
<TimeInput
|
|
||||||
value={dose.time}
|
|
||||||
onChange={newTime => onUpdateDoses(doses.map((d, i) => i === index ? {...d, time: newTime} : d))}
|
|
||||||
errorMessage={t.errorTimeRequired}
|
|
||||||
/>
|
|
||||||
<div className="w-40">
|
|
||||||
<NumericInput
|
|
||||||
value={dose.dose}
|
|
||||||
onChange={newDose => onUpdateDoses(doses.map((d, i) => i === index ? {...d, dose: newDose} : d))}
|
|
||||||
increment={doseIncrement}
|
|
||||||
min={0}
|
|
||||||
unit={t.mg}
|
|
||||||
errorMessage={t.fieldRequired}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-gray-600 text-sm flex-1">{t[dose.label] || dose.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DoseSchedule;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const LanguageSelector = ({ currentLanguage, onLanguageChange, t }) => {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<label className="text-sm font-medium text-gray-600">{t.language}:</label>
|
|
||||||
<select
|
|
||||||
value={currentLanguage}
|
|
||||||
onChange={(e) => onLanguageChange(e.target.value)}
|
|
||||||
className="p-1 border rounded text-sm bg-white"
|
|
||||||
>
|
|
||||||
<option value="en">{t.english}</option>
|
|
||||||
<option value="de">{t.german}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LanguageSelector;
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const NumericInput = ({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
increment,
|
|
||||||
min = -Infinity,
|
|
||||||
max = Infinity,
|
|
||||||
placeholder,
|
|
||||||
unit,
|
|
||||||
align = 'right', // 'left', 'center', 'right'
|
|
||||||
allowEmpty = false, // Allow empty value (e.g., for "Auto" mode)
|
|
||||||
clearButton = false, // Show clear button (with allowEmpty=true)
|
|
||||||
clearButtonText, // Custom text or element for clear button
|
|
||||||
clearButtonTitle, // Custom title for clear button
|
|
||||||
errorMessage = 'This field is required' // Error message for mandatory empty fields
|
|
||||||
}) => {
|
|
||||||
const [hasError, setHasError] = React.useState(false);
|
|
||||||
const [showErrorTooltip, setShowErrorTooltip] = React.useState(false);
|
|
||||||
const containerRef = React.useRef(null);
|
|
||||||
const showClearButton = clearButton && allowEmpty;
|
|
||||||
|
|
||||||
// Determine decimal places based on increment
|
|
||||||
const getDecimalPlaces = () => {
|
|
||||||
const inc = String(increment || '1');
|
|
||||||
const decimalIndex = inc.indexOf('.');
|
|
||||||
if (decimalIndex === -1) return 0;
|
|
||||||
return inc.length - decimalIndex - 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const decimalPlaces = getDecimalPlaces();
|
|
||||||
|
|
||||||
// Format value for display
|
|
||||||
const formatValue = (val) => {
|
|
||||||
const num = Number(val);
|
|
||||||
if (isNaN(num)) return val;
|
|
||||||
return num.toFixed(decimalPlaces);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateValue = (direction) => {
|
|
||||||
const numIncrement = parseFloat(increment) || 1;
|
|
||||||
let numValue = Number(value);
|
|
||||||
|
|
||||||
// If value is empty/Auto, treat as 0
|
|
||||||
if (isNaN(numValue)) {
|
|
||||||
numValue = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
numValue += direction * numIncrement;
|
|
||||||
numValue = Math.max(min, numValue);
|
|
||||||
numValue = Math.min(max, numValue);
|
|
||||||
const finalValue = formatValue(numValue);
|
|
||||||
setHasError(false); // Clear error when using buttons
|
|
||||||
onChange(finalValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
|
||||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
updateValue(e.key === 'ArrowUp' ? 1 : -1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
// Only allow valid numbers or empty string
|
|
||||||
if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
|
|
||||||
// Prevent object values
|
|
||||||
if (typeof val === 'object') return;
|
|
||||||
setHasError(false); // Clear error on input
|
|
||||||
onChange(val);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlur = (e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
|
|
||||||
// Check if field is empty and not allowed to be empty
|
|
||||||
if (val === '' && !allowEmpty) {
|
|
||||||
setHasError(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (val !== '' && !isNaN(Number(val))) {
|
|
||||||
// Format the value when user finishes editing
|
|
||||||
setHasError(false);
|
|
||||||
onChange(formatValue(val));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get alignment class
|
|
||||||
const getAlignmentClass = () => {
|
|
||||||
switch (align) {
|
|
||||||
case 'left': return 'text-left';
|
|
||||||
case 'center': return 'text-center';
|
|
||||||
case 'right': return 'text-right';
|
|
||||||
default: return 'text-right';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClear = () => {
|
|
||||||
setHasError(false);
|
|
||||||
setShowErrorTooltip(false);
|
|
||||||
onChange('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFocus = () => {
|
|
||||||
if (hasError) setShowErrorTooltip(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInputBlurWrapper = (e) => {
|
|
||||||
handleBlur(e);
|
|
||||||
// Small delay to allow error state to be set before hiding tooltip
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowErrorTooltip(false);
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
|
||||||
if (hasError) setShowErrorTooltip(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
setShowErrorTooltip(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
// Close tooltip when clicking outside
|
|
||||||
const handleClickOutside = (event) => {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
|
||||||
setShowErrorTooltip(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (showErrorTooltip) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, [showErrorTooltip]);
|
|
||||||
|
|
||||||
// Calculate tooltip position to prevent overflow
|
|
||||||
const [tooltipPosition, setTooltipPosition] = React.useState({ top: true, left: true });
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (showErrorTooltip && containerRef.current) {
|
|
||||||
const rect = containerRef.current.getBoundingClientRect();
|
|
||||||
const tooltipWidth = 300; // Approximate width
|
|
||||||
const tooltipHeight = 60; // Approximate height
|
|
||||||
|
|
||||||
const spaceRight = window.innerWidth - rect.right;
|
|
||||||
const spaceBottom = window.innerHeight - rect.bottom;
|
|
||||||
|
|
||||||
setTooltipPosition({
|
|
||||||
top: spaceBottom < tooltipHeight + 10,
|
|
||||||
left: spaceRight < tooltipWidth
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [showErrorTooltip]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="relative inline-block"
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
|
||||||
<div className="relative inline-flex items-center">
|
|
||||||
<div className={`relative inline-flex items-center ${hasError ? 'ring-2 ring-red-500 rounded-md pointer-events-auto' : ''}`}>
|
|
||||||
<button
|
|
||||||
onClick={() => updateValue(-1)}
|
|
||||||
className="px-2 py-1 border rounded-l-md bg-gray-100 bg-gray-100 hover:bg-gray-200 text-gray-600 hover:text-gray-800 text-lg font-bold"
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
-
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={handleChange}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onBlur={handleInputBlurWrapper}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
placeholder={placeholder}
|
|
||||||
style={{ width: '4em', minWidth: '4em', maxWidth: '8em' }}
|
|
||||||
className={`p-2 border-t border-b text-sm ${getAlignmentClass()} ${hasError ? 'bg-transparent' : ''}`}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => updateValue(1)}
|
|
||||||
className={`px-2 py-1 border-t border-b ${showClearButton ? 'border-l' : 'border-l border-r rounded-r-md'} bg-gray-100 bg-gray-100 hover:bg-gray-200 text-gray-600 hover:text-gray-800 text-lg font-bold`}
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
{showClearButton && (
|
|
||||||
<button
|
|
||||||
onClick={handleClear}
|
|
||||||
className="px-2 py-1 border rounded-r-md bg-gray-100 bg-gray-100 hover:bg-gray-200 text-gray-600 hover:text-gray-800 text-gray-600 hover:text-gray-800"
|
|
||||||
tabIndex={-1}
|
|
||||||
title={clearButtonTitle || "Clear"}
|
|
||||||
>
|
|
||||||
{clearButtonText || (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>)}
|
|
||||||
</div>
|
|
||||||
{unit && <span className="ml-2 text-gray-500 text-sm whitespace-nowrap">{unit}</span>}
|
|
||||||
</div>
|
|
||||||
{hasError && showErrorTooltip && (
|
|
||||||
<div
|
|
||||||
className={`absolute z-50 bg-white ring-2 ring-red-500 rounded-md shadow-lg p-2 flex items-start gap-2 ${
|
|
||||||
tooltipPosition.top ? 'bottom-full mb-1' : 'top-full mt-1'
|
|
||||||
} ${
|
|
||||||
tooltipPosition.left ? 'right-0' : 'left-0'
|
|
||||||
}`}
|
|
||||||
style={{ minWidth: '200px', maxWidth: '400px' }}
|
|
||||||
>
|
|
||||||
<div className="flex-shrink-0 mt-0.5">
|
|
||||||
<svg className="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle cx="12" cy="12" r="10" fill="#fa5252"/>
|
|
||||||
<path d="M17 15.59l-1.41 1.41-3.59-3.59-3.59 3.59-1.41-1.41 3.59-3.59-3.59-3.59 1.41-1.41 3.59 3.59 3.59-3.59 1.41 1.41-3.59 3.59z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-red-700 whitespace-nowrap">{errorMessage}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NumericInput;
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import NumericInput from './NumericInput.js';
|
|
||||||
|
|
||||||
const Settings = ({
|
|
||||||
pkParams,
|
|
||||||
therapeuticRange,
|
|
||||||
uiSettings,
|
|
||||||
onUpdatePkParams,
|
|
||||||
onUpdateTherapeuticRange,
|
|
||||||
onUpdateUiSetting,
|
|
||||||
onReset,
|
|
||||||
t
|
|
||||||
}) => {
|
|
||||||
const { showDayTimeOnXAxis, yAxisMin, yAxisMax, simulationDays, displayedDays } = uiSettings;
|
|
||||||
// https://www.svgrepo.com/svg/509013/actual-size
|
|
||||||
const clearButtonSVG = (<svg fill="#000000" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid" width="24px" height="24px" viewBox="0 0 31.812 31.906"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M23.263,9.969 L27.823,9.969 C28.372,9.969 28.817,10.415 28.817,10.966 C28.817,11.516 28.456,11.906 27.906,11.906 L21.154,11.906 C21.058,11.935 20.962,11.963 20.863,11.963 C20.608,11.963 20.354,11.865 20.160,11.671 C19.916,11.426 19.843,11.088 19.906,10.772 L19.906,3.906 C19.906,3.355 20.313,2.989 20.863,2.989 C21.412,2.989 21.857,3.435 21.857,3.986 L21.857,8.559 L30.103,0.289 C30.491,-0.100 31.120,-0.100 31.509,0.289 C31.897,0.679 31.897,1.310 31.509,1.699 L23.263,9.969 ZM11.914,27.917 C11.914,28.468 11.469,28.914 10.920,28.914 C10.370,28.914 9.926,28.468 9.926,27.917 L9.926,23.344 L1.680,31.613 C1.486,31.808 1.231,31.906 0.977,31.906 C0.723,31.906 0.468,31.808 0.274,31.613 C-0.114,31.224 -0.114,30.593 0.274,30.203 L8.520,21.934 L3.960,21.934 C3.410,21.934 2.966,21.488 2.966,20.937 C2.966,20.386 3.410,19.940 3.960,19.940 L10.920,19.940 C10.920,19.940 10.921,19.940 10.921,19.940 C11.050,19.940 11.179,19.967 11.300,20.017 C11.543,20.118 11.737,20.312 11.838,20.556 C11.888,20.678 11.914,20.807 11.914,20.937 L11.914,27.917 ZM10.920,11.963 C10.821,11.963 10.724,11.935 10.629,11.906 L3.906,11.906 C3.356,11.906 2.966,11.516 2.966,10.966 C2.966,10.415 3.410,9.969 3.960,9.969 L8.520,9.969 L0.274,1.699 C-0.114,1.310 -0.114,0.679 0.274,0.289 C0.662,-0.100 1.292,-0.100 1.680,0.289 L9.926,8.559 L9.926,3.986 C9.926,3.435 10.370,2.989 10.920,2.989 C11.469,2.989 11.914,3.435 11.914,3.986 L11.914,10.965 C11.914,11.221 11.817,11.476 11.623,11.671 C11.429,11.865 11.174,11.963 10.920,11.963 ZM20.174,20.222 C20.345,20.047 20.585,19.940 20.863,19.940 L27.823,19.940 C28.372,19.940 28.817,20.386 28.817,20.937 C28.817,21.488 28.372,21.934 27.823,21.934 L23.263,21.934 L31.509,30.203 C31.897,30.593 31.897,31.224 31.509,31.613 C31.314,31.808 31.060,31.906 30.806,31.906 C30.551,31.906 30.297,31.808 30.103,31.613 L21.857,23.344 L21.857,27.917 C21.857,28.468 21.412,28.914 20.863,28.914 C20.313,28.914 19.906,28.457 19.906,27.906 L19.906,21.130 C19.843,20.815 19.916,20.477 20.160,20.232 C20.164,20.228 20.170,20.227 20.174,20.222 Z"></path></g></svg>);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white p-5 rounded-lg shadow-sm border">
|
|
||||||
<h2 className="text-xl font-semibold mb-4 text-gray-700">{t.advancedSettings}</h2>
|
|
||||||
<div className="space-y-4 text-sm">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="showDayTimeOnXAxis"
|
|
||||||
checked={showDayTimeOnXAxis}
|
|
||||||
onChange={e => onUpdateUiSetting('showDayTimeOnXAxis', e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500"
|
|
||||||
/>
|
|
||||||
<label htmlFor="showDayTimeOnXAxis" className="ml-3 block font-medium text-gray-600">
|
|
||||||
{t.show24hTimeAxis}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-600 pt-2">{t.simulationDuration}</label>
|
|
||||||
<NumericInput
|
|
||||||
value={simulationDays}
|
|
||||||
onChange={val => onUpdateUiSetting('simulationDays', val)}
|
|
||||||
increment={'1'}
|
|
||||||
min={2}
|
|
||||||
max={7}
|
|
||||||
placeholder="#"
|
|
||||||
unit={t.days}
|
|
||||||
errorMessage={t.errorNumberRequired}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-600">{t.displayedDays}</label>
|
|
||||||
<NumericInput
|
|
||||||
value={displayedDays}
|
|
||||||
onChange={val => onUpdateUiSetting('displayedDays', val)}
|
|
||||||
increment={'1'}
|
|
||||||
min={1}
|
|
||||||
max={parseInt(simulationDays, 10) || 1}
|
|
||||||
placeholder="#"
|
|
||||||
unit={t.days}
|
|
||||||
errorMessage={t.errorNumberRequired}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-600 pt-2">{t.yAxisRange}</label>
|
|
||||||
<div className="flex items-center mt-1">
|
|
||||||
<div className="w-30">
|
|
||||||
<NumericInput
|
|
||||||
value={yAxisMin}
|
|
||||||
onChange={val => onUpdateUiSetting('yAxisMin', val)}
|
|
||||||
increment={'5'}
|
|
||||||
min={0}
|
|
||||||
placeholder={t.auto}
|
|
||||||
allowEmpty={true}
|
|
||||||
clearButton={true}
|
|
||||||
clearButtonText={clearButtonSVG || t.yAxisRangeAutoButton}
|
|
||||||
clearButtonTitle={t.yAxisRangeAutoButtonTitle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-gray-500 px-2">-</span>
|
|
||||||
<div className="w-32">
|
|
||||||
<NumericInput
|
|
||||||
value={yAxisMax}
|
|
||||||
onChange={val => onUpdateUiSetting('yAxisMax', val)}
|
|
||||||
increment={'5'}
|
|
||||||
min={0}
|
|
||||||
placeholder={t.auto}
|
|
||||||
unit="ng/ml"
|
|
||||||
allowEmpty={true}
|
|
||||||
clearButton={true}
|
|
||||||
clearButtonText={clearButtonSVG || t.yAxisRangeAutoButton}
|
|
||||||
clearButtonTitle={t.yAxisRangeAutoButtonTitle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-600">{t.therapeuticRange}</label>
|
|
||||||
<div className="flex items-center mt-1">
|
|
||||||
<div className="w-30">
|
|
||||||
<NumericInput
|
|
||||||
value={therapeuticRange.min}
|
|
||||||
onChange={val => onUpdateTherapeuticRange('min', val)}
|
|
||||||
increment={'0.5'}
|
|
||||||
min={0}
|
|
||||||
placeholder={t.min}
|
|
||||||
errorMessage={t.errorNumberRequired}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-gray-500 px-2">-</span>
|
|
||||||
<div className="w-32">
|
|
||||||
<NumericInput
|
|
||||||
value={therapeuticRange.max}
|
|
||||||
onChange={val => onUpdateTherapeuticRange('max', val)}
|
|
||||||
increment={'0.5'}
|
|
||||||
min={0}
|
|
||||||
placeholder={t.max}
|
|
||||||
unit="ng/ml"
|
|
||||||
errorMessage={t.errorNumberRequired}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold mt-4 pt-4 border-t">{t.dAmphetamineParameters}</h3>
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-600">{t.halfLife}</label>
|
|
||||||
<NumericInput
|
|
||||||
value={pkParams.damph.halfLife}
|
|
||||||
onChange={val => onUpdatePkParams('damph', { halfLife: val })}
|
|
||||||
increment={'0.5'}
|
|
||||||
min={0.1}
|
|
||||||
placeholder="#.#"
|
|
||||||
unit="h"
|
|
||||||
errorMessage={t.errorNumberRequired}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold mt-4 pt-4 border-t">{t.lisdexamfetamineParameters}</h3>
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-600">{t.conversionHalfLife}</label>
|
|
||||||
<NumericInput
|
|
||||||
value={pkParams.ldx.halfLife}
|
|
||||||
onChange={val => onUpdatePkParams('ldx', { halfLife: val })}
|
|
||||||
increment={'0.1'}
|
|
||||||
min={0.1}
|
|
||||||
placeholder="#.#"
|
|
||||||
unit="h"
|
|
||||||
errorMessage={t.errorNumberRequired}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-600">{t.absorptionRate}</label>
|
|
||||||
<NumericInput
|
|
||||||
value={pkParams.ldx.absorptionRate}
|
|
||||||
onChange={val => onUpdatePkParams('ldx', { absorptionRate: val })}
|
|
||||||
increment={'0.1'}
|
|
||||||
min={0.1}
|
|
||||||
placeholder="#.#"
|
|
||||||
unit={t.faster}
|
|
||||||
errorMessage={t.errorNumberRequired}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-4">
|
|
||||||
<button
|
|
||||||
onClick={onReset}
|
|
||||||
className="w-full bg-red-600 text-white py-2 rounded-md hover:bg-red-700 text-sm"
|
|
||||||
>
|
|
||||||
{t.resetAllSettings}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Settings;
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const SuggestionPanel = ({ suggestion, onApplySuggestion, t }) => {
|
|
||||||
if (!suggestion) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-sky-100 border-l-4 border-sky-500 p-4 rounded-r-lg shadow-md">
|
|
||||||
<h3 className="font-bold text-lg mb-2">{t.whatIf}</h3>
|
|
||||||
{suggestion.dose ? (
|
|
||||||
<>
|
|
||||||
<p className="text-sm text-sky-800 mb-3">
|
|
||||||
{t.suggestion}: <span className="font-bold">{suggestion.dose}{t.mg}</span> ({t.instead} {suggestion.originalDose}{t.mg}) {t.at} <span className="font-bold">{suggestion.time}</span>.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={onApplySuggestion}
|
|
||||||
className="w-full bg-sky-600 text-white py-2 rounded-md hover:bg-sky-700 text-sm"
|
|
||||||
>
|
|
||||||
{t.applySuggestion}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-sky-800">{suggestion.text}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};export default SuggestionPanel;
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const TimeInput = ({ value, onChange, errorMessage = 'Time is required' }) => {
|
|
||||||
const [displayValue, setDisplayValue] = React.useState(value);
|
|
||||||
const [isPickerOpen, setIsPickerOpen] = React.useState(false);
|
|
||||||
const [hasError, setHasError] = React.useState(false);
|
|
||||||
const [showErrorTooltip, setShowErrorTooltip] = React.useState(false);
|
|
||||||
const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number);
|
|
||||||
const pickerRef = React.useRef(null);
|
|
||||||
const containerRef = React.useRef(null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setDisplayValue(value);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
// Close the picker when clicking outside
|
|
||||||
const handleClickOutside = (event) => {
|
|
||||||
if (pickerRef.current && !pickerRef.current.contains(event.target)) {
|
|
||||||
setIsPickerOpen(false);
|
|
||||||
}
|
|
||||||
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
|
||||||
setShowErrorTooltip(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isPickerOpen || showErrorTooltip) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, [isPickerOpen, showErrorTooltip]);
|
|
||||||
|
|
||||||
const handleBlur = (e) => {
|
|
||||||
const inputValue = e.target.value.trim();
|
|
||||||
|
|
||||||
// Check if field is empty
|
|
||||||
if (inputValue === '') {
|
|
||||||
setHasError(true);
|
|
||||||
// Small delay before hiding tooltip
|
|
||||||
setTimeout(() => setShowErrorTooltip(false), 100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let input = inputValue.replace(/[^0-9]/g, '');
|
|
||||||
let hours = '00', minutes = '00';
|
|
||||||
if (input.length <= 2) {
|
|
||||||
hours = input.padStart(2, '0');
|
|
||||||
}
|
|
||||||
else if (input.length === 3) {
|
|
||||||
hours = input.substring(0, 1).padStart(2, '0');
|
|
||||||
minutes = input.substring(1, 3);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
hours = input.substring(0, 2);
|
|
||||||
minutes = input.substring(2, 4);
|
|
||||||
}
|
|
||||||
hours = Math.min(23, parseInt(hours, 10) || 0).toString().padStart(2, '0');
|
|
||||||
minutes = Math.min(59, parseInt(minutes, 10) || 0).toString().padStart(2, '0');
|
|
||||||
const formattedTime = `${hours}:${minutes}`;
|
|
||||||
setHasError(false);
|
|
||||||
setShowErrorTooltip(false);
|
|
||||||
setDisplayValue(formattedTime);
|
|
||||||
onChange(formattedTime);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (e) => {
|
|
||||||
setHasError(false); // Clear error on input
|
|
||||||
setShowErrorTooltip(false);
|
|
||||||
setDisplayValue(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePickerChange = (part, val) => {
|
|
||||||
let newHours = pickerHours, newMinutes = pickerMinutes;
|
|
||||||
if (part === 'h') {
|
|
||||||
newHours = val;
|
|
||||||
} else {
|
|
||||||
newMinutes = val;
|
|
||||||
}
|
|
||||||
const formattedTime = `${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart(2, '0')}`;
|
|
||||||
setHasError(false); // Clear error when using picker
|
|
||||||
setShowErrorTooltip(false);
|
|
||||||
onChange(formattedTime);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFocus = () => {
|
|
||||||
if (hasError) setShowErrorTooltip(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
|
||||||
if (hasError) setShowErrorTooltip(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
setShowErrorTooltip(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [tooltipPosition, setTooltipPosition] = React.useState({ top: true, left: true });
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (showErrorTooltip && containerRef.current) {
|
|
||||||
const rect = containerRef.current.getBoundingClientRect();
|
|
||||||
const tooltipWidth = 300; // Approximate width
|
|
||||||
const tooltipHeight = 60; // Approximate height
|
|
||||||
|
|
||||||
const spaceRight = window.innerWidth - rect.right;
|
|
||||||
const spaceBottom = window.innerHeight - rect.bottom;
|
|
||||||
|
|
||||||
setTooltipPosition({
|
|
||||||
top: spaceBottom < tooltipHeight + 10,
|
|
||||||
left: spaceRight < tooltipWidth
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [showErrorTooltip]);
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="relative"
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className={`${hasError ? 'ring-2 ring-red-500 rounded-md pointer-events-auto' : ''}`}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={displayValue}
|
|
||||||
onChange={handleChange}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
placeholder="HH:MM"
|
|
||||||
className={`p-2 border rounded-md w-24 text-sm text-center ${hasError ? 'border-transparent' : ''}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsPickerOpen(!isPickerOpen)}
|
|
||||||
className="ml-2 p-2 text-gray-500 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.414-1.415L11 9.586V6z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{isPickerOpen && (
|
|
||||||
<div ref={pickerRef} className="absolute top-full mt-2 z-[60] bg-white p-4 rounded-lg shadow-xl border w-64">
|
|
||||||
<div className="text-center text-lg font-bold mb-3">{value}</div>
|
|
||||||
<div>
|
|
||||||
<div className="mb-2"><span className="font-semibold">Hour:</span></div>
|
|
||||||
<div className="grid grid-cols-6 gap-1">
|
|
||||||
{[...Array(24).keys()].map(h => (
|
|
||||||
<button
|
|
||||||
key={h}
|
|
||||||
onClick={() => handlePickerChange('h', h)}
|
|
||||||
className={`p-1 rounded text-xs ${h === pickerHours ? 'bg-sky-500 text-white' : 'bg-gray-200 hover:bg-sky-200'}`}
|
|
||||||
>
|
|
||||||
{String(h).padStart(2,'0')}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<div className="mb-2"><span className="font-semibold">Minute:</span></div>
|
|
||||||
<div className="grid grid-cols-4 gap-1">
|
|
||||||
{[0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55].map(m => (
|
|
||||||
<button
|
|
||||||
key={m}
|
|
||||||
onClick={() => handlePickerChange('m', m)}
|
|
||||||
className={`p-1 rounded text-xs ${m === pickerMinutes ? 'bg-sky-500 text-white' : 'bg-gray-200 hover:bg-sky-200'}`}
|
|
||||||
>
|
|
||||||
{String(m).padStart(2,'0')}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsPickerOpen(false)}
|
|
||||||
className="mt-4 w-full bg-gray-600 text-white py-1 rounded-md text-sm"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasError && showErrorTooltip && (
|
|
||||||
<div
|
|
||||||
className={`absolute z-50 bg-white ring-2 ring-red-500 rounded-md shadow-lg p-2 flex items-start gap-2 ${
|
|
||||||
tooltipPosition.top ? 'bottom-full mb-1' : 'top-full mt-1'
|
|
||||||
} ${
|
|
||||||
tooltipPosition.left ? 'right-0' : 'left-0'
|
|
||||||
}`}
|
|
||||||
style={{ minWidth: '200px', maxWidth: '400px' }}
|
|
||||||
>
|
|
||||||
<div className="flex-shrink-0 mt-0.5">
|
|
||||||
<svg className="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle cx="12" cy="12" r="10" fill="#fa5252"/>
|
|
||||||
<path d="M17 15.59l-1.41 1.41-3.59-3.59-3.59 3.59-1.41-1.41 3.59-3.59-3.59-3.59 1.41-1.41 3.59 3.59 3.59-3.59 1.41 1.41-3.59 3.59z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-red-700 whitespace-nowrap">{errorMessage}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TimeInput;
|
|
||||||
106
src/components/deviation-list.tsx
Normal file
106
src/components/deviation-list.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FormTimeInput } from './ui/form-time-input';
|
||||||
|
import { FormNumericInput } from './ui/form-numeric-input';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||||
|
import { Switch } from './ui/switch';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||||
|
import { Trash2, X } from 'lucide-react';
|
||||||
|
|
||||||
|
const DeviationList = ({
|
||||||
|
deviations,
|
||||||
|
doseIncrement,
|
||||||
|
simulationDays,
|
||||||
|
onAddDeviation,
|
||||||
|
onRemoveDeviation,
|
||||||
|
onDeviationChange,
|
||||||
|
t
|
||||||
|
}: any) => {
|
||||||
|
return (
|
||||||
|
<Card className="bg-amber-50/50 border-amber-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t.deviationsFromPlan}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{deviations.map((dev: any, index: number) => (
|
||||||
|
<div key={index} className="flex items-center gap-3 p-3 bg-card rounded-lg border flex-wrap">
|
||||||
|
<Select
|
||||||
|
value={String(dev.dayOffset || 0)}
|
||||||
|
onValueChange={val => onDeviationChange(index, 'dayOffset', parseInt(val, 10))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-28">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[...Array(parseInt(simulationDays, 10) || 1).keys()].map(day => (
|
||||||
|
<SelectItem key={day} value={String(day)}>
|
||||||
|
{t.day} {day + 1}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<FormTimeInput
|
||||||
|
value={dev.time}
|
||||||
|
onChange={newTime => onDeviationChange(index, 'time', newTime)}
|
||||||
|
required={true}
|
||||||
|
errorMessage={t.timeRequired || 'Time is required'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormNumericInput
|
||||||
|
value={dev.dose}
|
||||||
|
onChange={newDose => onDeviationChange(index, 'dose', newDose)}
|
||||||
|
increment={doseIncrement}
|
||||||
|
min={0}
|
||||||
|
unit={t.mg}
|
||||||
|
required={true}
|
||||||
|
errorMessage={t.doseRequired || 'Dose is required'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onRemoveDeviation(index)}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id={`add_dose_${index}`}
|
||||||
|
checked={dev.isAdditional}
|
||||||
|
onCheckedChange={checked => onDeviationChange(index, 'isAdditional', checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`add_dose_${index}`} className="text-xs whitespace-nowrap">
|
||||||
|
{t.additional}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t.additionalTooltip}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddDeviation}
|
||||||
|
className="w-full bg-amber-500 hover:bg-amber-600 text-white"
|
||||||
|
>
|
||||||
|
{t.addDeviation}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeviationList;
|
||||||
38
src/components/dose-schedule.tsx
Normal file
38
src/components/dose-schedule.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FormTimeInput } from './ui/form-time-input';
|
||||||
|
import { FormNumericInput } from './ui/form-numeric-input';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||||
|
|
||||||
|
const DoseSchedule = ({ doses, doseIncrement, onUpdateDoses, t }: any) => {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t.myPlan}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{doses.map((dose: any, index: number) => (
|
||||||
|
<div key={index} className="flex items-center gap-3">
|
||||||
|
<FormTimeInput
|
||||||
|
value={dose.time}
|
||||||
|
onChange={newTime => onUpdateDoses(doses.map((d: any, i: number) => i === index ? {...d, time: newTime} : d))}
|
||||||
|
required={true}
|
||||||
|
errorMessage={t.timeRequired || 'Time is required'}
|
||||||
|
/>
|
||||||
|
<FormNumericInput
|
||||||
|
value={dose.dose}
|
||||||
|
onChange={newDose => onUpdateDoses(doses.map((d: any, i: number) => i === index ? {...d, dose: newDose} : d))}
|
||||||
|
increment={doseIncrement}
|
||||||
|
min={0}
|
||||||
|
unit={t.mg}
|
||||||
|
required={true}
|
||||||
|
errorMessage={t.doseRequired || 'Dose is required'}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground flex-1">{t[dose.label] || dose.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DoseSchedule;
|
||||||
22
src/components/language-selector.tsx
Normal file
22
src/components/language-selector.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
|
||||||
|
const LanguageSelector = ({ currentLanguage, onLanguageChange, t }: any) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-sm font-medium">{t.language}:</Label>
|
||||||
|
<Select value={currentLanguage} onValueChange={onLanguageChange}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="en">{t.english}</SelectItem>
|
||||||
|
<SelectItem value="de">{t.german}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LanguageSelector;
|
||||||
178
src/components/settings.tsx
Normal file
178
src/components/settings.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
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 { Label } from './ui/label';
|
||||||
|
import { Separator } from './ui/separator';
|
||||||
|
|
||||||
|
const Settings = ({
|
||||||
|
pkParams,
|
||||||
|
therapeuticRange,
|
||||||
|
uiSettings,
|
||||||
|
onUpdatePkParams,
|
||||||
|
onUpdateTherapeuticRange,
|
||||||
|
onUpdateUiSetting,
|
||||||
|
onReset,
|
||||||
|
t
|
||||||
|
}: any) => {
|
||||||
|
const { showDayTimeOnXAxis, yAxisMin, yAxisMax, simulationDays, displayedDays } = uiSettings;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t.advancedSettings}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="showDayTimeOnXAxis" className="font-medium">
|
||||||
|
{t.show24hTimeAxis}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="showDayTimeOnXAxis"
|
||||||
|
checked={showDayTimeOnXAxis}
|
||||||
|
onCheckedChange={checked => onUpdateUiSetting('showDayTimeOnXAxis', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium">{t.simulationDuration}</Label>
|
||||||
|
<FormNumericInput
|
||||||
|
value={simulationDays}
|
||||||
|
onChange={val => onUpdateUiSetting('simulationDays', val)}
|
||||||
|
increment={1}
|
||||||
|
min={2}
|
||||||
|
max={7}
|
||||||
|
unit={t.days}
|
||||||
|
required={true}
|
||||||
|
errorMessage={t.simulationDaysRequired || 'Simulation days is required'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium">{t.displayedDays}</Label>
|
||||||
|
<FormNumericInput
|
||||||
|
value={displayedDays}
|
||||||
|
onChange={val => onUpdateUiSetting('displayedDays', val)}
|
||||||
|
increment={1}
|
||||||
|
min={1}
|
||||||
|
max={parseInt(simulationDays, 10) || 1}
|
||||||
|
unit={t.days}
|
||||||
|
required={true}
|
||||||
|
errorMessage={t.displayedDaysRequired || 'Displayed days is required'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium">{t.yAxisRange}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FormNumericInput
|
||||||
|
value={yAxisMin}
|
||||||
|
onChange={val => onUpdateUiSetting('yAxisMin', val)}
|
||||||
|
increment={5}
|
||||||
|
min={0}
|
||||||
|
placeholder={t.auto}
|
||||||
|
allowEmpty={true}
|
||||||
|
clearButton={true}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
<FormNumericInput
|
||||||
|
value={yAxisMax}
|
||||||
|
onChange={val => onUpdateUiSetting('yAxisMax', val)}
|
||||||
|
increment={5}
|
||||||
|
min={0}
|
||||||
|
placeholder={t.auto}
|
||||||
|
unit="ng/ml"
|
||||||
|
allowEmpty={true}
|
||||||
|
clearButton={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium">{t.therapeuticRange}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FormNumericInput
|
||||||
|
value={therapeuticRange.min}
|
||||||
|
onChange={val => onUpdateTherapeuticRange('min', val)}
|
||||||
|
increment={0.5}
|
||||||
|
min={0}
|
||||||
|
placeholder={t.min}
|
||||||
|
required={true}
|
||||||
|
errorMessage={t.therapeuticRangeMinRequired || 'Minimum therapeutic range is required'}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
<FormNumericInput
|
||||||
|
value={therapeuticRange.max}
|
||||||
|
onChange={val => onUpdateTherapeuticRange('max', val)}
|
||||||
|
increment={0.5}
|
||||||
|
min={0}
|
||||||
|
placeholder={t.max}
|
||||||
|
unit="ng/ml"
|
||||||
|
required={true}
|
||||||
|
errorMessage={t.therapeuticRangeMaxRequired || 'Maximum therapeutic range is required'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-4" />
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold">{t.dAmphetamineParameters}</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium">{t.halfLife}</Label>
|
||||||
|
<FormNumericInput
|
||||||
|
value={pkParams.damph.halfLife}
|
||||||
|
onChange={val => onUpdatePkParams('damph', { halfLife: val })}
|
||||||
|
increment={0.5}
|
||||||
|
min={0.1}
|
||||||
|
unit="h"
|
||||||
|
required={true}
|
||||||
|
errorMessage={t.halfLifeRequired || 'Half-life is required'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-4" />
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold">{t.lisdexamfetamineParameters}</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium">{t.conversionHalfLife}</Label>
|
||||||
|
<FormNumericInput
|
||||||
|
value={pkParams.ldx.halfLife}
|
||||||
|
onChange={val => onUpdatePkParams('ldx', { halfLife: val })}
|
||||||
|
increment={0.1}
|
||||||
|
min={0.1}
|
||||||
|
unit="h"
|
||||||
|
required={true}
|
||||||
|
errorMessage={t.conversionHalfLifeRequired || 'Conversion half-life is required'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium">{t.absorptionRate}</Label>
|
||||||
|
<FormNumericInput
|
||||||
|
value={pkParams.ldx.absorptionRate}
|
||||||
|
onChange={val => onUpdatePkParams('ldx', { absorptionRate: val })}
|
||||||
|
increment={0.1}
|
||||||
|
min={0.1}
|
||||||
|
unit={t.faster}
|
||||||
|
required={true}
|
||||||
|
errorMessage={t.absorptionRateRequired || 'Absorption rate is required'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-4" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onReset}
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{t.resetAllSettings}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
@@ -13,12 +13,11 @@ const SimulationChart = ({
|
|||||||
yAxisMin,
|
yAxisMin,
|
||||||
yAxisMax,
|
yAxisMax,
|
||||||
t
|
t
|
||||||
}) => {
|
}: any) => {
|
||||||
const totalHours = (parseInt(simulationDays, 10) || 3) * 24;
|
const totalHours = (parseInt(simulationDays, 10) || 3) * 24;
|
||||||
const chartTicks = Array.from({length: Math.floor(totalHours / 6) + 1}, (_, i) => i * 6);
|
|
||||||
|
|
||||||
// Generate ticks for 24h repeating axis (every 6 hours across all days)
|
// Generate ticks for continuous time axis (every 6 hours)
|
||||||
const dayTimeTicks = React.useMemo(() => {
|
const chartTicks = React.useMemo(() => {
|
||||||
const ticks = [];
|
const ticks = [];
|
||||||
for (let i = 0; i <= totalHours; i += 6) {
|
for (let i = 0; i <= totalHours; i += 6) {
|
||||||
ticks.push(i);
|
ticks.push(i);
|
||||||
@@ -46,17 +45,25 @@ const SimulationChart = ({
|
|||||||
dataKey="timeHours"
|
dataKey="timeHours"
|
||||||
type="number"
|
type="number"
|
||||||
domain={[0, totalHours]}
|
domain={[0, totalHours]}
|
||||||
ticks={showDayTimeOnXAxis ? dayTimeTicks : chartTicks}
|
ticks={chartTicks}
|
||||||
tickFormatter={(h) => `${showDayTimeOnXAxis ? h % 24 : h}${t.hour}`}
|
tickFormatter={(h) => {
|
||||||
|
if (showDayTimeOnXAxis) {
|
||||||
|
// Show 24h repeating format (0-23h)
|
||||||
|
return `${h % 24}${t.hour}`;
|
||||||
|
} else {
|
||||||
|
// Show continuous time (0, 6, 12, 18, 24, 30, 36, ...)
|
||||||
|
return `${h}${t.hour}`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
xAxisId="hours"
|
xAxisId="hours"
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
label={{ value: t.concentration, angle: -90, position: 'insideLeft', offset: -10 }}
|
label={{ value: t.concentration, angle: -90, position: 'insideLeft', offset: -10 }}
|
||||||
domain={chartDomain}
|
domain={chartDomain as any}
|
||||||
allowDecimals={false}
|
allowDecimals={false}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value, name) => [`${value.toFixed(1)} ${t.ngml}`, name]}
|
formatter={(value: any, name) => [`${typeof value === 'number' ? value.toFixed(1) : value} ${t.ngml}`, name]}
|
||||||
labelFormatter={(label) => `${t.hour.replace('h', 'Hour')}: ${label}${t.hour}`}
|
labelFormatter={(label) => `${t.hour.replace('h', 'Hour')}: ${label}${t.hour}`}
|
||||||
/>
|
/>
|
||||||
<Legend verticalAlign="top" height={36} />
|
<Legend verticalAlign="top" height={36} />
|
||||||
34
src/components/suggestion-panel.tsx
Normal file
34
src/components/suggestion-panel.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
|
const SuggestionPanel = ({ suggestion, onApplySuggestion, t }: any) => {
|
||||||
|
if (!suggestion) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-sky-50/50 border-l-4 border-sky-500">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">{t.whatIf}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{suggestion.dose ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-sky-800">
|
||||||
|
{t.suggestion}: <span className="font-bold">{suggestion.dose}{t.mg}</span> ({t.instead} {suggestion.originalDose}{t.mg}) {t.at} <span className="font-bold">{suggestion.time}</span>.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={onApplySuggestion}
|
||||||
|
className="w-full bg-sky-600 hover:bg-sky-700"
|
||||||
|
>
|
||||||
|
{t.applySuggestion}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-sky-800">{suggestion.text}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SuggestionPanel;
|
||||||
57
src/components/ui/button.tsx
Normal file
57
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
76
src/components/ui/card.tsx
Normal file
76
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
201
src/components/ui/form-numeric-input.tsx
Normal file
201
src/components/ui/form-numeric-input.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Minus, Plus, X } from "lucide-react"
|
||||||
|
import { Button } from "./button"
|
||||||
|
import { Input } from "./input"
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
interface NumericInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
|
||||||
|
value: string | number
|
||||||
|
onChange: (value: string) => void
|
||||||
|
increment?: number | string
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
unit?: string
|
||||||
|
align?: 'left' | 'center' | 'right'
|
||||||
|
allowEmpty?: boolean
|
||||||
|
clearButton?: boolean
|
||||||
|
error?: boolean
|
||||||
|
required?: boolean
|
||||||
|
errorMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
||||||
|
({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
increment = 1,
|
||||||
|
min = -Infinity,
|
||||||
|
max = Infinity,
|
||||||
|
unit,
|
||||||
|
align = 'right',
|
||||||
|
allowEmpty = false,
|
||||||
|
clearButton = false,
|
||||||
|
error = false,
|
||||||
|
required = false,
|
||||||
|
errorMessage = 'This field is required',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
const [showError, setShowError] = React.useState(false)
|
||||||
|
const [touched, setTouched] = React.useState(false)
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Check if value is invalid (check validity regardless of touch state)
|
||||||
|
const isInvalid = required && !allowEmpty && (value === '' || value === null || value === undefined)
|
||||||
|
const hasError = error || isInvalid
|
||||||
|
|
||||||
|
// Check validity on mount and when value changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isInvalid && touched) {
|
||||||
|
setShowError(true)
|
||||||
|
} else if (!isInvalid) {
|
||||||
|
setShowError(false)
|
||||||
|
}
|
||||||
|
}, [isInvalid, touched])
|
||||||
|
// Determine decimal places based on increment
|
||||||
|
const getDecimalPlaces = () => {
|
||||||
|
const inc = String(increment || '1')
|
||||||
|
const decimalIndex = inc.indexOf('.')
|
||||||
|
if (decimalIndex === -1) return 0
|
||||||
|
return inc.length - decimalIndex - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const decimalPlaces = getDecimalPlaces()
|
||||||
|
|
||||||
|
// Format value for display
|
||||||
|
const formatValue = (val: string | number): string => {
|
||||||
|
const num = Number(val)
|
||||||
|
if (isNaN(num)) return String(val)
|
||||||
|
return num.toFixed(decimalPlaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateValue = (direction: number) => {
|
||||||
|
const numIncrement = parseFloat(String(increment)) || 1
|
||||||
|
let numValue = Number(value)
|
||||||
|
|
||||||
|
if (isNaN(numValue)) {
|
||||||
|
numValue = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
numValue += direction * numIncrement
|
||||||
|
numValue = Math.max(min, numValue)
|
||||||
|
numValue = Math.min(max, numValue)
|
||||||
|
onChange(formatValue(numValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
updateValue(e.key === 'ArrowUp' ? 1 : -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const val = e.target.value
|
||||||
|
if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
|
||||||
|
onChange(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
|
const val = e.target.value
|
||||||
|
setTouched(true)
|
||||||
|
setShowError(false)
|
||||||
|
|
||||||
|
if (val === '' && !allowEmpty) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (val !== '' && !isNaN(Number(val))) {
|
||||||
|
onChange(formatValue(val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
setShowError(hasError)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAlignmentClass = () => {
|
||||||
|
switch (align) {
|
||||||
|
case 'left': return 'text-left'
|
||||||
|
case 'center': return 'text-center'
|
||||||
|
case 'right': return 'text-right'
|
||||||
|
default: return 'text-right'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={cn("relative flex items-center gap-2", className)}>
|
||||||
|
<div className="flex items-center flex-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-9 rounded-r-none",
|
||||||
|
hasError && "border-destructive"
|
||||||
|
)}
|
||||||
|
onClick={() => updateValue(-1)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<Minus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={cn(
|
||||||
|
"rounded-none border-x-0 h-9",
|
||||||
|
getAlignmentClass(),
|
||||||
|
hasError && "border-destructive focus-visible:ring-destructive"
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{clearButton && allowEmpty ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-9 rounded-l-none",
|
||||||
|
hasError && "border-destructive"
|
||||||
|
)}
|
||||||
|
onClick={() => onChange('')}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-9 rounded-l-none",
|
||||||
|
hasError && "border-destructive"
|
||||||
|
)}
|
||||||
|
onClick={() => updateValue(1)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>}
|
||||||
|
{hasError && showError && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 z-50 w-64 bg-destructive text-destructive-foreground text-xs p-2 rounded-md shadow-lg">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
FormNumericInput.displayName = "FormNumericInput"
|
||||||
|
|
||||||
|
export { FormNumericInput }
|
||||||
173
src/components/ui/form-time-input.tsx
Normal file
173
src/components/ui/form-time-input.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Clock } from "lucide-react"
|
||||||
|
import { Button } from "./button"
|
||||||
|
import { Input } from "./input"
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "./popover"
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
interface TimeInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
error?: boolean
|
||||||
|
required?: boolean
|
||||||
|
errorMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
|
||||||
|
({ value, onChange, error = false, required = false, errorMessage = 'Time is required', className, ...props }, ref) => {
|
||||||
|
const [displayValue, setDisplayValue] = React.useState(value)
|
||||||
|
const [isPickerOpen, setIsPickerOpen] = React.useState(false)
|
||||||
|
const [showError, setShowError] = React.useState(false)
|
||||||
|
const [touched, setTouched] = React.useState(false)
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number)
|
||||||
|
|
||||||
|
// Check if value is invalid (check validity regardless of touch state)
|
||||||
|
const isInvalid = required && (!value || value.trim() === '')
|
||||||
|
const hasError = error || isInvalid
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setDisplayValue(value)
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
|
const inputValue = e.target.value.trim()
|
||||||
|
setTouched(true)
|
||||||
|
setShowError(false)
|
||||||
|
|
||||||
|
if (inputValue === '') {
|
||||||
|
// Update parent with empty value so validation works
|
||||||
|
onChange('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let input = inputValue.replace(/[^0-9]/g, '')
|
||||||
|
let hours = '00', minutes = '00'
|
||||||
|
|
||||||
|
if (input.length <= 2) {
|
||||||
|
hours = input.padStart(2, '0')
|
||||||
|
} else if (input.length === 3) {
|
||||||
|
hours = input.substring(0, 1).padStart(2, '0')
|
||||||
|
minutes = input.substring(1, 3)
|
||||||
|
} else {
|
||||||
|
hours = input.substring(0, 2)
|
||||||
|
minutes = input.substring(2, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
hours = Math.min(23, parseInt(hours, 10) || 0).toString().padStart(2, '0')
|
||||||
|
minutes = Math.min(59, parseInt(minutes, 10) || 0).toString().padStart(2, '0')
|
||||||
|
const formattedTime = `${hours}:${minutes}`
|
||||||
|
|
||||||
|
setDisplayValue(formattedTime)
|
||||||
|
onChange(formattedTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = e.target.value
|
||||||
|
setDisplayValue(newValue)
|
||||||
|
// Propagate changes to parent immediately (including empty values)
|
||||||
|
if (newValue.trim() === '') {
|
||||||
|
onChange('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
setShowError(hasError)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePickerChange = (part: 'h' | 'm', val: number) => {
|
||||||
|
let newHours = pickerHours, newMinutes = pickerMinutes
|
||||||
|
if (part === 'h') {
|
||||||
|
newHours = val
|
||||||
|
} else {
|
||||||
|
newMinutes = val
|
||||||
|
}
|
||||||
|
const formattedTime = `${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart(2, '0')}`
|
||||||
|
onChange(formattedTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={cn("relative flex items-center gap-2", className)}>
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
type="text"
|
||||||
|
value={displayValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
placeholder="HH:MM"
|
||||||
|
className={cn(
|
||||||
|
"w-24",
|
||||||
|
hasError && "border-destructive focus-visible:ring-destructive"
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<Popover open={isPickerOpen} onOpenChange={setIsPickerOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className={cn(hasError && "border-destructive")}
|
||||||
|
>
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-3 bg-popover shadow-md border">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="text-xs font-medium text-center mb-1">Hour</div>
|
||||||
|
<div className="grid grid-cols-4 gap-1 max-h-60 overflow-y-auto">
|
||||||
|
{Array.from({ length: 24 }, (_, i) => (
|
||||||
|
<Button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
variant={pickerHours === i ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-10"
|
||||||
|
onClick={() => {
|
||||||
|
handlePickerChange('h', i)
|
||||||
|
setIsPickerOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{String(i).padStart(2, '0')}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="text-xs font-medium text-center mb-1">Min</div>
|
||||||
|
<div className="grid grid-cols-4 gap-1 max-h-60 overflow-y-auto">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => i * 5).map(minute => (
|
||||||
|
<Button
|
||||||
|
key={minute}
|
||||||
|
type="button"
|
||||||
|
variant={pickerMinutes === minute ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-10"
|
||||||
|
onClick={() => {
|
||||||
|
handlePickerChange('m', minute)
|
||||||
|
setIsPickerOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{String(minute).padStart(2, '0')}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{hasError && showError && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 z-50 w-48 bg-destructive text-destructive-foreground text-xs p-2 rounded-md shadow-lg">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
FormTimeInput.displayName = "FormTimeInput"
|
||||||
|
|
||||||
|
export { FormTimeInput }
|
||||||
22
src/components/ui/input.tsx
Normal file
22
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
33
src/components/ui/popover.tsx
Normal file
33
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
157
src/components/ui/select.tsx
Normal file
157
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
}
|
||||||
31
src/components/ui/separator.tsx
Normal file
31
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
26
src/components/ui/slider.tsx
Normal file
26
src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
const Slider = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none select-none items-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||||
|
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
))
|
||||||
|
Slider.displayName = SliderPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Slider }
|
||||||
29
src/components/ui/switch.tsx
Normal file
29
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
30
src/components/ui/tooltip.tsx
Normal file
30
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
// Application constants
|
|
||||||
export const LOCAL_STORAGE_KEY = 'medPlanAssistantState_v5';
|
|
||||||
export const LDX_TO_DAMPH_CONVERSION_FACTOR = 0.2948;
|
|
||||||
|
|
||||||
// Default application state
|
|
||||||
export const getDefaultState = () => ({
|
|
||||||
pkParams: {
|
|
||||||
damph: { halfLife: '11' },
|
|
||||||
ldx: { halfLife: '0.8', absorptionRate: '1.5' },
|
|
||||||
},
|
|
||||||
doses: [
|
|
||||||
{ time: '06:30', dose: '25', label: 'morning' },
|
|
||||||
{ time: '12:30', dose: '10', label: 'midday' },
|
|
||||||
{ time: '17:00', dose: '10', label: 'afternoon' },
|
|
||||||
{ time: '22:00', dose: '10', label: 'evening' },
|
|
||||||
{ time: '01:00', dose: '0', label: 'night' },
|
|
||||||
],
|
|
||||||
steadyStateConfig: { daysOnMedication: '7' },
|
|
||||||
therapeuticRange: { min: '10.5', max: '11.5' },
|
|
||||||
doseIncrement: '2.5',
|
|
||||||
uiSettings: {
|
|
||||||
showDayTimeOnXAxis: true,
|
|
||||||
chartView: 'damph',
|
|
||||||
yAxisMin: '0',
|
|
||||||
yAxisMax: '16',
|
|
||||||
simulationDays: '3',
|
|
||||||
displayedDays: '2',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
79
src/constants/defaults.ts
Normal file
79
src/constants/defaults.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// Application constants
|
||||||
|
export const LOCAL_STORAGE_KEY = 'medPlanAssistantState_v5';
|
||||||
|
export const LDX_TO_DAMPH_CONVERSION_FACTOR = 0.2948;
|
||||||
|
|
||||||
|
// Type definitions
|
||||||
|
export interface PkParams {
|
||||||
|
damph: { halfLife: string };
|
||||||
|
ldx: { halfLife: string; absorptionRate: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Dose {
|
||||||
|
time: string;
|
||||||
|
dose: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Deviation extends Dose {
|
||||||
|
dayOffset?: number;
|
||||||
|
isAdditional: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyStateConfig {
|
||||||
|
daysOnMedication: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TherapeuticRange {
|
||||||
|
min: string;
|
||||||
|
max: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UiSettings {
|
||||||
|
showDayTimeOnXAxis: boolean;
|
||||||
|
chartView: 'damph' | 'ldx' | 'both';
|
||||||
|
yAxisMin: string;
|
||||||
|
yAxisMax: string;
|
||||||
|
simulationDays: string;
|
||||||
|
displayedDays: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppState {
|
||||||
|
pkParams: PkParams;
|
||||||
|
doses: Dose[];
|
||||||
|
steadyStateConfig: SteadyStateConfig;
|
||||||
|
therapeuticRange: TherapeuticRange;
|
||||||
|
doseIncrement: string;
|
||||||
|
uiSettings: UiSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConcentrationPoint {
|
||||||
|
timeHours: number;
|
||||||
|
ldx: number;
|
||||||
|
damph: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default application state
|
||||||
|
export const getDefaultState = (): AppState => ({
|
||||||
|
pkParams: {
|
||||||
|
damph: { halfLife: '11' },
|
||||||
|
ldx: { halfLife: '0.8', absorptionRate: '1.5' },
|
||||||
|
},
|
||||||
|
doses: [
|
||||||
|
{ time: '06:30', dose: '25', label: 'morning' },
|
||||||
|
{ time: '12:30', dose: '10', label: 'midday' },
|
||||||
|
{ time: '17:00', dose: '10', label: 'afternoon' },
|
||||||
|
{ time: '22:00', dose: '10', label: 'evening' },
|
||||||
|
{ time: '01:00', dose: '0', label: 'night' },
|
||||||
|
],
|
||||||
|
steadyStateConfig: { daysOnMedication: '7' },
|
||||||
|
therapeuticRange: { min: '10.5', max: '11.5' },
|
||||||
|
doseIncrement: '2.5',
|
||||||
|
uiSettings: {
|
||||||
|
showDayTimeOnXAxis: true,
|
||||||
|
chartView: 'both',
|
||||||
|
yAxisMin: '0',
|
||||||
|
yAxisMax: '16',
|
||||||
|
simulationDays: '3',
|
||||||
|
displayedDays: '2',
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { LOCAL_STORAGE_KEY, getDefaultState } from '../constants/defaults.js';
|
import { LOCAL_STORAGE_KEY, getDefaultState, type AppState } from '../constants/defaults';
|
||||||
|
|
||||||
export const useAppState = () => {
|
export const useAppState = () => {
|
||||||
const [appState, setAppState] = React.useState(getDefaultState);
|
const [appState, setAppState] = React.useState<AppState>(getDefaultState);
|
||||||
const [isLoaded, setIsLoaded] = React.useState(false);
|
const [isLoaded, setIsLoaded] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -42,21 +42,28 @@ export const useAppState = () => {
|
|||||||
}
|
}
|
||||||
}, [appState, isLoaded]);
|
}, [appState, isLoaded]);
|
||||||
|
|
||||||
const updateState = (key, value) => {
|
const updateState = <K extends keyof AppState>(key: K, value: AppState[K]) => {
|
||||||
setAppState(prev => ({ ...prev, [key]: value }));
|
setAppState(prev => ({ ...prev, [key]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateNestedState = (parentKey, childKey, value) => {
|
const updateNestedState = <P extends keyof AppState>(
|
||||||
|
parentKey: P,
|
||||||
|
childKey: string,
|
||||||
|
value: any
|
||||||
|
) => {
|
||||||
setAppState(prev => ({
|
setAppState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[parentKey]: { ...prev[parentKey], [childKey]: value }
|
[parentKey]: { ...(prev[parentKey] as any), [childKey]: value }
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUiSetting = (key, value) => {
|
const updateUiSetting = <K extends keyof AppState['uiSettings']>(
|
||||||
|
key: K,
|
||||||
|
value: AppState['uiSettings'][K]
|
||||||
|
) => {
|
||||||
const newUiSettings = { ...appState.uiSettings, [key]: value };
|
const newUiSettings = { ...appState.uiSettings, [key]: value };
|
||||||
if (key === 'simulationDays') {
|
if (key === 'simulationDays') {
|
||||||
const simDaysNum = parseInt(value, 10) || 1;
|
const simDaysNum = parseInt(value as string, 10) || 1;
|
||||||
const dispDaysNum = parseInt(newUiSettings.displayedDays, 10) || 1;
|
const dispDaysNum = parseInt(newUiSettings.displayedDays, 10) || 1;
|
||||||
if (dispDaysNum > simDaysNum) {
|
if (dispDaysNum > simDaysNum) {
|
||||||
newUiSettings.displayedDays = String(simDaysNum);
|
newUiSettings.displayedDays = String(simDaysNum);
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { translations, getInitialLanguage } from '../locales/index.js';
|
import { translations, getInitialLanguage } from '../locales/index';
|
||||||
|
|
||||||
export const useLanguage = () => {
|
export const useLanguage = () => {
|
||||||
const [currentLanguage, setCurrentLanguage] = React.useState(getInitialLanguage);
|
const [currentLanguage, setCurrentLanguage] = React.useState(getInitialLanguage);
|
||||||
|
|
||||||
// Get current translations
|
// Get current translations
|
||||||
const t = translations[currentLanguage] || translations.en;
|
const t = translations[currentLanguage as keyof typeof translations] || translations.en;
|
||||||
|
|
||||||
// Change language and save to localStorage
|
// Change language and save to localStorage
|
||||||
const changeLanguage = (lang) => {
|
const changeLanguage = (lang: string) => {
|
||||||
if (translations[lang]) {
|
if (translations[lang as keyof typeof translations]) {
|
||||||
setCurrentLanguage(lang);
|
setCurrentLanguage(lang);
|
||||||
localStorage.setItem('medPlanAssistant_language', lang);
|
localStorage.setItem('medPlanAssistant_language', lang);
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,27 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { calculateCombinedProfile } from '../utils/calculations.js';
|
import { calculateCombinedProfile } from '../utils/calculations';
|
||||||
import { generateSuggestion } from '../utils/suggestions.js';
|
import { generateSuggestion } from '../utils/suggestions';
|
||||||
import { timeToMinutes } from '../utils/timeUtils.js';
|
import { timeToMinutes } from '../utils/timeUtils';
|
||||||
|
import type { AppState, Deviation } from '../constants/defaults';
|
||||||
|
|
||||||
export const useSimulation = (appState) => {
|
interface SuggestionResult {
|
||||||
|
text?: string;
|
||||||
|
time?: string;
|
||||||
|
dose?: string;
|
||||||
|
isAdditional?: boolean;
|
||||||
|
originalDose?: string;
|
||||||
|
dayOffset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSimulation = (appState: AppState) => {
|
||||||
const { pkParams, doses, steadyStateConfig, doseIncrement, uiSettings } = appState;
|
const { pkParams, doses, steadyStateConfig, doseIncrement, uiSettings } = appState;
|
||||||
const { simulationDays } = uiSettings;
|
const { simulationDays } = uiSettings;
|
||||||
|
|
||||||
const [deviations, setDeviations] = React.useState([]);
|
const [deviations, setDeviations] = React.useState<Deviation[]>([]);
|
||||||
const [suggestion, setSuggestion] = React.useState(null);
|
const [suggestion, setSuggestion] = React.useState<SuggestionResult | null>(null);
|
||||||
|
|
||||||
const calculateCombinedProfileMemo = React.useCallback(
|
const calculateCombinedProfileMemo = React.useCallback(
|
||||||
(doseSchedule, deviationList = [], correction = null) =>
|
(doseSchedule = doses, deviationList: Deviation[] = [], correction: Deviation | null = null) =>
|
||||||
calculateCombinedProfile(
|
calculateCombinedProfile(
|
||||||
doseSchedule,
|
doseSchedule,
|
||||||
deviationList,
|
deviationList,
|
||||||
@@ -20,7 +30,7 @@ export const useSimulation = (appState) => {
|
|||||||
simulationDays,
|
simulationDays,
|
||||||
pkParams
|
pkParams
|
||||||
),
|
),
|
||||||
[steadyStateConfig, simulationDays, pkParams]
|
[doses, steadyStateConfig, simulationDays, pkParams]
|
||||||
);
|
);
|
||||||
|
|
||||||
const generateSuggestionMemo = React.useCallback(() => {
|
const generateSuggestionMemo = React.useCallback(() => {
|
||||||
@@ -50,39 +60,56 @@ export const useSimulation = (appState) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const correctedProfile = React.useMemo(() =>
|
const correctedProfile = React.useMemo(() =>
|
||||||
suggestion && suggestion.dose ? calculateCombinedProfileMemo(doses, deviations, suggestion) : null,
|
suggestion && suggestion.dose ? calculateCombinedProfileMemo(doses, deviations, suggestion as Deviation) : null,
|
||||||
[doses, deviations, suggestion, calculateCombinedProfileMemo]
|
[doses, deviations, suggestion, calculateCombinedProfileMemo]
|
||||||
);
|
);
|
||||||
|
|
||||||
const addDeviation = () => {
|
const addDeviation = () => {
|
||||||
const sortedDoses = [...doses].sort((a,b) => timeToMinutes(a.time) - timeToMinutes(b.time));
|
const templateDose = { time: '07:00', dose: '10', label: '' };
|
||||||
let nextDose = sortedDoses[0] || { time: '08:00', dose: '25' };
|
const sortedDoses = [...doses].sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time));
|
||||||
|
let nextDose: any = sortedDoses[0] || templateDose;
|
||||||
|
let nextDayOffset = 0;
|
||||||
|
|
||||||
if (deviations.length > 0) {
|
if (deviations.length > 0) {
|
||||||
const lastDev = deviations[deviations.length - 1];
|
const lastDev = deviations[deviations.length - 1];
|
||||||
const lastDevTime = timeToMinutes(lastDev.time) + (lastDev.dayOffset || 0) * 24 * 60;
|
const lastDevTime = timeToMinutes(lastDev.time) + (lastDev.dayOffset || 0) * 24 * 60;
|
||||||
const nextPlanned = sortedDoses.find(d => timeToMinutes(d.time) > (lastDevTime % (24*60)));
|
const nextPlanned = sortedDoses.find(d => timeToMinutes(d.time) > (lastDevTime % (24*60)));
|
||||||
if (nextPlanned) {
|
if (nextPlanned) {
|
||||||
nextDose = { ...nextPlanned, dayOffset: lastDev.dayOffset };
|
nextDose = nextPlanned;
|
||||||
|
nextDayOffset = lastDev.dayOffset || 0;
|
||||||
} else {
|
} else {
|
||||||
nextDose = { ...sortedDoses[0], dayOffset: (lastDev.dayOffset || 0) + 1 };
|
nextDose = sortedDoses[0];
|
||||||
|
nextDayOffset = (lastDev.dayOffset || 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setDeviations([...deviations, { ...nextDose, isAdditional: false, dayOffset: nextDose.dayOffset || 0 }]);
|
|
||||||
|
// Use templateDose if nextDose has no time
|
||||||
|
if (!nextDose.time || nextDose.time === '') {
|
||||||
|
nextDose = templateDose;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeviations([...deviations, {
|
||||||
|
time: nextDose.time,
|
||||||
|
dose: nextDose.dose,
|
||||||
|
label: nextDose.label || '',
|
||||||
|
isAdditional: false,
|
||||||
|
dayOffset: nextDayOffset
|
||||||
|
}]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeDeviation = (index) => {
|
const removeDeviation = (index: number) => {
|
||||||
setDeviations(deviations.filter((_, i) => i !== index));
|
setDeviations(deviations.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeviationChange = (index, field, value) => {
|
const handleDeviationChange = (index: number, field: keyof Deviation, value: any) => {
|
||||||
const newDeviations = [...deviations];
|
const newDeviations = [...deviations];
|
||||||
newDeviations[index][field] = value;
|
(newDeviations[index] as any)[field] = value;
|
||||||
setDeviations(newDeviations);
|
setDeviations(newDeviations);
|
||||||
};
|
};
|
||||||
|
|
||||||
const applySuggestion = () => {
|
const applySuggestion = () => {
|
||||||
if (!suggestion || !suggestion.dose) return;
|
if (!suggestion || !suggestion.dose) return;
|
||||||
setDeviations([...deviations, suggestion]);
|
setDeviations([...deviations, suggestion as Deviation]);
|
||||||
setSuggestion(null);
|
setSuggestion(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
17
src/index.js
17
src/index.js
@@ -1,17 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
|
||||||
import './index.css';
|
|
||||||
import App from './App';
|
|
||||||
import reportWebVitals from './reportWebVitals';
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
||||||
root.render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
|
|
||||||
// If you want to start measuring performance in your app, pass a function
|
|
||||||
// to log results (for example: reportWebVitals(console.log))
|
|
||||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
|
||||||
reportWebVitals();
|
|
||||||
14
src/index.tsx
Normal file
14
src/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import './styles/global.css';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('root');
|
||||||
|
if (!rootElement) throw new Error('Failed to find the root element');
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
7
src/lib/utils.ts
Normal file
7
src/lib/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//import * as React from "react"
|
||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import en from './en.js';
|
import en from './en';
|
||||||
import de from './de.js';
|
import de from './de';
|
||||||
|
|
||||||
export const translations = {
|
export const translations = {
|
||||||
en,
|
en,
|
||||||
@@ -8,14 +8,14 @@ export const translations = {
|
|||||||
|
|
||||||
// Get browser language preference
|
// Get browser language preference
|
||||||
export const getBrowserLanguage = () => {
|
export const getBrowserLanguage = () => {
|
||||||
const browserLang = navigator.language || navigator.userLanguage;
|
const browserLang = navigator.language;
|
||||||
return browserLang.startsWith('de') ? 'de' : 'en';
|
return browserLang.startsWith('de') ? 'de' : 'en';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get stored language or fall back to browser preference or English
|
// Get initial language from localStorage or browser preference
|
||||||
export const getInitialLanguage = () => {
|
export const getInitialLanguage = () => {
|
||||||
const stored = localStorage.getItem('medPlanAssistant_language');
|
const stored = localStorage.getItem('medPlanAssistant_language');
|
||||||
if (stored && translations[stored]) {
|
if (stored && translations[stored as keyof typeof translations]) {
|
||||||
return stored;
|
return stored;
|
||||||
}
|
}
|
||||||
return getBrowserLanguage();
|
return getBrowserLanguage();
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
const reportWebVitals = onPerfEntry => {
|
|
||||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
|
||||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
|
||||||
getCLS(onPerfEntry);
|
|
||||||
getFID(onPerfEntry);
|
|
||||||
getFCP(onPerfEntry);
|
|
||||||
getLCP(onPerfEntry);
|
|
||||||
getTTFB(onPerfEntry);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default reportWebVitals;
|
|
||||||
43
src/styles/global.css
Normal file
43
src/styles/global.css
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 98%;
|
||||||
|
--foreground: 0 0% 10%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 10%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 0 0% 10%;
|
||||||
|
--primary: 0 0% 15%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 0 0% 94%;
|
||||||
|
--secondary-foreground: 0 0% 15%;
|
||||||
|
--muted: 220 10% 95%;
|
||||||
|
--muted-foreground: 0 0% 45%;
|
||||||
|
--accent: 220 10% 95%;
|
||||||
|
--accent-foreground: 0 0% 15%;
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 0 0% 88%;
|
||||||
|
--input: 0 0% 88%;
|
||||||
|
--ring: 0 0% 70%;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-feature-settings: "rlig" 1, "calt" 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
import { timeToMinutes } from './timeUtils.js';
|
import { timeToMinutes } from './timeUtils';
|
||||||
import { calculateSingleDoseConcentration } from './pharmacokinetics.js';
|
import { calculateSingleDoseConcentration } from './pharmacokinetics';
|
||||||
|
import type { Dose, Deviation, SteadyStateConfig, PkParams, ConcentrationPoint } from '../constants/defaults';
|
||||||
|
|
||||||
|
interface DoseWithTime extends Omit<Dose, 'time'> {
|
||||||
|
time: number;
|
||||||
|
isPlan?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const calculateCombinedProfile = (
|
export const calculateCombinedProfile = (
|
||||||
doseSchedule,
|
doseSchedule: Dose[],
|
||||||
deviationList = [],
|
deviationList: Deviation[] = [],
|
||||||
correction = null,
|
correction: Deviation | null = null,
|
||||||
steadyStateConfig,
|
steadyStateConfig: SteadyStateConfig,
|
||||||
simulationDays,
|
simulationDays: string,
|
||||||
pkParams
|
pkParams: PkParams
|
||||||
) => {
|
): ConcentrationPoint[] => {
|
||||||
const dataPoints = [];
|
const dataPoints: ConcentrationPoint[] = [];
|
||||||
const timeStepHours = 0.25;
|
const timeStepHours = 0.25;
|
||||||
const totalHours = (parseInt(simulationDays, 10) || 3) * 24;
|
const totalHours = (parseInt(simulationDays, 10) || 3) * 24;
|
||||||
const daysToSimulate = Math.min(parseInt(steadyStateConfig.daysOnMedication, 10) || 0, 5);
|
const daysToSimulate = Math.min(parseInt(steadyStateConfig.daysOnMedication, 10) || 0, 5);
|
||||||
@@ -17,13 +23,21 @@ export const calculateCombinedProfile = (
|
|||||||
for (let t = 0; t <= totalHours; t += timeStepHours) {
|
for (let t = 0; t <= totalHours; t += timeStepHours) {
|
||||||
let totalLdx = 0;
|
let totalLdx = 0;
|
||||||
let totalDamph = 0;
|
let totalDamph = 0;
|
||||||
let allDoses = [];
|
const allDoses: DoseWithTime[] = [];
|
||||||
|
|
||||||
const maxDayOffset = (parseInt(simulationDays, 10) || 3) - 1;
|
const maxDayOffset = (parseInt(simulationDays, 10) || 3) - 1;
|
||||||
|
|
||||||
for (let day = -daysToSimulate; day <= maxDayOffset; day++) {
|
for (let day = -daysToSimulate; day <= maxDayOffset; day++) {
|
||||||
const dayOffset = day * 24 * 60;
|
const dayOffset = day * 24 * 60;
|
||||||
doseSchedule.forEach(d => {
|
doseSchedule.forEach(d => {
|
||||||
|
// Skip doses with empty or invalid time values
|
||||||
|
const timeStr = String(d.time || '').trim();
|
||||||
|
const doseStr = String(d.dose || '').trim();
|
||||||
|
const doseNum = parseFloat(doseStr);
|
||||||
|
|
||||||
|
if (!timeStr || timeStr === '' || !doseStr || doseStr === '' || doseNum === 0 || isNaN(doseNum)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
allDoses.push({ ...d, time: timeToMinutes(d.time) + dayOffset, isPlan: true });
|
allDoses.push({ ...d, time: timeToMinutes(d.time) + dayOffset, isPlan: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -34,6 +48,14 @@ export const calculateCombinedProfile = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentDeviations.forEach(dev => {
|
currentDeviations.forEach(dev => {
|
||||||
|
// Skip deviations with empty or invalid time values
|
||||||
|
const timeStr = String(dev.time || '').trim();
|
||||||
|
const doseStr = String(dev.dose || '').trim();
|
||||||
|
const doseNum = parseFloat(doseStr);
|
||||||
|
|
||||||
|
if (!timeStr || timeStr === '' || !doseStr || doseStr === '' || doseNum === 0 || isNaN(doseNum)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const devTime = timeToMinutes(dev.time) + (dev.dayOffset || 0) * 24 * 60;
|
const devTime = timeToMinutes(dev.time) + (dev.dayOffset || 0) * 24 * 60;
|
||||||
if (!dev.isAdditional) {
|
if (!dev.isAdditional) {
|
||||||
const closestDoseIndex = allDoses.reduce((closest, dose, index) => {
|
const closestDoseIndex = allDoses.reduce((closest, dose, index) => {
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
import { LDX_TO_DAMPH_CONVERSION_FACTOR } from '../constants/defaults.js';
|
import { LDX_TO_DAMPH_CONVERSION_FACTOR, type PkParams } from '../constants/defaults';
|
||||||
|
|
||||||
|
interface ConcentrationResult {
|
||||||
|
ldx: number;
|
||||||
|
damph: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Pharmacokinetic calculations
|
// Pharmacokinetic calculations
|
||||||
export const calculateSingleDoseConcentration = (dose, timeSinceDoseHours, pkParams) => {
|
export const calculateSingleDoseConcentration = (
|
||||||
|
dose: string,
|
||||||
|
timeSinceDoseHours: number,
|
||||||
|
pkParams: PkParams
|
||||||
|
): ConcentrationResult => {
|
||||||
const numDose = parseFloat(dose) || 0;
|
const numDose = parseFloat(dose) || 0;
|
||||||
if (timeSinceDoseHours < 0 || numDose <= 0) return { ldx: 0, damph: 0 };
|
if (timeSinceDoseHours < 0 || numDose <= 0) return { ldx: 0, damph: 0 };
|
||||||
|
|
||||||
@@ -1,14 +1,24 @@
|
|||||||
import { timeToMinutes } from './timeUtils.js';
|
import { timeToMinutes } from './timeUtils';
|
||||||
import { calculateCombinedProfile } from './calculations.js';
|
import { calculateCombinedProfile } from './calculations';
|
||||||
|
import type { Dose, Deviation, SteadyStateConfig, PkParams } from '../constants/defaults';
|
||||||
|
|
||||||
|
interface SuggestionResult {
|
||||||
|
text?: string;
|
||||||
|
time?: string;
|
||||||
|
dose?: string;
|
||||||
|
isAdditional?: boolean;
|
||||||
|
originalDose?: string;
|
||||||
|
dayOffset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const generateSuggestion = (
|
export const generateSuggestion = (
|
||||||
doses,
|
doses: Dose[],
|
||||||
deviations,
|
deviations: Deviation[],
|
||||||
doseIncrement,
|
doseIncrement: string,
|
||||||
simulationDays,
|
simulationDays: string,
|
||||||
steadyStateConfig,
|
steadyStateConfig: SteadyStateConfig,
|
||||||
pkParams
|
pkParams: PkParams
|
||||||
) => {
|
): SuggestionResult | null => {
|
||||||
if (deviations.length === 0) {
|
if (deviations.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -18,12 +28,23 @@ export const generateSuggestion = (
|
|||||||
(timeToMinutes(b.time) + (b.dayOffset || 0) * 1440)
|
(timeToMinutes(b.time) + (b.dayOffset || 0) * 1440)
|
||||||
).pop();
|
).pop();
|
||||||
|
|
||||||
|
if (!lastDeviation) return null;
|
||||||
|
|
||||||
const deviationTimeTotalMinutes = timeToMinutes(lastDeviation.time) + (lastDeviation.dayOffset || 0) * 1440;
|
const deviationTimeTotalMinutes = timeToMinutes(lastDeviation.time) + (lastDeviation.dayOffset || 0) * 1440;
|
||||||
|
|
||||||
let nextDose = null;
|
type DoseWithOffset = Dose & { dayOffset: number };
|
||||||
|
let nextDose: DoseWithOffset | null = null;
|
||||||
let minDiff = Infinity;
|
let minDiff = Infinity;
|
||||||
|
|
||||||
doses.forEach(d => {
|
doses.forEach(d => {
|
||||||
|
// Skip doses with empty or invalid time/dose values
|
||||||
|
const timeStr = String(d.time || '').trim();
|
||||||
|
const doseStr = String(d.dose || '').trim();
|
||||||
|
const doseNum = parseFloat(doseStr);
|
||||||
|
|
||||||
|
if (!timeStr || timeStr === '' || !doseStr || doseStr === '' || doseNum === 0 || isNaN(doseNum)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const doseTimeInMinutes = timeToMinutes(d.time);
|
const doseTimeInMinutes = timeToMinutes(d.time);
|
||||||
for (let i = 0; i < (parseInt(simulationDays, 10) || 1); i++) {
|
for (let i = 0; i < (parseInt(simulationDays, 10) || 1); i++) {
|
||||||
const absoluteTime = doseTimeInMinutes + i * 1440;
|
const absoluteTime = doseTimeInMinutes + i * 1440;
|
||||||
@@ -39,11 +60,14 @@ export const generateSuggestion = (
|
|||||||
return { text: "Keine passende nächste Dosis für Korrektur gefunden." };
|
return { text: "Keine passende nächste Dosis für Korrektur gefunden." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Type assertion after null check
|
||||||
|
const confirmedNextDose: DoseWithOffset = nextDose;
|
||||||
|
|
||||||
const numDoseIncrement = parseFloat(doseIncrement) || 1;
|
const numDoseIncrement = parseFloat(doseIncrement) || 1;
|
||||||
const idealProfile = calculateCombinedProfile(doses, [], null, steadyStateConfig, simulationDays, pkParams);
|
const idealProfile = calculateCombinedProfile(doses, [], null, steadyStateConfig, simulationDays, pkParams);
|
||||||
const deviatedProfile = calculateCombinedProfile(doses, deviations, null, steadyStateConfig, simulationDays, pkParams);
|
const deviatedProfile = calculateCombinedProfile(doses, deviations, null, steadyStateConfig, simulationDays, pkParams);
|
||||||
|
|
||||||
const nextDoseTimeHours = (timeToMinutes(nextDose.time) + (nextDose.dayOffset || 0) * 1440) / 60;
|
const nextDoseTimeHours = (timeToMinutes(confirmedNextDose.time) + (confirmedNextDose.dayOffset || 0) * 1440) / 60;
|
||||||
|
|
||||||
const idealConcentration = idealProfile.find(p => Math.abs(p.timeHours - nextDoseTimeHours) < 0.1)?.damph || 0;
|
const idealConcentration = idealProfile.find(p => Math.abs(p.timeHours - nextDoseTimeHours) < 0.1)?.damph || 0;
|
||||||
const deviatedConcentration = deviatedProfile.find(p => Math.abs(p.timeHours - nextDoseTimeHours) < 0.1)?.damph || 0;
|
const deviatedConcentration = deviatedProfile.find(p => Math.abs(p.timeHours - nextDoseTimeHours) < 0.1)?.damph || 0;
|
||||||
@@ -56,14 +80,14 @@ export const generateSuggestion = (
|
|||||||
const doseAdjustmentFactor = 0.5;
|
const doseAdjustmentFactor = 0.5;
|
||||||
let doseChange = concentrationDifference / doseAdjustmentFactor;
|
let doseChange = concentrationDifference / doseAdjustmentFactor;
|
||||||
doseChange = Math.round(doseChange / numDoseIncrement) * numDoseIncrement;
|
doseChange = Math.round(doseChange / numDoseIncrement) * numDoseIncrement;
|
||||||
let suggestedDoseValue = (parseFloat(nextDose.dose) || 0) + doseChange;
|
let suggestedDoseValue = (parseFloat(confirmedNextDose.dose) || 0) + doseChange;
|
||||||
suggestedDoseValue = Math.max(0, Math.min(70, suggestedDoseValue));
|
suggestedDoseValue = Math.max(0, Math.min(70, suggestedDoseValue));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
time: nextDose.time,
|
time: confirmedNextDose.time,
|
||||||
dose: String(suggestedDoseValue),
|
dose: String(suggestedDoseValue),
|
||||||
isAdditional: false,
|
isAdditional: false,
|
||||||
originalDose: nextDose.dose,
|
originalDose: confirmedNextDose.dose,
|
||||||
dayOffset: nextDose.dayOffset
|
dayOffset: confirmedNextDose.dayOffset
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// --- Helper Functions ---
|
// Time utility functions
|
||||||
export const timeToMinutes = (timeStr) => {
|
export const timeToMinutes = (timeStr: string): number => {
|
||||||
if (!timeStr || !timeStr.includes(':')) return 0;
|
if (!timeStr || !timeStr.includes(':')) return 0;
|
||||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
return hours * 60 + minutes;
|
return hours * 60 + minutes;
|
||||||
@@ -1,10 +1,59 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
darkMode: ["class"],
|
||||||
content: [
|
content: [
|
||||||
"./src/**/*.{js,jsx,ts,tsx}",
|
"./src/**/*.{js,jsx,ts,tsx}",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))'
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))'
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))'
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))'
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))'
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))'
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))'
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
'1': 'hsl(var(--chart-1))',
|
||||||
|
'2': 'hsl(var(--chart-2))',
|
||||||
|
'3': 'hsl(var(--chart-3))',
|
||||||
|
'4': 'hsl(var(--chart-4))',
|
||||||
|
'5': 'hsl(var(--chart-5))'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)'
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [require("tailwindcss-animate")],
|
||||||
}
|
}
|
||||||
|
|||||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": false,
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
0
vite.config.ts
Normal file
0
vite.config.ts
Normal file
Reference in New Issue
Block a user