Allow scheduling multiple medications at once, remove time conflicts

- Multi-med creation form: add any number of medication cards in one session, each with independent name/dosage/unit/frequency/times settings
- Submit button labels dynamically (Add 1 / Add N Medications)
- Removed all schedule conflict checking — medications can now coexist at the same time slot as each other and as routines

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 19:38:44 -06:00
parent 33db2629e3
commit 4c4ff5add3
2 changed files with 263 additions and 255 deletions

View File

@@ -3,7 +3,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import { ArrowLeftIcon } from '@/components/ui/Icons';
import { ArrowLeftIcon, PlusIcon, TrashIcon } from '@/components/ui/Icons';
const DAY_OPTIONS = [
{ value: 'mon', label: 'Mon' },
@@ -15,63 +15,258 @@ const DAY_OPTIONS = [
{ value: 'sun', label: 'Sun' },
];
interface MedEntry {
id: string;
name: string;
dosage: string;
unit: string;
frequency: string;
times: string[];
daysOfWeek: string[];
intervalDays: number;
startDate: string;
}
function blankEntry(): MedEntry {
return {
id: `med-${Date.now()}-${Math.random()}`,
name: '',
dosage: '',
unit: 'mg',
frequency: 'daily',
times: ['08:00'],
daysOfWeek: [],
intervalDays: 7,
startDate: new Date().toISOString().slice(0, 10),
};
}
function MedCard({
entry,
index,
total,
onChange,
onRemove,
}: {
entry: MedEntry;
index: number;
total: number;
onChange: (updates: Partial<MedEntry>) => void;
onRemove: () => void;
}) {
const handleAddTime = () => onChange({ times: [...entry.times, '12:00'] });
const handleRemoveTime = (i: number) => onChange({ times: entry.times.filter((_, idx) => idx !== i) });
const handleTimeChange = (i: number, val: string) => {
const t = [...entry.times];
t[i] = val;
onChange({ times: t });
};
const toggleDay = (day: string) =>
onChange({
daysOfWeek: entry.daysOfWeek.includes(day)
? entry.daysOfWeek.filter(d => d !== day)
: [...entry.daysOfWeek, day],
});
return (
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-500 dark:text-gray-400">
Medication {index + 1}
</span>
{total > 1 && (
<button type="button" onClick={onRemove} className="text-red-500 dark:text-red-400 p-1">
<TrashIcon size={18} />
</button>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
<input
type="text"
value={entry.name}
onChange={e => onChange({ name: e.target.value })}
placeholder="e.g., Vitamin D"
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Dosage</label>
<input
type="text"
value={entry.dosage}
onChange={e => onChange({ dosage: e.target.value })}
placeholder="e.g., 1000"
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Unit</label>
<select
value={entry.unit}
onChange={e => onChange({ unit: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
>
<option value="mg">mg</option>
<option value="mcg">mcg</option>
<option value="g">g</option>
<option value="ml">ml</option>
<option value="IU">IU</option>
<option value="tablets">tablets</option>
<option value="capsules">capsules</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Frequency</label>
<select
value={entry.frequency}
onChange={e => onChange({ frequency: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
>
<option value="daily">Daily</option>
<option value="specific_days">Specific Days of Week</option>
<option value="every_n_days">Every N Days</option>
<option value="as_needed">As Needed (PRN)</option>
</select>
</div>
{entry.frequency === 'specific_days' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label>
<div className="flex gap-2 flex-wrap">
{DAY_OPTIONS.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => toggleDay(value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
entry.daysOfWeek.includes(value)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
{label}
</button>
))}
</div>
</div>
)}
{entry.frequency === 'every_n_days' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Every N Days</label>
<input
type="number"
min={1}
value={entry.intervalDays}
onChange={e => onChange({ intervalDays: parseInt(e.target.value) || 1 })}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Starting From</label>
<input
type="date"
value={entry.startDate}
onChange={e => onChange({ startDate: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
</div>
)}
{entry.frequency !== 'as_needed' && (
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Times</label>
<button
type="button"
onClick={handleAddTime}
className="text-indigo-600 dark:text-indigo-400 text-sm font-medium"
>
+ Add Time
</button>
</div>
<div className="space-y-2">
{entry.times.map((time, i) => (
<div key={i} className="flex gap-2">
<input
type="time"
value={time}
onChange={e => handleTimeChange(i, e.target.value)}
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
{entry.times.length > 1 && (
<button
type="button"
onClick={() => handleRemoveTime(i)}
className="text-red-500 dark:text-red-400 px-3"
>
Remove
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
export default function NewMedicationPage() {
const router = useRouter();
const [name, setName] = useState('');
const [dosage, setDosage] = useState('');
const [unit, setUnit] = useState('mg');
const [frequency, setFrequency] = useState('daily');
const [times, setTimes] = useState<string[]>(['08:00']);
const [daysOfWeek, setDaysOfWeek] = useState<string[]>([]);
const [intervalDays, setIntervalDays] = useState(7);
const [startDate, setStartDate] = useState(new Date().toISOString().slice(0, 10));
const [entries, setEntries] = useState<MedEntry[]>([blankEntry()]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const handleAddTime = () => {
setTimes([...times, '12:00']);
const updateEntry = (index: number, updates: Partial<MedEntry>) => {
setEntries(prev => prev.map((e, i) => (i === index ? { ...e, ...updates } : e)));
};
const handleRemoveTime = (index: number) => {
setTimes(times.filter((_, i) => i !== index));
};
const handleTimeChange = (index: number, value: string) => {
const newTimes = [...times];
newTimes[index] = value;
setTimes(newTimes);
};
const toggleDay = (day: string) => {
setDaysOfWeek(prev =>
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day]
);
const removeEntry = (index: number) => {
setEntries(prev => prev.filter((_, i) => i !== index));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !dosage.trim()) {
setError('Name and dosage are required');
return;
}
if (frequency === 'specific_days' && daysOfWeek.length === 0) {
setError('Select at least one day of the week');
return;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!entry.name.trim() || !entry.dosage.trim()) {
setError(`Medication ${i + 1}: name and dosage are required`);
return;
}
if (entry.frequency === 'specific_days' && entry.daysOfWeek.length === 0) {
setError(`Medication ${i + 1}: select at least one day of the week`);
return;
}
}
setIsLoading(true);
setError('');
try {
await api.medications.create({
name,
dosage,
unit,
frequency,
times: frequency === 'as_needed' ? [] : times,
...(frequency === 'specific_days' && { days_of_week: daysOfWeek }),
...(frequency === 'every_n_days' && { interval_days: intervalDays, start_date: startDate }),
});
for (const entry of entries) {
await api.medications.create({
name: entry.name,
dosage: entry.dosage,
unit: entry.unit,
frequency: entry.frequency,
times: entry.frequency === 'as_needed' ? [] : entry.times,
...(entry.frequency === 'specific_days' && { days_of_week: entry.daysOfWeek }),
...(entry.frequency === 'every_n_days' && {
interval_days: entry.intervalDays,
start_date: entry.startDate,
}),
});
}
router.push('/dashboard/medications');
} catch (err) {
setError((err as Error).message || 'Failed to add medication');
@@ -80,6 +275,8 @@ export default function NewMedicationPage() {
}
};
const count = entries.length;
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10">
@@ -87,167 +284,47 @@ export default function NewMedicationPage() {
<button onClick={() => router.back()} className="p-1 text-gray-600 dark:text-gray-400">
<ArrowLeftIcon size={24} />
</button>
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Add Medication</h1>
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Add Medications</h1>
</div>
</header>
<form onSubmit={handleSubmit} className="p-4 space-y-6">
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{error && (
<div className="bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Medication Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Vitamin D"
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
{entries.map((entry, index) => (
<MedCard
key={entry.id}
entry={entry}
index={index}
total={count}
onChange={updates => updateEntry(index, updates)}
onRemove={() => removeEntry(index)}
/>
))}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Dosage</label>
<input
type="text"
value={dosage}
onChange={(e) => setDosage(e.target.value)}
placeholder="e.g., 1000"
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Unit</label>
<select
value={unit}
onChange={(e) => setUnit(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
>
<option value="mg">mg</option>
<option value="mcg">mcg</option>
<option value="g">g</option>
<option value="ml">ml</option>
<option value="IU">IU</option>
<option value="tablets">tablets</option>
<option value="capsules">capsules</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Frequency</label>
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
>
<option value="daily">Daily</option>
<option value="specific_days">Specific Days of Week</option>
<option value="every_n_days">Every N Days</option>
<option value="as_needed">As Needed (PRN)</option>
</select>
</div>
{/* Day-of-week picker for specific_days */}
{frequency === 'specific_days' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label>
<div className="flex gap-2 flex-wrap">
{DAY_OPTIONS.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => toggleDay(value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
daysOfWeek.includes(value)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
{label}
</button>
))}
</div>
</div>
)}
{/* Interval settings for every_n_days */}
{frequency === 'every_n_days' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Every N Days</label>
<input
type="number"
min={1}
value={intervalDays}
onChange={(e) => setIntervalDays(parseInt(e.target.value) || 1)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Starting From</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
</div>
)}
{/* Times picker — hidden for as_needed */}
{frequency !== 'as_needed' && (
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Times</label>
<button
type="button"
onClick={handleAddTime}
className="text-indigo-600 dark:text-indigo-400 text-sm font-medium"
>
+ Add Time
</button>
</div>
{frequency === 'daily' && (
<p className="text-xs text-gray-400 dark:text-gray-500 mb-2">Add multiple times for 2x, 3x, or more doses per day</p>
)}
<div className="space-y-2">
{times.map((time, index) => (
<div key={index} className="flex gap-2">
<input
type="time"
value={time}
onChange={(e) => handleTimeChange(index, e.target.value)}
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
{times.length > 1 && (
<button
type="button"
onClick={() => handleRemoveTime(index)}
className="text-red-500 dark:text-red-400 px-3"
>
Remove
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
<button
type="button"
onClick={() => setEntries(prev => [...prev, blankEntry()])}
className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-indigo-300 dark:border-indigo-700 rounded-xl text-indigo-600 dark:text-indigo-400 font-medium hover:border-indigo-400 dark:hover:border-indigo-600 transition-colors"
>
<PlusIcon size={18} />
Add Another Medication
</button>
<button
type="submit"
disabled={isLoading}
className="w-full bg-indigo-600 text-white font-semibold py-4 rounded-xl hover:bg-indigo-700 disabled:opacity-50"
>
{isLoading ? 'Adding...' : 'Add Medication'}
{isLoading
? 'Adding...'
: count === 1
? 'Add Medication'
: `Add ${count} Medications`}
</button>
</form>
</div>