New foundations: - src/types/index.ts — shared domain types (Child, Log, GrowthRecord, Medicine, Dose, Allergy, Visit, Illness, Vaccination, AIChat, ChatSession, Goal) - src/lib/formatting.ts — calculateAge, formatAge, formatTimeAgo (eliminates 3 duplicate implementations spread across page.tsx and growth/page.tsx) - src/lib/api.ts — typed fetch helpers (api.get/post/patch/delete) with consistent error handling; replaces manual fetch boilerplate New shared components: - src/components/PageHeader.tsx — reusable back-link + title header - src/components/TabBar.tsx — horizontal pill tab bar - src/components/CalendarView.tsx — extracted from activity/page.tsx (was ~170 inline lines) - src/components/medical/ — medical page split into 5 focused tab components: VaccineTab, MedicineTab, AllergyTab, VisitTab, IllnessTab Pages updated: - medical/page.tsx: 1029 → 42 lines (thin shell wiring the 5 tab components) - activity/page.tsx: uses CalendarView + shared Log type + api.ts - growth/page.tsx: uses shared GrowthRecord/Goal types + formatAge; fixes `any` catch clauses; fixes undefined → null in Chart.js dataset values - page.tsx (home): uses shared Log/AIChat/ChatSession types + formatTimeAgo/ calculateAge from formatting.ts; removes inline type definitions - ai/page.tsx: uses shared AIChat/ChatSession types - FamilyProvider.tsx: uses shared Child type; fixes `c: any` mapping Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
247 lines
11 KiB
TypeScript
247 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { Button, Input, Modal } from "@/components/ui";
|
||
import { api } from "@/lib/api";
|
||
import type { Medicine, Dose } from "@/types";
|
||
|
||
interface Props { childId: string }
|
||
|
||
export function MedicineTab({ childId }: Props) {
|
||
const [medicines, setMedicines] = useState<Medicine[]>([]);
|
||
const [editingMed, setEditingMed] = useState<Medicine | null>(null);
|
||
const [showAddMed, setShowAddMed] = useState(false);
|
||
const [name, setName] = useState("");
|
||
const [dose, setDose] = useState("");
|
||
const [notes, setNotes] = useState("");
|
||
|
||
const [logDoseMedId, setLogDoseMedId] = useState<string | null>(null);
|
||
const [doseAmount, setDoseAmount] = useState("");
|
||
const [doseNotes, setDoseNotes] = useState("");
|
||
const [doseTime, setDoseTime] = useState(() => new Date().toISOString().slice(0, 16));
|
||
const [doseLoading, setDoseLoading] = useState(false);
|
||
|
||
const [doseHistory, setDoseHistory] = useState<Record<string, Dose[]>>({});
|
||
const [showDoseHistory, setShowDoseHistory] = useState<Record<string, boolean>>({});
|
||
|
||
const [correctDose, setCorrectDose] = useState<{ medId: string; dose: Dose } | null>(null);
|
||
const [correctAmount, setCorrectAmount] = useState("");
|
||
const [correctNotes, setCorrectNotes] = useState("");
|
||
const [correctReason, setCorrectReason] = useState("");
|
||
const [correctLoading, setCorrectLoading] = useState(false);
|
||
|
||
useEffect(() => { fetchMedicines(); }, [childId]);
|
||
|
||
const fetchMedicines = () =>
|
||
api.get<{ medicines: Medicine[] }>(`/api/medicines?childId=${childId}`)
|
||
.then(d => setMedicines(d.medicines || []))
|
||
.catch(console.error);
|
||
|
||
const saveMedicine = async () => {
|
||
if (!name) return;
|
||
try {
|
||
if (editingMed) {
|
||
await api.patch("/api/medicines", { id: editingMed.id, name, dose, notes });
|
||
} else {
|
||
await api.post("/api/medicines", { childId, name, dose, notes });
|
||
}
|
||
fetchMedicines();
|
||
} catch (err) { console.error("Failed to save medicine:", err); }
|
||
resetForm();
|
||
};
|
||
|
||
const deleteMedicine = async (id: string) => {
|
||
try {
|
||
await api.delete(`/api/medicines?id=${id}`);
|
||
fetchMedicines();
|
||
} catch (err) { console.error("Failed to delete medicine:", err); }
|
||
};
|
||
|
||
const editMedicine = (med: Medicine) => {
|
||
setEditingMed(med);
|
||
setName(med.name);
|
||
setDose(med.dose);
|
||
setNotes(med.notes);
|
||
setShowAddMed(true);
|
||
};
|
||
|
||
const resetForm = () => {
|
||
setEditingMed(null);
|
||
setName("");
|
||
setDose("");
|
||
setNotes("");
|
||
setShowAddMed(false);
|
||
};
|
||
|
||
const openLogDose = (medId: string) => {
|
||
setLogDoseMedId(medId);
|
||
setDoseAmount("");
|
||
setDoseNotes("");
|
||
setDoseTime(new Date().toISOString().slice(0, 16));
|
||
};
|
||
|
||
const submitDose = async () => {
|
||
if (!logDoseMedId) return;
|
||
setDoseLoading(true);
|
||
try {
|
||
await api.post(`/api/medicines/${logDoseMedId}/doses`, {
|
||
amountGiven: doseAmount,
|
||
notes: doseNotes,
|
||
administeredAt: new Date(doseTime).toISOString(),
|
||
});
|
||
const medId = logDoseMedId;
|
||
setLogDoseMedId(null);
|
||
if (showDoseHistory[medId]) fetchDoseHistory(medId);
|
||
} catch (err) { console.error("Failed to log dose:", err); }
|
||
setDoseLoading(false);
|
||
};
|
||
|
||
const fetchDoseHistory = (medId: string) =>
|
||
api.get<{ doses: Dose[] }>(`/api/medicines/${medId}/doses`)
|
||
.then(d => setDoseHistory(prev => ({ ...prev, [medId]: d.doses || [] })))
|
||
.catch(console.error);
|
||
|
||
const toggleDoseHistory = (medId: string) => {
|
||
const next = !showDoseHistory[medId];
|
||
setShowDoseHistory(prev => ({ ...prev, [medId]: next }));
|
||
if (next && !doseHistory[medId]) fetchDoseHistory(medId);
|
||
};
|
||
|
||
const submitCorrection = async () => {
|
||
if (!correctDose) return;
|
||
setCorrectLoading(true);
|
||
try {
|
||
await api.post(`/api/medicines/${correctDose.medId}/doses/${correctDose.dose.id}/correct`, {
|
||
amountGiven: correctAmount,
|
||
notes: correctNotes,
|
||
reason: correctReason,
|
||
});
|
||
fetchDoseHistory(correctDose.medId);
|
||
setCorrectDose(null);
|
||
} catch (err) { console.error("Failed to submit correction:", err); }
|
||
setCorrectLoading(false);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
<h2 className="font-semibold mb-3">Medicine & Supplements</h2>
|
||
|
||
{showAddMed && (
|
||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl space-y-3">
|
||
<Input placeholder="Medicine name" value={name} onChange={e => setName(e.target.value)} />
|
||
<Input placeholder="Dose (e.g., 5ml, 1 tablet)" value={dose} onChange={e => setDose(e.target.value)} />
|
||
<Input placeholder="Notes" value={notes} onChange={e => setNotes(e.target.value)} />
|
||
<div className="flex gap-2">
|
||
<Button fullWidth onClick={saveMedicine}>{editingMed ? "Update" : "Add"}</Button>
|
||
<Button variant="secondary" fullWidth onClick={resetForm}>Cancel</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{medicines.length === 0 && !showAddMed ? (
|
||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl">
|
||
<p className="text-gray-500 dark:text-gray-400">No medicines added</p>
|
||
</div>
|
||
) : (
|
||
medicines.map(med => (
|
||
<div key={med.id} className="bg-white dark:bg-gray-800 rounded-xl overflow-hidden">
|
||
<div className="p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex-1">
|
||
<div className="font-medium dark:text-white">{med.name}</div>
|
||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||
{med.dose}{med.notes && ` · ${med.notes}`}
|
||
{med.reminderTime && ` · ⏰ ${med.reminderTime}`}
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-1">
|
||
<button onClick={() => openLogDose(med.id)} className="px-2 py-1 text-xs bg-rose-400 text-white rounded-lg">Log Dose</button>
|
||
<button onClick={() => editMedicine(med)} className="p-2 text-gray-400">✏️</button>
|
||
<button onClick={() => deleteMedicine(med.id)} className="p-2 text-red-400">🗑️</button>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => toggleDoseHistory(med.id)}
|
||
className="mt-2 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||
>
|
||
{showDoseHistory[med.id] ? "▲ Hide history" : "▼ Dose history"}
|
||
</button>
|
||
</div>
|
||
|
||
{showDoseHistory[med.id] && (
|
||
<div className="border-t dark:border-gray-700 px-4 pb-3 space-y-2 pt-2">
|
||
{!doseHistory[med.id] ? (
|
||
<p className="text-xs text-gray-400">Loading…</p>
|
||
) : doseHistory[med.id].length === 0 ? (
|
||
<p className="text-xs text-gray-400">No doses logged yet.</p>
|
||
) : doseHistory[med.id].map(d => {
|
||
const latest = d.corrections?.[0]?.corrected_value;
|
||
const displayAmount = latest?.amountGiven ?? d.amount_given;
|
||
const displayNotes = latest?.notes ?? d.notes;
|
||
return (
|
||
<div key={d.id} className="text-xs border dark:border-gray-700 rounded-lg p-2">
|
||
<div className="flex justify-between items-start">
|
||
<div>
|
||
<span className="text-gray-600 dark:text-gray-300 font-medium">
|
||
{new Date(d.administered_at).toLocaleString()}
|
||
</span>
|
||
{displayAmount && <span className="ml-2 text-gray-500">· {displayAmount}</span>}
|
||
{displayNotes && <span className="ml-1 text-gray-400">· {displayNotes}</span>}
|
||
{!!latest && <span className="ml-1 text-amber-500 text-[10px] font-medium">edited</span>}
|
||
</div>
|
||
<button
|
||
onClick={() => { setCorrectDose({ medId: med.id, dose: d }); setCorrectAmount(displayAmount || ""); setCorrectNotes(displayNotes || ""); setCorrectReason(""); }}
|
||
className="text-gray-300 dark:text-gray-600 hover:text-amber-500 ml-2"
|
||
>
|
||
✏️
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))
|
||
)}
|
||
|
||
{!showAddMed && (
|
||
<button onClick={() => setShowAddMed(true)} className="w-full p-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl text-gray-500 dark:text-gray-400">
|
||
+ Add Medicine
|
||
</button>
|
||
)}
|
||
|
||
<Modal open={!!logDoseMedId} onClose={() => setLogDoseMedId(null)} title="Log Dose" maxWidth="sm">
|
||
<div className="space-y-3">
|
||
<Input placeholder="Amount given (e.g. 5ml)" value={doseAmount} onChange={e => setDoseAmount(e.target.value)} />
|
||
<Input type="datetime-local" value={doseTime} onChange={e => setDoseTime(e.target.value)} />
|
||
<Input placeholder="Notes (optional)" value={doseNotes} onChange={e => setDoseNotes(e.target.value)} />
|
||
<div className="flex gap-2">
|
||
<Button fullWidth loading={doseLoading} onClick={submitDose}>Save Dose</Button>
|
||
<Button variant="secondary" fullWidth onClick={() => setLogDoseMedId(null)}>Cancel</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
<Modal open={!!correctDose} onClose={() => setCorrectDose(null)} title="Edit Dose (Correction)" maxWidth="sm">
|
||
<div className="space-y-3">
|
||
<p className="text-xs text-gray-400">Original is kept. A correction record is added.</p>
|
||
{correctDose && (
|
||
<div className="text-xs bg-gray-50 dark:bg-gray-700 rounded-lg p-2 text-gray-500 dark:text-gray-400">
|
||
Original: {correctDose.dose.amount_given || "—"} · {new Date(correctDose.dose.administered_at).toLocaleString()}
|
||
</div>
|
||
)}
|
||
<Input placeholder="Corrected amount" value={correctAmount} onChange={e => setCorrectAmount(e.target.value)} />
|
||
<Input placeholder="Corrected notes" value={correctNotes} onChange={e => setCorrectNotes(e.target.value)} />
|
||
<Input placeholder="Reason for correction (optional)" value={correctReason} onChange={e => setCorrectReason(e.target.value)} />
|
||
<div className="flex gap-2">
|
||
<Button fullWidth loading={correctLoading} onClick={submitCorrection} className="bg-amber-500 hover:bg-amber-600">
|
||
Save Correction
|
||
</Button>
|
||
<Button variant="secondary" fullWidth onClick={() => setCorrectDose(null)}>Cancel</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|