Add field vaidations, general improvements

This commit is contained in:
2025-11-24 17:22:11 +00:00
parent 0a610bd39a
commit c4ddf1f32b
9 changed files with 249 additions and 54 deletions

View File

@@ -67,9 +67,9 @@ const MedPlanAssistant = () => {
</div> </div>
</header> </header>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Left Column - Controls */} {/* Left Column - Controls */}
<div className="lg:col-span-1 space-y-6 lg:order-1"> <div className="xl:col-span-1 space-y-6">
<DoseSchedule <DoseSchedule
doses={doses} doses={doses}
doseIncrement={doseIncrement} doseIncrement={doseIncrement}
@@ -95,7 +95,7 @@ const MedPlanAssistant = () => {
</div> </div>
{/* Center Column - Chart */} {/* Center Column - Chart */}
<div className="lg:col-span-2 bg-white p-5 rounded-lg shadow-sm border min-h-[600px] flex flex-col lg:order-2"> <div className="xl:col-span-1 bg-white p-5 rounded-lg shadow-sm border min-h-[600px] flex flex-col">
<div className="flex justify-center space-x-2 mb-4"> <div className="flex justify-center space-x-2 mb-4">
<button <button
onClick={() => updateUiSetting('chartView', 'damph')} onClick={() => updateUiSetting('chartView', 'damph')}
@@ -133,7 +133,7 @@ const MedPlanAssistant = () => {
</div> </div>
{/* Right Column - Settings */} {/* Right Column - Settings */}
<div className="lg:col-span-1 space-y-6 lg:order-3"> <div className="xl:col-span-1 space-y-6">
<Settings <Settings
pkParams={pkParams} pkParams={pkParams}
therapeuticRange={therapeuticRange} therapeuticRange={therapeuticRange}

View File

@@ -28,6 +28,7 @@ const DeviationList = ({
<TimeInput <TimeInput
value={dev.time} value={dev.time}
onChange={newTime => onDeviationChange(index, 'time', newTime)} onChange={newTime => onDeviationChange(index, 'time', newTime)}
errorMessage={t.timeRequired}
/> />
<div className="w-32"> <div className="w-32">
<NumericInput <NumericInput
@@ -36,6 +37,7 @@ const DeviationList = ({
increment={doseIncrement} increment={doseIncrement}
min={0} min={0}
unit={t.mg} unit={t.mg}
errorMessage={t.fieldRequired}
/> />
</div> </div>
<button <button

View File

@@ -11,6 +11,7 @@ const DoseSchedule = ({ doses, doseIncrement, onUpdateDoses, t }) => {
<TimeInput <TimeInput
value={dose.time} value={dose.time}
onChange={newTime => onUpdateDoses(doses.map((d, i) => i === index ? {...d, time: newTime} : d))} onChange={newTime => onUpdateDoses(doses.map((d, i) => i === index ? {...d, time: newTime} : d))}
errorMessage={t.timeRequired}
/> />
<div className="w-40"> <div className="w-40">
<NumericInput <NumericInput
@@ -19,6 +20,7 @@ const DoseSchedule = ({ doses, doseIncrement, onUpdateDoses, t }) => {
increment={doseIncrement} increment={doseIncrement}
min={0} min={0}
unit={t.mg} unit={t.mg}
errorMessage={t.fieldRequired}
/> />
</div> </div>
<span className="text-gray-600 text-sm flex-1">{t[dose.label] || dose.label}</span> <span className="text-gray-600 text-sm flex-1">{t[dose.label] || dose.label}</span>

View File

@@ -8,8 +8,13 @@ const NumericInput = ({
max = Infinity, max = Infinity,
placeholder, placeholder,
unit, unit,
align = 'right' // 'left', 'center', 'right' align = 'right', // 'left', 'center', 'right'
allowEmpty = false, // Allow empty value (e.g., for "Auto" mode)
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);
// Determine decimal places based on increment // Determine decimal places based on increment
const getDecimalPlaces = () => { const getDecimalPlaces = () => {
const inc = String(increment || '1'); const inc = String(increment || '1');
@@ -30,13 +35,17 @@ const NumericInput = ({
const updateValue = (direction) => { const updateValue = (direction) => {
const numIncrement = parseFloat(increment) || 1; const numIncrement = parseFloat(increment) || 1;
let numValue = Number(value); let numValue = Number(value);
// If value is empty/Auto, treat as 0
if (isNaN(numValue)) { if (isNaN(numValue)) {
numValue = min !== -Infinity ? min : 0; numValue = 0;
} }
numValue += direction * numIncrement; numValue += direction * numIncrement;
numValue = Math.max(min, numValue); numValue = Math.max(min, numValue);
numValue = Math.min(max, numValue); numValue = Math.min(max, numValue);
const finalValue = formatValue(numValue); const finalValue = formatValue(numValue);
setHasError(false); // Clear error when using buttons
onChange(finalValue); onChange(finalValue);
}; };
@@ -53,14 +62,23 @@ const NumericInput = ({
if (val === '' || /^-?\d*\.?\d*$/.test(val)) { if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
// Prevent object values // Prevent object values
if (typeof val === 'object') return; if (typeof val === 'object') return;
setHasError(false); // Clear error on input
onChange(val); onChange(val);
} }
}; };
const handleBlur = (e) => { const handleBlur = (e) => {
const val = e.target.value; 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))) { if (val !== '' && !isNaN(Number(val))) {
// Format the value when user finishes editing // Format the value when user finishes editing
setHasError(false);
onChange(formatValue(val)); onChange(formatValue(val));
} }
}; };
@@ -75,8 +93,76 @@ const NumericInput = ({
} }
}; };
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 ( return (
<div className="flex items-center w-full"> <div
ref={containerRef}
className="relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="relative inline-flex items-center">
<button <button
onClick={() => updateValue(-1)} onClick={() => updateValue(-1)}
className="px-2 py-1 border rounded-l-md bg-gray-100 hover:bg-gray-200 text-lg font-bold" className="px-2 py-1 border rounded-l-md bg-gray-100 hover:bg-gray-200 text-lg font-bold"
@@ -89,19 +175,54 @@ const NumericInput = ({
value={value} value={value}
onChange={handleChange} onChange={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={handleBlur} onBlur={handleInputBlurWrapper}
onFocus={handleFocus}
placeholder={placeholder} placeholder={placeholder}
style={{ width: '4em', minWidth: '4em', maxWidth: '8em' }} style={{ width: '4em', minWidth: '4em', maxWidth: '8em' }}
className={`p-2 border-t border-b text-sm ${getAlignmentClass()}`} className={`p-2 border-t border-b text-sm ${getAlignmentClass()} ${hasError ? 'bg-red-50' : ''}`}
/> />
<button <button
onClick={() => updateValue(1)} onClick={() => updateValue(1)}
className="px-2 py-1 border rounded-r-md bg-gray-100 hover:bg-gray-200 text-lg font-bold" className={`px-2 py-1 border bg-gray-100 hover:bg-gray-200 text-lg font-bold ${!allowEmpty ? 'rounded-r-md' : ''}`}
tabIndex={-1} tabIndex={-1}
> >
+ +
</button> </button>
{allowEmpty && (
<button
onClick={handleClear}
className="px-2 py-1 border rounded-r-md bg-gray-100 hover:bg-gray-200 text-gray-600 hover:text-gray-800"
tabIndex={-1}
title="Clear (set to Auto)"
>
<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>
)}
{hasError && (
<div className="absolute inset-0 border-2 border-red-500 rounded-md pointer-events-none" style={{ zIndex: 10 }} />
)}
</div>
{unit && <span className="ml-2 text-gray-500 text-sm whitespace-nowrap">{unit}</span>} {unit && <span className="ml-2 text-gray-500 text-sm whitespace-nowrap">{unit}</span>}
{hasError && showErrorTooltip && (
<div
className={`absolute z-50 bg-white border-2 border-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>
); );
}; };

View File

@@ -39,6 +39,7 @@ const Settings = ({
min={2} min={2}
max={7} max={7}
unit={t.days} unit={t.days}
errorMessage={t.fieldRequired}
/> />
</div> </div>
@@ -51,19 +52,20 @@ const Settings = ({
min={1} min={1}
max={parseInt(simulationDays, 10) || 1} max={parseInt(simulationDays, 10) || 1}
unit={t.days} unit={t.days}
errorMessage={t.fieldRequired}
/> />
</div> </div>
<label className="block font-medium text-gray-600 pt-2">{t.yAxisRange}</label> <label className="block font-medium text-gray-600 pt-2">{t.yAxisRange}</label>
<div className="flex items-center space-x-3 mt-1"> <div className="flex items-center mt-1">
<div className="w-32"> <div className="w-30">
<NumericInput <NumericInput
value={yAxisMin} value={yAxisMin}
onChange={val => onUpdateUiSetting('yAxisMin', val)} onChange={val => onUpdateUiSetting('yAxisMin', val)}
increment={'5'} increment={'5'}
min={0} min={0}
placeholder={t.auto} placeholder={t.auto}
unit="ng/ml" allowEmpty={true}
/> />
</div> </div>
<span className="text-gray-500 px-2">-</span> <span className="text-gray-500 px-2">-</span>
@@ -75,20 +77,21 @@ const Settings = ({
min={0} min={0}
placeholder={t.auto} placeholder={t.auto}
unit="ng/ml" unit="ng/ml"
allowEmpty={true}
/> />
</div> </div>
</div> </div>
<label className="block font-medium text-gray-600">{t.therapeuticRange}</label> <label className="block font-medium text-gray-600">{t.therapeuticRange}</label>
<div className="flex items-center space-x-3 mt-1"> <div className="flex items-center mt-1">
<div className="w-32"> <div className="w-30">
<NumericInput <NumericInput
value={therapeuticRange.min} value={therapeuticRange.min}
onChange={val => onUpdateTherapeuticRange('min', val)} onChange={val => onUpdateTherapeuticRange('min', val)}
increment={'0.5'} increment={'0.5'}
min={0} min={0}
placeholder={t.min} placeholder={t.min}
unit="ng/ml" errorMessage={t.fieldRequired}
/> />
</div> </div>
<span className="text-gray-500 px-2">-</span> <span className="text-gray-500 px-2">-</span>
@@ -100,6 +103,7 @@ const Settings = ({
min={0} min={0}
placeholder={t.max} placeholder={t.max}
unit="ng/ml" unit="ng/ml"
errorMessage={t.fieldRequired}
/> />
</div> </div>
</div> </div>
@@ -113,6 +117,7 @@ const Settings = ({
increment={'0.5'} increment={'0.5'}
min={0.1} min={0.1}
unit="h" unit="h"
errorMessage={t.fieldRequired}
/> />
</div> </div>
@@ -125,6 +130,7 @@ const Settings = ({
increment={'0.1'} increment={'0.1'}
min={0.1} min={0.1}
unit="h" unit="h"
errorMessage={t.fieldRequired}
/> />
</div> </div>
<div className="w-40"> <div className="w-40">
@@ -135,6 +141,7 @@ const Settings = ({
increment={'0.1'} increment={'0.1'}
min={0.1} min={0.1}
unit={t.faster} unit={t.faster}
errorMessage={t.fieldRequired}
/> />
</div> </div>

View File

@@ -1,10 +1,13 @@
import React from 'react'; import React from 'react';
const TimeInput = ({ value, onChange }) => { const TimeInput = ({ value, onChange, errorMessage = 'Time is required' }) => {
const [displayValue, setDisplayValue] = React.useState(value); const [displayValue, setDisplayValue] = React.useState(value);
const [isPickerOpen, setIsPickerOpen] = React.useState(false); 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 [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number);
const pickerRef = React.useRef(null); const pickerRef = React.useRef(null);
const containerRef = React.useRef(null);
React.useEffect(() => { React.useEffect(() => {
setDisplayValue(value); setDisplayValue(value);
@@ -16,19 +19,32 @@ const TimeInput = ({ value, onChange }) => {
if (pickerRef.current && !pickerRef.current.contains(event.target)) { if (pickerRef.current && !pickerRef.current.contains(event.target)) {
setIsPickerOpen(false); setIsPickerOpen(false);
} }
if (containerRef.current && !containerRef.current.contains(event.target)) {
setShowErrorTooltip(false);
}
}; };
if (isPickerOpen) { if (isPickerOpen || showErrorTooltip) {
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
} }
return () => { return () => {
document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('mousedown', handleClickOutside);
}; };
}, [isPickerOpen]); }, [isPickerOpen, showErrorTooltip]);
const handleBlur = (e) => { const handleBlur = (e) => {
let input = e.target.value.replace(/[^0-9]/g, ''); 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'; let hours = '00', minutes = '00';
if (input.length <= 2) { if (input.length <= 2) {
hours = input.padStart(2, '0'); hours = input.padStart(2, '0');
@@ -44,11 +60,15 @@ const TimeInput = ({ value, onChange }) => {
hours = Math.min(23, parseInt(hours, 10) || 0).toString().padStart(2, '0'); hours = Math.min(23, parseInt(hours, 10) || 0).toString().padStart(2, '0');
minutes = Math.min(59, parseInt(minutes, 10) || 0).toString().padStart(2, '0'); minutes = Math.min(59, parseInt(minutes, 10) || 0).toString().padStart(2, '0');
const formattedTime = `${hours}:${minutes}`; const formattedTime = `${hours}:${minutes}`;
setHasError(false);
setShowErrorTooltip(false);
setDisplayValue(formattedTime); setDisplayValue(formattedTime);
onChange(formattedTime); onChange(formattedTime);
}; };
const handleChange = (e) => { const handleChange = (e) => {
setHasError(false); // Clear error on input
setShowErrorTooltip(false);
setDisplayValue(e.target.value); setDisplayValue(e.target.value);
}; };
@@ -60,19 +80,42 @@ const TimeInput = ({ value, onChange }) => {
newMinutes = val; newMinutes = val;
} }
const formattedTime = `${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart(2, '0')}`; const formattedTime = `${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart(2, '0')}`;
setHasError(false); // Clear error when using picker
setShowErrorTooltip(false);
onChange(formattedTime); onChange(formattedTime);
}; };
const handleFocus = () => {
if (hasError) setShowErrorTooltip(true);
};
const handleMouseEnter = () => {
if (hasError) setShowErrorTooltip(true);
};
const handleMouseLeave = () => {
setShowErrorTooltip(false);
};
return ( return (
<div className="relative flex items-center"> <div
ref={containerRef}
className="relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="flex items-center">
<div className={`${hasError ? 'ring-2 ring-red-500 rounded-md' : ''}`}>
<input <input
type="text" type="text"
value={displayValue} value={displayValue}
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
onFocus={handleFocus}
placeholder="HH:MM" placeholder="HH:MM"
className="p-2 border rounded-md w-24 text-sm text-center" className={`p-2 border rounded-md w-24 text-sm text-center ${hasError ? 'bg-red-50 border-transparent' : ''}`}
/> />
</div>
<button <button
onClick={() => setIsPickerOpen(!isPickerOpen)} onClick={() => setIsPickerOpen(!isPickerOpen)}
className="ml-2 p-2 text-gray-500 hover:text-gray-700" className="ml-2 p-2 text-gray-500 hover:text-gray-700"
@@ -82,7 +125,7 @@ const TimeInput = ({ value, onChange }) => {
</svg> </svg>
</button> </button>
{isPickerOpen && ( {isPickerOpen && (
<div ref={pickerRef} className="absolute top-full mt-2 z-10 bg-white p-4 rounded-lg shadow-xl border w-64"> <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 className="text-center text-lg font-bold mb-3">{value}</div>
<div> <div>
<div className="mb-2"><span className="font-semibold">Hour:</span></div> <div className="mb-2"><span className="font-semibold">Hour:</span></div>
@@ -120,6 +163,18 @@ const TimeInput = ({ value, onChange }) => {
</button> </button>
</div> </div>
)} )}
{hasError && showErrorTooltip && (
<div className="absolute left-0 top-full mt-1 z-50 bg-white border-2 border-red-500 rounded-md shadow-lg p-2 flex items-start gap-2" style={{ maxWidth: '28rem' }}>
<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 break-words">{errorMessage}</span>
</div>
)}
</div>
</div> </div>
); );
}; };

View File

@@ -12,7 +12,7 @@ export const getDefaultState = () => ({
{ time: '06:30', dose: '25', label: 'morning' }, { time: '06:30', dose: '25', label: 'morning' },
{ time: '12:30', dose: '10', label: 'midday' }, { time: '12:30', dose: '10', label: 'midday' },
{ time: '17:00', dose: '10', label: 'afternoon' }, { time: '17:00', dose: '10', label: 'afternoon' },
{ time: '21:00', dose: '10', label: 'evening' }, { time: '22:00', dose: '10', label: 'evening' },
{ time: '01:00', dose: '0', label: 'night' }, { time: '01:00', dose: '0', label: 'night' },
], ],
steadyStateConfig: { daysOnMedication: '7' }, steadyStateConfig: { daysOnMedication: '7' },

View File

@@ -71,7 +71,11 @@ export const de = {
// Footer disclaimer // Footer disclaimer
importantNote: "Wichtiger Hinweis", importantNote: "Wichtiger Hinweis",
disclaimer: "Dieses Tool dient ausschließlich zu Illustrations- und Informationszwecken. Es ist kein medizinisches Gerät und ersetzt nicht die Beratung durch einen Arzt oder Apotheker. Alle Berechnungen sind Simulationen, die auf allgemeinen pharmakokinetischen Modellen basieren und von individuellen Faktoren erheblich abweichen können. Bitte konsultiere deinen behandelnden Arzt, bevor du Anpassungen an deiner Medikation vornimmst." disclaimer: "Dieses Tool dient ausschließlich zu Illustrations- und Informationszwecken. Es ist kein medizinisches Gerät und ersetzt nicht die Beratung durch einen Arzt oder Apotheker. Alle Berechnungen sind Simulationen, die auf allgemeinen pharmakokinetischen Modellen basieren und von individuellen Faktoren erheblich abweichen können. Bitte konsultiere deinen behandelnden Arzt, bevor du Anpassungen an deiner Medikation vornimmst.",
// Field validation
fieldRequired: "Dieses Feld ist erforderlich",
timeRequired: "Zeitangabe ist erforderlich"
}; };
export default de; export default de;

View File

@@ -71,7 +71,11 @@ export const en = {
// Footer disclaimer // Footer disclaimer
importantNote: "Important Notice", importantNote: "Important Notice",
disclaimer: "This tool is for illustration and information purposes only. It is not a medical device and does not replace consultation with a doctor or pharmacist. All calculations are simulations based on general pharmacokinetic models and may differ significantly from individual factors. Please consult your treating physician before making adjustments to your medication." disclaimer: "This tool is for illustration and information purposes only. It is not a medical device and does not replace consultation with a doctor or pharmacist. All calculations are simulations based on general pharmacokinetic models and may differ significantly from individual factors. Please consult your treating physician before making adjustments to your medication.",
// Field validation
fieldRequired: "This field is required",
timeRequired: "Time is required"
}; };
export default en; export default en;