Update migrated js to ts and shadcn
This commit is contained in:
@@ -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,
|
||||
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} />
|
||||
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 }
|
||||
Reference in New Issue
Block a user