Update migrated js to ts and shadcn

This commit is contained in:
2025-11-26 20:00:39 +00:00
parent d5938046a2
commit 551ba9fd62
51 changed files with 1702 additions and 937 deletions

View File

@@ -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"
>
&times;
</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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;

View 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;

View 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
View 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;

View File

@@ -13,12 +13,11 @@ const SimulationChart = ({
yAxisMin,
yAxisMax,
t
}) => {
}: any) => {
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)
const dayTimeTicks = React.useMemo(() => {
// Generate ticks for continuous time axis (every 6 hours)
const chartTicks = React.useMemo(() => {
const ticks = [];
for (let i = 0; i <= totalHours; i += 6) {
ticks.push(i);
@@ -46,17 +45,25 @@ const SimulationChart = ({
dataKey="timeHours"
type="number"
domain={[0, totalHours]}
ticks={showDayTimeOnXAxis ? dayTimeTicks : chartTicks}
tickFormatter={(h) => `${showDayTimeOnXAxis ? h % 24 : h}${t.hour}`}
ticks={chartTicks}
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"
/>
<YAxis
label={{ value: t.concentration, angle: -90, position: 'insideLeft', offset: -10 }}
domain={chartDomain}
domain={chartDomain as any}
allowDecimals={false}
/>
<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}`}
/>
<Legend verticalAlign="top" height={36} />

View 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;

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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,
}

View 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 }

View 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 }

View 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 }

View 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 }