refactor: full codebase sweep — shared types, utilities, component splits
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>
This commit is contained in:
parent
3ebb3055a5
commit
96d28cbadc
18 changed files with 1315 additions and 1426 deletions
|
|
@ -3,13 +3,7 @@
|
|||
import { useState, useEffect, createContext, useContext } from "react";
|
||||
import { ReactNode } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
|
||||
interface Child {
|
||||
id: string;
|
||||
name: string;
|
||||
birthDate: string;
|
||||
sex: string;
|
||||
}
|
||||
import type { Child } from "@/types";
|
||||
|
||||
interface FamilyContextType {
|
||||
familyId: string | null;
|
||||
|
|
@ -87,7 +81,7 @@ export function FamilyProvider({ children: providerChildren }: { children: React
|
|||
}
|
||||
|
||||
if (data.children?.length > 0) {
|
||||
const childList = data.children.map((c: any) => ({
|
||||
const childList: Child[] = data.children.map((c: Child) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
birthDate: c.birthDate,
|
||||
|
|
|
|||
|
|
@ -1,34 +1,21 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useFamily } from "../FamilyProvider";
|
||||
import { getGuideline, getAgeInMonths } from "@/lib/guidelines";
|
||||
import { api } from "@/lib/api";
|
||||
import { CalendarView } from "@/components/CalendarView";
|
||||
import { LogModal, type LogType as ModalLogType } from "@/components/LogModal";
|
||||
import type { Log, LogType } from "@/types";
|
||||
|
||||
type ViewMode = "timeline" | "calendar";
|
||||
type LogType = "feed" | "sleep" | "diaper";
|
||||
|
||||
interface Log {
|
||||
id: string;
|
||||
type: LogType;
|
||||
subType?: string;
|
||||
amount?: number;
|
||||
notes?: string;
|
||||
loggedAt: string;
|
||||
}
|
||||
|
||||
interface DayLogs {
|
||||
date: string;
|
||||
logs: Log[];
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<LogType, string> = {
|
||||
feed: "bg-rose-400",
|
||||
sleep: "bg-blue-400",
|
||||
diaper: "bg-amber-400",
|
||||
};
|
||||
|
||||
function getIcon(type: LogType) {
|
||||
if (type === "feed") return "🍼";
|
||||
if (type === "sleep") return "😴";
|
||||
|
|
@ -36,193 +23,17 @@ function getIcon(type: LogType) {
|
|||
return "📝";
|
||||
}
|
||||
|
||||
// ─── Calendar view ────────────────────────────────────────────────────────────
|
||||
|
||||
function CalendarView({ logs, filter }: { logs: Log[]; filter: LogType | "all" }) {
|
||||
const now = new Date();
|
||||
const [calMonth, setCalMonth] = useState(new Date(now.getFullYear(), now.getMonth(), 1));
|
||||
const [selectedDay, setSelectedDay] = useState<string | null>(
|
||||
now.toISOString().slice(0, 10)
|
||||
);
|
||||
|
||||
const year = calMonth.getFullYear();
|
||||
const month = calMonth.getMonth();
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
const isCurrentMonth = year === now.getFullYear() && month === now.getMonth();
|
||||
|
||||
const filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter);
|
||||
|
||||
// Group logs by YYYY-MM-DD
|
||||
const logsByDate = useMemo(() => {
|
||||
const map: Record<string, Log[]> = {};
|
||||
filteredLogs.forEach(log => {
|
||||
const key = new Date(log.loggedAt).toISOString().slice(0, 10);
|
||||
if (!map[key]) map[key] = [];
|
||||
map[key].push(log);
|
||||
});
|
||||
return map;
|
||||
}, [filteredLogs]);
|
||||
|
||||
// Build grid: leading nulls for DOW offset, then day numbers
|
||||
const firstDow = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const cells: (number | null)[] = [
|
||||
...Array(firstDow).fill(null),
|
||||
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||
];
|
||||
|
||||
const dayKey = (d: number) =>
|
||||
`${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
|
||||
const selectedLogs = useMemo(
|
||||
() => (selectedDay ? (logsByDate[selectedDay] || [])
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime())
|
||||
: []),
|
||||
[selectedDay, logsByDate]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Month grid */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4">
|
||||
{/* Month nav */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={() => setCalMonth(new Date(year, month - 1, 1))}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300 text-lg"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span className="font-semibold text-gray-800 dark:text-white">
|
||||
{calMonth.toLocaleDateString("en-US", { month: "long", year: "numeric" })}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCalMonth(new Date(year, month + 1, 1))}
|
||||
disabled={isCurrentMonth}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300 text-lg disabled:opacity-30"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* DOW headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map(d => (
|
||||
<div key={d} className="text-center text-xs text-gray-400 font-medium py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day cells */}
|
||||
<div className="grid grid-cols-7 gap-y-1">
|
||||
{cells.map((day, i) => {
|
||||
if (!day) return <div key={i} />;
|
||||
const key = dayKey(day);
|
||||
const dayLogs = logsByDate[key] || [];
|
||||
const isToday = key === today;
|
||||
const isSel = key === selectedDay;
|
||||
const types = [...new Set(dayLogs.map(l => l.type))] as LogType[];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedDay(isSel ? null : key)}
|
||||
className={`flex flex-col items-center justify-center py-1.5 rounded-lg transition-colors ${
|
||||
isSel
|
||||
? "bg-rose-400"
|
||||
: isToday
|
||||
? "bg-rose-50 dark:bg-rose-900/30"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span className={`text-sm leading-none ${
|
||||
isSel ? "text-white font-semibold" :
|
||||
isToday ? "text-rose-500 font-bold" :
|
||||
"text-gray-700 dark:text-gray-300"
|
||||
}`}>
|
||||
{day}
|
||||
</span>
|
||||
<div className="flex gap-0.5 mt-1 h-2 items-center">
|
||||
{types.slice(0, 3).map(t => (
|
||||
<span
|
||||
key={t}
|
||||
className={`w-1.5 h-1.5 rounded-full ${isSel ? "bg-white/80" : TYPE_COLORS[t]}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex justify-center gap-5 mt-4 pt-3 border-t border-gray-100 dark:border-gray-700">
|
||||
{(["feed", "sleep", "diaper"] as LogType[]).map(t => (
|
||||
<div key={t} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className={`w-2 h-2 rounded-full ${TYPE_COLORS[t]}`} />
|
||||
<span className="capitalize">{t}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected day detail */}
|
||||
{selectedDay && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-800 dark:text-white">
|
||||
{new Date(selectedDay + "T12:00:00").toLocaleDateString("en-US", {
|
||||
weekday: "long", month: "short", day: "numeric",
|
||||
})}
|
||||
</h3>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">
|
||||
{selectedLogs.length} {selectedLogs.length === 1 ? "entry" : "entries"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{selectedLogs.length === 0 ? (
|
||||
<p className="text-center text-gray-400 text-sm py-6">No activity logged</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{selectedLogs.map(log => (
|
||||
<div key={log.id} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-xl">
|
||||
<span className="text-xl">{getIcon(log.type)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium capitalize text-sm dark:text-white">{log.type}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{[
|
||||
log.subType?.replace(/_/g, " "),
|
||||
log.amount ? `${log.amount}ml` : null,
|
||||
log.notes,
|
||||
].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 flex-shrink-0">
|
||||
{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ActivityPage() {
|
||||
const { child, childId: providerChildId } = useFamily();
|
||||
const [view, setView] = useState<ViewMode>("timeline");
|
||||
const [logs, setLogs] = useState<Log[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<LogType | "all">("all");
|
||||
const [view, setView] = useState<ViewMode>("timeline");
|
||||
const [logs, setLogs] = useState<Log[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<LogType | "all">("all");
|
||||
const [showSuggested, setShowSuggested] = useState(true);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [fabOpen, setFabOpen] = useState(false);
|
||||
const [modalType, setModalType] = useState<ModalLogType | null>(null);
|
||||
const childId = providerChildId || "";
|
||||
const childId = providerChildId ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (providerChildId) fetchLogs();
|
||||
|
|
@ -231,11 +42,10 @@ export default function ActivityPage() {
|
|||
const fetchLogs = async () => {
|
||||
if (!childId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/logs?childId=${childId}&limit=200`);
|
||||
const data = await res.json();
|
||||
const data = await api.get<{ entries: Log[] }>(`/api/logs?childId=${childId}&limit=200`);
|
||||
setLogs(data.entries || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch:", err);
|
||||
console.error("Failed to fetch logs:", err);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
|
@ -244,22 +54,17 @@ export default function ActivityPage() {
|
|||
if (!child) return;
|
||||
setGenerating(true);
|
||||
try {
|
||||
const res = await fetch("/api/history", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ childId: child.id, birthDate: child.birthDate }),
|
||||
});
|
||||
const data = await res.json();
|
||||
const data = await api.post<{ success: boolean }>("/api/history", { childId: child.id, birthDate: child.birthDate });
|
||||
if (data.success) fetchLogs();
|
||||
} catch (err) {
|
||||
console.error("Failed to generate:", err);
|
||||
console.error("Failed to generate history:", err);
|
||||
}
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
const filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter);
|
||||
|
||||
const groupedByDay = filteredLogs.reduce((acc: DayLogs[], log) => {
|
||||
const groupedByDay = filteredLogs.reduce<DayLogs[]>((acc, log) => {
|
||||
const date = new Date(log.loggedAt).toDateString();
|
||||
const existing = acc.find(d => d.date === date);
|
||||
if (existing) existing.logs.push(log);
|
||||
|
|
@ -286,22 +91,17 @@ export default function ActivityPage() {
|
|||
</div>
|
||||
{/* View toggle */}
|
||||
<div className="flex bg-white dark:bg-gray-800 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setView("timeline")}
|
||||
className={`px-3 py-1 rounded-md text-sm transition-colors ${
|
||||
view === "timeline" ? "bg-rose-400 text-white" : "text-gray-600 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
Timeline
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("calendar")}
|
||||
className={`px-3 py-1 rounded-md text-sm transition-colors ${
|
||||
view === "calendar" ? "bg-rose-400 text-white" : "text-gray-600 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
Calendar
|
||||
</button>
|
||||
{(["timeline", "calendar"] as const).map(v => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setView(v)}
|
||||
className={`px-3 py-1 rounded-md text-sm transition-colors capitalize ${
|
||||
view === v ? "bg-rose-400 text-white" : "text-gray-600 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -321,38 +121,36 @@ export default function ActivityPage() {
|
|||
</div>
|
||||
|
||||
{/* Guidelines card */}
|
||||
{child && showSuggested && (
|
||||
<div className="px-4 mb-4">
|
||||
{(() => {
|
||||
const guide = getGuideline(child.birthDate);
|
||||
const ageMonths = getAgeInMonths(child.birthDate);
|
||||
return (
|
||||
<div className="p-4 bg-gradient-to-r from-rose-100 to-amber-100 dark:from-rose-900 dark:to-amber-900 rounded-xl">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-medium text-rose-800 dark:text-rose-200">
|
||||
{child.name} · {ageMonths} months old
|
||||
</div>
|
||||
<button onClick={() => setShowSuggested(false)} className="text-gray-400 text-sm">✕</button>
|
||||
{child && showSuggested && (() => {
|
||||
const guide = getGuideline(child.birthDate);
|
||||
const ageMonths = getAgeInMonths(child.birthDate);
|
||||
return (
|
||||
<div className="px-4 mb-4">
|
||||
<div className="p-4 bg-gradient-to-r from-rose-100 to-amber-100 dark:from-rose-900 dark:to-amber-900 rounded-xl">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-medium text-rose-800 dark:text-rose-200">
|
||||
{child.name} · {ageMonths} months old
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-rose-600 dark:text-rose-400">{guide.feeds.times}</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">feeds/day</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-amber-600 dark:text-amber-400">{guide.sleep.totalHours}h</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">sleep/day</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">{guide.diapers.count}</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">diapers/day</div>
|
||||
</div>
|
||||
<button onClick={() => setShowSuggested(false)} className="text-gray-400 text-sm">✕</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-rose-600 dark:text-rose-400">{guide.feeds.times}</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">feeds/day</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-amber-600 dark:text-amber-400">{guide.sleep.totalHours}h</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">sleep/day</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">{guide.diapers.count}</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">diapers/day</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 pb-20">
|
||||
|
|
@ -370,9 +168,7 @@ export default function ActivityPage() {
|
|||
{groupedByDay.map(day => (
|
||||
<div key={day.date}>
|
||||
<div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||
{new Date(day.date).toLocaleDateString("en-US", {
|
||||
weekday: "long", month: "short", day: "numeric",
|
||||
})}
|
||||
{new Date(day.date).toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" })}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{day.logs
|
||||
|
|
@ -404,20 +200,16 @@ export default function ActivityPage() {
|
|||
|
||||
{/* FAB */}
|
||||
<div className="fixed bottom-6 right-5 flex flex-col items-end gap-2 z-40">
|
||||
{fabOpen && (
|
||||
<>
|
||||
{(["feed", "sleep", "diaper"] as ModalLogType[]).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => { setModalType(t); setFabOpen(false); }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 rounded-full shadow-lg text-sm font-medium capitalize"
|
||||
>
|
||||
<span>{t === "feed" ? "🍼" : t === "sleep" ? "😴" : "🚼"}</span>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{fabOpen && (["feed", "sleep", "diaper"] as ModalLogType[]).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => { setModalType(t); setFabOpen(false); }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 rounded-full shadow-lg text-sm font-medium capitalize"
|
||||
>
|
||||
<span>{t === "feed" ? "🍼" : t === "sleep" ? "😴" : "🚼"}</span>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setFabOpen(o => !o)}
|
||||
className="w-14 h-14 bg-rose-400 text-white rounded-full shadow-lg flex items-center justify-center text-2xl transition-transform"
|
||||
|
|
@ -427,6 +219,8 @@ export default function ActivityPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{fabOpen && <div className="fixed inset-0 z-30" onClick={() => setFabOpen(false)} />}
|
||||
|
||||
<LogModal
|
||||
type={modalType}
|
||||
childId={childId}
|
||||
|
|
@ -437,10 +231,6 @@ export default function ActivityPage() {
|
|||
return last ? { subType: last.subType ?? "", amountMl: last.amount ?? undefined } : null;
|
||||
})() : null}
|
||||
/>
|
||||
|
||||
{fabOpen && (
|
||||
<div className="fixed inset-0 z-30" onClick={() => setFabOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,21 +3,7 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useFamily } from "../FamilyProvider";
|
||||
import { Button, Input, ConfirmDialog } from "@/components/ui";
|
||||
|
||||
interface AIChat {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: AIChat[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
import type { AIChat, ChatSession } from "@/types";
|
||||
|
||||
export default function AIChatPage() {
|
||||
const { childId } = useFamily();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useFamily } from "../FamilyProvider";
|
||||
import { Button, Card, Input, ConfirmDialog } from "@/components/ui";
|
||||
import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile, type GrowthStandard } from "@/lib/growth-standards";
|
||||
import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile } from "@/lib/growth-standards";
|
||||
import { formatAge } from "@/lib/formatting";
|
||||
import type { GrowthRecord, Goal } from "@/types";
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
|
|
@ -18,57 +20,21 @@ import {
|
|||
import { Line } from "react-chartjs-2";
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
);
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler);
|
||||
|
||||
interface GrowthRecord {
|
||||
id: string;
|
||||
child_id: string;
|
||||
measured_at: string;
|
||||
weight_kg: number | null;
|
||||
height_cm: number | null;
|
||||
head_circumference_cm: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface Goal {
|
||||
weightKg?: number;
|
||||
heightCm?: number;
|
||||
targetDate?: string;
|
||||
}
|
||||
|
||||
function formatAge(birthDate: string, measurementDate?: string): string {
|
||||
const birth = new Date(birthDate);
|
||||
const now = measurementDate ? new Date(measurementDate) : new Date();
|
||||
const years = Math.floor((now.getTime() - birth.getTime()) / (1000 * 60 * 60 * 24 * 365));
|
||||
const months = Math.floor(((now.getTime() - birth.getTime()) % (1000 * 60 * 60 * 24 * 365)) / (1000 * 60 * 60 * 24 * 30));
|
||||
|
||||
if (years > 0 && months > 0) {
|
||||
return `${years}y ${months}mo`;
|
||||
} else if (years > 0) {
|
||||
return `${years}y`;
|
||||
} else if (months > 0) {
|
||||
return `${months}mo`;
|
||||
} else {
|
||||
return "Newborn";
|
||||
}
|
||||
interface Percentiles { p3: number; p15: number; p50: number; p85: number; p97: number }
|
||||
interface WhoStandard {
|
||||
weight: Percentiles;
|
||||
height: Percentiles;
|
||||
headCircumference: Percentiles;
|
||||
}
|
||||
|
||||
export default function GrowthPage() {
|
||||
const { childId, child, familyId } = useFamily();
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
useTheme(); // keep theme context alive for dark mode CSS
|
||||
|
||||
const [growthData, setGrowthData] = useState<GrowthRecord[]>([]);
|
||||
const [whoStandard, setWhoStandard] = useState<any>(null);
|
||||
const [whoStandard, setWhoStandard] = useState<WhoStandard | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [showGoals, setShowGoals] = useState(false);
|
||||
|
|
@ -150,8 +116,8 @@ export default function GrowthPage() {
|
|||
}
|
||||
resetForm();
|
||||
fetchGrowthData();
|
||||
} catch (e: any) {
|
||||
setSaveError(e.message || "Failed to save");
|
||||
} catch (e) {
|
||||
setSaveError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -312,7 +278,7 @@ export default function GrowthPage() {
|
|||
// WHO 85th percentile
|
||||
{
|
||||
label: "85th",
|
||||
data: labels.map(() => whoStandard?.[whoKey]?.p85),
|
||||
data: labels.map(() => whoStandard?.[whoKey]?.p85 ?? null),
|
||||
borderColor: "#fbbf24",
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
|
|
@ -322,7 +288,7 @@ export default function GrowthPage() {
|
|||
// WHO 50th percentile
|
||||
{
|
||||
label: "50th",
|
||||
data: labels.map(() => whoStandard?.[whoKey]?.p50),
|
||||
data: labels.map(() => whoStandard?.[whoKey]?.p50 ?? null),
|
||||
borderColor: "#22c55e",
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
|
|
@ -332,21 +298,21 @@ export default function GrowthPage() {
|
|||
// WHO 15th percentile
|
||||
{
|
||||
label: "15th",
|
||||
data: labels.map(() => whoStandard?.[whoKey]?.p15),
|
||||
data: labels.map(() => whoStandard?.[whoKey]?.p15 ?? null),
|
||||
borderColor: "#fbbf24",
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
},
|
||||
// WHO 3rd-97th band
|
||||
{
|
||||
label: "3rd-97th",
|
||||
data: labels.map(() => [whoStandard?.[whoKey]?.p3, whoStandard?.[whoKey]?.p97]),
|
||||
borderColor: "transparent",
|
||||
backgroundColor: "rgba(34, 197, 94, 0.1)",
|
||||
fill: "+1",
|
||||
label: "3rd",
|
||||
data: labels.map(() => whoStandard?.[whoKey]?.p3 ?? null),
|
||||
borderColor: "#fbbf24",
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -7,23 +7,9 @@ import { useFamily } from "./FamilyProvider";
|
|||
import { useStageCheck, type BabyStage } from "@/hooks/useStageCheck";
|
||||
import { Button } from "@/components/ui";
|
||||
import { LogModal, type LogType } from "@/components/LogModal";
|
||||
import { getOfflineQueue, addToOfflineQueue, processOfflineQueue } from "@/lib/offline-queue";
|
||||
|
||||
interface AIChat {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: AIChat[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
import { getOfflineQueue, processOfflineQueue } from "@/lib/offline-queue";
|
||||
import { calculateAge, formatTimeAgo } from "@/lib/formatting";
|
||||
import type { Log, AIChat, ChatSession } from "@/types";
|
||||
|
||||
async function getSessions(cid: string): Promise<ChatSession[]> {
|
||||
try {
|
||||
|
|
@ -46,25 +32,6 @@ async function createSession(cid: string): Promise<ChatSession | null> {
|
|||
}
|
||||
|
||||
|
||||
function calculateAge(birthDate: string) {
|
||||
if (!birthDate) return "";
|
||||
const birth = new Date(birthDate);
|
||||
const now = new Date();
|
||||
let years = now.getFullYear() - birth.getFullYear();
|
||||
let months = now.getMonth() - birth.getMonth();
|
||||
let days = now.getDate() - birth.getDate();
|
||||
if (days < 0) {
|
||||
months--;
|
||||
days += new Date(now.getFullYear(), now.getMonth(), 0).getDate();
|
||||
}
|
||||
if (months < 0) { years--; months += 12; }
|
||||
const parts = [];
|
||||
if (years > 0) parts.push(`${years} year${years > 1 ? "s" : ""}`);
|
||||
if (months > 0) parts.push(`${months} month${months > 1 ? "s" : ""}`);
|
||||
if (days > 0) parts.push(`${days} day${days > 1 ? "s" : ""}`);
|
||||
return parts.length > 0 ? parts.join(", ") : "Newborn";
|
||||
}
|
||||
|
||||
function getGreeting() {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return "Good morning";
|
||||
|
|
@ -72,21 +39,7 @@ function getGreeting() {
|
|||
return "Good evening";
|
||||
}
|
||||
|
||||
function formatTimeAgo(dateStr: string | null | undefined) {
|
||||
if (!dateStr) return null;
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffMins < 1) return "just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
function TodaySummary({ logs }: { logs: any[] }) {
|
||||
function TodaySummary({ logs }: { logs: Log[] }) {
|
||||
const todayStr = new Date().toDateString();
|
||||
const today = logs.filter(l => new Date(l.loggedAt).toDateString() === todayStr);
|
||||
const counts = {
|
||||
|
|
@ -132,7 +85,7 @@ export default function HomePage() {
|
|||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [homeSessionId, setHomeSessionId] = useState<string | null>(null);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [recentLogs, setRecentLogs] = useState<any[]>([]);
|
||||
const [recentLogs, setRecentLogs] = useState<Log[]>([]);
|
||||
const [logsLoading, setLogsLoading] = useState(true);
|
||||
const [vaccineReminders, setVaccineReminders] = useState<any[]>([]);
|
||||
const { theme, toggle: toggleTheme } = useTheme();
|
||||
|
|
@ -336,7 +289,7 @@ export default function HomePage() {
|
|||
<div className="px-4 mt-4">
|
||||
<h2 className="font-semibold mb-3 ml-1">Recent Activity</h2>
|
||||
<div className="space-y-2">
|
||||
{logsLoading ? <p className="text-gray-400 text-sm">Loading...</p> : recentLogs.length === 0 ? <p className="text-gray-400 text-sm">No logs yet today</p> : recentLogs.slice(0, 5).map((log: any) => (
|
||||
{logsLoading ? <p className="text-gray-400 text-sm">Loading...</p> : recentLogs.length === 0 ? <p className="text-gray-400 text-sm">No logs yet today</p> : recentLogs.slice(0, 5).map(log => (
|
||||
<div key={log.id} className="flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{log.type === "feed" && "🍼"}{log.type === "sleep" && "😴"}{log.type === "diaper" && "🚼"}</span>
|
||||
|
|
@ -355,7 +308,7 @@ export default function HomePage() {
|
|||
onSaved={fetchRecentLogs}
|
||||
smartDefault={modalType ? (() => {
|
||||
const last = recentLogs.find(l => l.type === modalType);
|
||||
return last ? { subType: last.subType, amountMl: last.amount ?? undefined } : null;
|
||||
return last?.subType ? { subType: last.subType, amountMl: last.amount ?? undefined } : null;
|
||||
})() : null}
|
||||
/>
|
||||
|
||||
|
|
|
|||
186
src/components/CalendarView.tsx
Normal file
186
src/components/CalendarView.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import type { Log, LogType } from "@/types";
|
||||
|
||||
const TYPE_COLORS: Record<LogType, string> = {
|
||||
feed: "bg-rose-400",
|
||||
sleep: "bg-blue-400",
|
||||
diaper: "bg-amber-400",
|
||||
};
|
||||
|
||||
function getIcon(type: LogType) {
|
||||
if (type === "feed") return "🍼";
|
||||
if (type === "sleep") return "😴";
|
||||
if (type === "diaper") return "🚼";
|
||||
return "📝";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
logs: Log[];
|
||||
filter: LogType | "all";
|
||||
}
|
||||
|
||||
export function CalendarView({ logs, filter }: Props) {
|
||||
const now = new Date();
|
||||
const [calMonth, setCalMonth] = useState(new Date(now.getFullYear(), now.getMonth(), 1));
|
||||
const [selectedDay, setSelectedDay] = useState<string | null>(now.toISOString().slice(0, 10));
|
||||
|
||||
const year = calMonth.getFullYear();
|
||||
const month = calMonth.getMonth();
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
const isCurrentMonth = year === now.getFullYear() && month === now.getMonth();
|
||||
|
||||
const filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter);
|
||||
|
||||
const logsByDate = useMemo(() => {
|
||||
const map: Record<string, Log[]> = {};
|
||||
filteredLogs.forEach(log => {
|
||||
const key = new Date(log.loggedAt).toISOString().slice(0, 10);
|
||||
if (!map[key]) map[key] = [];
|
||||
map[key].push(log);
|
||||
});
|
||||
return map;
|
||||
}, [filteredLogs]);
|
||||
|
||||
const firstDow = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const cells: (number | null)[] = [
|
||||
...Array(firstDow).fill(null),
|
||||
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||
];
|
||||
|
||||
const dayKey = (d: number) =>
|
||||
`${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
|
||||
const selectedLogs = useMemo(
|
||||
() =>
|
||||
selectedDay
|
||||
? (logsByDate[selectedDay] || [])
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime())
|
||||
: [],
|
||||
[selectedDay, logsByDate],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4">
|
||||
{/* Month nav */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={() => setCalMonth(new Date(year, month - 1, 1))}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300 text-lg"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span className="font-semibold text-gray-800 dark:text-white">
|
||||
{calMonth.toLocaleDateString("en-US", { month: "long", year: "numeric" })}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCalMonth(new Date(year, month + 1, 1))}
|
||||
disabled={isCurrentMonth}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300 text-lg disabled:opacity-30"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* DOW headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map(d => (
|
||||
<div key={d} className="text-center text-xs text-gray-400 font-medium py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day cells */}
|
||||
<div className="grid grid-cols-7 gap-y-1">
|
||||
{cells.map((day, i) => {
|
||||
if (!day) return <div key={i} />;
|
||||
const key = dayKey(day);
|
||||
const dayLogs = logsByDate[key] || [];
|
||||
const isToday = key === today;
|
||||
const isSel = key === selectedDay;
|
||||
const types = [...new Set(dayLogs.map(l => l.type))] as LogType[];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedDay(isSel ? null : key)}
|
||||
className={`flex flex-col items-center justify-center py-1.5 rounded-lg transition-colors ${
|
||||
isSel ? "bg-rose-400" :
|
||||
isToday ? "bg-rose-50 dark:bg-rose-900/30" :
|
||||
"hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span className={`text-sm leading-none ${
|
||||
isSel ? "text-white font-semibold" :
|
||||
isToday ? "text-rose-500 font-bold" :
|
||||
"text-gray-700 dark:text-gray-300"
|
||||
}`}>
|
||||
{day}
|
||||
</span>
|
||||
<div className="flex gap-0.5 mt-1 h-2 items-center">
|
||||
{types.slice(0, 3).map(t => (
|
||||
<span key={t} className={`w-1.5 h-1.5 rounded-full ${isSel ? "bg-white/80" : TYPE_COLORS[t]}`} />
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex justify-center gap-5 mt-4 pt-3 border-t border-gray-100 dark:border-gray-700">
|
||||
{(["feed", "sleep", "diaper"] as LogType[]).map(t => (
|
||||
<div key={t} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className={`w-2 h-2 rounded-full ${TYPE_COLORS[t]}`} />
|
||||
<span className="capitalize">{t}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected day detail */}
|
||||
{selectedDay && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-800 dark:text-white">
|
||||
{new Date(selectedDay + "T12:00:00").toLocaleDateString("en-US", {
|
||||
weekday: "long", month: "short", day: "numeric",
|
||||
})}
|
||||
</h3>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">
|
||||
{selectedLogs.length} {selectedLogs.length === 1 ? "entry" : "entries"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{selectedLogs.length === 0 ? (
|
||||
<p className="text-center text-gray-400 text-sm py-6">No activity logged</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{selectedLogs.map(log => (
|
||||
<div key={log.id} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-xl">
|
||||
<span className="text-xl">{getIcon(log.type)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium capitalize text-sm dark:text-white">{log.type}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{[
|
||||
log.subType?.replace(/_/g, " "),
|
||||
log.amount ? `${log.amount}ml` : null,
|
||||
log.notes,
|
||||
].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 flex-shrink-0">
|
||||
{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,8 +3,9 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Button, Modal, Select, Input } from "@/components/ui";
|
||||
import { addToOfflineQueue } from "@/lib/offline-queue";
|
||||
import type { LogType } from "@/types";
|
||||
|
||||
export type LogType = "feed" | "diaper" | "sleep";
|
||||
export type { LogType };
|
||||
|
||||
export interface SmartDefault {
|
||||
subType: string;
|
||||
|
|
|
|||
24
src/components/PageHeader.tsx
Normal file
24
src/components/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
backHref?: string;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, subtitle, backHref = "/menu", actions }: Props) {
|
||||
return (
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={backHref} className="p-2 text-gray-600 dark:text-gray-300">←</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">{title}</h1>
|
||||
{subtitle && <p className="text-sm text-gray-500 dark:text-gray-400">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/TabBar.tsx
Normal file
37
src/components/TabBar.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
interface Tab {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tabs: Tab[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
danger?: string;
|
||||
}
|
||||
|
||||
export function TabBar({ tabs, value, onChange, danger }: Props) {
|
||||
return (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 no-scrollbar">
|
||||
{tabs.map(tab => {
|
||||
const isActive = tab.value === value;
|
||||
const isDanger = danger && tab.value === danger;
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => onChange(tab.value)}
|
||||
className={`px-4 py-2 rounded-xl whitespace-nowrap flex-shrink-0 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? isDanger
|
||||
? "bg-red-500 text-white"
|
||||
: "bg-rose-400 text-white"
|
||||
: "bg-white dark:bg-gray-800 dark:text-white"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
src/components/medical/AllergyTab.tsx
Normal file
114
src/components/medical/AllergyTab.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button, Input, Select } from "@/components/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Allergy } from "@/types";
|
||||
|
||||
interface Props { childId: string }
|
||||
|
||||
export function AllergyTab({ childId }: Props) {
|
||||
const [allergies, setAllergies] = useState<Allergy[]>([]);
|
||||
const [editingAllergy, setEditingAllergy] = useState<Allergy | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [severity, setSeverity] = useState("mild");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
useEffect(() => { fetchAllergies(); }, [childId]);
|
||||
|
||||
const fetchAllergies = () =>
|
||||
api.get<{ allergies: Allergy[] }>(`/api/allergies?childId=${childId}`)
|
||||
.then(d => setAllergies(d.allergies || []))
|
||||
.catch(console.error);
|
||||
|
||||
const save = async () => {
|
||||
if (!name) return;
|
||||
try {
|
||||
if (editingAllergy) {
|
||||
await api.patch("/api/allergies", { id: editingAllergy.id, name, severity, notes });
|
||||
} else {
|
||||
await api.post("/api/allergies", { childId, name, severity, notes });
|
||||
}
|
||||
fetchAllergies();
|
||||
} catch (err) { console.error("Failed to save allergy:", err); }
|
||||
reset();
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/api/allergies?id=${id}`);
|
||||
fetchAllergies();
|
||||
} catch (err) { console.error("Failed to delete allergy:", err); }
|
||||
};
|
||||
|
||||
const edit = (a: Allergy) => {
|
||||
setEditingAllergy(a);
|
||||
setName(a.name);
|
||||
setSeverity(a.severity);
|
||||
setNotes(a.notes);
|
||||
setShowAdd(true);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setEditingAllergy(null);
|
||||
setName("");
|
||||
setSeverity("mild");
|
||||
setNotes("");
|
||||
setShowAdd(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold mb-3">Known Allergies</h2>
|
||||
|
||||
{showAdd && (
|
||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl space-y-3">
|
||||
<Input placeholder="Allergy name (e.g., Peanut, Milk)" value={name} onChange={e => setName(e.target.value)} />
|
||||
<Select value={severity} onChange={e => setSeverity(e.target.value)}>
|
||||
<option value="mild">Mild</option>
|
||||
<option value="moderate">Moderate</option>
|
||||
<option value="severe">Severe</option>
|
||||
</Select>
|
||||
<Input placeholder="Notes" value={notes} onChange={e => setNotes(e.target.value)} />
|
||||
<div className="flex gap-2">
|
||||
<Button fullWidth onClick={save}>{editingAllergy ? "Update" : "Add"}</Button>
|
||||
<Button variant="secondary" fullWidth onClick={reset}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allergies.length === 0 && !showAdd ? (
|
||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl">
|
||||
<p className="text-gray-500 dark:text-gray-400">No allergies recorded</p>
|
||||
</div>
|
||||
) : (
|
||||
allergies.map(a => (
|
||||
<div key={a.id} className="p-4 bg-white dark:bg-gray-800 rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{a.name}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className={a.severity === "severe" ? "text-red-500" : "text-orange-500"}>
|
||||
{a.severity.toUpperCase()}
|
||||
</span>
|
||||
{a.notes && ` · ${a.notes}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => edit(a)} className="p-2 text-gray-400">✏️</button>
|
||||
<button onClick={() => remove(a.id)} className="p-2 text-red-400">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{!showAdd && (
|
||||
<button onClick={() => setShowAdd(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 Allergy
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
src/components/medical/IllnessTab.tsx
Normal file
115
src/components/medical/IllnessTab.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button, Input } from "@/components/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Illness } from "@/types";
|
||||
|
||||
interface Props { childId: string }
|
||||
|
||||
export function IllnessTab({ childId }: Props) {
|
||||
const [illnesses, setIllnesses] = useState<Illness[]>([]);
|
||||
const [editingIllness, setEditingIllness] = useState<Illness | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
useEffect(() => { fetchIllnesses(); }, [childId]);
|
||||
|
||||
const fetchIllnesses = () =>
|
||||
api.get<{ illnesses: Illness[] }>(`/api/illnesses?childId=${childId}`)
|
||||
.then(d => setIllnesses(d.illnesses || []))
|
||||
.catch(console.error);
|
||||
|
||||
const save = async () => {
|
||||
if (!name) return;
|
||||
try {
|
||||
if (editingIllness) {
|
||||
await api.patch("/api/illnesses", { id: editingIllness.id, name, startDate, endDate, notes });
|
||||
} else {
|
||||
await api.post("/api/illnesses", { childId, name, startDate, endDate, notes });
|
||||
}
|
||||
fetchIllnesses();
|
||||
} catch (err) { console.error("Failed to save illness:", err); }
|
||||
reset();
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/api/illnesses?id=${id}`);
|
||||
fetchIllnesses();
|
||||
} catch (err) { console.error("Failed to delete illness:", err); }
|
||||
};
|
||||
|
||||
const edit = (ill: Illness) => {
|
||||
setEditingIllness(ill);
|
||||
setName(ill.name);
|
||||
setStartDate(ill.startDate);
|
||||
setEndDate(ill.endDate || "");
|
||||
setNotes(ill.notes);
|
||||
setShowAdd(true);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setEditingIllness(null);
|
||||
setName("");
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setNotes("");
|
||||
setShowAdd(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold mb-3">Illness Log</h2>
|
||||
|
||||
{showAdd && (
|
||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl space-y-3">
|
||||
<Input placeholder="Illness (e.g., Cold, Fever, Flu)" value={name} onChange={e => setName(e.target.value)} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
|
||||
<Input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} />
|
||||
</div>
|
||||
<Input placeholder="Notes" value={notes} onChange={e => setNotes(e.target.value)} />
|
||||
<div className="flex gap-2">
|
||||
<Button fullWidth onClick={save}>{editingIllness ? "Update" : "Add"}</Button>
|
||||
<Button variant="secondary" fullWidth onClick={reset}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{illnesses.length === 0 && !showAdd ? (
|
||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl">
|
||||
<p className="text-gray-500 dark:text-gray-400">No illnesses recorded</p>
|
||||
</div>
|
||||
) : (
|
||||
illnesses.map(ill => (
|
||||
<div key={ill.id} className="p-4 bg-white dark:bg-gray-800 rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{ill.name}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(ill.startDate).toLocaleDateString()}
|
||||
{ill.endDate && ` - ${new Date(ill.endDate).toLocaleDateString()}`}
|
||||
{ill.notes && ` · ${ill.notes}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => edit(ill)} className="p-2 text-gray-400">✏️</button>
|
||||
<button onClick={() => remove(ill.id)} className="p-2 text-red-400">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{!showAdd && (
|
||||
<button onClick={() => setShowAdd(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">
|
||||
+ Log Illness
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
247
src/components/medical/MedicineTab.tsx
Normal file
247
src/components/medical/MedicineTab.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
173
src/components/medical/VaccineTab.tsx
Normal file
173
src/components/medical/VaccineTab.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { TabBar } from "@/components/TabBar";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Vaccination } from "@/types";
|
||||
|
||||
interface Props {
|
||||
childId: string;
|
||||
birthDate: string;
|
||||
}
|
||||
|
||||
type VaccineStatus = "upcoming" | "completed" | "overdue";
|
||||
|
||||
const IAP_SCHEDULE = [
|
||||
{ name: "BCG", weeks: 0 },
|
||||
{ name: "OPV-0", weeks: 0 },
|
||||
{ name: "HepB-1", weeks: 0 },
|
||||
{ name: "OPV-1", weeks: 6 },
|
||||
{ name: "Pentavalent-1", weeks: 6 },
|
||||
{ name: "PCV-1", weeks: 6 },
|
||||
{ name: "Rota-1", weeks: 6 },
|
||||
{ name: "OPV-2", weeks: 10 },
|
||||
{ name: "Pentavalent-2", weeks: 10 },
|
||||
{ name: "PCV-2", weeks: 10 },
|
||||
{ name: "Rota-2", weeks: 10 },
|
||||
{ name: "OPV-3", weeks: 14 },
|
||||
{ name: "Pentavalent-3", weeks: 14 },
|
||||
{ name: "PCV-3", weeks: 14 },
|
||||
{ name: "Rota-3", weeks: 14 },
|
||||
{ name: "MR-1", weeks: 48 },
|
||||
{ name: "JE-1", weeks: 48 },
|
||||
{ name: "Vitamin A-1", weeks: 48 },
|
||||
{ name: "OPV-4", weeks: 48 },
|
||||
{ name: "MR-2", weeks: 96 },
|
||||
{ name: "JE-2", weeks: 96 },
|
||||
{ name: "DPT-Booster-1", weeks: 96 },
|
||||
{ name: "Vitamin A-2", weeks: 96 },
|
||||
{ name: "OPV-5", weeks: 96 },
|
||||
{ name: "DPT-Booster-2", weeks: 208 },
|
||||
{ name: "Td", weeks: 208 },
|
||||
];
|
||||
|
||||
function calculateDueDate(birthDate: string, weeks: number): string {
|
||||
const d = new Date(birthDate);
|
||||
d.setDate(d.getDate() + weeks * 7);
|
||||
return d.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ value: "upcoming", label: "" },
|
||||
{ value: "completed", label: "" },
|
||||
{ value: "overdue", label: "" },
|
||||
];
|
||||
|
||||
export function VaccineTab({ childId, birthDate }: Props) {
|
||||
const [vaccinations, setVaccinations] = useState<Vaccination[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [vaccineTab, setVaccineTab] = useState<VaccineStatus>("upcoming");
|
||||
const [showAddDate, setShowAddDate] = useState<string | null>(null);
|
||||
const [givenDate, setGivenDate] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
api.get<{ vaccinations: Vaccination[] }>(`/api/vaccinations?childId=${childId}`)
|
||||
.then(d => setVaccinations(d.vaccinations || []))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [childId]);
|
||||
|
||||
const isGiven = (name: string) => vaccinations.some(v => v.vaccine_name === name && v.status === "given");
|
||||
const getGivenDate = (name: string) => vaccinations.find(v => v.vaccine_name === name && v.status === "given")?.given_date;
|
||||
|
||||
const todayStr = new Date().toISOString().split("T")[0];
|
||||
|
||||
const getVaccineStatus = (name: string): VaccineStatus => {
|
||||
const due = calculateDueDate(birthDate, IAP_SCHEDULE.find(v => v.name === name)?.weeks ?? 0);
|
||||
if (isGiven(name)) return "completed";
|
||||
if (due < todayStr) return "overdue";
|
||||
return "upcoming";
|
||||
};
|
||||
|
||||
const getByStatus = (status: VaccineStatus) =>
|
||||
IAP_SCHEDULE.filter(v => {
|
||||
const due = calculateDueDate(birthDate, v.weeks);
|
||||
if (status === "completed") return isGiven(v.name);
|
||||
if (status === "overdue") return !isGiven(v.name) && due < todayStr;
|
||||
return !isGiven(v.name) && due >= todayStr;
|
||||
}).sort((a, b) => a.weeks - b.weeks);
|
||||
|
||||
const handleMarkGiven = async (vaccineName: string) => {
|
||||
const due = calculateDueDate(birthDate, IAP_SCHEDULE.find(v => v.name === vaccineName)?.weeks ?? 0);
|
||||
const dateToSave = givenDate || todayStr;
|
||||
try {
|
||||
await api.post("/api/vaccinations", { childId, vaccineName, scheduledDate: due, givenDate: dateToSave, status: "given" });
|
||||
setVaccinations(prev => [...prev, { vaccine_name: vaccineName, given_date: dateToSave, status: "given" }]);
|
||||
} catch (err) {
|
||||
console.error("Failed to mark given:", err);
|
||||
}
|
||||
setShowAddDate(null);
|
||||
setGivenDate("");
|
||||
};
|
||||
|
||||
const counts = {
|
||||
upcoming: getByStatus("upcoming").length,
|
||||
completed: getByStatus("completed").length,
|
||||
overdue: getByStatus("overdue").length,
|
||||
};
|
||||
|
||||
const statusTabs = STATUS_TABS.map(t => ({
|
||||
value: t.value,
|
||||
label: `${t.value.charAt(0).toUpperCase() + t.value.slice(1)} (${counts[t.value as VaccineStatus]})`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<TabBar tabs={statusTabs} value={vaccineTab} onChange={v => setVaccineTab(v as VaccineStatus)} danger="overdue" />
|
||||
|
||||
<h2 className="font-semibold mt-2">IAP Schedule</h2>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-gray-500">Loading...</p>
|
||||
) : (
|
||||
getByStatus(vaccineTab).map(vaccine => {
|
||||
const due = calculateDueDate(birthDate, vaccine.weeks);
|
||||
const status = getVaccineStatus(vaccine.name);
|
||||
const actualDate = getGivenDate(vaccine.name);
|
||||
const daysOverdue = status === "overdue"
|
||||
? Math.floor((Date.now() - new Date(due).getTime()) / 86_400_000)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={vaccine.name}
|
||||
className={`p-4 bg-white dark:bg-gray-800 rounded-xl ${status === "completed" ? "opacity-60" : ""} ${status === "overdue" ? "border-l-4 border-red-500" : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{vaccine.name}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Due: {new Date(due).toLocaleDateString()}
|
||||
{actualDate && ` · Given: ${new Date(actualDate).toLocaleDateString()}`}
|
||||
</div>
|
||||
{status === "overdue" && (
|
||||
<div className="text-red-500 text-sm font-medium">{daysOverdue} days overdue</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isGiven(vaccine.name) ? (
|
||||
<span className="text-green-500">✓</span>
|
||||
) : showAddDate === vaccine.name ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={givenDate}
|
||||
onChange={e => setGivenDate(e.target.value)}
|
||||
className="p-1 text-sm border dark:border-gray-600 rounded dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
<button onClick={() => handleMarkGiven(vaccine.name)} className="px-3 py-1 bg-rose-400 text-white rounded-lg text-sm">✓</button>
|
||||
<button onClick={() => { setShowAddDate(null); setGivenDate(""); }} className="px-2 py-1 text-gray-400">✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAddDate(vaccine.name)} className="px-4 py-2 bg-rose-400 text-white rounded-lg text-sm">
|
||||
Mark Given
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
src/components/medical/VisitTab.tsx
Normal file
113
src/components/medical/VisitTab.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button, Input } from "@/components/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Visit } from "@/types";
|
||||
|
||||
interface Props { childId: string }
|
||||
|
||||
export function VisitTab({ childId }: Props) {
|
||||
const [visits, setVisits] = useState<Visit[]>([]);
|
||||
const [editingVisit, setEditingVisit] = useState<Visit | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [doctor, setDoctor] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [date, setDate] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
useEffect(() => { fetchVisits(); }, [childId]);
|
||||
|
||||
const fetchVisits = () =>
|
||||
api.get<{ visits: Visit[] }>(`/api/visits?childId=${childId}`)
|
||||
.then(d => setVisits(d.visits || []))
|
||||
.catch(console.error);
|
||||
|
||||
const save = async () => {
|
||||
if (!doctor) return;
|
||||
try {
|
||||
if (editingVisit) {
|
||||
await api.patch("/api/visits", { id: editingVisit.id, doctorName: doctor, reason, date, notes });
|
||||
} else {
|
||||
await api.post("/api/visits", { childId, doctorName: doctor, reason, date, notes });
|
||||
}
|
||||
fetchVisits();
|
||||
} catch (err) { console.error("Failed to save visit:", err); }
|
||||
reset();
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/api/visits?id=${id}`);
|
||||
fetchVisits();
|
||||
} catch (err) { console.error("Failed to delete visit:", err); }
|
||||
};
|
||||
|
||||
const edit = (v: Visit) => {
|
||||
setEditingVisit(v);
|
||||
setDoctor(v.doctorName);
|
||||
setReason(v.reason);
|
||||
setDate(v.date);
|
||||
setNotes(v.notes);
|
||||
setShowAdd(true);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setEditingVisit(null);
|
||||
setDoctor("");
|
||||
setReason("");
|
||||
setDate("");
|
||||
setNotes("");
|
||||
setShowAdd(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold mb-3">Doctor Visits</h2>
|
||||
|
||||
{showAdd && (
|
||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl space-y-3">
|
||||
<Input placeholder="Doctor name" value={doctor} onChange={e => setDoctor(e.target.value)} />
|
||||
<Input placeholder="Reason (e.g., Checkup, Fever)" value={reason} onChange={e => setReason(e.target.value)} />
|
||||
<Input type="date" value={date} onChange={e => setDate(e.target.value)} />
|
||||
<Input placeholder="Notes" value={notes} onChange={e => setNotes(e.target.value)} />
|
||||
<div className="flex gap-2">
|
||||
<Button fullWidth onClick={save}>{editingVisit ? "Update" : "Add"}</Button>
|
||||
<Button variant="secondary" fullWidth onClick={reset}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visits.length === 0 && !showAdd ? (
|
||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl">
|
||||
<p className="text-gray-500 dark:text-gray-400">No visits recorded</p>
|
||||
</div>
|
||||
) : (
|
||||
visits.map(v => (
|
||||
<div key={v.id} className="p-4 bg-white dark:bg-gray-800 rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{v.doctorName}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(v.date).toLocaleDateString()}
|
||||
{v.reason && ` · ${v.reason}`}
|
||||
{v.notes && ` · ${v.notes}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => edit(v)} className="p-2 text-gray-400">✏️</button>
|
||||
<button onClick={() => remove(v.id)} className="p-2 text-red-400">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{!showAdd && (
|
||||
<button onClick={() => setShowAdd(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 Visit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/lib/api.ts
Normal file
30
src/lib/api.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, init);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(url: string) =>
|
||||
request<T>(url),
|
||||
|
||||
post: <T>(url: string, body: unknown) =>
|
||||
request<T>(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
patch: <T>(url: string, body: unknown) =>
|
||||
request<T>(url, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
delete: (url: string) =>
|
||||
request<void>(url, { method: "DELETE" }),
|
||||
};
|
||||
42
src/lib/formatting.ts
Normal file
42
src/lib/formatting.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
export function calculateAge(birthDate: string): string {
|
||||
if (!birthDate) return "";
|
||||
const birth = new Date(birthDate);
|
||||
const now = new Date();
|
||||
let years = now.getFullYear() - birth.getFullYear();
|
||||
let months = now.getMonth() - birth.getMonth();
|
||||
let days = now.getDate() - birth.getDate();
|
||||
if (days < 0) {
|
||||
months--;
|
||||
days += new Date(now.getFullYear(), now.getMonth(), 0).getDate();
|
||||
}
|
||||
if (months < 0) { years--; months += 12; }
|
||||
const parts: string[] = [];
|
||||
if (years > 0) parts.push(`${years} year${years > 1 ? "s" : ""}`);
|
||||
if (months > 0) parts.push(`${months} month${months > 1 ? "s" : ""}`);
|
||||
if (days > 0) parts.push(`${days} day${days > 1 ? "s" : ""}`);
|
||||
return parts.length > 0 ? parts.join(", ") : "Newborn";
|
||||
}
|
||||
|
||||
export function formatAge(birthDate: string, measurementDate?: string): string {
|
||||
const birth = new Date(birthDate);
|
||||
const ref = measurementDate ? new Date(measurementDate) : new Date();
|
||||
const totalDays = (ref.getTime() - birth.getTime()) / (1000 * 60 * 60 * 24);
|
||||
const years = Math.floor(totalDays / 365);
|
||||
const months = Math.floor((totalDays % 365) / 30);
|
||||
if (years > 0 && months > 0) return `${years}y ${months}mo`;
|
||||
if (years > 0) return `${years}y`;
|
||||
if (months > 0) return `${months}mo`;
|
||||
return "Newborn";
|
||||
}
|
||||
|
||||
export function formatTimeAgo(dateStr: string | null | undefined): string | null {
|
||||
if (!dateStr) return null;
|
||||
const diffMs = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diffMs / 60_000);
|
||||
const hours = Math.floor(mins / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${days}d ago`;
|
||||
}
|
||||
102
src/types/index.ts
Normal file
102
src/types/index.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// Shared domain types — import from here rather than defining inline in each page
|
||||
|
||||
export interface Child {
|
||||
id: string;
|
||||
name: string;
|
||||
birthDate: string;
|
||||
sex: string;
|
||||
}
|
||||
|
||||
export type LogType = "feed" | "diaper" | "sleep";
|
||||
|
||||
export interface Log {
|
||||
id: string;
|
||||
type: LogType;
|
||||
subType?: string;
|
||||
amount?: number | null;
|
||||
notes?: string | null;
|
||||
loggedAt: string;
|
||||
}
|
||||
|
||||
export interface GrowthRecord {
|
||||
id: string;
|
||||
child_id: string;
|
||||
measured_at: string;
|
||||
weight_kg: number | null;
|
||||
height_cm: number | null;
|
||||
head_circumference_cm: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface Goal {
|
||||
weightKg?: number;
|
||||
heightCm?: number;
|
||||
targetDate?: string;
|
||||
}
|
||||
|
||||
export interface Medicine {
|
||||
id: string;
|
||||
name: string;
|
||||
dose: string;
|
||||
notes: string;
|
||||
reminderTime?: string;
|
||||
}
|
||||
|
||||
export interface DoseCorrection {
|
||||
id: string;
|
||||
corrected_value: Record<string, string>;
|
||||
reason: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Dose {
|
||||
id: string;
|
||||
administered_at: string;
|
||||
amount_given: string | null;
|
||||
notes: string | null;
|
||||
corrections?: DoseCorrection[];
|
||||
}
|
||||
|
||||
export interface Allergy {
|
||||
id: string;
|
||||
name: string;
|
||||
severity: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface Visit {
|
||||
id: string;
|
||||
doctorName: string;
|
||||
reason: string;
|
||||
date: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface Illness {
|
||||
id: string;
|
||||
name: string;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface Vaccination {
|
||||
vaccine_name: string;
|
||||
given_date: string;
|
||||
status: "given" | "pending";
|
||||
}
|
||||
|
||||
export interface AIChat {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: AIChat[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue