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 { useState, useEffect, createContext, useContext } from "react";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { useRouter, usePathname } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
|
import type { Child } from "@/types";
|
||||||
interface Child {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
birthDate: string;
|
|
||||||
sex: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FamilyContextType {
|
interface FamilyContextType {
|
||||||
familyId: string | null;
|
familyId: string | null;
|
||||||
|
|
@ -87,7 +81,7 @@ export function FamilyProvider({ children: providerChildren }: { children: React
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.children?.length > 0) {
|
if (data.children?.length > 0) {
|
||||||
const childList = data.children.map((c: any) => ({
|
const childList: Child[] = data.children.map((c: Child) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
birthDate: c.birthDate,
|
birthDate: c.birthDate,
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,21 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useFamily } from "../FamilyProvider";
|
import { useFamily } from "../FamilyProvider";
|
||||||
import { getGuideline, getAgeInMonths } from "@/lib/guidelines";
|
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 { LogModal, type LogType as ModalLogType } from "@/components/LogModal";
|
||||||
|
import type { Log, LogType } from "@/types";
|
||||||
|
|
||||||
type ViewMode = "timeline" | "calendar";
|
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 {
|
interface DayLogs {
|
||||||
date: string;
|
date: string;
|
||||||
logs: Log[];
|
logs: Log[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_COLORS: Record<LogType, string> = {
|
|
||||||
feed: "bg-rose-400",
|
|
||||||
sleep: "bg-blue-400",
|
|
||||||
diaper: "bg-amber-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
function getIcon(type: LogType) {
|
function getIcon(type: LogType) {
|
||||||
if (type === "feed") return "🍼";
|
if (type === "feed") return "🍼";
|
||||||
if (type === "sleep") return "😴";
|
if (type === "sleep") return "😴";
|
||||||
|
|
@ -36,193 +23,17 @@ function getIcon(type: LogType) {
|
||||||
return "📝";
|
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() {
|
export default function ActivityPage() {
|
||||||
const { child, childId: providerChildId } = useFamily();
|
const { child, childId: providerChildId } = useFamily();
|
||||||
const [view, setView] = useState<ViewMode>("timeline");
|
const [view, setView] = useState<ViewMode>("timeline");
|
||||||
const [logs, setLogs] = useState<Log[]>([]);
|
const [logs, setLogs] = useState<Log[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filter, setFilter] = useState<LogType | "all">("all");
|
const [filter, setFilter] = useState<LogType | "all">("all");
|
||||||
const [showSuggested, setShowSuggested] = useState(true);
|
const [showSuggested, setShowSuggested] = useState(true);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [fabOpen, setFabOpen] = useState(false);
|
const [fabOpen, setFabOpen] = useState(false);
|
||||||
const [modalType, setModalType] = useState<ModalLogType | null>(null);
|
const [modalType, setModalType] = useState<ModalLogType | null>(null);
|
||||||
const childId = providerChildId || "";
|
const childId = providerChildId ?? "";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (providerChildId) fetchLogs();
|
if (providerChildId) fetchLogs();
|
||||||
|
|
@ -231,11 +42,10 @@ export default function ActivityPage() {
|
||||||
const fetchLogs = async () => {
|
const fetchLogs = async () => {
|
||||||
if (!childId) return;
|
if (!childId) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/logs?childId=${childId}&limit=200`);
|
const data = await api.get<{ entries: Log[] }>(`/api/logs?childId=${childId}&limit=200`);
|
||||||
const data = await res.json();
|
|
||||||
setLogs(data.entries || []);
|
setLogs(data.entries || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch:", err);
|
console.error("Failed to fetch logs:", err);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
@ -244,22 +54,17 @@ export default function ActivityPage() {
|
||||||
if (!child) return;
|
if (!child) return;
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/history", {
|
const data = await api.post<{ success: boolean }>("/api/history", { childId: child.id, birthDate: child.birthDate });
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ childId: child.id, birthDate: child.birthDate }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) fetchLogs();
|
if (data.success) fetchLogs();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to generate:", err);
|
console.error("Failed to generate history:", err);
|
||||||
}
|
}
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter);
|
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 date = new Date(log.loggedAt).toDateString();
|
||||||
const existing = acc.find(d => d.date === date);
|
const existing = acc.find(d => d.date === date);
|
||||||
if (existing) existing.logs.push(log);
|
if (existing) existing.logs.push(log);
|
||||||
|
|
@ -286,22 +91,17 @@ export default function ActivityPage() {
|
||||||
</div>
|
</div>
|
||||||
{/* View toggle */}
|
{/* View toggle */}
|
||||||
<div className="flex bg-white dark:bg-gray-800 rounded-lg p-1">
|
<div className="flex bg-white dark:bg-gray-800 rounded-lg p-1">
|
||||||
<button
|
{(["timeline", "calendar"] as const).map(v => (
|
||||||
onClick={() => setView("timeline")}
|
<button
|
||||||
className={`px-3 py-1 rounded-md text-sm transition-colors ${
|
key={v}
|
||||||
view === "timeline" ? "bg-rose-400 text-white" : "text-gray-600 dark:text-gray-400"
|
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"
|
||||||
Timeline
|
}`}
|
||||||
</button>
|
>
|
||||||
<button
|
{v}
|
||||||
onClick={() => setView("calendar")}
|
</button>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -321,38 +121,36 @@ export default function ActivityPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Guidelines card */}
|
{/* Guidelines card */}
|
||||||
{child && showSuggested && (
|
{child && showSuggested && (() => {
|
||||||
<div className="px-4 mb-4">
|
const guide = getGuideline(child.birthDate);
|
||||||
{(() => {
|
const ageMonths = getAgeInMonths(child.birthDate);
|
||||||
const guide = getGuideline(child.birthDate);
|
return (
|
||||||
const ageMonths = getAgeInMonths(child.birthDate);
|
<div className="px-4 mb-4">
|
||||||
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="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="flex items-center justify-between mb-2">
|
<div className="font-medium text-rose-800 dark:text-rose-200">
|
||||||
<div className="font-medium text-rose-800 dark:text-rose-200">
|
{child.name} · {ageMonths} months old
|
||||||
{child.name} · {ageMonths} months old
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setShowSuggested(false)} className="text-gray-400 text-sm">✕</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
<button onClick={() => setShowSuggested(false)} className="text-gray-400 text-sm">✕</button>
|
||||||
<div className="text-center">
|
</div>
|
||||||
<div className="text-lg font-bold text-rose-600 dark:text-rose-400">{guide.feeds.times}</div>
|
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-400">feeds/day</div>
|
<div className="text-center">
|
||||||
</div>
|
<div className="text-lg font-bold text-rose-600 dark:text-rose-400">{guide.feeds.times}</div>
|
||||||
<div className="text-center">
|
<div className="text-xs text-gray-600 dark:text-gray-400">feeds/day</div>
|
||||||
<div className="text-lg font-bold text-amber-600 dark:text-amber-400">{guide.sleep.totalHours}h</div>
|
</div>
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-400">sleep/day</div>
|
<div className="text-center">
|
||||||
</div>
|
<div className="text-lg font-bold text-amber-600 dark:text-amber-400">{guide.sleep.totalHours}h</div>
|
||||||
<div className="text-center">
|
<div className="text-xs text-gray-600 dark:text-gray-400">sleep/day</div>
|
||||||
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">{guide.diapers.count}</div>
|
</div>
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-400">diapers/day</div>
|
<div className="text-center">
|
||||||
</div>
|
<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>
|
||||||
})()}
|
</div>
|
||||||
</div>
|
);
|
||||||
)}
|
})()}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-4 pb-20">
|
<div className="px-4 pb-20">
|
||||||
|
|
@ -370,9 +168,7 @@ export default function ActivityPage() {
|
||||||
{groupedByDay.map(day => (
|
{groupedByDay.map(day => (
|
||||||
<div key={day.date}>
|
<div key={day.date}>
|
||||||
<div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
<div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||||
{new Date(day.date).toLocaleDateString("en-US", {
|
{new Date(day.date).toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" })}
|
||||||
weekday: "long", month: "short", day: "numeric",
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{day.logs
|
{day.logs
|
||||||
|
|
@ -404,20 +200,16 @@ export default function ActivityPage() {
|
||||||
|
|
||||||
{/* FAB */}
|
{/* FAB */}
|
||||||
<div className="fixed bottom-6 right-5 flex flex-col items-end gap-2 z-40">
|
<div className="fixed bottom-6 right-5 flex flex-col items-end gap-2 z-40">
|
||||||
{fabOpen && (
|
{fabOpen && (["feed", "sleep", "diaper"] as ModalLogType[]).map(t => (
|
||||||
<>
|
<button
|
||||||
{(["feed", "sleep", "diaper"] as ModalLogType[]).map(t => (
|
key={t}
|
||||||
<button
|
onClick={() => { setModalType(t); setFabOpen(false); }}
|
||||||
key={t}
|
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"
|
||||||
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}
|
||||||
<span>{t === "feed" ? "🍼" : t === "sleep" ? "😴" : "🚼"}</span>
|
</button>
|
||||||
{t}
|
))}
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setFabOpen(o => !o)}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{fabOpen && <div className="fixed inset-0 z-30" onClick={() => setFabOpen(false)} />}
|
||||||
|
|
||||||
<LogModal
|
<LogModal
|
||||||
type={modalType}
|
type={modalType}
|
||||||
childId={childId}
|
childId={childId}
|
||||||
|
|
@ -437,10 +231,6 @@ export default function ActivityPage() {
|
||||||
return last ? { subType: last.subType ?? "", amountMl: last.amount ?? undefined } : null;
|
return last ? { subType: last.subType ?? "", amountMl: last.amount ?? undefined } : null;
|
||||||
})() : null}
|
})() : null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{fabOpen && (
|
|
||||||
<div className="fixed inset-0 z-30" onClick={() => setFabOpen(false)} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,7 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useFamily } from "../FamilyProvider";
|
import { useFamily } from "../FamilyProvider";
|
||||||
import { Button, Input, ConfirmDialog } from "@/components/ui";
|
import { Button, Input, ConfirmDialog } from "@/components/ui";
|
||||||
|
import type { AIChat, ChatSession } from "@/types";
|
||||||
interface AIChat {
|
|
||||||
id: string;
|
|
||||||
role: "user" | "assistant";
|
|
||||||
content: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatSession {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
messages: AIChat[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AIChatPage() {
|
export default function AIChatPage() {
|
||||||
const { childId } = useFamily();
|
const { childId } = useFamily();
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useFamily } from "../FamilyProvider";
|
import { useFamily } from "../FamilyProvider";
|
||||||
import { Button, Card, Input, ConfirmDialog } from "@/components/ui";
|
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 {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
|
|
@ -18,57 +20,21 @@ import {
|
||||||
import { Line } from "react-chartjs-2";
|
import { Line } from "react-chartjs-2";
|
||||||
import { useTheme } from "../ThemeProvider";
|
import { useTheme } from "../ThemeProvider";
|
||||||
|
|
||||||
ChartJS.register(
|
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler);
|
||||||
CategoryScale,
|
|
||||||
LinearScale,
|
|
||||||
PointElement,
|
|
||||||
LineElement,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
Filler
|
|
||||||
);
|
|
||||||
|
|
||||||
interface GrowthRecord {
|
interface Percentiles { p3: number; p15: number; p50: number; p85: number; p97: number }
|
||||||
id: string;
|
interface WhoStandard {
|
||||||
child_id: string;
|
weight: Percentiles;
|
||||||
measured_at: string;
|
height: Percentiles;
|
||||||
weight_kg: number | null;
|
headCircumference: Percentiles;
|
||||||
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";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GrowthPage() {
|
export default function GrowthPage() {
|
||||||
const { childId, child, familyId } = useFamily();
|
const { childId, child, familyId } = useFamily();
|
||||||
const { theme } = useTheme();
|
useTheme(); // keep theme context alive for dark mode CSS
|
||||||
const isDark = theme === "dark";
|
|
||||||
|
|
||||||
const [growthData, setGrowthData] = useState<GrowthRecord[]>([]);
|
const [growthData, setGrowthData] = useState<GrowthRecord[]>([]);
|
||||||
const [whoStandard, setWhoStandard] = useState<any>(null);
|
const [whoStandard, setWhoStandard] = useState<WhoStandard | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
const [showGoals, setShowGoals] = useState(false);
|
const [showGoals, setShowGoals] = useState(false);
|
||||||
|
|
@ -150,8 +116,8 @@ export default function GrowthPage() {
|
||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
fetchGrowthData();
|
fetchGrowthData();
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
setSaveError(e.message || "Failed to save");
|
setSaveError(e instanceof Error ? e.message : "Failed to save");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -312,7 +278,7 @@ export default function GrowthPage() {
|
||||||
// WHO 85th percentile
|
// WHO 85th percentile
|
||||||
{
|
{
|
||||||
label: "85th",
|
label: "85th",
|
||||||
data: labels.map(() => whoStandard?.[whoKey]?.p85),
|
data: labels.map(() => whoStandard?.[whoKey]?.p85 ?? null),
|
||||||
borderColor: "#fbbf24",
|
borderColor: "#fbbf24",
|
||||||
borderDash: [5, 5],
|
borderDash: [5, 5],
|
||||||
fill: false,
|
fill: false,
|
||||||
|
|
@ -322,7 +288,7 @@ export default function GrowthPage() {
|
||||||
// WHO 50th percentile
|
// WHO 50th percentile
|
||||||
{
|
{
|
||||||
label: "50th",
|
label: "50th",
|
||||||
data: labels.map(() => whoStandard?.[whoKey]?.p50),
|
data: labels.map(() => whoStandard?.[whoKey]?.p50 ?? null),
|
||||||
borderColor: "#22c55e",
|
borderColor: "#22c55e",
|
||||||
borderDash: [5, 5],
|
borderDash: [5, 5],
|
||||||
fill: false,
|
fill: false,
|
||||||
|
|
@ -332,21 +298,21 @@ export default function GrowthPage() {
|
||||||
// WHO 15th percentile
|
// WHO 15th percentile
|
||||||
{
|
{
|
||||||
label: "15th",
|
label: "15th",
|
||||||
data: labels.map(() => whoStandard?.[whoKey]?.p15),
|
data: labels.map(() => whoStandard?.[whoKey]?.p15 ?? null),
|
||||||
borderColor: "#fbbf24",
|
borderColor: "#fbbf24",
|
||||||
borderDash: [5, 5],
|
borderDash: [5, 5],
|
||||||
fill: false,
|
fill: false,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
tension: 0.4,
|
tension: 0.4,
|
||||||
},
|
},
|
||||||
// WHO 3rd-97th band
|
|
||||||
{
|
{
|
||||||
label: "3rd-97th",
|
label: "3rd",
|
||||||
data: labels.map(() => [whoStandard?.[whoKey]?.p3, whoStandard?.[whoKey]?.p97]),
|
data: labels.map(() => whoStandard?.[whoKey]?.p3 ?? null),
|
||||||
borderColor: "transparent",
|
borderColor: "#fbbf24",
|
||||||
backgroundColor: "rgba(34, 197, 94, 0.1)",
|
borderDash: [5, 5],
|
||||||
fill: "+1",
|
fill: false,
|
||||||
pointRadius: 0,
|
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 { useStageCheck, type BabyStage } from "@/hooks/useStageCheck";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
import { LogModal, type LogType } from "@/components/LogModal";
|
import { LogModal, type LogType } from "@/components/LogModal";
|
||||||
import { getOfflineQueue, addToOfflineQueue, processOfflineQueue } from "@/lib/offline-queue";
|
import { getOfflineQueue, processOfflineQueue } from "@/lib/offline-queue";
|
||||||
|
import { calculateAge, formatTimeAgo } from "@/lib/formatting";
|
||||||
interface AIChat {
|
import type { Log, AIChat, ChatSession } from "@/types";
|
||||||
id: string;
|
|
||||||
role: "user" | "assistant";
|
|
||||||
content: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatSession {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
messages: AIChat[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function getSessions(cid: string): Promise<ChatSession[]> {
|
async function getSessions(cid: string): Promise<ChatSession[]> {
|
||||||
try {
|
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() {
|
function getGreeting() {
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
if (hour < 12) return "Good morning";
|
if (hour < 12) return "Good morning";
|
||||||
|
|
@ -72,21 +39,7 @@ function getGreeting() {
|
||||||
return "Good evening";
|
return "Good evening";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimeAgo(dateStr: string | null | undefined) {
|
function TodaySummary({ logs }: { logs: Log[] }) {
|
||||||
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[] }) {
|
|
||||||
const todayStr = new Date().toDateString();
|
const todayStr = new Date().toDateString();
|
||||||
const today = logs.filter(l => new Date(l.loggedAt).toDateString() === todayStr);
|
const today = logs.filter(l => new Date(l.loggedAt).toDateString() === todayStr);
|
||||||
const counts = {
|
const counts = {
|
||||||
|
|
@ -132,7 +85,7 @@ export default function HomePage() {
|
||||||
const [aiLoading, setAiLoading] = useState(false);
|
const [aiLoading, setAiLoading] = useState(false);
|
||||||
const [homeSessionId, setHomeSessionId] = useState<string | null>(null);
|
const [homeSessionId, setHomeSessionId] = useState<string | null>(null);
|
||||||
const [pendingCount, setPendingCount] = useState(0);
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
const [recentLogs, setRecentLogs] = useState<any[]>([]);
|
const [recentLogs, setRecentLogs] = useState<Log[]>([]);
|
||||||
const [logsLoading, setLogsLoading] = useState(true);
|
const [logsLoading, setLogsLoading] = useState(true);
|
||||||
const [vaccineReminders, setVaccineReminders] = useState<any[]>([]);
|
const [vaccineReminders, setVaccineReminders] = useState<any[]>([]);
|
||||||
const { theme, toggle: toggleTheme } = useTheme();
|
const { theme, toggle: toggleTheme } = useTheme();
|
||||||
|
|
@ -336,7 +289,7 @@ export default function HomePage() {
|
||||||
<div className="px-4 mt-4">
|
<div className="px-4 mt-4">
|
||||||
<h2 className="font-semibold mb-3 ml-1">Recent Activity</h2>
|
<h2 className="font-semibold mb-3 ml-1">Recent Activity</h2>
|
||||||
<div className="space-y-2">
|
<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 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">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xl">{log.type === "feed" && "🍼"}{log.type === "sleep" && "😴"}{log.type === "diaper" && "🚼"}</span>
|
<span className="text-xl">{log.type === "feed" && "🍼"}{log.type === "sleep" && "😴"}{log.type === "diaper" && "🚼"}</span>
|
||||||
|
|
@ -355,7 +308,7 @@ export default function HomePage() {
|
||||||
onSaved={fetchRecentLogs}
|
onSaved={fetchRecentLogs}
|
||||||
smartDefault={modalType ? (() => {
|
smartDefault={modalType ? (() => {
|
||||||
const last = recentLogs.find(l => l.type === 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}
|
})() : 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 { useState, useEffect } from "react";
|
||||||
import { Button, Modal, Select, Input } from "@/components/ui";
|
import { Button, Modal, Select, Input } from "@/components/ui";
|
||||||
import { addToOfflineQueue } from "@/lib/offline-queue";
|
import { addToOfflineQueue } from "@/lib/offline-queue";
|
||||||
|
import type { LogType } from "@/types";
|
||||||
|
|
||||||
export type LogType = "feed" | "diaper" | "sleep";
|
export type { LogType };
|
||||||
|
|
||||||
export interface SmartDefault {
|
export interface SmartDefault {
|
||||||
subType: string;
|
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