2026-03-28 18:25:49 +09:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
|
|
|
import {
|
|
|
|
|
listRegimens,
|
|
|
|
|
createRegimen,
|
|
|
|
|
updateRegimen,
|
|
|
|
|
deleteRegimen,
|
|
|
|
|
getBurnRates,
|
|
|
|
|
} from '@/services/regimens';
|
|
|
|
|
import { listMedicines } from '@/services/medicines';
|
|
|
|
|
import {
|
|
|
|
|
DosageFrequency,
|
|
|
|
|
TimeOfDay,
|
|
|
|
|
DosageUnit,
|
2026-04-26 18:44:59 +09:00
|
|
|
type MedicineForm,
|
2026-03-28 18:25:49 +09:00
|
|
|
allowedUnitsForForm,
|
|
|
|
|
defaultUnitForForm,
|
|
|
|
|
} from '@meshitrack/shared';
|
|
|
|
|
import type { CreateRegimenInput } from '@meshitrack/shared';
|
|
|
|
|
import type { z } from 'zod/v4';
|
|
|
|
|
import type {
|
|
|
|
|
RegimenResponseSchema,
|
|
|
|
|
RegimenMedicationInputSchema,
|
|
|
|
|
BurnRateItemSchema,
|
|
|
|
|
} from '@meshitrack/shared';
|
|
|
|
|
|
|
|
|
|
type Regimen = z.infer<typeof RegimenResponseSchema>;
|
|
|
|
|
type MedicationInput = z.infer<typeof RegimenMedicationInputSchema>;
|
|
|
|
|
type BurnRateItem = z.infer<typeof BurnRateItemSchema>;
|
|
|
|
|
|
|
|
|
|
type MedicineOption = {
|
|
|
|
|
_id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
strength: number;
|
|
|
|
|
strengthUnit: string;
|
|
|
|
|
form: MedicineForm;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const FREQUENCY_LABELS: Record<string, string> = {
|
|
|
|
|
daily: 'Once daily',
|
|
|
|
|
twice_daily: 'Twice daily',
|
|
|
|
|
three_times_daily: 'Three times daily',
|
|
|
|
|
weekly: 'Weekly',
|
|
|
|
|
every_other_day: 'Every other day',
|
|
|
|
|
as_needed: 'As needed',
|
|
|
|
|
custom: 'Custom',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const TIME_LABELS: Record<string, string> = {
|
|
|
|
|
morning: 'Morning',
|
|
|
|
|
afternoon: 'Afternoon',
|
|
|
|
|
evening: 'Evening',
|
|
|
|
|
bedtime: 'Bedtime',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const FORM_LABELS: Record<string, string> = {
|
|
|
|
|
tablet: 'Tablet',
|
|
|
|
|
capsule: 'Capsule',
|
|
|
|
|
liquid: 'Liquid',
|
|
|
|
|
injection: 'Injection',
|
|
|
|
|
other: 'Other',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function formatDate(dateStr: string): string {
|
|
|
|
|
return new Date(dateStr).toLocaleDateString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Medication sub-form row ---
|
|
|
|
|
|
|
|
|
|
function MedicationRow({
|
|
|
|
|
medication,
|
|
|
|
|
index,
|
|
|
|
|
medicines,
|
|
|
|
|
onChange,
|
|
|
|
|
onRemove,
|
|
|
|
|
}: {
|
|
|
|
|
medication: MedicationInput;
|
|
|
|
|
index: number;
|
|
|
|
|
medicines: MedicineOption[];
|
|
|
|
|
onChange: (index: number, updated: MedicationInput) => void;
|
|
|
|
|
onRemove: (index: number) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const selectedMed = medicines.find((m) => m._id === medication.medicineId);
|
|
|
|
|
const allowedUnits = selectedMed
|
|
|
|
|
? allowedUnitsForForm(selectedMed.form)
|
|
|
|
|
: Object.values(DosageUnit);
|
|
|
|
|
|
|
|
|
|
function handleMedicineChange(medicineId: string) {
|
|
|
|
|
const med = medicines.find((m) => m._id === medicineId);
|
|
|
|
|
const newUnit = med ? defaultUnitForForm(med.form) : DosageUnit.TABLET;
|
|
|
|
|
onChange(index, { ...medication, medicineId, dosageUnit: newUnit });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-lg border bg-gray-50 p-4 space-y-3">
|
|
|
|
|
<div className="flex items-start justify-between gap-2">
|
|
|
|
|
<span className="text-sm font-medium text-gray-600">Medication {index + 1}</span>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => onRemove(index)}
|
2026-04-26 18:44:59 +09:00
|
|
|
className="mt-btn mt-btn--danger-icon"
|
2026-03-28 18:25:49 +09:00
|
|
|
title="Remove medication"
|
|
|
|
|
>
|
|
|
|
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
2026-04-26 18:44:59 +09:00
|
|
|
<path
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
d="M6 18L18 6M6 6l12 12"
|
|
|
|
|
/>
|
2026-03-28 18:25:49 +09:00
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
|
|
|
<div>
|
2026-04-26 18:44:59 +09:00
|
|
|
<label className="mt-field-label">Medicine</label>
|
2026-03-28 18:25:49 +09:00
|
|
|
<select
|
|
|
|
|
required
|
|
|
|
|
value={medication.medicineId}
|
|
|
|
|
onChange={(e) => handleMedicineChange(e.target.value)}
|
2026-04-26 18:44:59 +09:00
|
|
|
className="mt-field"
|
2026-03-28 18:25:49 +09:00
|
|
|
>
|
|
|
|
|
<option value="">Select medicine...</option>
|
|
|
|
|
{medicines.map((m) => (
|
|
|
|
|
<option key={m._id} value={m._id}>
|
|
|
|
|
{m.name} {m.strength} {m.strengthUnit} ({FORM_LABELS[m.form] ?? m.form})
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<div className="flex-1">
|
2026-04-26 18:44:59 +09:00
|
|
|
<label className="mt-field-label">Dosage</label>
|
2026-03-28 18:25:49 +09:00
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
required
|
|
|
|
|
min={0.01}
|
|
|
|
|
step="any"
|
|
|
|
|
value={medication.dosage || ''}
|
|
|
|
|
onChange={(e) => onChange(index, { ...medication, dosage: Number(e.target.value) })}
|
2026-04-26 18:44:59 +09:00
|
|
|
className="mt-field"
|
2026-03-28 18:25:49 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1">
|
2026-04-26 18:44:59 +09:00
|
|
|
<label className="mt-field-label">Unit</label>
|
2026-03-28 18:25:49 +09:00
|
|
|
<select
|
|
|
|
|
value={medication.dosageUnit}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
onChange(index, { ...medication, dosageUnit: e.target.value as DosageUnit })
|
|
|
|
|
}
|
2026-04-26 18:44:59 +09:00
|
|
|
className="mt-field"
|
2026-03-28 18:25:49 +09:00
|
|
|
>
|
|
|
|
|
{allowedUnits.map((u) => (
|
|
|
|
|
<option key={u} value={u}>
|
|
|
|
|
{u}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
2026-04-26 18:44:59 +09:00
|
|
|
<label className="mt-field-label">Frequency</label>
|
2026-03-28 18:25:49 +09:00
|
|
|
<select
|
|
|
|
|
value={medication.frequency}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
onChange(index, {
|
|
|
|
|
...medication,
|
|
|
|
|
frequency: e.target.value as DosageFrequency,
|
|
|
|
|
customFrequencyPerDay:
|
|
|
|
|
e.target.value === DosageFrequency.CUSTOM
|
|
|
|
|
? (medication.customFrequencyPerDay ?? 1)
|
|
|
|
|
: undefined,
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-04-26 18:44:59 +09:00
|
|
|
className="mt-field"
|
2026-03-28 18:25:49 +09:00
|
|
|
>
|
|
|
|
|
{Object.values(DosageFrequency).map((f) => (
|
|
|
|
|
<option key={f} value={f}>
|
|
|
|
|
{FREQUENCY_LABELS[f] ?? f}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{medication.frequency === DosageFrequency.CUSTOM && (
|
|
|
|
|
<div>
|
2026-04-26 18:44:59 +09:00
|
|
|
<label className="mt-field-label">Times per day</label>
|
2026-03-28 18:25:49 +09:00
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
required
|
|
|
|
|
min={1}
|
|
|
|
|
step={1}
|
|
|
|
|
value={medication.customFrequencyPerDay ?? ''}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
onChange(index, { ...medication, customFrequencyPerDay: Number(e.target.value) })
|
|
|
|
|
}
|
2026-04-26 18:44:59 +09:00
|
|
|
className="mt-field"
|
2026-03-28 18:25:49 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div>
|
2026-04-26 18:44:59 +09:00
|
|
|
<label className="mt-field-label">Time of day (optional)</label>
|
2026-03-28 18:25:49 +09:00
|
|
|
<select
|
|
|
|
|
value={medication.timeOfDay ?? ''}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
onChange(index, {
|
|
|
|
|
...medication,
|
|
|
|
|
timeOfDay: e.target.value ? (e.target.value as TimeOfDay) : undefined,
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-04-26 18:44:59 +09:00
|
|
|
className="mt-field"
|
2026-03-28 18:25:49 +09:00
|
|
|
>
|
|
|
|
|
<option value="">Any time</option>
|
|
|
|
|
{Object.values(TimeOfDay).map((t) => (
|
|
|
|
|
<option key={t} value={t}>
|
|
|
|
|
{TIME_LABELS[t] ?? t}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
2026-04-26 18:44:59 +09:00
|
|
|
<label className="mt-field-label">Instructions (optional)</label>
|
2026-03-28 18:25:49 +09:00
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
maxLength={500}
|
|
|
|
|
value={medication.instructions ?? ''}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
onChange(index, { ...medication, instructions: e.target.value || undefined })
|
|
|
|
|
}
|
|
|
|
|
placeholder="e.g. Take with food"
|
2026-04-26 18:44:59 +09:00
|
|
|
className="mt-field"
|
2026-03-28 18:25:49 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Regimen create / edit form ---
|
|
|
|
|
|
|
|
|
|
function emptyMedication(): MedicationInput {
|
|
|
|
|
return {
|
|
|
|
|
medicineId: '',
|
|
|
|
|
dosage: 1,
|
|
|
|
|
dosageUnit: DosageUnit.TABLET,
|
|
|
|
|
frequency: DosageFrequency.DAILY,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function RegimenForm({
|
|
|
|
|
householdId,
|
|
|
|
|
medicines,
|
|
|
|
|
initial,
|
|
|
|
|
onSaved,
|
|
|
|
|
onCancel,
|
|
|
|
|
}: {
|
|
|
|
|
householdId: string;
|
|
|
|
|
medicines: MedicineOption[];
|
|
|
|
|
initial?: Regimen;
|
|
|
|
|
onSaved: () => void;
|
|
|
|
|
onCancel: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const [name, setName] = useState(initial?.name ?? '');
|
|
|
|
|
const [isActive, setIsActive] = useState(initial?.isActive ?? true);
|
|
|
|
|
const [medications, setMedications] = useState<MedicationInput[]>(
|
|
|
|
|
initial?.medications.map((m) => ({
|
|
|
|
|
medicineId: m.medicineId,
|
|
|
|
|
dosage: m.dosage,
|
|
|
|
|
dosageUnit: m.dosageUnit as DosageUnit,
|
|
|
|
|
frequency: m.frequency as DosageFrequency,
|
|
|
|
|
customFrequencyPerDay: m.customFrequencyPerDay,
|
|
|
|
|
timeOfDay: m.timeOfDay as TimeOfDay | undefined,
|
|
|
|
|
instructions: m.instructions,
|
|
|
|
|
})) ?? [emptyMedication()],
|
|
|
|
|
);
|
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
|
const [error, setError] = useState('');
|
|
|
|
|
|
|
|
|
|
function updateMedication(index: number, updated: MedicationInput) {
|
|
|
|
|
setMedications((prev) => prev.map((m, i) => (i === index ? updated : m)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function removeMedication(index: number) {
|
|
|
|
|
setMedications((prev) => prev.filter((_, i) => i !== index));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addMedication() {
|
|
|
|
|
setMedications((prev) => [...prev, emptyMedication()]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (medications.length === 0) {
|
|
|
|
|
setError('At least one medication is required.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setError('');
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
try {
|
|
|
|
|
const payload: CreateRegimenInput = { name: name.trim(), isActive, medications };
|
|
|
|
|
if (initial) {
|
|
|
|
|
await updateRegimen(householdId, initial._id, payload);
|
|
|
|
|
} else {
|
|
|
|
|
await createRegimen(householdId, payload);
|
|
|
|
|
}
|
|
|
|
|
onSaved();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Failed to save regimen');
|
|
|
|
|
} finally {
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-26 18:44:59 +09:00
|
|
|
<div className="mt-card mb-6">
|
2026-03-28 18:25:49 +09:00
|
|
|
<h2 className="text-lg font-semibold mb-4">{initial ? 'Edit Regimen' : 'New Regimen'}</h2>
|
2026-04-26 18:44:59 +09:00
|
|
|
{error && <div className="mt-alert mt-alert--danger mb-4">{error}</div>}
|
2026-03-28 18:25:49 +09:00
|
|
|
<form onSubmit={handleSubmit} className="space-y-5">
|
|
|
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
|
|
|
<div>
|
2026-04-26 18:44:59 +09:00
|
|
|
<label className="mt-field-label">Name</label>
|
2026-03-28 18:25:49 +09:00
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
required
|
|
|
|
|
maxLength={200}
|
|
|
|
|
value={name}
|
|
|
|
|
onChange={(e) => setName(e.target.value)}
|
|
|
|
|
placeholder="e.g. Morning routine"
|
2026-04-26 18:44:59 +09:00
|
|
|
className="mt-field"
|
2026-03-28 18:25:49 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-3 pt-6">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
id="isActive"
|
|
|
|
|
checked={isActive}
|
|
|
|
|
onChange={(e) => setIsActive(e.target.checked)}
|
2026-04-26 18:44:59 +09:00
|
|
|
className="h-4 w-4 rounded border-gray-300"
|
2026-03-28 18:25:49 +09:00
|
|
|
/>
|
|
|
|
|
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
|
|
|
|
|
Active
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex items-center justify-between mb-3">
|
|
|
|
|
<h3 className="text-sm font-semibold text-gray-800">Medications</h3>
|
2026-04-26 18:44:59 +09:00
|
|
|
<button type="button" onClick={addMedication} className="mt-btn mt-btn--ghost">
|
2026-03-28 18:25:49 +09:00
|
|
|
+ Add medication
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{medications.length === 0 ? (
|
|
|
|
|
<p className="text-sm text-gray-500 italic">No medications added yet.</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{medications.map((med, i) => (
|
|
|
|
|
<MedicationRow
|
|
|
|
|
key={i}
|
|
|
|
|
index={i}
|
|
|
|
|
medication={med}
|
|
|
|
|
medicines={medicines}
|
|
|
|
|
onChange={updateMedication}
|
|
|
|
|
onRemove={removeMedication}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-3 pt-2">
|
2026-04-26 18:44:59 +09:00
|
|
|
<button type="submit" disabled={submitting} className="mt-btn mt-btn--primary">
|
2026-03-28 18:25:49 +09:00
|
|
|
{submitting ? 'Saving...' : initial ? 'Save changes' : 'Create regimen'}
|
|
|
|
|
</button>
|
2026-04-26 18:44:59 +09:00
|
|
|
<button type="button" onClick={onCancel} className="mt-btn mt-btn--ghost">
|
2026-03-28 18:25:49 +09:00
|
|
|
Cancel
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Burn rate table ---
|
|
|
|
|
|
|
|
|
|
function BurnRateTable({ burnRates }: { burnRates: BurnRateItem[] }) {
|
|
|
|
|
if (burnRates.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<p className="text-sm text-gray-500">
|
|
|
|
|
No active regimens with cabinet stock to calculate burn rates.
|
|
|
|
|
</p>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
<table className="w-full text-sm">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr className="border-b text-left text-gray-500">
|
|
|
|
|
<th className="pb-2 font-medium">Medicine</th>
|
|
|
|
|
<th className="pb-2 font-medium text-right">Daily use</th>
|
|
|
|
|
<th className="pb-2 font-medium text-right">In cabinet</th>
|
|
|
|
|
<th className="pb-2 font-medium text-right">Days left</th>
|
|
|
|
|
<th className="pb-2 font-medium text-right">Monthly cost</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="divide-y divide-gray-100">
|
|
|
|
|
{burnRates.map((item) => {
|
|
|
|
|
const daysLeft = item.daysUntilEmpty;
|
|
|
|
|
const daysColor =
|
|
|
|
|
daysLeft === null
|
|
|
|
|
? 'text-gray-400'
|
|
|
|
|
: daysLeft <= 7
|
|
|
|
|
? 'text-red-600 font-semibold'
|
|
|
|
|
: daysLeft <= 30
|
|
|
|
|
? 'text-yellow-600'
|
|
|
|
|
: 'text-green-600';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<tr key={item.medicineId} className="py-2">
|
|
|
|
|
<td className="py-2 font-medium text-gray-900">{item.medicineName}</td>
|
|
|
|
|
<td className="py-2 text-right text-gray-600">
|
|
|
|
|
{item.dailyConsumption.toFixed(2)}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="py-2 text-right text-gray-600">{item.totalInCabinet}</td>
|
|
|
|
|
<td className={`py-2 text-right ${daysColor}`}>
|
|
|
|
|
{daysLeft !== null ? daysLeft : '-'}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="py-2 text-right text-gray-600">
|
|
|
|
|
{item.projectedMonthlyCost !== null
|
|
|
|
|
? `${item.currency ?? ''} ${item.projectedMonthlyCost.toFixed(2)}`.trim()
|
|
|
|
|
: '-'}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Main component ---
|
|
|
|
|
|
|
|
|
|
export function RegimensTab({ householdId }: { householdId: string }) {
|
|
|
|
|
const [regimens, setRegimens] = useState<Regimen[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [error, setError] = useState('');
|
|
|
|
|
const [medicines, setMedicines] = useState<MedicineOption[]>([]);
|
|
|
|
|
const [showForm, setShowForm] = useState(false);
|
|
|
|
|
const [editingRegimen, setEditingRegimen] = useState<Regimen | null>(null);
|
|
|
|
|
const [filterActive, setFilterActive] = useState<'all' | 'active' | 'inactive'>('all');
|
|
|
|
|
const [burnRates, setBurnRates] = useState<BurnRateItem[]>([]);
|
|
|
|
|
const [showBurnRate, setShowBurnRate] = useState(false);
|
|
|
|
|
const [burnRateLoading, setBurnRateLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
const fetchRegimens = useCallback(async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const query =
|
|
|
|
|
filterActive === 'active'
|
|
|
|
|
? { isActive: true }
|
|
|
|
|
: filterActive === 'inactive'
|
|
|
|
|
? { isActive: false }
|
|
|
|
|
: {};
|
|
|
|
|
const result = await listRegimens(householdId, { ...query, limit: 50 });
|
|
|
|
|
setRegimens(result.data);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Failed to load regimens');
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, [householdId, filterActive]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetchRegimens();
|
|
|
|
|
}, [fetchRegimens]);
|
|
|
|
|
|
|
|
|
|
// Medicines are needed for the form
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
listMedicines(householdId, { limit: 100 })
|
|
|
|
|
.then((r) => setMedicines(r.data as MedicineOption[]))
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
}, [householdId]);
|
|
|
|
|
|
|
|
|
|
async function handleDelete(id: string, name: string) {
|
|
|
|
|
if (!confirm(`Delete regimen "${name}"?`)) return;
|
|
|
|
|
try {
|
|
|
|
|
await deleteRegimen(householdId, id);
|
|
|
|
|
setRegimens((prev) => prev.filter((r) => r._id !== id));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Failed to delete');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleToggleActive(regimen: Regimen) {
|
|
|
|
|
try {
|
|
|
|
|
const updated = await updateRegimen(householdId, regimen._id, {
|
|
|
|
|
isActive: !regimen.isActive,
|
|
|
|
|
});
|
|
|
|
|
setRegimens((prev) => prev.map((r) => (r._id === regimen._id ? updated : r)));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Failed to update');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleShowBurnRate() {
|
|
|
|
|
setShowBurnRate((prev) => !prev);
|
|
|
|
|
if (!showBurnRate && burnRates.length === 0) {
|
|
|
|
|
setBurnRateLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const result = await getBurnRates(householdId);
|
|
|
|
|
setBurnRates(result.data);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Failed to load burn rates');
|
|
|
|
|
} finally {
|
|
|
|
|
setBurnRateLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isFormOpen = showForm || editingRegimen !== null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<select
|
|
|
|
|
value={filterActive}
|
|
|
|
|
onChange={(e) => setFilterActive(e.target.value as 'all' | 'active' | 'inactive')}
|
2026-04-26 18:44:59 +09:00
|
|
|
className="mt-field"
|
|
|
|
|
style={{ width: 'auto' }}
|
2026-03-28 18:25:49 +09:00
|
|
|
>
|
|
|
|
|
<option value="all">All regimens</option>
|
|
|
|
|
<option value="active">Active</option>
|
|
|
|
|
<option value="inactive">Inactive</option>
|
|
|
|
|
</select>
|
2026-04-26 18:44:59 +09:00
|
|
|
<button onClick={handleShowBurnRate} className="mt-btn mt-btn--ghost">
|
2026-03-28 18:25:49 +09:00
|
|
|
{showBurnRate ? 'Hide burn rate' : 'Burn rate'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setEditingRegimen(null);
|
|
|
|
|
setShowForm(!showForm);
|
|
|
|
|
}}
|
2026-04-26 18:44:59 +09:00
|
|
|
className="mt-btn mt-btn--primary"
|
2026-03-28 18:25:49 +09:00
|
|
|
>
|
|
|
|
|
{showForm ? 'Cancel' : 'New Regimen'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{error && (
|
2026-04-26 18:44:59 +09:00
|
|
|
<div className="mt-alert mt-alert--danger mb-4">
|
2026-03-28 18:25:49 +09:00
|
|
|
{error}
|
|
|
|
|
<button onClick={() => setError('')} className="ml-2 underline">
|
|
|
|
|
Dismiss
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{showBurnRate && (
|
2026-04-26 18:44:59 +09:00
|
|
|
<div className="mb-6 mt-card">
|
2026-03-28 18:25:49 +09:00
|
|
|
<h2 className="text-lg font-semibold mb-4">Burn Rate & Spending Projections</h2>
|
|
|
|
|
{burnRateLoading ? (
|
|
|
|
|
<div className="animate-pulse space-y-2">
|
|
|
|
|
<div className="h-6 w-full rounded bg-gray-200" />
|
|
|
|
|
<div className="h-6 w-full rounded bg-gray-200" />
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<BurnRateTable burnRates={burnRates} />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{showForm && !editingRegimen && (
|
|
|
|
|
<RegimenForm
|
|
|
|
|
householdId={householdId}
|
|
|
|
|
medicines={medicines}
|
|
|
|
|
onSaved={() => {
|
|
|
|
|
setShowForm(false);
|
|
|
|
|
fetchRegimens();
|
|
|
|
|
setBurnRates([]);
|
|
|
|
|
}}
|
|
|
|
|
onCancel={() => setShowForm(false)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{editingRegimen && (
|
|
|
|
|
<RegimenForm
|
|
|
|
|
householdId={householdId}
|
|
|
|
|
medicines={medicines}
|
|
|
|
|
initial={editingRegimen}
|
|
|
|
|
onSaved={() => {
|
|
|
|
|
setEditingRegimen(null);
|
|
|
|
|
fetchRegimens();
|
|
|
|
|
setBurnRates([]);
|
|
|
|
|
}}
|
|
|
|
|
onCancel={() => setEditingRegimen(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
|
|
|
<div key={i} className="animate-pulse rounded-xl border bg-white p-4 h-24" />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : regimens.length === 0 ? (
|
2026-04-26 18:44:59 +09:00
|
|
|
<div className="mt-card text-center" style={{ color: 'var(--ink-muted)' }}>
|
2026-03-28 18:25:49 +09:00
|
|
|
{filterActive !== 'all'
|
|
|
|
|
? `No ${filterActive} regimens found.`
|
|
|
|
|
: isFormOpen
|
|
|
|
|
? null
|
|
|
|
|
: 'No regimens yet. Create your first medication schedule above.'}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{regimens.map((regimen) => (
|
2026-04-26 18:44:59 +09:00
|
|
|
<div key={regimen._id} className="mt-card">
|
2026-03-28 18:25:49 +09:00
|
|
|
<div className="flex items-start justify-between gap-4">
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="flex items-center gap-2 mb-1">
|
|
|
|
|
<h3 className="font-semibold text-gray-900">{regimen.name}</h3>
|
|
|
|
|
<span
|
2026-04-26 18:44:59 +09:00
|
|
|
className={`mt-pill ${regimen.isActive ? 'mt-pill--ok' : 'mt-pill--ghost'}`}
|
2026-03-28 18:25:49 +09:00
|
|
|
>
|
|
|
|
|
{regimen.isActive ? 'Active' : 'Inactive'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-sm text-gray-500 mb-2">
|
|
|
|
|
{regimen.medications.length} medication
|
|
|
|
|
{regimen.medications.length !== 1 ? 's' : ''}
|
|
|
|
|
</p>
|
|
|
|
|
<div className="flex flex-wrap gap-1">
|
|
|
|
|
{regimen.medications.map((med, i) => (
|
2026-04-26 18:44:59 +09:00
|
|
|
<span key={i} className="mt-pill mt-pill--info">
|
2026-03-28 18:25:49 +09:00
|
|
|
{med.medicineName} — {med.dosage} {med.dosageUnit} (
|
|
|
|
|
{FREQUENCY_LABELS[med.frequency] ?? med.frequency})
|
|
|
|
|
</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-04-26 18:44:59 +09:00
|
|
|
<p className="mt-2 text-xs text-gray-400">
|
|
|
|
|
Created {formatDate(regimen.createdAt)}
|
|
|
|
|
</p>
|
2026-03-28 18:25:49 +09:00
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleToggleActive(regimen)}
|
2026-04-26 18:44:59 +09:00
|
|
|
className="mt-btn mt-btn--ghost"
|
2026-03-28 18:25:49 +09:00
|
|
|
title={regimen.isActive ? 'Deactivate' : 'Activate'}
|
|
|
|
|
>
|
|
|
|
|
{regimen.isActive ? 'Deactivate' : 'Activate'}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setShowForm(false);
|
|
|
|
|
setEditingRegimen(regimen);
|
|
|
|
|
}}
|
2026-04-26 18:44:59 +09:00
|
|
|
className="mt-btn mt-btn--icon"
|
2026-03-28 18:25:49 +09:00
|
|
|
title="Edit"
|
|
|
|
|
>
|
|
|
|
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
|
|
|
|
/>
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleDelete(regimen._id, regimen.name)}
|
2026-04-26 18:44:59 +09:00
|
|
|
className="mt-btn mt-btn--danger-icon"
|
2026-03-28 18:25:49 +09:00
|
|
|
title="Delete"
|
|
|
|
|
>
|
|
|
|
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
|
|
|
/>
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|