feat: doctor visit log, schedule prediction, chart PDF, natural digest
- Doctor notes: add doctorName, reason, prescriptions, nextAppointmentAt fields (schema + API + UI) - Dashboard: baby schedule prediction widget (avg nap/bedtime/first-feed over 14 days) - Growth PDF: embed weight chart as image (SVG→Canvas capture) before data table - Weekly digest: natural language push notification instead of bullet stats Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+4
-4
@@ -73,10 +73,10 @@
|
||||
|
||||
- [x] **Vaccination tracker** — full UI + API, overdue badges, done/upcoming sections
|
||||
- [x] **Teeth tracker** — visual grid of 20 baby teeth, tap to mark appeared (in progress)
|
||||
- [ ] **Doctor visit log** — notes, prescriptions, next appointment date
|
||||
- [ ] **Baby schedule prediction** — "usually naps around 14h30" from historical patterns
|
||||
- [ ] **Growth chart in PDF** — embed chart image (jsPDF canvas) not just table
|
||||
- [ ] **Natural language digest** — "Emma feeds every 2h45 on average" weekly push
|
||||
- [x] **Doctor visit log** — notes, prescriptions, next appointment date
|
||||
- [x] **Baby schedule prediction** — "usually naps around 14h30" from historical patterns
|
||||
- [x] **Growth chart in PDF** — embed chart image (jsPDF canvas) not just table
|
||||
- [x] **Natural language digest** — "Emma feeds every 2h45 on average" weekly push
|
||||
- [ ] **Wellness check-in** — parent self-reports sleep/mood for postpartum monitoring
|
||||
- [ ] **Weight gain on-track indicator** — projected line on growth chart
|
||||
- [ ] **Contraction timer** — repurpose timer infra for pre-birth use
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "DoctorNote" ADD COLUMN "doctorName" TEXT,
|
||||
ADD COLUMN "nextAppointmentAt" TIMESTAMP(3),
|
||||
ADD COLUMN "prescriptions" TEXT,
|
||||
ADD COLUMN "reason" TEXT;
|
||||
@@ -190,6 +190,10 @@ model DoctorNote {
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
content String
|
||||
doctorName String?
|
||||
reason String?
|
||||
prescriptions String?
|
||||
nextAppointmentAt DateTime?
|
||||
date DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
@@ -554,6 +554,13 @@ export default function DashboardPage() {
|
||||
enabled: !!selectedBaby?.id,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const { data: scheduleData } = useQuery({
|
||||
queryKey: ["events-schedule", selectedBaby?.id],
|
||||
queryFn: () =>
|
||||
fetch(`/api/events?babyId=${selectedBaby!.id}&limit=200&dateFrom=${new Date(Date.now() - 14 * 86400000).toISOString()}`).then((r) => r.json()),
|
||||
enabled: !!selectedBaby?.id,
|
||||
staleTime: 300_000,
|
||||
});
|
||||
|
||||
if (familyLoading) {
|
||||
return (
|
||||
@@ -594,6 +601,63 @@ export default function DashboardPage() {
|
||||
return { avgH, avgM, lastFeedTime, nextExpected, untilNextMs };
|
||||
})();
|
||||
|
||||
const schedulePrediction = (() => {
|
||||
const evs: Event[] = scheduleData?.events ?? [];
|
||||
if (evs.length < 10) return null;
|
||||
|
||||
function avgHourMin(times: number[]): string | null {
|
||||
if (times.length === 0) return null;
|
||||
const avgMs = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
const totalMin = Math.round(avgMs / 60000);
|
||||
const h = Math.floor(totalMin / 60) % 24;
|
||||
const m = totalMin % 60;
|
||||
return `${h.toString().padStart(2, "0")}h${m.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const byDay: Record<string, Event[]> = {};
|
||||
for (const e of evs) {
|
||||
const day = e.startedAt.slice(0, 10);
|
||||
if (!byDay[day]) byDay[day] = [];
|
||||
byDay[day].push(e);
|
||||
}
|
||||
|
||||
const napTimes: number[] = [];
|
||||
const nightTimes: number[] = [];
|
||||
const firstFeedTimes: number[] = [];
|
||||
|
||||
for (const [, dayEvs] of Object.entries(byDay)) {
|
||||
const nap = dayEvs.find((e) => e.type === "NAP");
|
||||
if (nap) {
|
||||
const t = new Date(nap.startedAt);
|
||||
napTimes.push((t.getHours() * 60 + t.getMinutes()) * 60000);
|
||||
}
|
||||
|
||||
const night = dayEvs.find((e) => e.type === "NIGHT_SLEEP");
|
||||
if (night) {
|
||||
const t = new Date(night.startedAt);
|
||||
nightTimes.push((t.getHours() * 60 + t.getMinutes()) * 60000);
|
||||
}
|
||||
|
||||
const feeds = dayEvs
|
||||
.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE")
|
||||
.sort((a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime());
|
||||
if (feeds.length > 0) {
|
||||
const t = new Date(feeds[0].startedAt);
|
||||
const minuteOfDay = t.getHours() * 60 + t.getMinutes();
|
||||
if (minuteOfDay > 240) {
|
||||
firstFeedTimes.push(minuteOfDay * 60000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nap = avgHourMin(napTimes);
|
||||
const night = avgHourMin(nightTimes);
|
||||
const firstFeed = avgHourMin(firstFeedTimes);
|
||||
|
||||
if (!nap && !night && !firstFeed) return null;
|
||||
return { nap, night, firstFeed };
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 pb-24 md:pb-8">
|
||||
{/* Page header */}
|
||||
@@ -670,6 +734,42 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schedulePrediction && (
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-6">
|
||||
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">Planning habituel</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{schedulePrediction.firstFeed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">🍼</span>
|
||||
<div>
|
||||
<p className="text-[10px] text-slate-400 dark:text-slate-500">1er repas</p>
|
||||
<p className="text-sm font-bold text-slate-700 dark:text-slate-200 font-mono">{schedulePrediction.firstFeed}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{schedulePrediction.nap && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">😴</span>
|
||||
<div>
|
||||
<p className="text-[10px] text-slate-400 dark:text-slate-500">Sieste</p>
|
||||
<p className="text-sm font-bold text-slate-700 dark:text-slate-200 font-mono">{schedulePrediction.nap}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{schedulePrediction.night && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">🌙</span>
|
||||
<div>
|
||||
<p className="text-[10px] text-slate-400 dark:text-slate-500">Coucher</p>
|
||||
<p className="text-sm font-bold text-slate-700 dark:text-slate-200 font-mono">{schedulePrediction.night}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-300 dark:text-slate-600 mt-2">Moyenne sur 14 jours</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state for new users */}
|
||||
{todayEvents.length === 0 && events.filter((e) => !e.endedAt && EVENT_CONFIG[e.type]?.hasTimer).length === 0 && (
|
||||
<div className="bg-indigo-50 dark:bg-indigo-950 border border-indigo-100 dark:border-indigo-900 rounded-xl p-5 mb-4 flex items-start gap-3">
|
||||
|
||||
@@ -12,6 +12,10 @@ interface DoctorNote {
|
||||
id: string;
|
||||
content: string;
|
||||
date: string;
|
||||
doctorName?: string | null;
|
||||
reason?: string | null;
|
||||
prescriptions?: string | null;
|
||||
nextAppointmentAt?: string | null;
|
||||
createdAt: string;
|
||||
userId: string;
|
||||
user: { id: string; name: string };
|
||||
@@ -29,8 +33,14 @@ export default function DoctorNotesPage() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const [content, setContent] = useState("");
|
||||
const [date, setDate] = useState(today);
|
||||
const [doctorName, setDoctorName] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [prescriptions, setPrescriptions] = useState("");
|
||||
const [nextAppointmentAt, setNextAppointmentAt] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const inputCls = "w-full px-3 py-2.5 rounded-lg border border-slate-200 dark:border-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100";
|
||||
|
||||
const { data: notes = [], isLoading: notesLoading } = useQuery<DoctorNote[]>({
|
||||
queryKey: ["doctor-notes", selectedBaby?.id],
|
||||
queryFn: () =>
|
||||
@@ -39,7 +49,7 @@ export default function DoctorNotesPage() {
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: { babyId: string; content: string; date: string }) =>
|
||||
mutationFn: (body: { babyId: string; content: string; date: string; doctorName?: string; reason?: string; prescriptions?: string; nextAppointmentAt?: string }) =>
|
||||
fetch("/api/doctor-notes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -55,6 +65,10 @@ export default function DoctorNotesPage() {
|
||||
qc.invalidateQueries({ queryKey: ["doctor-notes", selectedBaby?.id] });
|
||||
setContent("");
|
||||
setDate(today);
|
||||
setDoctorName("");
|
||||
setReason("");
|
||||
setPrescriptions("");
|
||||
setNextAppointmentAt("");
|
||||
setFormError(null);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
@@ -78,7 +92,15 @@ export default function DoctorNotesPage() {
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!selectedBaby?.id || !content.trim() || !date) return;
|
||||
createMutation.mutate({ babyId: selectedBaby.id, content: content.trim(), date });
|
||||
createMutation.mutate({
|
||||
babyId: selectedBaby.id,
|
||||
content: content.trim(),
|
||||
date,
|
||||
doctorName: doctorName || undefined,
|
||||
reason: reason || undefined,
|
||||
prescriptions: prescriptions || undefined,
|
||||
nextAppointmentAt: nextAppointmentAt || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (familyLoading) {
|
||||
@@ -135,6 +157,53 @@ export default function DoctorNotesPage() {
|
||||
className="w-full rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-slate-100 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:focus:ring-emerald-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
|
||||
Médecin <span className="text-slate-400 font-normal">(optionnel)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={doctorName}
|
||||
onChange={(e) => setDoctorName(e.target.value)}
|
||||
placeholder="Dr. Dupont"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
|
||||
Motif <span className="text-slate-400 font-normal">(optionnel)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Visite de routine, fièvre..."
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
|
||||
Prescriptions <span className="text-slate-400 font-normal">(optionnel)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={prescriptions}
|
||||
onChange={(e) => setPrescriptions(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Doliprane 2.4% si fièvre..."
|
||||
className={inputCls + " resize-none"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
|
||||
Prochain rendez-vous <span className="text-slate-400 font-normal">(optionnel)</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={nextAppointmentAt}
|
||||
onChange={(e) => setNextAppointmentAt(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">
|
||||
Observation
|
||||
@@ -214,6 +283,26 @@ export default function DoctorNotesPage() {
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300 whitespace-pre-wrap leading-relaxed">
|
||||
{note.content}
|
||||
</p>
|
||||
{note.doctorName && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
<span className="font-medium">Médecin :</span> {note.doctorName}
|
||||
</p>
|
||||
)}
|
||||
{note.reason && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
<span className="font-medium">Motif :</span> {note.reason}
|
||||
</p>
|
||||
)}
|
||||
{note.prescriptions && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 whitespace-pre-line">
|
||||
<span className="font-medium">Prescriptions :</span> {note.prescriptions}
|
||||
</p>
|
||||
)}
|
||||
{note.nextAppointmentAt && (
|
||||
<p className="text-xs text-indigo-600 dark:text-indigo-400 mt-1 font-medium">
|
||||
📅 Prochain RDV : {format(new Date(note.nextAppointmentAt), "d MMMM yyyy", { locale: fr })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||||
@@ -168,6 +168,7 @@ export default function GrowthPage() {
|
||||
setShowForm(true);
|
||||
}
|
||||
const [saving, setSaving] = useState(false);
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("tout");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editForm, setEditForm] = useState({ date: "", weight: "", height: "", headCirc: "", notes: "" });
|
||||
@@ -241,7 +242,38 @@ export default function GrowthPage() {
|
||||
qc.invalidateQueries({ queryKey: ["growth"] });
|
||||
}
|
||||
|
||||
function exportPDF() {
|
||||
async function captureChartAsImage(): Promise<string | null> {
|
||||
if (!chartRef.current) return null;
|
||||
const svg = chartRef.current.querySelector("svg");
|
||||
if (!svg) return null;
|
||||
try {
|
||||
const svgData = new XMLSerializer().serializeToString(svg);
|
||||
const svgBlob = new Blob([svgData], { type: "image/svg+xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
return await new Promise<string>((resolve) => {
|
||||
const img = new window.Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const scale = 2;
|
||||
canvas.width = svg.clientWidth * scale;
|
||||
canvas.height = svg.clientHeight * scale;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.scale(scale, scale);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(canvas.toDataURL("image/png"));
|
||||
};
|
||||
img.onerror = () => { URL.revokeObjectURL(url); resolve(null as unknown as string); };
|
||||
img.src = url;
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportPDF() {
|
||||
function getPercentileBand(ageWeeks: number, weightKg: number): string {
|
||||
const values = PERCENTILE_LABELS.map((_, i) => interpolatePercentile(ageWeeks, i) ?? 0);
|
||||
if (weightKg < values[0]) return "< P3";
|
||||
@@ -251,6 +283,7 @@ export default function GrowthPage() {
|
||||
return "> P97";
|
||||
}
|
||||
|
||||
const chartImg = await captureChartAsImage();
|
||||
import("jspdf").then(({ default: jsPDF }) => {
|
||||
const doc = new jsPDF();
|
||||
const pageW = doc.internal.pageSize.getWidth();
|
||||
@@ -267,7 +300,18 @@ export default function GrowthPage() {
|
||||
// Table header
|
||||
const COL = [14, 55, 95, 130, 155];
|
||||
const headers = ["Date", "Poids (kg)", "Taille (cm)", "PC (cm)", "Notes"];
|
||||
let y = 46;
|
||||
let y: number;
|
||||
if (chartImg) {
|
||||
const imgWidth = pageW - 28;
|
||||
const imgHeight = 70;
|
||||
doc.setFontSize(11);
|
||||
doc.setTextColor(30, 41, 59);
|
||||
doc.text("Courbe de poids", 14, 42);
|
||||
doc.addImage(chartImg, "PNG", 14, 45, imgWidth, imgHeight);
|
||||
y = 45 + imgHeight + 10;
|
||||
} else {
|
||||
y = 46;
|
||||
}
|
||||
|
||||
doc.setFillColor(238, 242, 255);
|
||||
doc.rect(14, y - 5, pageW - 28, 9, "F");
|
||||
@@ -419,7 +463,7 @@ export default function GrowthPage() {
|
||||
<div className="space-y-4">
|
||||
{/* Weight + WHO */}
|
||||
{weightData.length > 0 && (
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
|
||||
<div ref={chartRef} className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">Poids (kg)</h3>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-400 dark:text-slate-500">
|
||||
|
||||
@@ -47,11 +47,9 @@ export async function POST(req: Request) {
|
||||
for (const family of families) {
|
||||
if (family.users.length === 0) continue;
|
||||
|
||||
const lines: string[] = [`📊 Résumé semaine — ${family.name}`, ""];
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const baby of family.babies) {
|
||||
lines.push(`👶 ${baby.name}`);
|
||||
|
||||
const events = await prisma.event.findMany({
|
||||
where: { babyId: baby.id, startedAt: { gte: since } },
|
||||
orderBy: { startedAt: "asc" },
|
||||
@@ -59,36 +57,50 @@ export async function POST(req: Request) {
|
||||
|
||||
// Feedings
|
||||
const feeds = events.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE");
|
||||
if (feeds.length > 0) {
|
||||
const perDay = (feeds.length / 7).toFixed(1);
|
||||
lines.push(`🍼 Repas: ${feeds.length} au total (${perDay}/j)`);
|
||||
}
|
||||
|
||||
// Sleep
|
||||
const sleepEvents = events.filter(
|
||||
(e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt
|
||||
);
|
||||
if (sleepEvents.length > 0) {
|
||||
const totalSleepMs = sleepEvents.reduce((acc, e) => {
|
||||
return acc + (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime());
|
||||
}, 0);
|
||||
const avgPerDay = totalSleepMs / 7;
|
||||
lines.push(`😴 Sommeil: ${fmtMs(totalSleepMs)} total, ~${fmtMs(avgPerDay)}/j`);
|
||||
}
|
||||
|
||||
// Diapers
|
||||
const diapers = events.filter((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL");
|
||||
if (diapers.length > 0) {
|
||||
lines.push(`👶 Couches: ${diapers.length} (${(diapers.length / 7).toFixed(1)}/j)`);
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (feeds.length > 0) {
|
||||
const perDay = Math.round(feeds.length / 7);
|
||||
parts.push(`${feeds.length} repas (${perDay}/j)`);
|
||||
}
|
||||
|
||||
if (sleepEvents.length > 0) {
|
||||
const avgPerDay = totalSleepMs / 7;
|
||||
parts.push(`${fmtMs(avgPerDay)}/j de sommeil`);
|
||||
}
|
||||
|
||||
if (diapers.length > 0) {
|
||||
const perDay = Math.round(diapers.length / 7);
|
||||
parts.push(`${diapers.length} couches (${perDay}/j)`);
|
||||
}
|
||||
|
||||
let babyLine = `👶 ${baby.name} cette semaine : `;
|
||||
if (parts.length > 0) {
|
||||
babyLine += parts.join(", ") + ".";
|
||||
} else {
|
||||
babyLine += "peu d'événements enregistrés.";
|
||||
}
|
||||
lines.push(babyLine);
|
||||
|
||||
// Weight (latest growth log in the period)
|
||||
const latestWeight = await prisma.growthLog.findFirst({
|
||||
where: { babyId: baby.id, date: { gte: since }, weight: { not: null } },
|
||||
orderBy: { date: "desc" },
|
||||
});
|
||||
if (latestWeight?.weight) {
|
||||
lines.push(`⚖️ Poids: ${latestWeight.weight} kg`);
|
||||
lines.push(`⚖️ Poids : ${(latestWeight.weight / 1000).toFixed(3)} kg`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
@@ -98,7 +110,7 @@ export async function POST(req: Request) {
|
||||
|
||||
await Promise.allSettled(
|
||||
family.users.map((u) =>
|
||||
sendPushover(u.pushoverUserKey!, message, "Grow — Résumé hebdomadaire")
|
||||
sendPushover(u.pushoverUserKey!, message, `📊 Résumé hebdo — ${family.name}`)
|
||||
)
|
||||
);
|
||||
sent += family.users.length;
|
||||
|
||||
@@ -29,3 +29,45 @@ export async function DELETE(
|
||||
await prisma.doctorNote.delete({ where: { id } });
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const userRole = (session.user as { role?: string }).role;
|
||||
if (userRole !== "DOCTOR") {
|
||||
return NextResponse.json({ error: "Réservé aux médecins" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const note = await prisma.doctorNote.findUnique({ where: { id } });
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json({ error: "Observation introuvable" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (note.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: "Vous ne pouvez modifier que vos propres observations" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
|
||||
const updated = await prisma.doctorNote.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.content !== undefined && { content: body.content }),
|
||||
...(body.date !== undefined && { date: new Date(body.date) }),
|
||||
doctorName: body.doctorName || null,
|
||||
reason: body.reason || null,
|
||||
prescriptions: body.prescriptions || null,
|
||||
nextAppointmentAt: body.nextAppointmentAt ? new Date(body.nextAppointmentAt) : null,
|
||||
},
|
||||
include: { user: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ export async function POST(req: Request) {
|
||||
userId: session.user.id,
|
||||
content,
|
||||
date: noteDate,
|
||||
doctorName: body.doctorName || null,
|
||||
reason: body.reason || null,
|
||||
prescriptions: body.prescriptions || null,
|
||||
nextAppointmentAt: body.nextAppointmentAt ? new Date(body.nextAppointmentAt) : null,
|
||||
},
|
||||
include: { user: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user