feat: running timers, symptom log, search, PDF export
- dashboard: ActiveTimers widget — live h:mm:ss for any in-progress timed event (breastfeed, nap, sleep, walk…), Terminer button patches endedAt
- dashboard: reorganised quick-log groups; Santé group (Température, Traitement, Symptôme)
- events: SYMPTOM type — tag chips (Fièvre, Reflux, Colique, Éruption, Toux, Congestion, Diarrhée, Vomissement, Irritabilité, Pleurs excessifs), stored in metadata.tags
- prisma: migration adding SYMPTOM to EventType enum
- search: GET /api/search (events by notes ILIKE, journal by content ILIKE), /search page with debounced input + keyword highlight
- nav: Recherche link added to LINKS + More drawer
- growth: PDF export button — generates formatted table with all measurements, zebra rows, downloads croissance-{name}.pdf
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
-- This migration adds more than one value to an enum.
|
||||||
|
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||||
|
-- in a single migration. This can be worked around by creating
|
||||||
|
-- multiple migrations, each migration adding only one value to
|
||||||
|
-- the enum.
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TYPE "EventType" ADD VALUE 'PUMP';
|
||||||
|
ALTER TYPE "EventType" ADD VALUE 'BATH';
|
||||||
|
ALTER TYPE "EventType" ADD VALUE 'WALK';
|
||||||
|
ALTER TYPE "EventType" ADD VALUE 'TUMMY_TIME';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Family" ADD COLUMN "pinnedNote" TEXT,
|
||||||
|
ADD COLUMN "pinnedNoteAuthor" TEXT,
|
||||||
|
ADD COLUMN "pinnedNoteUpdatedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "forcedLogoutAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "EventTemplate" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"familyId" TEXT NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"type" "EventType" NOT NULL,
|
||||||
|
"metadata" JSONB,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "EventTemplate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "JournalNote" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"babyId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "JournalNote_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Milestone" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"babyId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"notes" TEXT,
|
||||||
|
"icon" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Milestone_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Vaccination" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"babyId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"date" TIMESTAMP(3),
|
||||||
|
"dueDate" TIMESTAMP(3),
|
||||||
|
"notes" TEXT,
|
||||||
|
"done" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Vaccination_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AuditLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"userName" TEXT NOT NULL,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"targetId" TEXT,
|
||||||
|
"familyId" TEXT,
|
||||||
|
"detail" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MedicationProfile" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"familyId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"molecule" TEXT,
|
||||||
|
"defaultDose" TEXT,
|
||||||
|
"unit" TEXT NOT NULL DEFAULT 'mL',
|
||||||
|
"intervalHours" DOUBLE PRECISION NOT NULL DEFAULT 6,
|
||||||
|
"minIntervalHours" DOUBLE PRECISION,
|
||||||
|
"isPreset" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "MedicationProfile_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MilkStock" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"babyId" TEXT NOT NULL,
|
||||||
|
"volume" DOUBLE PRECISION NOT NULL,
|
||||||
|
"storedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"location" TEXT NOT NULL DEFAULT 'fridge',
|
||||||
|
"notes" TEXT,
|
||||||
|
"used" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"usedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "MilkStock_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MedicationReminder" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"babyId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"dose" TEXT,
|
||||||
|
"unit" TEXT,
|
||||||
|
"intervalHours" DOUBLE PRECISION NOT NULL,
|
||||||
|
"startAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"lastSentAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "MedicationReminder_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "EventTemplate" ADD CONSTRAINT "EventTemplate_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "JournalNote" ADD CONSTRAINT "JournalNote_babyId_fkey" FOREIGN KEY ("babyId") REFERENCES "Baby"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "JournalNote" ADD CONSTRAINT "JournalNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Milestone" ADD CONSTRAINT "Milestone_babyId_fkey" FOREIGN KEY ("babyId") REFERENCES "Baby"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Vaccination" ADD CONSTRAINT "Vaccination_babyId_fkey" FOREIGN KEY ("babyId") REFERENCES "Baby"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MilkStock" ADD CONSTRAINT "MilkStock_babyId_fkey" FOREIGN KEY ("babyId") REFERENCES "Baby"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MedicationReminder" ADD CONSTRAINT "MedicationReminder_babyId_fkey" FOREIGN KEY ("babyId") REFERENCES "Baby"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "EventType" ADD VALUE 'SYMPTOM';
|
||||||
@@ -154,6 +154,7 @@ enum EventType {
|
|||||||
BATH
|
BATH
|
||||||
WALK
|
WALK
|
||||||
TUMMY_TIME
|
TUMMY_TIME
|
||||||
|
SYMPTOM
|
||||||
}
|
}
|
||||||
|
|
||||||
model GrowthLog {
|
model GrowthLog {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
(()=>{"use strict";self.onmessage=async e=>{switch(e.data.type){case"__START_URL_CACHE__":{let t=e.data.url,a=await fetch(t);if(!a.redirected)return(await caches.open("start-url")).put(t,a);return Promise.resolve()}case"__FRONTEND_NAV_CACHE__":{let t=e.data.url,a=await caches.open("pages");if(await a.match(t,{ignoreSearch:!0}))return;let s=await fetch(t);if(!s.ok)return;if(a.put(t,s.clone()),e.data.shouldCacheAggressively&&s.headers.get("Content-Type")?.includes("text/html"))try{let e=await s.text(),t=[],a=await caches.open("static-style-assets"),r=await caches.open("next-static-js-assets"),c=await caches.open("static-js-assets");for(let[s,r]of e.matchAll(/<link.*?href=['"](.*?)['"].*?>/g))/rel=['"]stylesheet['"]/.test(s)&&t.push(a.match(r).then(e=>e?Promise.resolve():a.add(r)));for(let[,a]of e.matchAll(/<script.*?src=['"](.*?)['"].*?>/g)){let e=/\/_next\/static.+\.js$/i.test(a)?r:c;t.push(e.match(a).then(t=>t?Promise.resolve():e.add(a)))}return await Promise.all(t)}catch{}return Promise.resolve()}default:return Promise.resolve()}}})();
|
||||||
File diff suppressed because one or more lines are too long
@@ -15,12 +15,16 @@ import { useSession } from "next-auth/react";
|
|||||||
const QUICK_LOG_GROUPS: { label: string; types: EventType[] }[] = [
|
const QUICK_LOG_GROUPS: { label: string; types: EventType[] }[] = [
|
||||||
{
|
{
|
||||||
label: "Alimentation & Soins",
|
label: "Alimentation & Soins",
|
||||||
types: ["BREASTFEED", "BOTTLE", "PUMP", "DIAPER_WET", "DIAPER_STOOL", "MEDICATION", "TEMPERATURE"],
|
types: ["BREASTFEED", "BOTTLE", "PUMP", "DIAPER_WET", "DIAPER_STOOL"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Sommeil & Activités",
|
label: "Sommeil & Activités",
|
||||||
types: ["NAP", "NIGHT_SLEEP", "BATH", "WALK", "TUMMY_TIME"],
|
types: ["NAP", "NIGHT_SLEEP", "BATH", "WALK", "TUMMY_TIME"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Santé",
|
||||||
|
types: ["TEMPERATURE", "MEDICATION", "SYMPTOM"],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Event {
|
interface Event {
|
||||||
@@ -64,12 +68,73 @@ function getEventSummary(event: Event): string {
|
|||||||
if (event.type === "DIAPER_STOOL") return String(meta.color ?? "");
|
if (event.type === "DIAPER_STOOL") return String(meta.color ?? "");
|
||||||
if (event.type === "TEMPERATURE") return meta.value ? `${meta.value}°${meta.unit ?? "C"}` : "";
|
if (event.type === "TEMPERATURE") return meta.value ? `${meta.value}°${meta.unit ?? "C"}` : "";
|
||||||
if (event.type === "MEDICATION") return String(meta.name ?? "");
|
if (event.type === "MEDICATION") return String(meta.name ?? "");
|
||||||
|
if (event.type === "SYMPTOM") { const tags = (meta.tags as string[] | undefined) ?? []; return tags.join(", "); }
|
||||||
if ((event.type === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) {
|
if ((event.type === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) {
|
||||||
return formatDuration(new Date(event.startedAt), new Date(event.endedAt));
|
return formatDuration(new Date(event.startedAt), new Date(event.endedAt));
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatElapsed(startedAt: string, now: Date): string {
|
||||||
|
const ms = Math.max(0, now.getTime() - new Date(startedAt).getTime());
|
||||||
|
const h = Math.floor(ms / 3600000);
|
||||||
|
const m = Math.floor((ms % 3600000) / 60000);
|
||||||
|
const s = Math.floor((ms % 60000) / 1000);
|
||||||
|
if (h > 0) return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActiveTimers({ events, onStop }: { events: Event[]; onStop: () => void }) {
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
const active = events.filter((e) => !e.endedAt && EVENT_CONFIG[e.type]?.hasTimer);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (active.length === 0) return;
|
||||||
|
const id = setInterval(() => setNow(new Date()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [active.length]);
|
||||||
|
|
||||||
|
if (active.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 space-y-2">
|
||||||
|
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-2">En cours</h2>
|
||||||
|
{active.map((ev) => {
|
||||||
|
const cfg = EVENT_CONFIG[ev.type];
|
||||||
|
return (
|
||||||
|
<div key={ev.id} className={`flex items-center gap-3 px-4 py-3 rounded-xl border ${cfg.bgClass} ${cfg.borderClass}`}>
|
||||||
|
<div className={`w-9 h-9 rounded-lg bg-white/60 dark:bg-slate-900/40 flex items-center justify-center flex-shrink-0`}>
|
||||||
|
<EventIcon name={cfg.icon} className={`w-5 h-5 ${cfg.colorClass}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">{cfg.label}</p>
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||||||
|
Depuis {format(new Date(ev.startedAt), "HH:mm")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`text-lg font-bold font-mono ${cfg.colorClass} tabular-nums`}>
|
||||||
|
{formatElapsed(ev.startedAt, now)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await fetch(`/api/events/${ev.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ endedAt: new Date().toISOString() }),
|
||||||
|
});
|
||||||
|
onStop();
|
||||||
|
}}
|
||||||
|
className="ml-1 px-3 py-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg text-xs font-medium text-slate-600 dark:text-slate-300 hover:border-red-300 hover:text-red-600 transition"
|
||||||
|
>
|
||||||
|
Terminer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function QuickNoteWidget({ babyId }: { babyId: string }) {
|
function QuickNoteWidget({ babyId }: { babyId: string }) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const todayStr = format(new Date(), "yyyy-MM-dd");
|
const todayStr = format(new Date(), "yyyy-MM-dd");
|
||||||
@@ -512,6 +577,9 @@ export default function DashboardPage() {
|
|||||||
{/* Handoff summary */}
|
{/* Handoff summary */}
|
||||||
<HandoffWidget events={events} userId={session?.user?.id ?? ""} />
|
<HandoffWidget events={events} userId={session?.user?.id ?? ""} />
|
||||||
|
|
||||||
|
{/* Active timers */}
|
||||||
|
<ActiveTimers events={events} onStop={() => qc.invalidateQueries({ queryKey: ["events"] })} />
|
||||||
|
|
||||||
{/* Pinned note + Quick note */}
|
{/* Pinned note + Quick note */}
|
||||||
<div className="grid sm:grid-cols-2 gap-4 mb-6">
|
<div className="grid sm:grid-cols-2 gap-4 mb-6">
|
||||||
<PinnedNoteWidget />
|
<PinnedNoteWidget />
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { useBaby } from "@/contexts/baby-context";
|
import { useBaby } from "@/contexts/baby-context";
|
||||||
import { format, subDays } from "date-fns";
|
import { format, subDays } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
import { Plus, Pencil, Trash2, FileDown } from "lucide-react";
|
||||||
|
|
||||||
interface GrowthLog {
|
interface GrowthLog {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -241,6 +241,53 @@ export default function GrowthPage() {
|
|||||||
qc.invalidateQueries({ queryKey: ["growth"] });
|
qc.invalidateQueries({ queryKey: ["growth"] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportPDF() {
|
||||||
|
import("jspdf").then(({ default: jsPDF }) => {
|
||||||
|
const doc = new jsPDF();
|
||||||
|
const pageW = doc.internal.pageSize.getWidth();
|
||||||
|
|
||||||
|
doc.setFontSize(18);
|
||||||
|
doc.setTextColor(30, 41, 59);
|
||||||
|
doc.text("Courbe de croissance", 14, 20);
|
||||||
|
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.setTextColor(100, 116, 139);
|
||||||
|
doc.text(`${baby!.name} · né${baby!.gender === "F" ? "e" : ""} le ${format(new Date(baby!.birthDate), "d MMMM yyyy", { locale: fr })}`, 14, 28);
|
||||||
|
doc.text(`Généré le ${format(new Date(), "d MMMM yyyy", { locale: fr })}`, 14, 34);
|
||||||
|
|
||||||
|
// Table header
|
||||||
|
const COL = [14, 55, 95, 130, 155];
|
||||||
|
const headers = ["Date", "Poids (kg)", "Taille (cm)", "PC (cm)", "Notes"];
|
||||||
|
let y = 46;
|
||||||
|
|
||||||
|
doc.setFillColor(238, 242, 255);
|
||||||
|
doc.rect(14, y - 5, pageW - 28, 9, "F");
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setTextColor(79, 70, 229);
|
||||||
|
headers.forEach((h, i) => doc.text(h, COL[i], y));
|
||||||
|
y += 8;
|
||||||
|
|
||||||
|
doc.setFontSize(9);
|
||||||
|
const sorted = [...logs].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||||
|
sorted.forEach((log, idx) => {
|
||||||
|
if (y > 270) { doc.addPage(); y = 20; }
|
||||||
|
if (idx % 2 === 0) {
|
||||||
|
doc.setFillColor(248, 250, 252);
|
||||||
|
doc.rect(14, y - 4, pageW - 28, 7, "F");
|
||||||
|
}
|
||||||
|
doc.setTextColor(30, 41, 59);
|
||||||
|
doc.text(format(new Date(log.date), "d MMM yyyy", { locale: fr }), COL[0], y);
|
||||||
|
doc.text(log.weight ? (log.weight / 1000).toFixed(3) : "—", COL[1], y);
|
||||||
|
doc.text(log.height ? String(log.height) : "—", COL[2], y);
|
||||||
|
doc.text(log.headCirc ? String(log.headCirc) : "—", COL[3], y);
|
||||||
|
if (log.notes) doc.text(log.notes.slice(0, 30), COL[4], y);
|
||||||
|
y += 7;
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.save(`croissance-${baby!.name.toLowerCase()}.pdf`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const latest = logs.at(-1);
|
const latest = logs.at(-1);
|
||||||
|
|
||||||
const rangeOptions: { label: string; value: TimeRange }[] = [
|
const rangeOptions: { label: string; value: TimeRange }[] = [
|
||||||
@@ -257,13 +304,22 @@ export default function GrowthPage() {
|
|||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Courbes de croissance</h1>
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Courbes de croissance</h1>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{baby?.name}</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{baby?.name}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex gap-2">
|
||||||
onClick={() => showForm ? setShowForm(false) : openAddForm()}
|
{logs.length > 0 && (
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition"
|
<button onClick={exportPDF}
|
||||||
>
|
className="flex items-center gap-2 px-3 py-2 border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 text-sm font-medium rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition">
|
||||||
<Plus className="w-4 h-4" />
|
<FileDown className="w-4 h-4" />
|
||||||
Mesure
|
PDF
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => showForm ? setShowForm(false) : openAddForm()}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Mesure
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add measurement form */}
|
{/* Add measurement form */}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { EVENT_CONFIG, EventType, formatDuration } from "@/lib/event-config";
|
||||||
|
import { EventIcon } from "@/components/event-icon";
|
||||||
|
import { useBaby } from "@/contexts/baby-context";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
import { Search, FileText, StickyNote } from "lucide-react";
|
||||||
|
|
||||||
|
interface SearchEvent {
|
||||||
|
id: string;
|
||||||
|
type: EventType;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt?: string;
|
||||||
|
notes?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
user: { id: string; name: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchNote {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
content: string;
|
||||||
|
user: { id: string; name: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventSummary(ev: SearchEvent): string {
|
||||||
|
const meta = ev.metadata ?? {};
|
||||||
|
if (ev.type === "BREASTFEED") {
|
||||||
|
const side = meta.breastSide === "left" ? "gauche" : meta.breastSide === "right" ? "droite" : "les deux";
|
||||||
|
if (ev.endedAt) return `${formatDuration(new Date(ev.startedAt), new Date(ev.endedAt))} · ${side}`;
|
||||||
|
return side;
|
||||||
|
}
|
||||||
|
if (ev.type === "BOTTLE") return meta.volume ? `${meta.volume} mL` : "";
|
||||||
|
if (ev.type === "TEMPERATURE") return meta.value ? `${meta.value}°${meta.unit ?? "C"}` : "";
|
||||||
|
if (ev.type === "MEDICATION") return String(meta.name ?? "");
|
||||||
|
if (ev.type === "SYMPTOM") return ((meta.tags as string[]) ?? []).join(", ");
|
||||||
|
if ((ev.type === "NAP" || ev.type === "NIGHT_SLEEP") && ev.endedAt)
|
||||||
|
return formatDuration(new Date(ev.startedAt), new Date(ev.endedAt));
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlight(text: string, q: string): React.ReactNode {
|
||||||
|
if (!q || !text) return text;
|
||||||
|
const idx = text.toLowerCase().indexOf(q.toLowerCase());
|
||||||
|
if (idx === -1) return text;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{text.slice(0, idx)}
|
||||||
|
<mark className="bg-yellow-200 dark:bg-yellow-700 rounded px-0.5">{text.slice(idx, idx + q.length)}</mark>
|
||||||
|
{text.slice(idx + q.length)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchPage() {
|
||||||
|
const { selectedBaby } = useBaby();
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [debouncedQ, setDebouncedQ] = useState("");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => { inputRef.current?.focus(); }, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebouncedQ(q), 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
|
const { data, isFetching } = useQuery<{ events: SearchEvent[]; notes: SearchNote[] }>({
|
||||||
|
queryKey: ["search", selectedBaby?.id, debouncedQ],
|
||||||
|
queryFn: () =>
|
||||||
|
fetch(`/api/search?babyId=${selectedBaby!.id}&q=${encodeURIComponent(debouncedQ)}`).then((r) => r.json()),
|
||||||
|
enabled: !!selectedBaby?.id && debouncedQ.length >= 2,
|
||||||
|
placeholderData: { events: [], notes: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = data?.events ?? [];
|
||||||
|
const notes = data?.notes ?? [];
|
||||||
|
const hasResults = events.length > 0 || notes.length > 0;
|
||||||
|
const searched = debouncedQ.length >= 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-8 pb-24 md:pb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-5">Recherche</h1>
|
||||||
|
|
||||||
|
<div className="relative mb-6">
|
||||||
|
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="search"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Rechercher dans les notes et événements…"
|
||||||
|
className="w-full pl-10 pr-4 py-3 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||||
|
/>
|
||||||
|
{isFetching && (
|
||||||
|
<div className="absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 border-2 border-indigo-400 border-t-transparent rounded-full animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!searched && (
|
||||||
|
<p className="text-sm text-slate-400 dark:text-slate-500 text-center py-12">Saisissez au moins 2 caractères</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searched && !hasResults && !isFetching && (
|
||||||
|
<p className="text-sm text-slate-400 dark:text-slate-500 text-center py-12">Aucun résultat pour « {debouncedQ} »</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{events.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3 flex items-center gap-1.5">
|
||||||
|
<FileText className="w-3.5 h-3.5" />
|
||||||
|
Événements · {events.length}
|
||||||
|
</h2>
|
||||||
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
|
||||||
|
{events.map((ev) => {
|
||||||
|
const cfg = EVENT_CONFIG[ev.type];
|
||||||
|
const summary = eventSummary(ev);
|
||||||
|
return (
|
||||||
|
<div key={ev.id} className="flex items-start gap-3 px-4 py-3">
|
||||||
|
<div className={`w-8 h-8 rounded-lg ${cfg.bgClass} flex items-center justify-center flex-shrink-0 mt-0.5`}>
|
||||||
|
<EventIcon name={cfg.icon} className={`w-4 h-4 ${cfg.colorClass}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{cfg.label}</span>
|
||||||
|
{summary && <span className="text-xs text-slate-400 dark:text-slate-500">{summary}</span>}
|
||||||
|
</div>
|
||||||
|
{ev.notes && (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 line-clamp-2">
|
||||||
|
{highlight(ev.notes, debouncedQ)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-slate-400 dark:text-slate-500 mt-1">
|
||||||
|
{format(new Date(ev.startedAt), "d MMM yyyy HH:mm", { locale: fr })} · {ev.user.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3 flex items-center gap-1.5">
|
||||||
|
<StickyNote className="w-3.5 h-3.5" />
|
||||||
|
Journal · {notes.length}
|
||||||
|
</h2>
|
||||||
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<div key={note.id} className="px-4 py-3">
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500 mb-1">
|
||||||
|
{format(new Date(note.date), "d MMMM yyyy", { locale: fr })} · {note.user.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-700 dark:text-slate-200 line-clamp-3">
|
||||||
|
{highlight(note.content, debouncedQ)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,6 +57,10 @@ function getEventSummary(event: Event): string {
|
|||||||
if (event.type === "DIAPER_STOOL") return String(meta.color ?? "");
|
if (event.type === "DIAPER_STOOL") return String(meta.color ?? "");
|
||||||
if (event.type === "TEMPERATURE") return meta.value ? `${meta.value}°${meta.unit ?? "C"}` : "";
|
if (event.type === "TEMPERATURE") return meta.value ? `${meta.value}°${meta.unit ?? "C"}` : "";
|
||||||
if (event.type === "MEDICATION") return String(meta.name ?? "");
|
if (event.type === "MEDICATION") return String(meta.name ?? "");
|
||||||
|
if (event.type === "SYMPTOM") {
|
||||||
|
const tags = (meta.tags as string[] | undefined) ?? [];
|
||||||
|
return tags.length > 0 ? tags.join(", ") : "";
|
||||||
|
}
|
||||||
if ((event.type === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) {
|
if ((event.type === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) {
|
||||||
return formatDuration(new Date(event.startedAt), new Date(event.endedAt));
|
return formatDuration(new Date(event.startedAt), new Date(event.endedAt));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const q = searchParams.get("q")?.trim() ?? "";
|
||||||
|
const babyId = searchParams.get("babyId");
|
||||||
|
|
||||||
|
if (!babyId || q.length < 2) return NextResponse.json({ events: [], notes: [] });
|
||||||
|
|
||||||
|
const [events, notes] = await Promise.all([
|
||||||
|
prisma.event.findMany({
|
||||||
|
where: {
|
||||||
|
babyId,
|
||||||
|
OR: [
|
||||||
|
{ notes: { contains: q, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { startedAt: "desc" },
|
||||||
|
take: 30,
|
||||||
|
include: { user: { select: { id: true, name: true } } },
|
||||||
|
}),
|
||||||
|
prisma.journalNote.findMany({
|
||||||
|
where: { babyId, content: { contains: q, mode: "insensitive" } },
|
||||||
|
orderBy: { date: "desc" },
|
||||||
|
take: 20,
|
||||||
|
include: { user: { select: { id: true, name: true } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({ events, notes });
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ interface FormData {
|
|||||||
medUnit?: string;
|
medUnit?: string;
|
||||||
temperature?: string;
|
temperature?: string;
|
||||||
tempUnit?: "C" | "F";
|
tempUnit?: "C" | "F";
|
||||||
|
symptomTags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MedProfile {
|
interface MedProfile {
|
||||||
@@ -116,6 +117,7 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
|
|||||||
medUnit: (meta.unit as string) ?? undefined,
|
medUnit: (meta.unit as string) ?? undefined,
|
||||||
temperature: meta.value != null ? String(meta.value) : undefined,
|
temperature: meta.value != null ? String(meta.value) : undefined,
|
||||||
tempUnit: (meta.unit as "C" | "F") ?? "C",
|
tempUnit: (meta.unit as "C" | "F") ?? "C",
|
||||||
|
symptomTags: (meta.tags as string[]) ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const [timerRunning, setTimerRunning] = useState(false);
|
const [timerRunning, setTimerRunning] = useState(false);
|
||||||
@@ -310,6 +312,7 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (type === "TEMPERATURE") { metadata.value = form.temperature ? parseFloat(form.temperature) : null; metadata.unit = form.tempUnit; }
|
if (type === "TEMPERATURE") { metadata.value = form.temperature ? parseFloat(form.temperature) : null; metadata.unit = form.tempUnit; }
|
||||||
|
if (type === "SYMPTOM") { metadata.tags = form.symptomTags ?? []; }
|
||||||
if (photoUrl) metadata.photo = photoUrl;
|
if (photoUrl) metadata.photo = photoUrl;
|
||||||
|
|
||||||
const toUTC = (local: string) => local ? new Date(local).toISOString() : null;
|
const toUTC = (local: string) => local ? new Date(local).toISOString() : null;
|
||||||
@@ -784,6 +787,34 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Symptom tags */}
|
||||||
|
{type === "SYMPTOM" && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-2">Symptômes</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{["Fièvre", "Reflux", "Colique", "Éruption", "Toux", "Congestion", "Diarrhée", "Vomissement", "Irritabilité", "Pleurs excessifs"].map((tag) => {
|
||||||
|
const active = (form.symptomTags ?? []).includes(tag);
|
||||||
|
return (
|
||||||
|
<button key={tag} type="button"
|
||||||
|
onClick={() => setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
symptomTags: active
|
||||||
|
? (f.symptomTags ?? []).filter((t) => t !== tag)
|
||||||
|
: [...(f.symptomTags ?? []), tag],
|
||||||
|
}))}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition ${
|
||||||
|
active
|
||||||
|
? "bg-red-100 dark:bg-red-950 border-red-300 dark:border-red-700 text-red-700 dark:text-red-300"
|
||||||
|
: "bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:border-red-200 dark:hover:border-red-800"
|
||||||
|
}`}>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Time fields — split date + time for iOS Safari compatibility */}
|
{/* Time fields — split date + time for iOS Safari compatibility */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
Milk,
|
Milk,
|
||||||
Pill,
|
Pill,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
|
Search,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const LINKS = [
|
const LINKS = [
|
||||||
@@ -44,6 +45,7 @@ const LINKS = [
|
|||||||
{ href: "/vaccinations", label: "Vaccins", Icon: Syringe },
|
{ href: "/vaccinations", label: "Vaccins", Icon: Syringe },
|
||||||
{ href: "/medications", label: "Médicaments", Icon: Pill },
|
{ href: "/medications", label: "Médicaments", Icon: Pill },
|
||||||
{ href: "/reminders", label: "Rappels", Icon: Bell },
|
{ href: "/reminders", label: "Rappels", Icon: Bell },
|
||||||
|
{ href: "/search", label: "Recherche", Icon: Search },
|
||||||
{ href: "/settings", label: "Réglages", Icon: Settings },
|
{ href: "/settings", label: "Réglages", Icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ const DRAWER_GROUPS: { label: string; hrefs: string[] }[] = [
|
|||||||
{ label: "Suivi", hrefs: ["/calendar", "/stats", "/milk"] },
|
{ label: "Suivi", hrefs: ["/calendar", "/stats", "/milk"] },
|
||||||
{ label: "Santé", hrefs: ["/medications", "/reminders", "/vaccinations", "/doctor-notes"] },
|
{ label: "Santé", hrefs: ["/medications", "/reminders", "/vaccinations", "/doctor-notes"] },
|
||||||
{ label: "Vie du bébé", hrefs: ["/photos", "/milestones", "/notes", "/growth"] },
|
{ label: "Vie du bébé", hrefs: ["/photos", "/milestones", "/notes", "/growth"] },
|
||||||
|
{ label: "Outils", hrefs: ["/search"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
const PRIMARY_HREFS = ["/dashboard", "/timeline", "/growth", "/settings"];
|
const PRIMARY_HREFS = ["/dashboard", "/timeline", "/growth", "/settings"];
|
||||||
|
|||||||
+12
-1
@@ -10,7 +10,8 @@ export type EventType =
|
|||||||
| "TEMPERATURE"
|
| "TEMPERATURE"
|
||||||
| "BATH"
|
| "BATH"
|
||||||
| "WALK"
|
| "WALK"
|
||||||
| "TUMMY_TIME";
|
| "TUMMY_TIME"
|
||||||
|
| "SYMPTOM";
|
||||||
|
|
||||||
export const EVENT_CONFIG: Record<
|
export const EVENT_CONFIG: Record<
|
||||||
EventType,
|
EventType,
|
||||||
@@ -145,6 +146,16 @@ export const EVENT_CONFIG: Record<
|
|||||||
hasDuration: true,
|
hasDuration: true,
|
||||||
hasTimer: true,
|
hasTimer: true,
|
||||||
},
|
},
|
||||||
|
SYMPTOM: {
|
||||||
|
label: "Symptôme",
|
||||||
|
icon: "Activity",
|
||||||
|
color: "#dc2626",
|
||||||
|
colorClass: "text-red-600",
|
||||||
|
bgClass: "bg-red-50",
|
||||||
|
borderClass: "border-red-200",
|
||||||
|
hasDuration: false,
|
||||||
|
hasTimer: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ALL_EVENT_TYPES = Object.keys(EVENT_CONFIG) as EventType[];
|
export const ALL_EVENT_TYPES = Object.keys(EVENT_CONFIG) as EventType[];
|
||||||
|
|||||||
Reference in New Issue
Block a user