Compare commits
15 Commits
169309af05
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| febf9ed107 | |||
| 4abee45877 | |||
| bdf53980c0 | |||
| b4067c7685 | |||
| d3f1753555 | |||
| f2cc181ece | |||
| 319d70c3a4 | |||
| 6dc09fb696 | |||
| da60efd7c1 | |||
| 6f2336ffae | |||
| 2d0c44e21d | |||
| 1dac9690f5 | |||
| 432feed88f | |||
| 0a12cdfbcc | |||
| 9e32766046 |
Submodule .claude/worktrees/agent-a31af47fbdb654bea deleted from 8bb048ff56
@@ -42,3 +42,4 @@ next-env.d.ts
|
|||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
|
.claude/worktrees/
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ onlyBuiltDependencies[]=core-js
|
|||||||
onlyBuiltDependencies[]=prisma
|
onlyBuiltDependencies[]=prisma
|
||||||
onlyBuiltDependencies[]=sharp
|
onlyBuiltDependencies[]=sharp
|
||||||
onlyBuiltDependencies[]=unrs-resolver
|
onlyBuiltDependencies[]=unrs-resolver
|
||||||
|
confirmModulesPurge=false
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ RUN apk add --no-cache openssl && \
|
|||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV PRISMA_CLI_BINARY_TARGETS=linux-musl-openssl-3.0.x
|
ENV PRISMA_CLI_BINARY_TARGETS=linux-musl-openssl-3.0.x
|
||||||
|
ENV CI=true
|
||||||
COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
|||||||
+117
-2
@@ -11,10 +11,12 @@
|
|||||||
| Severity | Found | Fixed |
|
| Severity | Found | Fixed |
|
||||||
|----------|-------|-------|
|
|----------|-------|-------|
|
||||||
| CRITICAL | 9 | 9 |
|
| CRITICAL | 9 | 9 |
|
||||||
| HIGH | 6 | 5 |
|
| HIGH | 13 | 12 |
|
||||||
| MEDIUM | 6 | 2 |
|
| MEDIUM | 10 | 8 |
|
||||||
| LOW | 1 | 0 |
|
| LOW | 1 | 0 |
|
||||||
|
|
||||||
|
_Updated after third-pass audit (pass 3 of 3)._
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CRITICAL — Fixed
|
## CRITICAL — Fixed
|
||||||
@@ -145,6 +147,119 @@ If `SUPERADMIN_EMAIL` env var is not set, the condition `SUPERADMIN_EMAIL && ses
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HIGH — Fixed (pass 2)
|
||||||
|
|
||||||
|
### 17. `medication-profiles/[id]` — no family ownership check
|
||||||
|
**Affected:** PATCH and DELETE
|
||||||
|
|
||||||
|
**Problem:** Any authenticated user could modify or delete any medication profile by ID.
|
||||||
|
|
||||||
|
**Fix:** Added `prisma.medicationProfile.findFirst({ where: { id, familyId } })` check before PATCH and DELETE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 18. `event-templates/[id]` — no family ownership check
|
||||||
|
**Affected:** PATCH and DELETE
|
||||||
|
|
||||||
|
**Problem:** Any authenticated user could modify or delete any event template by ID.
|
||||||
|
|
||||||
|
**Fix:** Added `prisma.eventTemplate.findFirst({ where: { id, familyId } })` check before PATCH and DELETE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 19. `notify/push` — no family ownership check on babyId
|
||||||
|
**Problem:** Baby was fetched by ID but its `familyId` was never compared against the session user's family. Any authenticated user could trigger push notifications using another family's baby ID (leaking feeding status via the notification message).
|
||||||
|
|
||||||
|
**Fix:** Added `if (baby.familyId !== sessionFamilyId) return 404`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 20. `events/route.ts` GET — event `type` not validated against allowlist
|
||||||
|
**Problem:** The `type` query param was passed directly into the Prisma where clause without validation. While Prisma parameterizes values (no SQL injection), arbitrary strings could be stored and returned.
|
||||||
|
|
||||||
|
**Fix:** Imported `ALL_EVENT_TYPES` from `event-config.ts` and added `if (type && !ALL_EVENT_TYPES.includes(type)) return 400`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 21. `events/route.ts` GET — `limit`/`offset` not sanitized
|
||||||
|
**Problem:** `parseInt()` returns `NaN` on non-numeric input; `NaN` passed to Prisma `take`/`skip` causes an error or is treated as 0, potentially returning all rows.
|
||||||
|
|
||||||
|
**Fix:** Added clamping: `limit = Math.max(1, Math.min(parseInt(limit) || 50, 500))`, `offset = Math.max(0, parseInt(offset) || 0)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MEDIUM — Fixed (pass 2)
|
||||||
|
|
||||||
|
### 22. `v1/summary` — arithmetic operator precedence bug
|
||||||
|
**Problem:** `counts.BREASTFEED ?? 0 + (counts.BOTTLE ?? 0)` evaluated as `counts.BREASTFEED ?? (0 + (counts.BOTTLE ?? 0))` due to `??` precedence, causing incorrect feed count.
|
||||||
|
|
||||||
|
**Fix:** Added explicit parentheses: `(counts.BREASTFEED ?? 0) + (counts.BOTTLE ?? 0)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 23. `v1/events` GET — `offset` not sanitized (pass 2)
|
||||||
|
**Fix:** `Math.max(0, parseInt(offset) || 0)`. Also fixed `limit`: `Math.min` of `NaN` returns `NaN`; added `|| 50` fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 24. `v1/growth` POST — date not validated before `new Date()`
|
||||||
|
**Fix:** Added `if (isNaN(new Date(date).getTime())) return 400`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HIGH — Fixed (pass 3)
|
||||||
|
|
||||||
|
### 25. SSRF via webhook URL — internal IP/localhost allowed
|
||||||
|
**Affected:** `webhooks/route.ts` POST (creation) and `lib/webhooks.ts` dispatch
|
||||||
|
|
||||||
|
**Problem:** Webhook URLs were accepted without hostname validation. An attacker could register `http://localhost:5432` or `http://169.254.169.254/latest/meta-data/` (AWS metadata endpoint) and cause the server to make requests to internal services, leaking credentials or probing the internal network.
|
||||||
|
|
||||||
|
**Fix:** Added `isSafeWebhookUrl()` helper using regex to reject `localhost`, `127.x`, `0.0.0.0`, `::1`, `10.x`, `172.16–31.x`, `192.168.x`, `169.254.x`. Applied at both creation (returns 400) and dispatch (skips the hook silently).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 26. `medication-profiles/last-intakes` — missing family ownership check
|
||||||
|
**Problem:** Accepted any `babyId` without verifying it belongs to the session user's family. Cross-family medication data leak.
|
||||||
|
|
||||||
|
**Fix:** Added `prisma.baby.findFirst({ where: { id: babyId, familyId } })` check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MEDIUM — Fixed (pass 3)
|
||||||
|
|
||||||
|
### 27. Host header injection in invite email URL
|
||||||
|
**Affected:** `invite/send-email/route.ts`
|
||||||
|
|
||||||
|
**Problem:** Used `req.headers.get("origin")` to build the invite URL, allowing an attacker to craft a request with `Origin: https://evil.com` and send phishing links to invited users.
|
||||||
|
|
||||||
|
**Fix:** Now uses `process.env.NEXTAUTH_URL` exclusively; request Origin header ignored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 28. `baby/route.ts` POST — birthDate not validated
|
||||||
|
**Problem:** `new Date(birthDate)` accepted invalid strings silently; also no required-fields check.
|
||||||
|
|
||||||
|
**Fix:** Added `!name || !birthDate` check + `isNaN` date validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 29. `milk/route.ts` POST — storedAt not validated
|
||||||
|
### 30. `journal/route.ts` GET — date filter not validated
|
||||||
|
**Fix:** Added `isNaN(new Date(x).getTime())` guards on both.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MEDIUM — Not applicable (already handled)
|
||||||
|
|
||||||
|
### Upload route — extension from MIME type, not filename
|
||||||
|
The audit flagged `upload/route.ts` for unsafe extension extraction. The route derives the extension from `file.type` (e.g., `"image/jpeg".split("/")[1]`) — not from the original filename — and `ALLOWED_TYPES.includes(file.type)` is checked before that line. No fix needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Routes verified as already secure
|
## Routes verified as already secure
|
||||||
|
|
||||||
- `webhooks/route.ts` — familyId from session, no cross-family access possible
|
- `webhooks/route.ts` — familyId from session, no cross-family access possible
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "EventType" ADD VALUE 'OTHER';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PendingInvite" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"familyId" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"role" "UserRole" NOT NULL DEFAULT 'PARENT',
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "PendingInvite_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Webhook" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"familyId" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"secret" TEXT NOT NULL,
|
||||||
|
"events" TEXT[],
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PendingInvite_token_key" ON "PendingInvite"("token");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PendingInvite" ADD CONSTRAINT "PendingInvite_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Baby" ADD COLUMN "hasPump" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN "isBreastfed" BOOLEAN NOT NULL DEFAULT true;
|
||||||
@@ -149,7 +149,9 @@ model Baby {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
birthDate DateTime
|
birthDate DateTime
|
||||||
gender String?
|
gender String?
|
||||||
|
isBreastfed Boolean @default(true)
|
||||||
|
hasPump Boolean @default(true)
|
||||||
familyId String
|
familyId String
|
||||||
family Family @relation(fields: [familyId], references: [id])
|
family Family @relation(fields: [familyId], references: [id])
|
||||||
events Event[]
|
events Event[]
|
||||||
@@ -193,6 +195,7 @@ enum EventType {
|
|||||||
WALK
|
WALK
|
||||||
TUMMY_TIME
|
TUMMY_TIME
|
||||||
SYMPTOM
|
SYMPTOM
|
||||||
|
OTHER
|
||||||
}
|
}
|
||||||
|
|
||||||
model GrowthLog {
|
model GrowthLog {
|
||||||
|
|||||||
@@ -9,23 +9,24 @@ import { useBaby } from "@/contexts/baby-context";
|
|||||||
import { checkFeedAlert, getThresholds } from "@/lib/notifications";
|
import { checkFeedAlert, getThresholds } from "@/lib/notifications";
|
||||||
import { format, isToday } from "date-fns";
|
import { format, isToday } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
import { AlertTriangle, Clock, CheckCircle2, Pin, PinOff, Pencil, Check, X, Plus, Trash2, FileText, ImageIcon } from "lucide-react";
|
import { AlertTriangle, Clock, CheckCircle2, Pin, PinOff, Pencil, Check, X, Plus, FileText, ImageIcon } from "lucide-react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
const QUICK_LOG_GROUPS: { label: string; types: EventType[] }[] = [
|
function buildQuickLogGroups(isBreastfed: boolean, hasPump: boolean): { label: string; types: EventType[] }[] {
|
||||||
{
|
const feedTypes: EventType[] = [
|
||||||
label: "Alimentation & Soins",
|
...(isBreastfed ? (["BREASTFEED"] as EventType[]) : []),
|
||||||
types: ["BREASTFEED", "BOTTLE", "PUMP", "DIAPER_WET", "DIAPER_STOOL"],
|
"BOTTLE",
|
||||||
},
|
...(hasPump ? (["PUMP"] as EventType[]) : []),
|
||||||
{
|
"DIAPER_WET",
|
||||||
label: "Sommeil & Activités",
|
"DIAPER_STOOL",
|
||||||
types: ["NAP", "NIGHT_SLEEP", "BATH", "WALK", "TUMMY_TIME"],
|
];
|
||||||
},
|
return [
|
||||||
{
|
{ label: "Alimentation & Soins", types: feedTypes },
|
||||||
label: "Santé",
|
{ label: "Sommeil & Activités", types: ["NAP", "NIGHT_SLEEP", "BATH", "WALK", "TUMMY_TIME"] },
|
||||||
types: ["TEMPERATURE", "MEDICATION", "SYMPTOM"],
|
{ label: "Santé", types: ["TEMPERATURE", "MEDICATION", "SYMPTOM"] },
|
||||||
},
|
{ label: "Autre", types: ["OTHER"] },
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
interface Event {
|
interface Event {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -37,12 +38,6 @@ interface Event {
|
|||||||
user: { id: string; name: string };
|
user: { id: string; name: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventTemplate {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
type: EventType;
|
|
||||||
metadata?: Record<string, unknown> | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBabyAge(birthDate: string): string {
|
function getBabyAge(birthDate: string): string {
|
||||||
const birth = new Date(birthDate);
|
const birth = new Date(birthDate);
|
||||||
@@ -65,13 +60,15 @@ function getEventSummary(event: Event): string {
|
|||||||
if (event.type === "BOTTLE") {
|
if (event.type === "BOTTLE") {
|
||||||
return [meta.volume ? `${meta.volume} mL` : "", meta.bottleType === "maternal" ? "maternel" : "artificiel"].filter(Boolean).join(" · ");
|
return [meta.volume ? `${meta.volume} mL` : "", meta.bottleType === "maternal" ? "maternel" : "artificiel"].filter(Boolean).join(" · ");
|
||||||
}
|
}
|
||||||
if (event.type === "DIAPER_STOOL") return String(meta.color ?? "");
|
if (event.type === "DIAPER_STOOL") return [String(meta.color ?? ""), meta.leaked ? "débordement" : ""].filter(Boolean).join(" · ");
|
||||||
|
if (event.type === "DIAPER_WET") return meta.leaked ? "débordement" : "";
|
||||||
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 === "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));
|
||||||
}
|
}
|
||||||
|
if (event.type === "OTHER") return event.notes ? event.notes.slice(0, 50) : "";
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,115 +381,6 @@ function PinnedNoteWidget() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TemplatesRow({ babyId, onSaved }: { babyId: string; onSaved: () => void }) {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const [activeTemplate, setActiveTemplate] = useState<EventTemplate | null>(null);
|
|
||||||
const [addingType, setAddingType] = useState<EventType | null>(null);
|
|
||||||
const [addLabel, setAddLabel] = useState("");
|
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { data: templates = [] } = useQuery<EventTemplate[]>({
|
|
||||||
queryKey: ["event-templates"],
|
|
||||||
queryFn: () => fetch("/api/event-templates").then((r) => r.json()),
|
|
||||||
});
|
|
||||||
|
|
||||||
async function deleteTemplate(id: string) {
|
|
||||||
setDeletingId(id);
|
|
||||||
await fetch(`/api/event-templates/${id}`, { method: "DELETE" });
|
|
||||||
qc.invalidateQueries({ queryKey: ["event-templates"] });
|
|
||||||
setDeletingId(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createTemplate(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!addingType || !addLabel.trim()) return;
|
|
||||||
await fetch("/api/event-templates", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ type: addingType, label: addLabel.trim() }),
|
|
||||||
});
|
|
||||||
qc.invalidateQueries({ queryKey: ["event-templates"] });
|
|
||||||
setAddingType(null);
|
|
||||||
setAddLabel("");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">Modèles rapides</h2>
|
|
||||||
<div className="relative">
|
|
||||||
<select
|
|
||||||
value=""
|
|
||||||
onChange={(e) => { if (e.target.value) { setAddingType(e.target.value as EventType); setAddLabel(""); } }}
|
|
||||||
className="text-xs text-indigo-600 dark:text-indigo-400 border-none bg-transparent appearance-none cursor-pointer focus:outline-none pr-1"
|
|
||||||
>
|
|
||||||
<option value="">+ Nouveau modèle</option>
|
|
||||||
{(Object.keys(EVENT_CONFIG) as EventType[]).map((t) => (
|
|
||||||
<option key={t} value={t}>{EVENT_CONFIG[t].label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{addingType && (
|
|
||||||
<form onSubmit={createTemplate} className="flex gap-2 mb-3">
|
|
||||||
<div className={`w-8 h-8 rounded-lg ${EVENT_CONFIG[addingType].bgClass} flex items-center justify-center flex-shrink-0`}>
|
|
||||||
<EventIcon name={EVENT_CONFIG[addingType].icon} className={`w-4 h-4 ${EVENT_CONFIG[addingType].colorClass}`} />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
type="text"
|
|
||||||
value={addLabel}
|
|
||||||
onChange={(e) => setAddLabel(e.target.value)}
|
|
||||||
placeholder={`Nom du modèle (ex: Sieste midi)`}
|
|
||||||
className="flex-1 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 text-sm bg-white dark:bg-slate-800 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
|
||||||
/>
|
|
||||||
<button type="submit" className="px-3 py-1.5 bg-indigo-600 text-white rounded-lg text-xs font-medium">OK</button>
|
|
||||||
<button type="button" onClick={() => setAddingType(null)} className="px-2 py-1.5 border border-slate-200 dark:border-slate-700 rounded-lg text-xs text-slate-500">✕</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{templates.length > 0 && (
|
|
||||||
<div className="flex gap-2 overflow-x-auto pb-1 no-scrollbar">
|
|
||||||
{templates.map((t) => {
|
|
||||||
const cfg = EVENT_CONFIG[t.type];
|
|
||||||
return (
|
|
||||||
<div key={t.id} className="flex-shrink-0 flex items-center gap-1 group">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTemplate(t)}
|
|
||||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl border text-xs font-medium transition active:scale-95 ${cfg.bgClass} border-transparent hover:border-slate-200 dark:hover:border-slate-600`}
|
|
||||||
>
|
|
||||||
<EventIcon name={cfg.icon} className={`w-3.5 h-3.5 ${cfg.colorClass}`} />
|
|
||||||
<span className="text-slate-700 dark:text-slate-200">{t.label}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => deleteTemplate(t.id)}
|
|
||||||
disabled={deletingId === t.id}
|
|
||||||
className="w-5 h-5 flex items-center justify-center rounded text-slate-300 hover:text-red-400 dark:text-slate-600 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{templates.length === 0 && !addingType && (
|
|
||||||
<p className="text-xs text-slate-400 dark:text-slate-500 italic">Aucun modèle. Créez-en un pour accélérer la saisie.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTemplate && (
|
|
||||||
<EventModal
|
|
||||||
type={activeTemplate.type}
|
|
||||||
babyId={babyId}
|
|
||||||
onClose={() => setActiveTemplate(null)}
|
|
||||||
onSaved={() => { onSaved(); setActiveTemplate(null); }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -544,7 +432,7 @@ export default function DashboardPage() {
|
|||||||
queryKey: ["events-feeds", selectedBaby?.id],
|
queryKey: ["events-feeds", selectedBaby?.id],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetch(`/api/events?babyId=${selectedBaby!.id}&limit=100&type=BREASTFEED`).then((r) => r.json()),
|
fetch(`/api/events?babyId=${selectedBaby!.id}&limit=100&type=BREASTFEED`).then((r) => r.json()),
|
||||||
enabled: !!selectedBaby?.id,
|
enabled: !!selectedBaby?.id && (selectedBaby?.isBreastfed ?? true),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
const { data: bottleData } = useQuery({
|
const { data: bottleData } = useQuery({
|
||||||
@@ -687,8 +575,11 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Today's summary counters */}
|
{/* Today's summary counters */}
|
||||||
<div className="grid grid-cols-4 gap-3 mb-6">
|
<div className={`grid gap-3 mb-6 ${selectedBaby.isBreastfed ? "grid-cols-4" : "grid-cols-3"}`}>
|
||||||
{(["BREASTFEED", "BOTTLE", "DIAPER_WET", "DIAPER_STOOL"] as EventType[]).map((t) => {
|
{(["BREASTFEED", "BOTTLE", "DIAPER_WET", "DIAPER_STOOL"] as EventType[]).filter((t) => {
|
||||||
|
if (t === "BREASTFEED" && !selectedBaby.isBreastfed) return false;
|
||||||
|
return true;
|
||||||
|
}).map((t) => {
|
||||||
const cfg = EVENT_CONFIG[t];
|
const cfg = EVENT_CONFIG[t];
|
||||||
return (
|
return (
|
||||||
<div key={t} className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-3 text-center">
|
<div key={t} className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-3 text-center">
|
||||||
@@ -783,7 +674,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{/* Quick log */}
|
{/* Quick log */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{QUICK_LOG_GROUPS.map((group) => (
|
{buildQuickLogGroups(selectedBaby.isBreastfed, selectedBaby.hasPump).map((group) => (
|
||||||
<div key={group.label}>
|
<div key={group.label}>
|
||||||
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-2">{group.label}</h2>
|
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-2">{group.label}</h2>
|
||||||
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2">
|
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2">
|
||||||
@@ -809,11 +700,6 @@ export default function DashboardPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Event templates */}
|
|
||||||
<div className="mt-6 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
|
|
||||||
<TemplatesRow babyId={selectedBaby.id} onSaved={() => qc.invalidateQueries({ queryKey: ["events"] })} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Medication status */}
|
{/* Medication status */}
|
||||||
{medProfiles.length > 0 && (() => {
|
{medProfiles.length > 0 && (() => {
|
||||||
const activeMeds = medProfiles.filter((p) => lastIntakes[p.name]);
|
const activeMeds = medProfiles.filter((p) => lastIntakes[p.name]);
|
||||||
|
|||||||
@@ -104,9 +104,10 @@ function buildSimpleChartData(logs: GrowthLog[], baby: BabyShape, field: "height
|
|||||||
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 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";
|
||||||
|
|
||||||
function fmtAgeWeeks(weeks: number): string {
|
function fmtAgeWeeks(weeks: number): string {
|
||||||
if (weeks === 0) return "Naiss.";
|
const w = Math.round(weeks * 10) / 10;
|
||||||
if (weeks < 4) return `${weeks}sem`;
|
if (w === 0) return "Naiss.";
|
||||||
const months = Math.round(weeks / 4.345);
|
if (w < 4) return `${w}sem`;
|
||||||
|
const months = Math.round(w / 4.345);
|
||||||
if (months >= 12) return "1 an";
|
if (months >= 12) return "1 an";
|
||||||
return `${months}m`;
|
return `${months}m`;
|
||||||
}
|
}
|
||||||
|
|||||||
+237
-28
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useBaby } from "@/contexts/baby-context";
|
import { useBaby } from "@/contexts/baby-context";
|
||||||
import { Plus, Trash2, Check, AlertTriangle, Clock, Thermometer, Snowflake, Wind } from "lucide-react";
|
import { Plus, Trash2, Check, AlertTriangle, Clock, Thermometer, Snowflake, Wind, Pencil, Combine, X } from "lucide-react";
|
||||||
import { format, formatDistanceToNow } from "date-fns";
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
|
|
||||||
@@ -33,11 +33,15 @@ function localNowString() {
|
|||||||
return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toLocalDatetime(iso: string) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
function expiryStatus(stock: Stock): "expired" | "soon" | "ok" {
|
function expiryStatus(stock: Stock): "expired" | "soon" | "ok" {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const exp = new Date(stock.expiresAt).getTime();
|
const exp = new Date(stock.expiresAt).getTime();
|
||||||
if (exp <= now) return "expired";
|
if (exp <= now) return "expired";
|
||||||
// "soon" threshold scales with location: room <2h, fridge <24h, freezer <7d
|
|
||||||
const remaining = exp - now;
|
const remaining = exp - now;
|
||||||
const soonMs =
|
const soonMs =
|
||||||
stock.location === "freezer" ? 7 * 24 * 3600_000 :
|
stock.location === "freezer" ? 7 * 24 * 3600_000 :
|
||||||
@@ -50,6 +54,8 @@ function expiryStatus(stock: Stock): "expired" | "soon" | "ok" {
|
|||||||
export default function MilkPage() {
|
export default function MilkPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { selectedBaby } = useBaby();
|
const { selectedBaby } = useBaby();
|
||||||
|
|
||||||
|
// Add form
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [showUsed, setShowUsed] = useState(false);
|
const [showUsed, setShowUsed] = useState(false);
|
||||||
const [location, setLocation] = useState<Location>("fridge");
|
const [location, setLocation] = useState<Location>("fridge");
|
||||||
@@ -57,10 +63,25 @@ export default function MilkPage() {
|
|||||||
volume: "",
|
volume: "",
|
||||||
storedAt: localNowString(),
|
storedAt: localNowString(),
|
||||||
notes: "",
|
notes: "",
|
||||||
expiryCustom: "", // in hours (room), days (fridge), months (freezer)
|
expiryCustom: "",
|
||||||
});
|
});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Edit
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
volume: "",
|
||||||
|
location: "fridge" as Location,
|
||||||
|
expiresAt: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
const [editSaving, setEditSaving] = useState(false);
|
||||||
|
|
||||||
|
// Assemble
|
||||||
|
const [assembleMode, setAssembleMode] = useState(false);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [assembleSaving, setAssembleSaving] = useState(false);
|
||||||
|
|
||||||
const { data: stocks = [], isLoading } = useQuery<Stock[]>({
|
const { data: stocks = [], isLoading } = useQuery<Stock[]>({
|
||||||
queryKey: ["milk", selectedBaby?.id],
|
queryKey: ["milk", selectedBaby?.id],
|
||||||
queryFn: () => fetch(`/api/milk?babyId=${selectedBaby!.id}`).then((r) => r.json()),
|
queryFn: () => fetch(`/api/milk?babyId=${selectedBaby!.id}`).then((r) => r.json()),
|
||||||
@@ -108,6 +129,35 @@ export default function MilkPage() {
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openEdit(s: Stock) {
|
||||||
|
setEditingId(s.id);
|
||||||
|
setEditForm({
|
||||||
|
volume: String(s.volume),
|
||||||
|
location: (s.location as Location) ?? "fridge",
|
||||||
|
expiresAt: toLocalDatetime(s.expiresAt),
|
||||||
|
notes: s.notes ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEdit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editingId) return;
|
||||||
|
setEditSaving(true);
|
||||||
|
await fetch(`/api/milk/${editingId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
volume: parseFloat(editForm.volume),
|
||||||
|
location: editForm.location,
|
||||||
|
expiresAt: new Date(editForm.expiresAt).toISOString(),
|
||||||
|
notes: editForm.notes || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({ queryKey: ["milk"] });
|
||||||
|
setEditingId(null);
|
||||||
|
setEditSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
async function markUsed(id: string) {
|
async function markUsed(id: string) {
|
||||||
await fetch(`/api/milk/${id}`, {
|
await fetch(`/api/milk/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -123,6 +173,48 @@ export default function MilkPage() {
|
|||||||
qc.invalidateQueries({ queryKey: ["milk"] });
|
qc.invalidateQueries({ queryKey: ["milk"] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleSelect(id: string) {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedStocks = available.filter((s) => selectedIds.has(s.id));
|
||||||
|
const assembleVolume = selectedStocks.reduce((sum, s) => sum + s.volume, 0);
|
||||||
|
const assembleExpiry = selectedStocks.length > 0
|
||||||
|
? selectedStocks.reduce((min, s) => {
|
||||||
|
const t = new Date(s.expiresAt).getTime();
|
||||||
|
return t < min ? t : min;
|
||||||
|
}, Infinity)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
async function handleAssemble() {
|
||||||
|
if (!selectedBaby || selectedStocks.length < 2) return;
|
||||||
|
setAssembleSaving(true);
|
||||||
|
await fetch("/api/milk", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
babyId: selectedBaby.id,
|
||||||
|
volume: assembleVolume,
|
||||||
|
storedAt: new Date().toISOString(),
|
||||||
|
location: selectedStocks[0].location,
|
||||||
|
notes: `Assemblage de ${selectedStocks.length} lots`,
|
||||||
|
expiresAt: assembleExpiry ? new Date(assembleExpiry).toISOString() : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await Promise.all(selectedStocks.map((s) =>
|
||||||
|
fetch(`/api/milk/${s.id}`, { method: "DELETE" })
|
||||||
|
));
|
||||||
|
qc.invalidateQueries({ queryKey: ["milk"] });
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
setAssembleMode(false);
|
||||||
|
setAssembleSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
const defaultExpiryHours = LOCATION_CONFIG[location].defaultHours;
|
const defaultExpiryHours = LOCATION_CONFIG[location].defaultHours;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -133,13 +225,28 @@ export default function MilkPage() {
|
|||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Stock de lait</h1>
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Stock de lait</h1>
|
||||||
{selectedBaby && <p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{selectedBaby.name}</p>}
|
{selectedBaby && <p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{selectedBaby.name}</p>}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => setShowForm((v) => !v)}
|
{available.filter((s) => expiryStatus(s) !== "expired").length >= 2 && (
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-cyan-600 text-white rounded-xl text-sm font-medium hover:bg-cyan-700 transition"
|
<button
|
||||||
>
|
onClick={() => { setAssembleMode((v) => !v); setSelectedIds(new Set()); }}
|
||||||
<Plus className="w-4 h-4" />
|
className={`flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition ${
|
||||||
Ajouter
|
assembleMode
|
||||||
</button>
|
? "bg-indigo-100 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300 border border-indigo-300 dark:border-indigo-700"
|
||||||
|
: "border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Combine className="w-4 h-4" />
|
||||||
|
Assembler
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm((v) => !v)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-cyan-600 text-white rounded-xl text-sm font-medium hover:bg-cyan-700 transition"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary cards */}
|
{/* Summary cards */}
|
||||||
@@ -174,12 +281,19 @@ export default function MilkPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Assemble hint */}
|
||||||
|
{assembleMode && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2.5 bg-indigo-50 dark:bg-indigo-950 border border-indigo-200 dark:border-indigo-800 rounded-xl mb-4 text-sm text-indigo-700 dark:text-indigo-300">
|
||||||
|
<Combine className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>Sélectionnez au moins 2 lots à assembler</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Add form */}
|
{/* Add form */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<form onSubmit={handleCreate} className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4 space-y-3">
|
<form onSubmit={handleCreate} className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4 space-y-3">
|
||||||
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">Nouveau lot</p>
|
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">Nouveau lot</p>
|
||||||
|
|
||||||
{/* Location selector */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Conservation</label>
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Conservation</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -227,9 +341,7 @@ export default function MilkPage() {
|
|||||||
value={form.expiryCustom}
|
value={form.expiryCustom}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, expiryCustom: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, expiryCustom: e.target.value }))}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
placeholder={
|
placeholder={location === "freezer" ? "6" : location === "fridge" ? "4" : "4"}
|
||||||
location === "freezer" ? "6" : location === "fridge" ? "4" : "4"
|
|
||||||
}
|
|
||||||
min="1"
|
min="1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,13 +392,28 @@ export default function MilkPage() {
|
|||||||
.map((s) => {
|
.map((s) => {
|
||||||
const status = expiryStatus(s);
|
const status = expiryStatus(s);
|
||||||
const LocIcon = LOCATION_CONFIG[s.location as Location]?.icon ?? Thermometer;
|
const LocIcon = LOCATION_CONFIG[s.location as Location]?.icon ?? Thermometer;
|
||||||
|
const isEditing = editingId === s.id;
|
||||||
|
const isSelectable = assembleMode && status !== "expired";
|
||||||
|
const isSelected = selectedIds.has(s.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={s.id} className={`bg-white dark:bg-slate-900 border rounded-xl p-4 transition ${
|
<div key={s.id} className={`bg-white dark:bg-slate-900 border rounded-xl overflow-hidden transition ${
|
||||||
|
isSelected ? "border-indigo-400 dark:border-indigo-600 ring-1 ring-indigo-300 dark:ring-indigo-700" :
|
||||||
status === "expired" ? "border-red-200 dark:border-red-800" :
|
status === "expired" ? "border-red-200 dark:border-red-800" :
|
||||||
status === "soon" ? "border-amber-200 dark:border-amber-800" :
|
status === "soon" ? "border-amber-200 dark:border-amber-800" :
|
||||||
"border-slate-200 dark:border-slate-700"
|
"border-slate-200 dark:border-slate-700"
|
||||||
}`}>
|
}`}>
|
||||||
<div className="flex items-start gap-3">
|
<div
|
||||||
|
className={`flex items-start gap-3 p-4 ${isSelectable ? "cursor-pointer" : ""}`}
|
||||||
|
onClick={isSelectable ? () => toggleSelect(s.id) : undefined}
|
||||||
|
>
|
||||||
|
{assembleMode && (
|
||||||
|
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 mt-2 transition ${
|
||||||
|
isSelected ? "border-indigo-500 bg-indigo-500" : "border-slate-300 dark:border-slate-600"
|
||||||
|
}`}>
|
||||||
|
{isSelected && <Check className="w-3 h-3 text-white" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className={`w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0 ${
|
<div className={`w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0 ${
|
||||||
status === "expired" ? "bg-red-50 dark:bg-red-950" :
|
status === "expired" ? "bg-red-50 dark:bg-red-950" :
|
||||||
status === "soon" ? "bg-amber-50 dark:bg-amber-950" :
|
status === "soon" ? "bg-amber-50 dark:bg-amber-950" :
|
||||||
@@ -321,25 +448,107 @@ export default function MilkPage() {
|
|||||||
</p>
|
</p>
|
||||||
{s.notes && <p className="text-xs text-slate-400 dark:text-slate-500 italic mt-0.5">{s.notes}</p>}
|
{s.notes && <p className="text-xs text-slate-400 dark:text-slate-500 italic mt-0.5">{s.notes}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5 flex-shrink-0">
|
{!assembleMode && (
|
||||||
<button onClick={() => markUsed(s.id)}
|
<div className="flex flex-col gap-1.5 flex-shrink-0">
|
||||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-950 transition"
|
<button onClick={() => isEditing ? setEditingId(null) : openEdit(s)}
|
||||||
title="Marquer comme utilisé">
|
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-950 transition"
|
||||||
<Check className="w-4 h-4" />
|
title="Modifier">
|
||||||
</button>
|
{isEditing ? <X className="w-4 h-4" /> : <Pencil className="w-3.5 h-3.5" />}
|
||||||
<button onClick={() => deleteStock(s.id)}
|
</button>
|
||||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950 transition"
|
<button onClick={() => markUsed(s.id)}
|
||||||
title="Supprimer">
|
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-950 transition"
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
title="Marquer comme utilisé">
|
||||||
</button>
|
<Check className="w-4 h-4" />
|
||||||
</div>
|
</button>
|
||||||
|
<button onClick={() => deleteStock(s.id)}
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950 transition"
|
||||||
|
title="Supprimer">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Inline edit form */}
|
||||||
|
{isEditing && (
|
||||||
|
<form onSubmit={handleSaveEdit} className="border-t border-slate-100 dark:border-slate-700 px-4 pb-4 pt-3 space-y-3 bg-slate-50 dark:bg-slate-800/60">
|
||||||
|
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">Modifier le lot</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(["room", "fridge", "freezer"] as Location[]).map((loc) => {
|
||||||
|
const cfg = LOCATION_CONFIG[loc];
|
||||||
|
const Icon = cfg.icon;
|
||||||
|
return (
|
||||||
|
<button key={loc} type="button" onClick={() => setEditForm((f) => ({ ...f, location: loc }))}
|
||||||
|
className={`flex-1 flex flex-col items-center gap-1 py-2 px-1 border rounded-xl text-xs font-medium transition ${
|
||||||
|
editForm.location === loc
|
||||||
|
? "border-indigo-500 bg-indigo-50 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300"
|
||||||
|
: "border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400"
|
||||||
|
}`}>
|
||||||
|
<Icon className={`w-4 h-4 ${editForm.location === loc ? "text-indigo-600 dark:text-indigo-400" : "text-slate-400"}`} />
|
||||||
|
{loc === "room" ? "Ambiant" : loc === "fridge" ? "Frigo" : "Congélo"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Volume (mL)</label>
|
||||||
|
<input type="number" value={editForm.volume} onChange={(e) => setEditForm((f) => ({ ...f, volume: e.target.value }))}
|
||||||
|
required className={inputCls} min="1" max="2000" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Péremption</label>
|
||||||
|
<input type="datetime-local" value={editForm.expiresAt} onChange={(e) => setEditForm((f) => ({ ...f, expiresAt: e.target.value }))}
|
||||||
|
required className={inputCls} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Notes</label>
|
||||||
|
<input type="text" value={editForm.notes} onChange={(e) => setEditForm((f) => ({ ...f, notes: e.target.value }))}
|
||||||
|
className={inputCls} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => setEditingId(null)}
|
||||||
|
className="flex-1 py-2 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={editSaving}
|
||||||
|
className="flex-[2] py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium disabled:opacity-50">
|
||||||
|
{editSaving ? "..." : "Enregistrer"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Assemble action bar */}
|
||||||
|
{assembleMode && selectedIds.size >= 2 && (
|
||||||
|
<div className="fixed bottom-[calc(env(safe-area-inset-bottom)+4.5rem)] md:bottom-8 left-4 right-4 md:left-auto md:right-8 md:w-80 z-40">
|
||||||
|
<div className="bg-indigo-600 text-white rounded-2xl shadow-2xl p-4 flex items-center gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-semibold">{selectedIds.size} lots · {assembleVolume} mL</p>
|
||||||
|
{assembleExpiry && (
|
||||||
|
<p className="text-xs text-indigo-200 mt-0.5">
|
||||||
|
Expire le {format(new Date(assembleExpiry), "dd/MM à HH:mm")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleAssemble}
|
||||||
|
disabled={assembleSaving}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-white text-indigo-700 rounded-xl text-sm font-semibold hover:bg-indigo-50 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Combine className="w-4 h-4" />
|
||||||
|
{assembleSaving ? "..." : "Assembler"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Used / archived */}
|
{/* Used / archived */}
|
||||||
{used.length > 0 && (
|
{used.length > 0 && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useBaby } from "@/contexts/baby-context";
|
import { useBaby } from "@/contexts/baby-context";
|
||||||
import { Plus, Trash2, Bell, BellOff, Clock } from "lucide-react";
|
import { Plus, Trash2, Bell, BellOff, Clock, CheckCircle2, RotateCcw } from "lucide-react";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
|
||||||
interface Reminder {
|
interface Reminder {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -72,6 +74,24 @@ export default function RemindersPage() {
|
|||||||
qc.invalidateQueries({ queryKey: ["reminders"] });
|
qc.invalidateQueries({ queryKey: ["reminders"] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function markTaken(id: string) {
|
||||||
|
await fetch(`/api/reminders/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ lastSentAt: new Date().toISOString() }),
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({ queryKey: ["reminders"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unmarkTaken(id: string) {
|
||||||
|
await fetch(`/api/reminders/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ lastSentAt: null }),
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({ queryKey: ["reminders"] });
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteReminder(id: string) {
|
async function deleteReminder(id: string) {
|
||||||
if (!confirm("Supprimer ce rappel ?")) return;
|
if (!confirm("Supprimer ce rappel ?")) return;
|
||||||
await fetch(`/api/reminders/${id}`, { method: "DELETE" });
|
await fetch(`/api/reminders/${id}`, { method: "DELETE" });
|
||||||
@@ -175,8 +195,31 @@ export default function RemindersPage() {
|
|||||||
<span className="text-xs text-indigo-600 dark:text-indigo-400 ml-2">{nextDueLabel(r)}</span>
|
<span className="text-xs text-indigo-600 dark:text-indigo-400 ml-2">{nextDueLabel(r)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{r.lastSentAt && (
|
||||||
|
<p className="text-xs text-green-600 dark:text-green-400 mt-0.5 flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
Pris {formatDistanceToNow(new Date(r.lastSentAt), { locale: fr, addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{r.lastSentAt ? (
|
||||||
|
<button
|
||||||
|
onClick={() => unmarkTaken(r.id)}
|
||||||
|
title="Annuler la prise"
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded-lg text-green-500 hover:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => markTaken(r.id)}
|
||||||
|
title="Marquer comme pris"
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-950 transition"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleEnabled(r.id, r.enabled)}
|
onClick={() => toggleEnabled(r.id, r.enabled)}
|
||||||
className={`w-9 h-5 rounded-full transition relative overflow-hidden ${r.enabled ? "bg-green-500" : "bg-slate-200 dark:bg-slate-700"}`}
|
className={`w-9 h-5 rounded-full transition relative overflow-hidden ${r.enabled ? "bg-green-500" : "bg-slate-200 dark:bg-slate-700"}`}
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ export default function SettingsPage() {
|
|||||||
const [dateTo, setDateTo] = useState("");
|
const [dateTo, setDateTo] = useState("");
|
||||||
|
|
||||||
const [editingBaby, setEditingBaby] = useState(false);
|
const [editingBaby, setEditingBaby] = useState(false);
|
||||||
const [babyForm, setBabyForm] = useState({ name: "", birthDate: "", gender: "" });
|
const [babyForm, setBabyForm] = useState({ name: "", birthDate: "", gender: "", isBreastfed: true, hasPump: true });
|
||||||
const [savingBaby, setSavingBaby] = useState(false);
|
const [savingBaby, setSavingBaby] = useState(false);
|
||||||
|
|
||||||
const [thresholds, setThresholdsState] = useState(getThresholds);
|
const [thresholds, setThresholdsState] = useState(getThresholds);
|
||||||
@@ -563,13 +563,15 @@ export default function SettingsPage() {
|
|||||||
setExportLoading(false);
|
setExportLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEditBaby(b?: { name: string; birthDate: string; gender?: string }) {
|
function startEditBaby(b?: { name: string; birthDate: string; gender?: string; isBreastfed?: boolean; hasPump?: boolean }) {
|
||||||
const src = b ?? selectedBaby;
|
const src = b ?? selectedBaby;
|
||||||
if (!src) return;
|
if (!src) return;
|
||||||
setBabyForm({
|
setBabyForm({
|
||||||
name: src.name,
|
name: src.name,
|
||||||
birthDate: src.birthDate.slice(0, 10),
|
birthDate: src.birthDate.slice(0, 10),
|
||||||
gender: src.gender ?? "",
|
gender: src.gender ?? "",
|
||||||
|
isBreastfed: src.isBreastfed !== false,
|
||||||
|
hasPump: src.hasPump !== false,
|
||||||
});
|
});
|
||||||
setEditingBaby(true);
|
setEditingBaby(true);
|
||||||
}
|
}
|
||||||
@@ -696,6 +698,25 @@ export default function SettingsPage() {
|
|||||||
<option value="girl">Fille</option>
|
<option value="girl">Fille</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400">Alimentation</label>
|
||||||
|
{[
|
||||||
|
{ key: "isBreastfed" as const, label: "Allaitement maternel" },
|
||||||
|
{ key: "hasPump" as const, label: "Tire-lait & stock lait" },
|
||||||
|
].map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBabyForm((f) => ({ ...f, [key]: !f[key] }))}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition"
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
<div className={`w-9 h-5 rounded-full transition-colors ${babyForm[key] ? "bg-indigo-600" : "bg-slate-200 dark:bg-slate-600"} relative flex-shrink-0`}>
|
||||||
|
<div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${babyForm[key] ? "translate-x-4" : "translate-x-0.5"}`} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="button" onClick={() => setEditingBaby(false)} className="flex-1 py-2 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-400 bg-white dark:bg-slate-700">Annuler</button>
|
<button type="button" onClick={() => setEditingBaby(false)} className="flex-1 py-2 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-400 bg-white dark:bg-slate-700">Annuler</button>
|
||||||
<button type="submit" disabled={savingBaby} className="flex-1 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium">
|
<button type="submit" disabled={savingBaby} className="flex-1 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium">
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ interface Event {
|
|||||||
type: EventType;
|
type: EventType;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
endedAt?: string;
|
endedAt?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MilkStock {
|
||||||
|
id: string;
|
||||||
|
volume: number;
|
||||||
|
storedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
location: string;
|
||||||
|
used: boolean;
|
||||||
|
usedAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWeeklyData(events: Event[], days: number) {
|
function buildWeeklyData(events: Event[], days: number) {
|
||||||
@@ -30,10 +41,11 @@ function buildWeeklyData(events: Event[], days: number) {
|
|||||||
});
|
});
|
||||||
const feeds = dayEvents.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE").length;
|
const feeds = dayEvents.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE").length;
|
||||||
const diapers = dayEvents.filter((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL").length;
|
const diapers = dayEvents.filter((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL").length;
|
||||||
|
const activities = dayEvents.filter((e) => e.type === "BATH" || e.type === "WALK" || e.type === "TUMMY_TIME" || e.type === "OTHER").length;
|
||||||
const sleepMins = dayEvents
|
const sleepMins = dayEvents
|
||||||
.filter((e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt)
|
.filter((e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt)
|
||||||
.reduce((acc, e) => acc + (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime()) / 60000, 0);
|
.reduce((acc, e) => acc + (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime()) / 60000, 0);
|
||||||
return { label: format(d, labelFormat, { locale: fr }), feeds, diapers, sleep: Math.round(sleepMins / 60 * 10) / 10 };
|
return { label: format(d, labelFormat, { locale: fr }), feeds, diapers, activities, sleep: Math.round(sleepMins / 60 * 10) / 10 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +137,19 @@ function buildDurationTrend(events: Event[], days: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildMilkDailyData(events: Event[], days: number) {
|
||||||
|
const labelFormat = days <= 7 ? "EEE" : "d/M";
|
||||||
|
return Array.from({ length: days }, (_, i) => {
|
||||||
|
const d = subDays(new Date(), days - 1 - i);
|
||||||
|
const key = format(d, "yyyy-MM-dd");
|
||||||
|
const label = format(d, labelFormat, { locale: fr });
|
||||||
|
const pumped = events
|
||||||
|
.filter((e) => e.type === "PUMP" && format(new Date(e.startedAt), "yyyy-MM-dd") === key)
|
||||||
|
.reduce((acc, e) => acc + (typeof e.metadata?.volume === "number" ? e.metadata.volume : 0), 0);
|
||||||
|
return { label, pumped: Math.round(pumped) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function useDarkMode() {
|
function useDarkMode() {
|
||||||
const [isDark, setIsDark] = useState(false);
|
const [isDark, setIsDark] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -365,6 +390,12 @@ export default function StatsPage() {
|
|||||||
enabled: !!baby?.id,
|
enabled: !!baby?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: milkStocks } = useQuery<MilkStock[]>({
|
||||||
|
queryKey: ["milk", baby?.id],
|
||||||
|
queryFn: () => fetch(`/api/milk?babyId=${baby!.id}`).then((r) => r.json()),
|
||||||
|
enabled: !!baby?.id && !!baby?.hasPump,
|
||||||
|
});
|
||||||
|
|
||||||
const events: Event[] = eventsData?.events ?? [];
|
const events: Event[] = eventsData?.events ?? [];
|
||||||
const hitLimit = events.length >= 500;
|
const hitLimit = events.length >= 500;
|
||||||
const weeklyData = buildWeeklyData(events, days);
|
const weeklyData = buildWeeklyData(events, days);
|
||||||
@@ -374,6 +405,20 @@ export default function StatsPage() {
|
|||||||
const feedingStats = computeFeedingStats(events);
|
const feedingStats = computeFeedingStats(events);
|
||||||
|
|
||||||
const diaperStreak = computeDiaperStreak(events, days);
|
const diaperStreak = computeDiaperStreak(events, days);
|
||||||
|
const milkDailyData = buildMilkDailyData(events, days);
|
||||||
|
const totalPumpedMl = milkDailyData.reduce((acc, d) => acc + d.pumped, 0);
|
||||||
|
const availableStocks = (milkStocks ?? []).filter((s) => !s.used);
|
||||||
|
const totalStockMl = availableStocks.reduce((acc, s) => acc + s.volume, 0);
|
||||||
|
const stockByLocation = availableStocks.reduce<Record<string, number>>((acc, s) => {
|
||||||
|
acc[s.location] = (acc[s.location] ?? 0) + s.volume;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const LOCATION_LABELS: Record<string, string> = { room: "Ambiant", fridge: "Frigo", freezer: "Congélateur" };
|
||||||
|
const LOCATION_COLORS: Record<string, string> = { room: "text-amber-600", fridge: "text-cyan-600", freezer: "text-indigo-600" };
|
||||||
|
const expiringSoon = availableStocks.filter((s) => {
|
||||||
|
const diff = new Date(s.expiresAt).getTime() - Date.now();
|
||||||
|
return diff > 0 && diff < 24 * 3600000;
|
||||||
|
}).length;
|
||||||
|
|
||||||
const today = events.filter((e) => new Date(e.startedAt).toDateString() === new Date().toDateString());
|
const today = events.filter((e) => new Date(e.startedAt).toDateString() === new Date().toDateString());
|
||||||
const todayFeeds = today.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE").length;
|
const todayFeeds = today.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE").length;
|
||||||
@@ -507,6 +552,56 @@ export default function StatsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Milk stock stats */}
|
||||||
|
{baby?.hasPump && (
|
||||||
|
<div className="mb-4 space-y-4">
|
||||||
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 mb-1">Stock de lait</p>
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">Lots disponibles · {periodSubtitle(days)}</p>
|
||||||
|
<div className="grid grid-cols-3 gap-2 md:gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">En stock</p>
|
||||||
|
<p className="text-lg font-bold text-cyan-600 leading-tight">{Math.round(totalStockMl)} <span className="text-sm font-normal text-slate-400">mL</span></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Lots dispo</p>
|
||||||
|
<p className="text-lg font-bold text-cyan-600 leading-tight">{availableStocks.length}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Pompé (période)</p>
|
||||||
|
<p className="text-lg font-bold text-cyan-600 leading-tight">{totalPumpedMl} <span className="text-sm font-normal text-slate-400">mL</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expiringSoon > 0 && (
|
||||||
|
<div className="mb-4 px-3 py-2 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-lg text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
{expiringSoon} lot{expiringSoon > 1 ? "s" : ""} expire{expiringSoon > 1 ? "nt" : ""} dans moins de 24h
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Object.keys(stockByLocation).length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries(stockByLocation).map(([loc, vol]) => (
|
||||||
|
<span key={loc} className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-slate-100 dark:bg-slate-700 ${LOCATION_COLORS[loc] ?? "text-slate-600"}`}>
|
||||||
|
{LOCATION_LABELS[loc] ?? loc} · {Math.round(vol)} mL
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1">Volume pompé par jour</h3>
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">mL · {periodSubtitle(days)}</p>
|
||||||
|
<ResponsiveContainer width="100%" height={150}>
|
||||||
|
<BarChart data={milkDailyData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
|
||||||
|
<Tooltip formatter={(val) => [`${val} mL`, "Pompé"]} contentStyle={tooltipStyle} cursor={{ fill: cursorFill }} />
|
||||||
|
<Bar dataKey="pumped" fill="#0891b2" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
{/* Feeds */}
|
{/* Feeds */}
|
||||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
|
||||||
@@ -534,6 +629,20 @@ export default function StatsPage() {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Activities */}
|
||||||
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 md:col-span-2">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1">Activités par jour</h3>
|
||||||
|
<p className="text-[10px] text-slate-400 dark:text-slate-500 mb-4">Bain · Balade · Plat ventre · Autre</p>
|
||||||
|
<ResponsiveContainer width="100%" height={150}>
|
||||||
|
<BarChart data={weeklyData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} allowDecimals={false} />
|
||||||
|
<Tooltip formatter={(val) => [val, "Activités"]} contentStyle={tooltipStyle} cursor={{ fill: cursorFill }} />
|
||||||
|
<Bar dataKey="activities" fill="#65a30d" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sleep */}
|
{/* Sleep */}
|
||||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 md:col-span-2">
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 md:col-span-2">
|
||||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Sommeil par jour (heures)</h3>
|
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Sommeil par jour (heures)</h3>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { EventIcon } from "@/components/event-icon";
|
|||||||
import { useBaby } from "@/contexts/baby-context";
|
import { useBaby } from "@/contexts/baby-context";
|
||||||
import { format, isToday, isYesterday } from "date-fns";
|
import { format, isToday, isYesterday } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
import { Trash2, StickyNote, Pencil, CheckSquare, Square, XCircle, ImageIcon } from "lucide-react";
|
import { Trash2, StickyNote, Pencil, CheckSquare, Square, XCircle, ImageIcon, Copy } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { EventModal } from "@/components/event-modal";
|
import { EventModal } from "@/components/event-modal";
|
||||||
|
|
||||||
@@ -55,7 +55,8 @@ function getEventSummary(event: Event): string {
|
|||||||
if (event.endedAt) parts.unshift(formatDuration(new Date(event.startedAt), new Date(event.endedAt)));
|
if (event.endedAt) parts.unshift(formatDuration(new Date(event.startedAt), new Date(event.endedAt)));
|
||||||
return parts.filter(Boolean).join(" · ");
|
return parts.filter(Boolean).join(" · ");
|
||||||
}
|
}
|
||||||
if (event.type === "DIAPER_STOOL") return String(meta.color ?? "");
|
if (event.type === "DIAPER_STOOL") return [String(meta.color ?? ""), meta.leaked ? "débordement" : ""].filter(Boolean).join(" · ");
|
||||||
|
if (event.type === "DIAPER_WET") return meta.leaked ? "débordement" : "";
|
||||||
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") {
|
if (event.type === "SYMPTOM") {
|
||||||
@@ -65,6 +66,7 @@ function getEventSummary(event: Event): string {
|
|||||||
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));
|
||||||
}
|
}
|
||||||
|
if (event.type === "OTHER") return event.notes ? event.notes.slice(0, 50) : "";
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +84,10 @@ export default function TimelinePage() {
|
|||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [selectMode, setSelectMode] = useState(false);
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [duplicating, setDuplicating] = useState<string | null>(null); // sourceDate being duplicated
|
||||||
|
const [dupTarget, setDupTarget] = useState(""); // target date string
|
||||||
|
const [dupLoading, setDupLoading] = useState(false);
|
||||||
|
const [dupError, setDupError] = useState("");
|
||||||
|
|
||||||
const { data: eventsData, isLoading } = useQuery({
|
const { data: eventsData, isLoading } = useQuery({
|
||||||
queryKey: ["events", selectedBaby?.id, filterType, page],
|
queryKey: ["events", selectedBaby?.id, filterType, page],
|
||||||
@@ -105,6 +111,27 @@ export default function TimelinePage() {
|
|||||||
qc.invalidateQueries({ queryKey: ["events"] });
|
qc.invalidateQueries({ queryKey: ["events"] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function duplicateDay(sourceDate: string, targetDate: string) {
|
||||||
|
if (!selectedBaby?.id) return;
|
||||||
|
setDupLoading(true);
|
||||||
|
setDupError("");
|
||||||
|
const res = await fetch("/api/events/duplicate-day", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ babyId: selectedBaby.id, sourceDate, targetDate }),
|
||||||
|
});
|
||||||
|
setDupLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setDupError(data.error ?? "Erreur");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDuplicating(null);
|
||||||
|
setDupTarget("");
|
||||||
|
setDupError("");
|
||||||
|
qc.invalidateQueries({ queryKey: ["events"] });
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteSelected() {
|
async function deleteSelected() {
|
||||||
if (selected.size === 0) return;
|
if (selected.size === 0) return;
|
||||||
await Promise.all([...selected].map((id) => fetch(`/api/events/${id}`, { method: "DELETE" })));
|
await Promise.all([...selected].map((id) => fetch(`/api/events/${id}`, { method: "DELETE" })));
|
||||||
@@ -240,8 +267,48 @@ export default function TimelinePage() {
|
|||||||
<span className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide capitalize">{dayLabel(day)}</span>
|
<span className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide capitalize">{dayLabel(day)}</span>
|
||||||
<div className="flex-1 h-px bg-slate-200 dark:bg-slate-700" />
|
<div className="flex-1 h-px bg-slate-200 dark:bg-slate-700" />
|
||||||
<span className="text-xs text-slate-400 dark:text-slate-500">{grouped[day].length}</span>
|
<span className="text-xs text-slate-400 dark:text-slate-500">{grouped[day].length}</span>
|
||||||
|
{!selectMode && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setDuplicating(day); setDupTarget(""); setDupError(""); }}
|
||||||
|
title="Dupliquer ce jour"
|
||||||
|
className="p-1 rounded-lg text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{duplicating === day && (
|
||||||
|
<div className="mb-3 px-4 py-3 bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800 rounded-xl">
|
||||||
|
<p className="text-xs font-medium text-indigo-700 dark:text-indigo-300 mb-2">
|
||||||
|
Copier les {grouped[day].length} événements vers…
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dupTarget}
|
||||||
|
onChange={(e) => { setDupTarget(e.target.value); setDupError(""); }}
|
||||||
|
min={format(new Date(), "yyyy-MM-dd")}
|
||||||
|
className="flex-1 px-3 py-1.5 text-sm rounded-lg border border-indigo-200 dark:border-indigo-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
disabled={!dupTarget || dupLoading}
|
||||||
|
onClick={() => duplicateDay(day, dupTarget)}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 dark:disabled:bg-indigo-900 text-white rounded-lg transition"
|
||||||
|
>
|
||||||
|
{dupLoading ? "…" : "Copier"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setDuplicating(null); setDupError(""); }}
|
||||||
|
className="px-3 py-1.5 text-xs text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 hover:text-slate-700 transition"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{dupError && <p className="mt-1.5 text-xs text-red-500">{dupError}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<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">
|
<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">
|
||||||
{grouped[day].map((ev) => {
|
{grouped[day].map((ev) => {
|
||||||
const cfg = EVENT_CONFIG[ev.type];
|
const cfg = EVENT_CONFIG[ev.type];
|
||||||
|
|||||||
@@ -7,99 +7,7 @@ export async function GET() {
|
|||||||
<title>Grow API — Documentation</title>
|
<title>Grow API — Documentation</title>
|
||||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; background: #0f172a; }
|
|
||||||
|
|
||||||
/* topbar + scheme bar */
|
|
||||||
.swagger-ui .topbar { background: #1e293b; border-bottom: 1px solid #334155; }
|
|
||||||
.swagger-ui .topbar .download-url-wrapper { display: none; }
|
.swagger-ui .topbar .download-url-wrapper { display: none; }
|
||||||
.swagger-ui .scheme-container { background: #1e293b; box-shadow: none; border-bottom: 1px solid #334155; padding: 12px 0; }
|
|
||||||
|
|
||||||
/* main wrapper */
|
|
||||||
.swagger-ui { background: #0f172a; color: #e2e8f0; }
|
|
||||||
.swagger-ui .wrapper { background: #0f172a; }
|
|
||||||
|
|
||||||
/* info block */
|
|
||||||
.swagger-ui .info .title,
|
|
||||||
.swagger-ui .info h1, .swagger-ui .info h2, .swagger-ui .info h3,
|
|
||||||
.swagger-ui .info p, .swagger-ui .info li,
|
|
||||||
.swagger-ui .info a { color: #e2e8f0; }
|
|
||||||
.swagger-ui .info .base-url { color: #94a3b8; }
|
|
||||||
|
|
||||||
/* operation blocks */
|
|
||||||
.swagger-ui .opblock { background: #1e293b; border-color: #334155; margin-bottom: 8px; }
|
|
||||||
.swagger-ui .opblock .opblock-summary { border-color: #334155; }
|
|
||||||
.swagger-ui .opblock .opblock-summary-description { color: #cbd5e1; }
|
|
||||||
.swagger-ui .opblock .opblock-summary-path { color: #e2e8f0; }
|
|
||||||
.swagger-ui .opblock-description-wrapper p,
|
|
||||||
.swagger-ui .opblock-external-docs-wrapper p,
|
|
||||||
.swagger-ui .opblock-title_normal p { color: #cbd5e1; }
|
|
||||||
|
|
||||||
/* section headers (Parameters / Responses) */
|
|
||||||
.swagger-ui .opblock-section-header { background: #263348; border-bottom: 1px solid #334155; }
|
|
||||||
.swagger-ui .opblock-section-header h4,
|
|
||||||
.swagger-ui .opblock-section-header label { color: #e2e8f0; }
|
|
||||||
|
|
||||||
/* tables */
|
|
||||||
.swagger-ui table thead tr td, .swagger-ui table thead tr th { color: #94a3b8; border-bottom: 1px solid #334155; }
|
|
||||||
.swagger-ui .parameters-col_description p,
|
|
||||||
.swagger-ui .parameter__name,
|
|
||||||
.swagger-ui .parameter__type,
|
|
||||||
.swagger-ui .parameter__in { color: #cbd5e1; }
|
|
||||||
.swagger-ui .response-col_status { color: #e2e8f0; }
|
|
||||||
.swagger-ui .response-col_description p { color: #cbd5e1; }
|
|
||||||
|
|
||||||
/* "Try it out" / Execute */
|
|
||||||
.swagger-ui .btn { color: #e2e8f0; border-color: #475569; background: #1e293b; }
|
|
||||||
.swagger-ui .btn:hover { background: #263348; }
|
|
||||||
.swagger-ui .btn.execute { background: #2563eb; border-color: #2563eb; color: #fff; }
|
|
||||||
.swagger-ui .btn.execute:hover { background: #1d4ed8; }
|
|
||||||
.swagger-ui .btn.cancel { background: #7f1d1d; border-color: #991b1b; color: #fca5a5; }
|
|
||||||
|
|
||||||
/* inputs */
|
|
||||||
.swagger-ui input[type=text], .swagger-ui textarea, .swagger-ui select {
|
|
||||||
background: #0f172a; color: #e2e8f0; border: 1px solid #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* models / schemas */
|
|
||||||
.swagger-ui section.models { background: #0f172a; border: 1px solid #334155; }
|
|
||||||
.swagger-ui section.models h4 { color: #e2e8f0; }
|
|
||||||
.swagger-ui .model-box { background: #1e293b; }
|
|
||||||
.swagger-ui .model { color: #cbd5e1; }
|
|
||||||
.swagger-ui .model-title { color: #e2e8f0; }
|
|
||||||
.swagger-ui .prop-type { color: #38bdf8; }
|
|
||||||
.swagger-ui .prop-format { color: #94a3b8; }
|
|
||||||
|
|
||||||
/* response bodies */
|
|
||||||
.swagger-ui .highlight-code { background: #0f172a; }
|
|
||||||
.swagger-ui .microlight { background: #0f172a !important; color: #e2e8f0 !important; }
|
|
||||||
.swagger-ui .responses-inner { background: #1e293b; }
|
|
||||||
.swagger-ui .response-controls { background: #263348; }
|
|
||||||
|
|
||||||
/* auth modal */
|
|
||||||
.swagger-ui .dialog-ux .modal-ux { background: #1e293b; border: 1px solid #334155; }
|
|
||||||
.swagger-ui .dialog-ux .modal-ux-header { background: #263348; border-bottom: 1px solid #334155; }
|
|
||||||
.swagger-ui .dialog-ux .modal-ux-header h3 { color: #e2e8f0; }
|
|
||||||
.swagger-ui .dialog-ux .modal-ux-content p,
|
|
||||||
.swagger-ui .dialog-ux .modal-ux-content label { color: #cbd5e1; }
|
|
||||||
.swagger-ui .auth-container .errors { background: #7f1d1d; color: #fca5a5; }
|
|
||||||
|
|
||||||
/* tag sections */
|
|
||||||
.swagger-ui .opblock-tag { color: #e2e8f0; border-bottom: 1px solid #334155; }
|
|
||||||
.swagger-ui .opblock-tag:hover { background: #1e293b; }
|
|
||||||
|
|
||||||
/* server selector */
|
|
||||||
.swagger-ui .servers > label select { background: #1e293b; color: #e2e8f0; border-color: #475569; }
|
|
||||||
|
|
||||||
/* authorize button */
|
|
||||||
.swagger-ui .authorization__btn { color: #38bdf8; }
|
|
||||||
.swagger-ui .btn.authorize { color: #38bdf8; border-color: #38bdf8; background: transparent; }
|
|
||||||
.swagger-ui .btn.authorize svg { fill: #38bdf8; }
|
|
||||||
.swagger-ui .btn.authorize.locked { color: #4ade80; border-color: #4ade80; }
|
|
||||||
.swagger-ui .btn.authorize.locked svg { fill: #4ade80; }
|
|
||||||
|
|
||||||
/* misc text */
|
|
||||||
.swagger-ui .markdown p, .swagger-ui .renderedMarkdown p { color: #cbd5e1; }
|
|
||||||
.swagger-ui .loading-container .loading::after { color: #94a3b8; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export async function PATCH(
|
|||||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { name, birthDate, gender } = await req.json();
|
const { name, birthDate, gender, isBreastfed, hasPump } = await req.json();
|
||||||
|
|
||||||
const familyId = (session.user as { familyId?: string }).familyId;
|
const familyId = (session.user as { familyId?: string }).familyId;
|
||||||
if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
@@ -25,6 +25,8 @@ export async function PATCH(
|
|||||||
...(name ? { name } : {}),
|
...(name ? { name } : {}),
|
||||||
...(birthDate ? { birthDate: new Date(birthDate) } : {}),
|
...(birthDate ? { birthDate: new Date(birthDate) } : {}),
|
||||||
...(gender !== undefined ? { gender } : {}),
|
...(gender !== undefined ? { gender } : {}),
|
||||||
|
...(isBreastfed !== undefined ? { isBreastfed } : {}),
|
||||||
|
...(hasPump !== undefined ? { hasPump } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const { name, birthDate, gender } = await req.json();
|
const { name, birthDate, gender } = await req.json();
|
||||||
|
|
||||||
|
if (!name || !birthDate) return NextResponse.json({ error: "name et birthDate requis" }, { status: 400 });
|
||||||
|
if (isNaN(new Date(birthDate).getTime())) return NextResponse.json({ error: "Date invalide" }, { status: 400 });
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
|
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
|
||||||
if (!user?.familyId) return NextResponse.json({ error: "Aucune famille" }, { status: 400 });
|
if (!user?.familyId) return NextResponse.json({ error: "Aucune famille" }, { status: 400 });
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ export async function PATCH(
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { familyId: true } });
|
||||||
|
if (!user?.familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
const owned = await prisma.eventTemplate.findFirst({ where: { id, familyId: user.familyId } });
|
||||||
|
if (!owned) return NextResponse.json({ error: "Introuvable" }, { status: 404 });
|
||||||
|
|
||||||
const template = await prisma.eventTemplate.update({
|
const template = await prisma.eventTemplate.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -33,6 +38,12 @@ export async function DELETE(
|
|||||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
|
const delUser = await prisma.user.findUnique({ where: { id: session.user.id }, select: { familyId: true } });
|
||||||
|
if (!delUser?.familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
const delOwned = await prisma.eventTemplate.findFirst({ where: { id, familyId: delUser.familyId } });
|
||||||
|
if (!delOwned) return NextResponse.json({ error: "Introuvable" }, { status: 404 });
|
||||||
|
|
||||||
await prisma.eventTemplate.delete({ where: { id } });
|
await prisma.eventTemplate.delete({ where: { id } });
|
||||||
return new NextResponse(null, { status: 204 });
|
return new NextResponse(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
const { babyId, sourceDate, targetDate } = await req.json();
|
||||||
|
|
||||||
|
if (!babyId || !sourceDate || !targetDate) {
|
||||||
|
return NextResponse.json({ error: "babyId, sourceDate et targetDate requis" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (isNaN(new Date(sourceDate).getTime()) || isNaN(new Date(targetDate).getTime())) {
|
||||||
|
return NextResponse.json({ error: "Date invalide" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (sourceDate === targetDate) {
|
||||||
|
return NextResponse.json({ error: "Les dates source et cible doivent être différentes" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const familyId = (session.user as { familyId?: string }).familyId;
|
||||||
|
if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } });
|
||||||
|
if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 });
|
||||||
|
|
||||||
|
const dayStart = new Date(sourceDate);
|
||||||
|
dayStart.setHours(0, 0, 0, 0);
|
||||||
|
const dayEnd = new Date(sourceDate);
|
||||||
|
dayEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const source = await prisma.event.findMany({
|
||||||
|
where: { babyId, startedAt: { gte: dayStart, lte: dayEnd } },
|
||||||
|
orderBy: { startedAt: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (source.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Aucun événement ce jour-là" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute day offset in ms between sourceDate and targetDate
|
||||||
|
const srcMidnight = new Date(sourceDate);
|
||||||
|
srcMidnight.setHours(0, 0, 0, 0);
|
||||||
|
const tgtMidnight = new Date(targetDate);
|
||||||
|
tgtMidnight.setHours(0, 0, 0, 0);
|
||||||
|
const offsetMs = tgtMidnight.getTime() - srcMidnight.getTime();
|
||||||
|
|
||||||
|
const created = await prisma.$transaction(
|
||||||
|
source.map((ev) =>
|
||||||
|
prisma.event.create({
|
||||||
|
data: {
|
||||||
|
babyId,
|
||||||
|
userId,
|
||||||
|
type: ev.type,
|
||||||
|
startedAt: new Date(ev.startedAt.getTime() + offsetMs),
|
||||||
|
endedAt: ev.endedAt ? new Date(ev.endedAt.getTime() + offsetMs) : null,
|
||||||
|
notes: ev.notes,
|
||||||
|
metadata: ev.metadata ?? undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ count: created.length, events: created }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { auth } from "@/lib/auth";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAudit } from "@/lib/audit";
|
import { logAudit } from "@/lib/audit";
|
||||||
import { dispatchWebhook } from "@/lib/webhooks";
|
import { dispatchWebhook } from "@/lib/webhooks";
|
||||||
|
import { ALL_EVENT_TYPES } from "@/lib/event-config";
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@@ -10,9 +11,12 @@ export async function GET(req: Request) {
|
|||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const babyId = searchParams.get("babyId");
|
const babyId = searchParams.get("babyId");
|
||||||
const limit = parseInt(searchParams.get("limit") ?? "50");
|
const limit = Math.max(1, Math.min(parseInt(searchParams.get("limit") ?? "50") || 50, 500));
|
||||||
const offset = parseInt(searchParams.get("offset") ?? "0");
|
const offset = Math.max(0, parseInt(searchParams.get("offset") ?? "0") || 0);
|
||||||
const type = searchParams.get("type");
|
const type = searchParams.get("type");
|
||||||
|
if (type && !ALL_EVENT_TYPES.includes(type as never)) {
|
||||||
|
return NextResponse.json({ error: "Type invalide" }, { status: 400 });
|
||||||
|
}
|
||||||
const dateFrom = searchParams.get("dateFrom");
|
const dateFrom = searchParams.get("dateFrom");
|
||||||
const dateTo = searchParams.get("dateTo");
|
const dateTo = searchParams.get("dateTo");
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ error: "Réservé aux parents" }, { status: 403 });
|
return NextResponse.json({ error: "Réservé aux parents" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const origin = req.headers.get("origin") ?? process.env.NEXTAUTH_URL ?? "";
|
const origin = (process.env.NEXTAUTH_URL ?? "").replace(/\/$/, "");
|
||||||
const assignedRole = role as Role;
|
const assignedRole = role as Role;
|
||||||
|
|
||||||
const expiresAt = new Date(Date.now() + 7 * 24 * 3600 * 1000);
|
const expiresAt = new Date(Date.now() + 7 * 24 * 3600 * 1000);
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export async function GET(req: Request) {
|
|||||||
const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } });
|
const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } });
|
||||||
if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 });
|
if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 });
|
||||||
|
|
||||||
|
if (date && isNaN(new Date(date).getTime())) {
|
||||||
|
return NextResponse.json({ error: "Date invalide" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const where: Record<string, unknown> = { babyId };
|
const where: Record<string, unknown> = { babyId };
|
||||||
|
|
||||||
if (date) {
|
if (date) {
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ export async function PATCH(
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
|
|
||||||
|
const familyId = (session.user as { familyId?: string }).familyId;
|
||||||
|
if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
const owned = await prisma.medicationProfile.findFirst({ where: { id, familyId } });
|
||||||
|
if (!owned) return NextResponse.json({ error: "Introuvable" }, { status: 404 });
|
||||||
|
|
||||||
const profile = await prisma.medicationProfile.update({
|
const profile = await prisma.medicationProfile.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -35,6 +40,12 @@ export async function DELETE(
|
|||||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
|
const delFamilyId = (session.user as { familyId?: string }).familyId;
|
||||||
|
if (!delFamilyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
const delOwned = await prisma.medicationProfile.findFirst({ where: { id, familyId: delFamilyId } });
|
||||||
|
if (!delOwned) return NextResponse.json({ error: "Introuvable" }, { status: 404 });
|
||||||
|
|
||||||
await prisma.medicationProfile.delete({ where: { id } });
|
await prisma.medicationProfile.delete({ where: { id } });
|
||||||
return new NextResponse(null, { status: 204 });
|
return new NextResponse(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ export async function GET(req: Request) {
|
|||||||
const babyId = searchParams.get("babyId");
|
const babyId = searchParams.get("babyId");
|
||||||
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
|
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
|
||||||
|
|
||||||
|
const familyId = (session.user as { familyId?: string }).familyId;
|
||||||
|
if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } });
|
||||||
|
if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 });
|
||||||
|
|
||||||
// Fetch last 100 medication events — enough to find latest per med
|
// Fetch last 100 medication events — enough to find latest per med
|
||||||
const events = await prisma.event.findMany({
|
const events = await prisma.event.findMany({
|
||||||
where: { babyId, type: "MEDICATION" },
|
where: { babyId, type: "MEDICATION" },
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export async function POST(req: Request) {
|
|||||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { babyId, volume, storedAt, location, notes, expiryHours } = body;
|
const { babyId, volume, storedAt, location, notes, expiryHours, expiresAt: expiresAtOverride } = body;
|
||||||
|
|
||||||
if (!babyId || !volume) {
|
if (!babyId || !volume) {
|
||||||
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
|
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
|
||||||
@@ -46,10 +46,18 @@ export async function POST(req: Request) {
|
|||||||
const postBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId: postFamilyId } });
|
const postBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId: postFamilyId } });
|
||||||
if (!postBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 });
|
if (!postBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 });
|
||||||
|
|
||||||
|
if (storedAt && isNaN(new Date(storedAt).getTime())) {
|
||||||
|
return NextResponse.json({ error: "Date invalide" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const loc = location ?? "fridge";
|
const loc = location ?? "fridge";
|
||||||
const storedDate = storedAt ? new Date(storedAt) : new Date();
|
const storedDate = storedAt ? new Date(storedAt) : new Date();
|
||||||
const hours = expiryHours ?? EXPIRY_HOURS[loc] ?? EXPIRY_HOURS.fridge;
|
const expiresAt = expiresAtOverride
|
||||||
const expiresAt = new Date(storedDate.getTime() + hours * 60 * 60 * 1000);
|
? new Date(expiresAtOverride)
|
||||||
|
: (() => {
|
||||||
|
const hours = expiryHours ?? EXPIRY_HOURS[loc] ?? EXPIRY_HOURS.fridge;
|
||||||
|
return new Date(storedDate.getTime() + hours * 60 * 60 * 1000);
|
||||||
|
})();
|
||||||
|
|
||||||
const stock = await prisma.milkStock.create({
|
const stock = await prisma.milkStock.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
if (!baby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 });
|
if (!baby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 });
|
||||||
|
|
||||||
|
const sessionFamilyId = (session.user as { familyId?: string }).familyId;
|
||||||
|
if (!sessionFamilyId || baby.familyId !== sessionFamilyId) {
|
||||||
|
return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
const lastFeed = await prisma.event.findFirst({
|
const lastFeed = await prisma.event.findFirst({
|
||||||
where: { babyId, type: { in: ["BREASTFEED", "BOTTLE"] } },
|
where: { babyId, type: { in: ["BREASTFEED", "BOTTLE"] } },
|
||||||
orderBy: { startedAt: "desc" },
|
orderBy: { startedAt: "desc" },
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export async function PATCH(
|
|||||||
...(body.intervalHours !== undefined ? { intervalHours: parseFloat(body.intervalHours) } : {}),
|
...(body.intervalHours !== undefined ? { intervalHours: parseFloat(body.intervalHours) } : {}),
|
||||||
...(body.startAt !== undefined ? { startAt: new Date(body.startAt) } : {}),
|
...(body.startAt !== undefined ? { startAt: new Date(body.startAt) } : {}),
|
||||||
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
|
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
|
||||||
|
...(body.lastSentAt !== undefined ? { lastSentAt: body.lastSentAt ? new Date(body.lastSentAt) : null } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { join } from "path";
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||||
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
|
const MAX_SIZE = 30 * 1024 * 1024; // 30 MB
|
||||||
|
|
||||||
// Configurable via env so Docker volume can be mounted at /data/uploads
|
// Configurable via env so Docker volume can be mounted at /data/uploads
|
||||||
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? join(process.cwd(), "public", "uploads");
|
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? join(process.cwd(), "public", "uploads");
|
||||||
@@ -19,7 +19,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
if (!file) return NextResponse.json({ error: "Fichier manquant" }, { status: 400 });
|
if (!file) return NextResponse.json({ error: "Fichier manquant" }, { status: 400 });
|
||||||
if (!ALLOWED_TYPES.includes(file.type)) return NextResponse.json({ error: "Type de fichier non supporté" }, { status: 400 });
|
if (!ALLOWED_TYPES.includes(file.type)) return NextResponse.json({ error: "Type de fichier non supporté" }, { status: 400 });
|
||||||
if (file.size > MAX_SIZE) return NextResponse.json({ error: "Fichier trop volumineux (max 5 MB)" }, { status: 400 });
|
if (file.size > MAX_SIZE) return NextResponse.json({ error: "Fichier trop volumineux (max 30 MB)" }, { status: 400 });
|
||||||
|
|
||||||
const ext = file.type.split("/")[1].replace("jpeg", "jpg");
|
const ext = file.type.split("/")[1].replace("jpeg", "jpg");
|
||||||
const filename = `${randomUUID()}.${ext}`;
|
const filename = `${randomUUID()}.${ext}`;
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export async function GET(req: Request) {
|
|||||||
const type = searchParams.get("type");
|
const type = searchParams.get("type");
|
||||||
const from = searchParams.get("from");
|
const from = searchParams.get("from");
|
||||||
const to = searchParams.get("to");
|
const to = searchParams.get("to");
|
||||||
const limit = Math.min(parseInt(searchParams.get("limit") ?? "50"), 500);
|
const limit = Math.max(1, Math.min(parseInt(searchParams.get("limit") ?? "50") || 50, 500));
|
||||||
const offset = parseInt(searchParams.get("offset") ?? "0");
|
const offset = Math.max(0, parseInt(searchParams.get("offset") ?? "0") || 0);
|
||||||
|
|
||||||
if (!babyId) return NextResponse.json({ error: "babyId is required" }, { status: 400 });
|
if (!babyId) return NextResponse.json({ error: "babyId is required" }, { status: 400 });
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const { babyId, date, weight, height, headCirc, notes } = body;
|
const { babyId, date, weight, height, headCirc, notes } = body;
|
||||||
if (!babyId || !date) return NextResponse.json({ error: "babyId and date are required" }, { status: 400 });
|
if (!babyId || !date) return NextResponse.json({ error: "babyId and date are required" }, { status: 400 });
|
||||||
|
if (isNaN(new Date(date).getTime())) return NextResponse.json({ error: "Invalid date format" }, { status: 400 });
|
||||||
|
|
||||||
const baby = await prisma.baby.findFirst({ where: { id: babyId, familyId: ctx.familyId } });
|
const baby = await prisma.baby.findFirst({ where: { id: babyId, familyId: ctx.familyId } });
|
||||||
if (!baby) return NextResponse.json({ error: "Baby not found" }, { status: 404 });
|
if (!baby) return NextResponse.json({ error: "Baby not found" }, { status: 404 });
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export async function GET(req: Request) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
date: dayStart.toISOString().slice(0, 10),
|
date: dayStart.toISOString().slice(0, 10),
|
||||||
counts,
|
counts,
|
||||||
feeds: counts.BREASTFEED ?? 0 + (counts.BOTTLE ?? 0),
|
feeds: (counts.BREASTFEED ?? 0) + (counts.BOTTLE ?? 0),
|
||||||
diapers: (counts.DIAPER_WET ?? 0) + (counts.DIAPER_STOOL ?? 0),
|
diapers: (counts.DIAPER_WET ?? 0) + (counts.DIAPER_STOOL ?? 0),
|
||||||
sleepMinutes: Math.round(sleepMs / 60000),
|
sleepMinutes: Math.round(sleepMs / 60000),
|
||||||
lastFeedAt: lastFeed ? lastFeed.startedAt : null,
|
lastFeedAt: lastFeed ? lastFeed.startedAt : null,
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ import { auth } from "@/lib/auth";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { generateWebhookSecret } from "@/lib/webhooks";
|
import { generateWebhookSecret } from "@/lib/webhooks";
|
||||||
|
|
||||||
|
const PRIVATE_IP_RE =
|
||||||
|
/^(localhost|127\.|0\.0\.0\.0|::1|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.)/i;
|
||||||
|
|
||||||
|
function isSafeUrl(raw: string): boolean {
|
||||||
|
try {
|
||||||
|
const { protocol, hostname } = new URL(raw);
|
||||||
|
if (protocol !== "https:" && protocol !== "http:") return false;
|
||||||
|
return !PRIVATE_IP_RE.test(hostname);
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const familyId = (session?.user as { familyId?: string } | undefined)?.familyId;
|
const familyId = (session?.user as { familyId?: string } | undefined)?.familyId;
|
||||||
@@ -25,7 +36,7 @@ export async function POST(req: Request) {
|
|||||||
const { url, events } = await req.json();
|
const { url, events } = await req.json();
|
||||||
if (!url || !events?.length) return NextResponse.json({ error: "url et events requis" }, { status: 400 });
|
if (!url || !events?.length) return NextResponse.json({ error: "url et events requis" }, { status: 400 });
|
||||||
|
|
||||||
try { new URL(url); } catch { return NextResponse.json({ error: "URL invalide" }, { status: 400 }); }
|
if (!isSafeUrl(url)) return NextResponse.json({ error: "URL invalide ou non autorisée" }, { status: 400 });
|
||||||
|
|
||||||
const secret = generateWebhookSecret();
|
const secret = generateWebhookSecret();
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Footprints,
|
Footprints,
|
||||||
Baby,
|
Baby,
|
||||||
Activity,
|
Activity,
|
||||||
|
FileText,
|
||||||
LucideProps,
|
LucideProps,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ const ICONS: Record<string, React.FC<LucideProps>> = {
|
|||||||
Footprints,
|
Footprints,
|
||||||
Baby,
|
Baby,
|
||||||
Activity,
|
Activity,
|
||||||
|
FileText,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface EventIconProps extends LucideProps {
|
interface EventIconProps extends LucideProps {
|
||||||
|
|||||||
+101
-10
@@ -33,6 +33,7 @@ interface FormData {
|
|||||||
pumpVolume?: string;
|
pumpVolume?: string;
|
||||||
stoolColor?: string;
|
stoolColor?: string;
|
||||||
stoolAmount?: "small" | "medium" | "large";
|
stoolAmount?: "small" | "medium" | "large";
|
||||||
|
leaked?: boolean;
|
||||||
medName?: string;
|
medName?: string;
|
||||||
medDose?: string;
|
medDose?: string;
|
||||||
medUnit?: string;
|
medUnit?: string;
|
||||||
@@ -69,12 +70,12 @@ interface MilkStock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STOOL_COLORS = [
|
const STOOL_COLORS = [
|
||||||
{ value: "jaune", label: "Jaune", bg: "#fef3c7", dot: "#f59e0b" },
|
{ value: "jaune", label: "Jaune", bg: "#fef3c7", darkBg: "#451a03", dot: "#f59e0b" },
|
||||||
{ value: "vert", label: "Vert", bg: "#d1fae5", dot: "#10b981" },
|
{ value: "vert", label: "Vert", bg: "#d1fae5", darkBg: "#022c22", dot: "#10b981" },
|
||||||
{ value: "marron", label: "Marron", bg: "#fef3c7", dot: "#92400e" },
|
{ value: "marron", label: "Marron", bg: "#fef3c7", darkBg: "#1c0a00", dot: "#92400e" },
|
||||||
{ value: "orange", label: "Orange", bg: "#ffedd5", dot: "#f97316" },
|
{ value: "orange", label: "Orange", bg: "#ffedd5", darkBg: "#431407", dot: "#f97316" },
|
||||||
{ value: "noir", label: "Méconium", bg: "#f1f5f9", dot: "#1e293b" },
|
{ value: "noir", label: "Méconium", bg: "#f1f5f9", darkBg: "#0f172a", dot: "#1e293b" },
|
||||||
{ value: "rouge", label: "Rouge", bg: "#fee2e2", dot: "#ef4444" },
|
{ value: "rouge", label: "Rouge", bg: "#fee2e2", darkBg: "#450a0a", dot: "#ef4444" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function localNowString() {
|
function localNowString() {
|
||||||
@@ -112,6 +113,7 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
|
|||||||
volume: meta.volume != null && type === "BOTTLE" ? String(meta.volume) : undefined,
|
volume: meta.volume != null && type === "BOTTLE" ? String(meta.volume) : undefined,
|
||||||
stoolColor: (meta.color as string) ?? "jaune",
|
stoolColor: (meta.color as string) ?? "jaune",
|
||||||
stoolAmount: (meta.amount as "small" | "medium" | "large") ?? "medium",
|
stoolAmount: (meta.amount as "small" | "medium" | "large") ?? "medium",
|
||||||
|
leaked: (meta.leaked as boolean) ?? false,
|
||||||
medName: (meta.name as string) ?? undefined,
|
medName: (meta.name as string) ?? undefined,
|
||||||
medDose: (meta.dose as string) ?? undefined,
|
medDose: (meta.dose as string) ?? undefined,
|
||||||
medUnit: (meta.unit as string) ?? undefined,
|
medUnit: (meta.unit as string) ?? undefined,
|
||||||
@@ -139,6 +141,17 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
|
|||||||
// Milk stock state (BOTTLE type)
|
// Milk stock state (BOTTLE type)
|
||||||
const [milkStocks, setMilkStocks] = useState<MilkStock[]>([]);
|
const [milkStocks, setMilkStocks] = useState<MilkStock[]>([]);
|
||||||
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
|
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
|
||||||
|
const [isDark, setIsDark] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () => setIsDark(document.documentElement.classList.contains("dark"));
|
||||||
|
check();
|
||||||
|
const obs = new MutationObserver(check);
|
||||||
|
obs.observe(document.documentElement, { attributeFilter: ["class"] });
|
||||||
|
return () => obs.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Combined diaper toggle
|
||||||
|
const [diaperBoth, setDiaperBoth] = useState(false);
|
||||||
|
|
||||||
// Pump → stock state
|
// Pump → stock state
|
||||||
const [addToStock, setAddToStock] = useState(false);
|
const [addToStock, setAddToStock] = useState(false);
|
||||||
@@ -246,8 +259,8 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
|
|||||||
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
|
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
if (file.size > 30 * 1024 * 1024) {
|
||||||
alert("Image trop volumineuse (max 5 Mo)");
|
alert("Image trop volumineuse (max 30 Mo)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPhotoUploading(true);
|
setPhotoUploading(true);
|
||||||
@@ -306,6 +319,7 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
|
|||||||
if (type === "BOTTLE") { metadata.volume = form.volume ? parseFloat(form.volume) : null; metadata.bottleType = form.bottleType; }
|
if (type === "BOTTLE") { metadata.volume = form.volume ? parseFloat(form.volume) : null; metadata.bottleType = form.bottleType; }
|
||||||
if (type === "PUMP") { metadata.side = form.pumpSide; metadata.volume = form.pumpVolume ? parseFloat(form.pumpVolume) : null; }
|
if (type === "PUMP") { metadata.side = form.pumpSide; metadata.volume = form.pumpVolume ? parseFloat(form.pumpVolume) : null; }
|
||||||
if (type === "DIAPER_STOOL") { metadata.color = form.stoolColor; metadata.amount = form.stoolAmount; }
|
if (type === "DIAPER_STOOL") { metadata.color = form.stoolColor; metadata.amount = form.stoolAmount; }
|
||||||
|
if (type === "DIAPER_WET" || type === "DIAPER_STOOL") { if (form.leaked) metadata.leaked = true; }
|
||||||
if (type === "MEDICATION") {
|
if (type === "MEDICATION") {
|
||||||
metadata.name = form.medName;
|
metadata.name = form.medName;
|
||||||
metadata.dose = form.medDose;
|
metadata.dose = form.medDose;
|
||||||
@@ -358,6 +372,24 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
|
|||||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
if (diaperBoth && (type === "DIAPER_WET" || type === "DIAPER_STOOL")) {
|
||||||
|
const secondType = type === "DIAPER_WET" ? "DIAPER_STOOL" : "DIAPER_WET";
|
||||||
|
const secondMeta = type === "DIAPER_WET"
|
||||||
|
? { color: form.stoolColor, amount: form.stoolAmount, ...(form.leaked ? { leaked: true } : {}) }
|
||||||
|
: form.leaked ? { leaked: true } : null;
|
||||||
|
await fetch("/api/events", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
babyId,
|
||||||
|
type: secondType,
|
||||||
|
startedAt: toUTC(form.startedAt),
|
||||||
|
endedAt: null,
|
||||||
|
notes: null,
|
||||||
|
metadata: secondMeta,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (type === "BOTTLE" && selectedStockId && form.bottleType === "maternal") {
|
if (type === "BOTTLE" && selectedStockId && form.bottleType === "maternal") {
|
||||||
await fetch(`/api/milk/${selectedStockId}`, {
|
await fetch(`/api/milk/${selectedStockId}`, {
|
||||||
@@ -612,11 +644,11 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Couleur</label>
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Couleur</label>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{STOOL_COLORS.map(({ value, label, bg, dot }) => (
|
{STOOL_COLORS.map(({ value, label, bg, darkBg, dot }) => (
|
||||||
<button key={value} type="button" onClick={() => setForm((f) => ({ ...f, stoolColor: value }))}
|
<button key={value} type="button" onClick={() => setForm((f) => ({ ...f, stoolColor: value }))}
|
||||||
className={`flex items-center gap-2 px-2.5 py-2 rounded-lg border text-xs font-medium transition ${
|
className={`flex items-center gap-2 px-2.5 py-2 rounded-lg border text-xs font-medium transition ${
|
||||||
form.stoolColor === value ? "border-indigo-500 ring-1 ring-indigo-300" : "border-slate-200 dark:border-slate-600 hover:border-slate-300"
|
form.stoolColor === value ? "border-indigo-500 ring-1 ring-indigo-300" : "border-slate-200 dark:border-slate-600 hover:border-slate-300"
|
||||||
}`} style={{ background: bg }}>
|
}`} style={{ background: isDark ? darkBg : bg }}>
|
||||||
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ background: dot }} />
|
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ background: dot }} />
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
@@ -637,6 +669,65 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Leaked diaper toggle */}
|
||||||
|
{(type === "DIAPER_WET" || type === "DIAPER_STOOL") && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, leaked: !f.leaked }))}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl border border-slate-200 dark:border-slate-600 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition"
|
||||||
|
>
|
||||||
|
<span>Débordement</span>
|
||||||
|
<div className={`w-9 h-5 rounded-full transition-colors ${form.leaked ? "bg-indigo-600" : "bg-slate-200 dark:bg-slate-600"} relative flex-shrink-0`}>
|
||||||
|
<div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${form.leaked ? "translate-x-4" : "translate-x-0.5"}`} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Combined diaper toggle */}
|
||||||
|
{(type === "DIAPER_WET" || type === "DIAPER_STOOL") && !isEditing && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDiaperBoth((v) => !v)}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl border border-slate-200 dark:border-slate-600 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition"
|
||||||
|
>
|
||||||
|
<span>{type === "DIAPER_WET" ? "Aussi des selles" : "Aussi mouillée"}</span>
|
||||||
|
<div className={`w-9 h-5 rounded-full transition-colors ${diaperBoth ? "bg-indigo-600" : "bg-slate-200 dark:bg-slate-600"} relative flex-shrink-0`}>
|
||||||
|
<div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${diaperBoth ? "translate-x-4" : "translate-x-0.5"}`} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{diaperBoth && type === "DIAPER_WET" && (
|
||||||
|
<div className="space-y-3 pl-1">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Couleur des selles</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{STOOL_COLORS.map(({ value, label, bg, darkBg, dot }) => (
|
||||||
|
<button key={value} type="button" onClick={() => setForm((f) => ({ ...f, stoolColor: value }))}
|
||||||
|
className={`flex items-center gap-2 px-2.5 py-2 rounded-lg border text-xs font-medium text-slate-800 transition ${
|
||||||
|
form.stoolColor === value ? "border-indigo-500 ring-1 ring-indigo-300" : "border-slate-200 dark:border-slate-600 hover:border-slate-300"
|
||||||
|
}`} style={{ background: isDark ? darkBg : bg }}>
|
||||||
|
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ background: dot }} />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Quantité</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(["small", "medium", "large"] as const).map((amt) => (
|
||||||
|
<button key={amt} type="button" onClick={() => setForm((f) => ({ ...f, stoolAmount: amt }))}
|
||||||
|
className={`${segmentBase} ${form.stoolAmount === amt ? segmentActive : segmentInactive}`}>
|
||||||
|
{amt === "small" ? "Peu" : amt === "medium" ? "Moyen" : "Beaucoup"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Medication — profile picker */}
|
{/* Medication — profile picker */}
|
||||||
{type === "MEDICATION" && (
|
{type === "MEDICATION" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ export function SidebarNav() {
|
|||||||
{/* Grouped links */}
|
{/* Grouped links */}
|
||||||
{DRAWER_GROUPS.map((group) => {
|
{DRAWER_GROUPS.map((group) => {
|
||||||
const groupLinks = group.hrefs
|
const groupLinks = group.hrefs
|
||||||
|
.filter((href) => !(href === "/milk" && !selectedBaby?.hasPump))
|
||||||
.map((href) => LINKS.find((l) => l.href === href))
|
.map((href) => LINKS.find((l) => l.href === href))
|
||||||
.filter(Boolean) as typeof LINKS;
|
.filter(Boolean) as typeof LINKS;
|
||||||
if (groupLinks.length === 0) return null;
|
if (groupLinks.length === 0) return null;
|
||||||
@@ -265,6 +266,7 @@ export function BottomNav() {
|
|||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const isSuperAdmin = (session?.user as { isSuperAdmin?: boolean })?.isSuperAdmin;
|
const isSuperAdmin = (session?.user as { isSuperAdmin?: boolean })?.isSuperAdmin;
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const { selectedBaby } = useBaby();
|
||||||
|
|
||||||
const secondaryLinks = isSuperAdmin
|
const secondaryLinks = isSuperAdmin
|
||||||
? [...SECONDARY_LINKS, { href: "/admin", label: "Admin", Icon: ShieldCheck }]
|
? [...SECONDARY_LINKS, { href: "/admin", label: "Admin", Icon: ShieldCheck }]
|
||||||
@@ -327,6 +329,7 @@ export function BottomNav() {
|
|||||||
<div className="px-4 pb-4 space-y-4">
|
<div className="px-4 pb-4 space-y-4">
|
||||||
{DRAWER_GROUPS.map((group) => {
|
{DRAWER_GROUPS.map((group) => {
|
||||||
const groupLinks = group.hrefs
|
const groupLinks = group.hrefs
|
||||||
|
.filter((href) => !(href === "/milk" && !selectedBaby?.hasPump))
|
||||||
.map((href) => secondaryLinks.find((l) => l.href === href))
|
.map((href) => secondaryLinks.find((l) => l.href === href))
|
||||||
.filter(Boolean) as typeof secondaryLinks;
|
.filter(Boolean) as typeof secondaryLinks;
|
||||||
if (groupLinks.length === 0) return null;
|
if (groupLinks.length === 0) return null;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Plus, X } from "lucide-react";
|
|||||||
|
|
||||||
const FAB_HIDDEN_PATHS = ["/settings", "/admin"];
|
const FAB_HIDDEN_PATHS = ["/settings", "/admin"];
|
||||||
|
|
||||||
const QUICK_TYPES: EventType[] = [
|
const ALL_QUICK_TYPES: EventType[] = [
|
||||||
"BREASTFEED",
|
"BREASTFEED",
|
||||||
"BOTTLE",
|
"BOTTLE",
|
||||||
"PUMP",
|
"PUMP",
|
||||||
@@ -21,12 +21,23 @@ const QUICK_TYPES: EventType[] = [
|
|||||||
"NIGHT_SLEEP",
|
"NIGHT_SLEEP",
|
||||||
"MEDICATION",
|
"MEDICATION",
|
||||||
"TEMPERATURE",
|
"TEMPERATURE",
|
||||||
|
"BATH",
|
||||||
|
"WALK",
|
||||||
|
"TUMMY_TIME",
|
||||||
|
"SYMPTOM",
|
||||||
|
"OTHER",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function QuickAddFab() {
|
export function QuickAddFab() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { selectedBaby } = useBaby();
|
const { selectedBaby } = useBaby();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const quickTypes = ALL_QUICK_TYPES.filter((t) => {
|
||||||
|
if (t === "BREASTFEED" && !selectedBaby?.isBreastfed) return false;
|
||||||
|
if (t === "PUMP" && !selectedBaby?.hasPump) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [activeModal, setActiveModal] = useState<EventType | null>(null);
|
const [activeModal, setActiveModal] = useState<EventType | null>(null);
|
||||||
|
|
||||||
@@ -69,8 +80,8 @@ export function QuickAddFab() {
|
|||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 gap-2 px-4 pb-2">
|
<div className="grid grid-cols-5 gap-2 px-4 pb-2">
|
||||||
{QUICK_TYPES.map((type) => {
|
{quickTypes.map((type) => {
|
||||||
const cfg = EVENT_CONFIG[type];
|
const cfg = EVENT_CONFIG[type];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ interface Baby {
|
|||||||
name: string;
|
name: string;
|
||||||
birthDate: string;
|
birthDate: string;
|
||||||
gender?: string;
|
gender?: string;
|
||||||
|
isBreastfed: boolean;
|
||||||
|
hasPump: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Family {
|
interface Family {
|
||||||
|
|||||||
+12
-1
@@ -11,7 +11,8 @@ export type EventType =
|
|||||||
| "BATH"
|
| "BATH"
|
||||||
| "WALK"
|
| "WALK"
|
||||||
| "TUMMY_TIME"
|
| "TUMMY_TIME"
|
||||||
| "SYMPTOM";
|
| "SYMPTOM"
|
||||||
|
| "OTHER";
|
||||||
|
|
||||||
export const EVENT_CONFIG: Record<
|
export const EVENT_CONFIG: Record<
|
||||||
EventType,
|
EventType,
|
||||||
@@ -164,6 +165,16 @@ export const EVENT_CONFIG: Record<
|
|||||||
hasDuration: false,
|
hasDuration: false,
|
||||||
hasTimer: false,
|
hasTimer: false,
|
||||||
},
|
},
|
||||||
|
OTHER: {
|
||||||
|
label: "Autre",
|
||||||
|
icon: "FileText",
|
||||||
|
color: "#64748b",
|
||||||
|
colorClass: "text-slate-500",
|
||||||
|
bgClass: "bg-slate-100 dark:bg-slate-800",
|
||||||
|
borderClass: "border-slate-200 dark:border-slate-700",
|
||||||
|
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[];
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
import { createHmac, randomBytes } from "crypto";
|
import { createHmac, randomBytes } from "crypto";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
const PRIVATE_IP_RE =
|
||||||
|
/^(localhost|127\.|0\.0\.0\.0|::1|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.)/i;
|
||||||
|
|
||||||
|
function isSafeWebhookUrl(raw: string): boolean {
|
||||||
|
try {
|
||||||
|
const { protocol, hostname } = new URL(raw);
|
||||||
|
if (protocol !== "https:" && protocol !== "http:") return false;
|
||||||
|
if (PRIVATE_IP_RE.test(hostname)) return false;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type WebhookEventType =
|
export type WebhookEventType =
|
||||||
| "event.created"
|
| "event.created"
|
||||||
| "growth.created"
|
| "growth.created"
|
||||||
@@ -26,6 +40,7 @@ export async function dispatchWebhook(
|
|||||||
|
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
hooks.map(async (hook) => {
|
hooks.map(async (hook) => {
|
||||||
|
if (!isSafeWebhookUrl(hook.url)) return;
|
||||||
const sig = createHmac("sha256", hook.secret).update(body).digest("hex");
|
const sig = createHmac("sha256", hook.secret).update(body).digest("hex");
|
||||||
await fetch(hook.url, {
|
await fetch(hook.url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
Reference in New Issue
Block a user