Initial commit — Grow baby tracker

Next.js 16 App Router, Prisma + PostgreSQL, NextAuth v5 JWT.

Features: dashboard quick-log, timeline, growth charts (WHO percentiles),
stats, journal/notes, doctor notes, milestones, vaccinations, settings,
superadmin panel. Mobile-first with sidebar nav + bottom nav + quick-add FAB.
Dark mode, PWA push notifications, multi-family invite system.

Docker: multi-stage Dockerfile + docker-compose with postgres service.
This commit is contained in:
2026-06-13 01:52:46 +02:00
commit cd16c354c0
94 changed files with 14354 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.next
node_modules
.git
*.md
.env*
.DS_Store
coverage
+43
View File
@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma
+2
View File
@@ -0,0 +1,2 @@
approve-builds=false
ignore-scripts=true
+48
View File
@@ -0,0 +1,48 @@
FROM node:22-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
# ── deps ────────────────────────────────────────────────────────────────────
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
# ── builder ─────────────────────────────────────────────────────────────────
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Generate Prisma client before build
RUN pnpm prisma generate
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# ── runner ──────────────────────────────────────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Prisma client for runtime
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
COPY --from=builder /app/node_modules/@prisma /node_modules/@prisma
COPY --from=builder /app/src/generated ./src/generated
COPY --from=builder /app/prisma ./prisma
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+43
View File
@@ -0,0 +1,43 @@
# Grow — Feature Tracker
## Completed
✅ Edit/delete events from timeline
✅ Journal/notes page (free-form daily notes) — /notes
✅ Doctor notes view — /doctor-notes (DOCTOR adds, all read)
✅ Baby profile edit (in settings)
✅ Multiple babies — adding from settings + switcher in sidebar
✅ Sleep summary in stats (total, avg/day, longest stretch)
✅ Feeding tracker summary in stats (breastfeed duration, bottle count, avg interval)
✅ WHO growth percentile curves on growth chart
✅ Vaccination tracker — /vaccinations
✅ Milestones — /milestones
✅ CSV export per event type with date filter (in settings)
✅ Dark mode filter pills
✅ Dark mode graph hover/tooltip
✅ Growth graph time range selector
✅ Growth graph hover order + dark mode
✅ Signout button in sidebar
✅ Journal full page width
✅ All pages full width (no max-w constraint)
✅ Baby switcher in sidebar (not homepage)
✅ New pages in mobile nav (via More menu)
✅ Disable signups (admin toggle)
✅ User list in admin (with role editing and delete)
✅ Force logout user (admin)
✅ Family list in admin (with reset invite code)
✅ Storage stats in admin
✅ Pushover test button in admin
✅ Email test button in admin
✅ Backup/export all (admin)
✅ Maintenance mode toggle (admin config — banner not yet wired)
## Pending
🔲 Maintenance mode banner (config key exists, layout doesn't check it yet)
🔲 Audit log (last 50 actions across all users)
🔲 Data retention (auto-delete events older than N months)
🔲 Recurring medication reminders
🔲 Bilingual UI (FR/EN toggle)
🔲 Weight trend alert (no growth log in 30 days)
🔲 Photo log (attach photo to event)
+208
View File
@@ -0,0 +1,208 @@
# Plan: Baby Tracker Webapp ("Grow")
## Context
Build a beautiful, mobile-responsive webapp for two parents to track their newborn's daily activities in real-time. Parents log actions quickly (one-tap), view a chronological timeline, get reminders, and analyze trends via a stats dashboard and growth charts.
---
## Tech Stack
| Layer | Choice | Reason |
|-------|--------|--------|
| Framework | **Next.js 15** (App Router) | SSR + API routes in one, Vercel deploy |
| Database | **PostgreSQL** | Multi-user, relational, scalable |
| ORM | **Prisma** | Type-safe, easy migrations |
| Auth | **NextAuth.js v5** | Simple 2-parent shared account + invite link |
| UI | **Tailwind CSS + shadcn/ui** | Beautiful, accessible, fast to build |
| Charts | **Recharts** | React-native, works with Tailwind |
| State | **TanStack Query (React Query)** | Cache + real-time polling for 2-parent sync |
| Realtime sync | **Polling (5s)** or **Supabase Realtime** | Keep both parents in sync |
| Push notifs | **web-push** (PWA) | "Last feed 3h ago" alerts |
| Export | **PapaParse (CSV) + jsPDF** | CSV/PDF export |
---
## Data Model (Prisma)
```prisma
model User {
id String @id @default(cuid())
email String @unique
name String
familyId String
family Family @relation(fields: [familyId], references: [id])
}
model Family {
id String @id @default(cuid())
name String
users User[]
babies Baby[]
inviteCode String @unique
}
model Baby {
id String @id @default(cuid())
name String
birthDate DateTime
familyId String
family Family @relation(fields: [familyId], references: [id])
events Event[]
growthLogs GrowthLog[]
}
model Event {
id String @id @default(cuid())
babyId String
baby Baby @relation(fields: [babyId], references: [id])
userId String // who logged it
type EventType
startedAt DateTime
endedAt DateTime? // for timed events
notes String?
metadata Json? // type-specific data (volume, breast side, color, dose...)
createdAt DateTime @default(now())
}
enum EventType {
BREASTFEED
BOTTLE
DIAPER_WET
DIAPER_STOOL
NAP
NIGHT_SLEEP
MEDICATION
TEMPERATURE
}
model GrowthLog {
id String @id @default(cuid())
babyId String
baby Baby @relation(fields: [babyId], references: [id])
date DateTime
weight Float? // grams
height Float? // cm
headCirc Float? // cm
notes String?
}
```
---
## Feature List
### Core Tracking
- **Allaitement** — timer (live), left/right breast toggle, duration saved
- **Biberon** — volume (mL), type (lait maternel / artificiel)
- **Couche** — wet / selles (color picker: jaune/vert/marron/rouge), quantité
- **Sieste** — start/stop timer or manual duration
- **Sommeil nocturne** — start/stop timer or manual
- **Traitement** — name, dose, unit (mL/mg), notes
- **Température** — value + unit
### Notes
Every event has an optional notes field, editable after creation.
### Quick-Log UX
- Home = big action buttons (68 tiles, thumb-friendly)
- One tap → opens modal with minimal required fields → Save
- For timers: tap starts timer (persists on reload), tap again stops
### Timeline
- Chronological feed, grouped by day
- Each entry shows type icon, time, duration/value, who logged, notes snippet
- Tap to expand/edit/delete
### Stats Dashboard
- Today: counts per category, total sleep, total feed time
- Weekly trends: bar charts (feeds/day, sleep/day, diapers/day)
- Feed intervals: avg time between feeds
### Growth Charts
- Plot weight, height, head circumference over time
- Overlay WHO percentile curves (P3, P10, P25, P50, P75, P90, P97)
- Data from `GrowthLog` model
### Reminders / Alerts
- In-app badge: "Dernière tétée il y a 2h45"
- PWA push notification: configurable threshold (e.g. alert if no feed in 3h)
### Multi-parent Sync
- Family created on signup → invite code/link for second parent
- React Query polls every 510s → both parents see same timeline
- Event shows avatar/name of who logged it
### Export
- CSV: all events with filters (date range, type)
- PDF: weekly report summary
- Growth data exportable separately
---
## Pages / Routes
```
/ → redirect to /dashboard
/auth/login → email magic link or credentials
/auth/invite/[code] → join family
/dashboard → quick-log + today's summary + last-event badges
/timeline → full chronological log with filters
/stats → charts and summaries
/growth → growth log + WHO charts
/settings → baby info, family members, notifications, export
```
---
## UI/UX Design Principles
- **Mobile-first**, full responsive
- Large tap targets (min 48px)
- Color-coded event types (each type has icon + color)
- Dark mode support
- Minimal friction: most logs = 2 taps max
- French-first UI (labels in French, date locale fr-FR)
---
## Implementation Phases
### Phase 1 — Foundation
- Next.js 15 project scaffold, Tailwind, shadcn/ui
- Prisma + PostgreSQL schema + migrations
- NextAuth with credentials + invite system
### Phase 2 — Core Logging
- Quick-log home page (all 7 event types)
- Live timer component (persists via localStorage)
- Event modal (type-specific fields)
- Timeline page
### Phase 3 — Stats + Growth
- Stats dashboard with Recharts
- Growth log form + WHO percentile chart
### Phase 4 — Polish + Extras
- PWA manifest + push notifications
- Export (CSV + PDF)
- Settings page
- Dark mode
---
## Verification
- Run `pnpm dev`, log all event types, confirm they appear in timeline
- Log from two browser sessions (two parents), confirm 5s sync
- Check growth chart renders with WHO curves
- Test export (download CSV, open in Excel)
- Test PWA install on mobile + push notification
---
## Key Files to Create
- `prisma/schema.prisma` — full schema
- `src/app/(app)/dashboard/page.tsx` — main quick-log UI
- `src/app/(app)/timeline/page.tsx` — chronological view
- `src/components/event-modal/` — per-type log modals
- `src/components/timer/` — live timer component
- `src/lib/who-percentiles.ts` — WHO curve data
- `src/app/api/events/` — REST endpoints (CRUD)
+85
View File
@@ -0,0 +1,85 @@
# 🌱 Grow — Suivi nouveau-né
Application web pour suivre les activités de votre nouveau-né (allaitement, couches, sommeil, croissance...).
## Stack
- **Next.js 16** (App Router, TypeScript)
- **PostgreSQL** + Prisma ORM (v7)
- **NextAuth v5** (authentification par email/mot de passe)
- **Tailwind CSS v4**
- **Recharts** (graphiques stats + courbes OMS)
- **TanStack Query** (sync temps réel entre les deux parents)
## Démarrage rapide
### 1. Base de données PostgreSQL
Option A — Docker (recommandé) :
```bash
docker run -d \
--name grow-db \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=grow \
-p 5432:5432 \
postgres:16
```
Option B — Homebrew :
```bash
brew install postgresql@16
brew services start postgresql@16
createdb grow
```
Option C — Cloud (Railway, Supabase, Neon) : copier l'URL de connexion.
### 2. Variables d'environnement
Éditer `.env` :
```env
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/grow"
AUTH_SECRET="votre-secret-32-chars" # openssl rand -base64 32
NEXTAUTH_URL="http://localhost:3000"
```
### 3. Migrations + démarrage
```bash
pnpm install
npx prisma migrate dev --name init
pnpm dev
```
Ouvrir [http://localhost:3000](http://localhost:3000).
### 4. Inviter le deuxième parent
Après inscription → **Réglages** → copier le lien d'invitation → envoyer à l'autre parent.
## Fonctionnalités
- **Dashboard** : boutons de log rapide (2 taps max), résumé du jour, dernières activités avec horodatage relatif
- **Journal** : timeline chronologique filtrée par type, édition des notes, suppression
- **Statistiques** : graphiques hebdomadaires (repas, couches, sommeil), intervalle moyen entre les repas
- **Courbes** : courbe de poids avec courbes OMS (P3P97), taille, périmètre crânien
- **Export CSV** : tous les événements avec filtres de dates (compatible Excel)
- **Multi-parents** : sync automatique toutes les 10 secondes, chaque événement indique qui l'a enregistré
- **PWA** : installable sur iPhone/Android depuis Safari/Chrome
## Types d'événements
| Emoji | Événement | Champs spécifiques |
|-------|-----------|-------------------|
| 🤱 | Allaitement | Sein (gauche/droite/les deux), timer live |
| 🍼 | Biberon | Volume (mL), type (maternel/artificiel) |
| 💧 | Couche mouillée | — |
| 💩 | Selles | Couleur, quantité |
| 😴 | Sieste | Timer live, durée |
| 🌙 | Sommeil nocturne | Timer live, durée |
| 💊 | Traitement | Nom, dose, unité |
| 🌡️ | Température | Valeur, °C/°F |
## Déploiement
Vercel + Railway (PostgreSQL) recommandés. Ajouter les variables d'environnement dans Vercel.
+53
View File
@@ -0,0 +1,53 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: grow
POSTGRES_USER: grow
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U grow"]
interval: 5s
timeout: 5s
retries: 10
app:
build: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "${PORT:-3000}:3000"
environment:
DATABASE_URL: postgresql://grow:${POSTGRES_PASSWORD:-changeme}@db:5432/grow
NEXTAUTH_URL: ${NEXTAUTH_URL}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL}
# Optional
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-}
PUSHOVER_APP_TOKEN: ${PUSHOVER_APP_TOKEN:-}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-}
migrate:
build: .
command: >
sh -c "npx prisma db push --skip-generate"
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql://grow:${POSTGRES_PASSWORD:-changeme}@db:5432/grow
restart: "no"
volumes:
postgres_data:
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
+46
View File
@@ -0,0 +1,46 @@
{
"name": "grow",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.2",
"@hookform/resolvers": "^5.4.0",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@tanstack/react-query": "^5.101.0",
"@types/nodemailer": "^8.0.1",
"bcryptjs": "^3.0.3",
"date-fns": "^4.4.0",
"jspdf": "^4.2.1",
"lucide-react": "^1.17.0",
"next": "16.2.9",
"next-auth": "5.0.0-beta.31",
"nodemailer": "^8.0.11",
"papaparse": "^5.5.3",
"pg": "^8.21.0",
"prisma": "^7.8.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "^7.78.0",
"recharts": "^3.8.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^20",
"@types/papaparse": "^5.5.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.9",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+5679
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
allowBuilds:
sharp: set this to true or false
unrs-resolver: set this to true or false
ignoredBuiltDependencies:
- sharp
- unrs-resolver
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DIRECT_DATABASE_URL"] ?? process.env["DATABASE_URL"],
},
});
@@ -0,0 +1,141 @@
-- CreateEnum
CREATE TYPE "EventType" AS ENUM ('BREASTFEED', 'BOTTLE', 'DIAPER_WET', 'DIAPER_STOOL', 'NAP', 'NIGHT_SLEEP', 'MEDICATION', 'TEMPERATURE');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"password" TEXT,
"image" TEXT,
"familyId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Family" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"inviteCode" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Family_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Baby" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"birthDate" TIMESTAMP(3) NOT NULL,
"gender" TEXT,
"familyId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Baby_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Event" (
"id" TEXT NOT NULL,
"babyId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "EventType" NOT NULL,
"startedAt" TIMESTAMP(3) NOT NULL,
"endedAt" TIMESTAMP(3),
"notes" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Event_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GrowthLog" (
"id" TEXT NOT NULL,
"babyId" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"weight" DOUBLE PRECISION,
"height" DOUBLE PRECISION,
"headCirc" DOUBLE PRECISION,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GrowthLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "Family_inviteCode_key" ON "Family"("inviteCode");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Baby" ADD CONSTRAINT "Baby_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Event" ADD CONSTRAINT "Event_babyId_fkey" FOREIGN KEY ("babyId") REFERENCES "Baby"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Event" ADD CONSTRAINT "Event_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GrowthLog" ADD CONSTRAINT "GrowthLog_babyId_fkey" FOREIGN KEY ("babyId") REFERENCES "Baby"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,52 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('PARENT', 'READONLY', 'DOCTOR');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isSuperAdmin" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "pushoverUserKey" TEXT,
ADD COLUMN "role" "UserRole" NOT NULL DEFAULT 'PARENT';
-- CreateTable
CREATE TABLE "PasswordResetToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SystemConfig" (
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("key")
);
-- CreateTable
CREATE TABLE "DoctorNote" (
"id" TEXT NOT NULL,
"babyId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DoctorNote_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token");
-- AddForeignKey
ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DoctorNote" ADD CONSTRAINT "DoctorNote_babyId_fkey" FOREIGN KEY ("babyId") REFERENCES "Baby"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DoctorNote" ADD CONSTRAINT "DoctorNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+194
View File
@@ -0,0 +1,194 @@
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
enum UserRole {
PARENT
READONLY
DOCTOR
}
model User {
id String @id @default(cuid())
email String @unique
name String
password String?
image String?
role UserRole @default(PARENT)
isSuperAdmin Boolean @default(false)
pushoverUserKey String?
familyId String?
family Family? @relation(fields: [familyId], references: [id])
events Event[]
sessions Session[]
accounts Account[]
doctorNotes DoctorNote[]
journalNotes JournalNote[]
passwordResetTokens PasswordResetToken[]
forcedLogoutAt DateTime?
createdAt DateTime @default(now())
}
model PasswordResetToken {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String @unique @default(cuid())
expiresAt DateTime
used Boolean @default(false)
createdAt DateTime @default(now())
}
model SystemConfig {
key String @id
value String
updatedAt DateTime @updatedAt
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model Family {
id String @id @default(cuid())
name String
inviteCode String @unique @default(cuid())
users User[]
babies Baby[]
createdAt DateTime @default(now())
}
model Baby {
id String @id @default(cuid())
name String
birthDate DateTime
gender String?
familyId String
family Family @relation(fields: [familyId], references: [id])
events Event[]
growthLogs GrowthLog[]
doctorNotes DoctorNote[]
journalNotes JournalNote[]
milestones Milestone[]
vaccinations Vaccination[]
createdAt DateTime @default(now())
}
model Event {
id String @id @default(cuid())
babyId String
baby Baby @relation(fields: [babyId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id])
type EventType
startedAt DateTime
endedAt DateTime?
notes String?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum EventType {
BREASTFEED
BOTTLE
DIAPER_WET
DIAPER_STOOL
NAP
NIGHT_SLEEP
MEDICATION
TEMPERATURE
}
model GrowthLog {
id String @id @default(cuid())
babyId String
baby Baby @relation(fields: [babyId], references: [id], onDelete: Cascade)
date DateTime
weight Float?
height Float?
headCirc Float?
notes String?
createdAt DateTime @default(now())
}
model DoctorNote {
id String @id @default(cuid())
babyId String
baby Baby @relation(fields: [babyId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id])
content String
date DateTime @default(now())
createdAt DateTime @default(now())
}
model JournalNote {
id String @id @default(cuid())
babyId String
baby Baby @relation(fields: [babyId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id])
date DateTime
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Milestone {
id String @id @default(cuid())
babyId String
baby Baby @relation(fields: [babyId], references: [id], onDelete: Cascade)
title String
date DateTime
notes String?
icon String?
createdAt DateTime @default(now())
}
model Vaccination {
id String @id @default(cuid())
babyId String
baby Baby @relation(fields: [babyId], references: [id], onDelete: Cascade)
name String
date DateTime?
dueDate DateTime?
notes String?
done Boolean @default(false)
createdAt DateTime @default(now())
}
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#4f46e5"/>
<g transform="translate(128, 128) scale(10.667)" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none">
<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/>
<path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 441 B

+18
View File
@@ -0,0 +1,18 @@
{
"name": "Grow — Suivi bébé",
"short_name": "Grow",
"description": "Suivez les activités de votre nouveau-né",
"start_url": "/dashboard",
"display": "standalone",
"background_color": "#f8fafc",
"theme_color": "#4f46e5",
"orientation": "portrait",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+616
View File
@@ -0,0 +1,616 @@
"use client";
import { useState, useEffect } from "react";
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 dark:text-slate-100";
const labelCls = "block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5";
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden mb-4">
<div className="px-4 py-3 border-b border-slate-100 dark:border-slate-700">
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">{title}</h2>
</div>
<div className="p-4 space-y-4">{children}</div>
</div>
);
}
interface UserRow {
id: string;
name: string;
email: string;
role: "PARENT" | "READONLY" | "DOCTOR";
isSuperAdmin: boolean;
familyId: string | null;
family: { name: string } | null;
createdAt: string;
}
interface FamilyRow {
id: string;
name: string;
inviteCode: string;
createdAt: string;
_count: {
users: number;
babies: number;
};
}
interface Stats {
userCount: number;
familyCount: number;
babyCount: number;
eventCount: number;
growthLogCount: number;
}
function StatCard({ label, value }: { label: string; value: number }) {
return (
<div className="bg-slate-50 dark:bg-slate-800 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-indigo-600 dark:text-indigo-400">{value}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{label}</p>
</div>
);
}
export default function AdminConfigForm() {
const [config, setConfig] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const [stats, setStats] = useState<Stats | null>(null);
const [statsError, setStatsError] = useState<string | null>(null);
const [users, setUsers] = useState<UserRow[]>([]);
const [usersError, setUsersError] = useState<string | null>(null);
const [forceLogoutMsgs, setForceLogoutMsgs] = useState<Record<string, { type: "ok" | "err"; text: string }>>({});
const [families, setFamilies] = useState<FamilyRow[]>([]);
const [familiesError, setFamiliesError] = useState<string | null>(null);
const [resetInviteMsgs, setResetInviteMsgs] = useState<Record<string, { type: "ok" | "err"; text: string }>>({});
const [testPushMsg, setTestPushMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const [testingPush, setTestingPush] = useState(false);
const [testEmailMsg, setTestEmailMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const [testingEmail, setTestingEmail] = useState(false);
useEffect(() => {
fetch("/api/admin/config")
.then((r) => r.json())
.then((data) => {
if (!data.error) setConfig(data);
});
fetch("/api/admin/stats")
.then((r) => r.json())
.then((data) => {
if (data.error) setStatsError(data.error);
else setStats(data);
})
.catch(() => setStatsError("Erreur réseau"));
fetch("/api/admin/users")
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data)) setUsers(data);
else setUsersError(data.error ?? "Erreur inconnue");
})
.catch(() => setUsersError("Erreur réseau"));
fetch("/api/admin/families")
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data)) setFamilies(data);
else setFamiliesError(data.error ?? "Erreur inconnue");
})
.catch(() => setFamiliesError("Erreur réseau"));
}, []);
function set(key: string, value: string) {
setConfig((prev) => ({ ...prev, [key]: value }));
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
try {
const res = await fetch("/api/admin/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
allow_registration: config.allow_registration ?? "true",
maintenance_mode: config.maintenance_mode ?? "false",
email_provider: config.email_provider ?? "",
mailgun_api_key: config.mailgun_api_key ?? "",
mailgun_domain: config.mailgun_domain ?? "",
mailgun_from: config.mailgun_from ?? "",
smtp_host: config.smtp_host ?? "",
smtp_port: config.smtp_port ?? "",
smtp_user: config.smtp_user ?? "",
smtp_pass: config.smtp_pass ?? "",
smtp_from: config.smtp_from ?? "",
pushover_app_token: config.pushover_app_token ?? "",
}),
});
if (res.ok) {
setMsg({ type: "ok", text: "Configuration sauvegardée." });
} else {
setMsg({ type: "err", text: "Erreur lors de la sauvegarde." });
}
} catch {
setMsg({ type: "err", text: "Erreur réseau." });
}
setSaving(false);
}
async function handleRoleChange(userId: string, role: string) {
const res = await fetch(`/api/admin/users/${userId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role }),
});
if (res.ok) {
setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, role: role as UserRow["role"] } : u)));
}
}
async function handleDeleteUser(userId: string, name: string) {
if (!confirm(`Supprimer l'utilisateur "${name}" ? Cette action est irréversible.`)) return;
const res = await fetch(`/api/admin/users/${userId}`, { method: "DELETE" });
if (res.ok) {
setUsers((prev) => prev.filter((u) => u.id !== userId));
} else {
const data = await res.json();
alert(data.error ?? "Erreur lors de la suppression");
}
}
async function handleTestPush() {
setTestingPush(true);
setTestPushMsg(null);
try {
const res = await fetch("/api/admin/test-push", { method: "POST" });
const data = await res.json();
if (data.ok) setTestPushMsg({ type: "ok", text: "Notification envoyée avec succès." });
else setTestPushMsg({ type: "err", text: data.error ?? "Erreur inconnue" });
} catch {
setTestPushMsg({ type: "err", text: "Erreur réseau." });
}
setTestingPush(false);
}
async function handleForceLogout(userId: string) {
const res = await fetch(`/api/admin/users/${userId}/force-logout`, { method: "POST" });
const data = await res.json();
if (res.ok) {
setForceLogoutMsgs((prev) => ({ ...prev, [userId]: { type: "ok", text: "Déconnecté." } }));
} else {
setForceLogoutMsgs((prev) => ({ ...prev, [userId]: { type: "err", text: data.error ?? "Erreur inconnue" } }));
}
}
async function handleResetInvite(familyId: string) {
const res = await fetch(`/api/admin/family/${familyId}/reset-invite`, { method: "POST" });
const data = await res.json();
if (res.ok && data.inviteCode) {
setFamilies((prev) =>
prev.map((f) => (f.id === familyId ? { ...f, inviteCode: data.inviteCode } : f))
);
setResetInviteMsgs((prev) => ({ ...prev, [familyId]: { type: "ok", text: "Lien réinitialisé." } }));
} else {
setResetInviteMsgs((prev) => ({ ...prev, [familyId]: { type: "err", text: data.error ?? "Erreur inconnue" } }));
}
}
async function handleTestEmail() {
setTestingEmail(true);
setTestEmailMsg(null);
try {
const res = await fetch("/api/admin/test-email", { method: "POST" });
const data = await res.json();
if (data.ok) setTestEmailMsg({ type: "ok", text: "Email envoyé avec succès." });
else setTestEmailMsg({ type: "err", text: data.error ?? "Erreur inconnue" });
} catch {
setTestEmailMsg({ type: "err", text: "Erreur réseau." });
}
setTestingEmail(false);
}
const provider = config.email_provider ?? "none";
const allowRegistration = config.allow_registration !== "false";
const maintenanceMode = config.maintenance_mode === "true";
const inlineMsgCls = (type: "ok" | "err") =>
`mt-2 px-3 py-2 rounded-lg text-xs font-medium ${
type === "ok"
? "bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-400 border border-green-200 dark:border-green-800"
: "bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-800"
}`;
return (
<form onSubmit={handleSave}>
{/* Stats */}
<Section title="Statistiques">
{statsError ? (
<p className="text-sm text-red-500">{statsError}</p>
) : stats ? (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-5">
<StatCard label="Utilisateurs" value={stats.userCount} />
<StatCard label="Familles" value={stats.familyCount} />
<StatCard label="Bébés" value={stats.babyCount} />
<StatCard label="Événements" value={stats.eventCount} />
<StatCard label="Courbe de croissance" value={stats.growthLogCount} />
</div>
) : (
<p className="text-sm text-slate-400 dark:text-slate-500">Chargement</p>
)}
</Section>
{/* Access */}
<Section title="Accès">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">Inscriptions ouvertes</p>
<p className="text-xs text-slate-400 dark:text-slate-500">Autoriser la création de nouveaux comptes</p>
</div>
<button
type="button"
onClick={() => set("allow_registration", allowRegistration ? "false" : "true")}
className={`w-10 h-5 rounded-full transition relative overflow-hidden flex-shrink-0 ${allowRegistration ? "bg-indigo-600" : "bg-slate-200 dark:bg-slate-700"}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${allowRegistration ? "translate-x-[18px]" : "translate-x-0"}`} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">Mode maintenance</p>
<p className="text-xs text-slate-400 dark:text-slate-500">Afficher un message de maintenance aux utilisateurs</p>
</div>
<button
type="button"
onClick={() => set("maintenance_mode", maintenanceMode ? "false" : "true")}
className={`w-10 h-5 rounded-full transition relative overflow-hidden flex-shrink-0 ${maintenanceMode ? "bg-indigo-600" : "bg-slate-200 dark:bg-slate-700"}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${maintenanceMode ? "translate-x-[18px]" : "translate-x-0"}`} />
</button>
</div>
</Section>
{/* Email */}
<Section title="Email">
<div>
<label className={labelCls}>Fournisseur</label>
<select value={provider} onChange={(e) => set("email_provider", e.target.value)} className={inputCls}>
<option value="none">Désactivé</option>
<option value="mailgun">Mailgun</option>
<option value="smtp">SMTP</option>
</select>
</div>
{provider === "mailgun" && (
<>
<div>
<label className={labelCls}>Clé API Mailgun</label>
<input
type="password"
value={config.mailgun_api_key ?? ""}
onChange={(e) => set("mailgun_api_key", e.target.value)}
className={inputCls}
placeholder="key-••••••••••••••••••••••••••••••••"
/>
</div>
<div>
<label className={labelCls}>Domaine Mailgun</label>
<input
type="text"
value={config.mailgun_domain ?? ""}
onChange={(e) => set("mailgun_domain", e.target.value)}
className={inputCls}
placeholder="mg.exemple.com"
/>
</div>
<div>
<label className={labelCls}>Adresse expéditeur</label>
<input
type="text"
value={config.mailgun_from ?? ""}
onChange={(e) => set("mailgun_from", e.target.value)}
className={inputCls}
placeholder="Grow <noreply@exemple.com>"
/>
</div>
</>
)}
{provider === "smtp" && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className={labelCls}>Hôte SMTP</label>
<input
type="text"
value={config.smtp_host ?? ""}
onChange={(e) => set("smtp_host", e.target.value)}
className={inputCls}
placeholder="smtp.exemple.com"
/>
</div>
<div>
<label className={labelCls}>Port</label>
<input
type="text"
value={config.smtp_port ?? ""}
onChange={(e) => set("smtp_port", e.target.value)}
className={inputCls}
placeholder="587"
/>
</div>
</div>
<div>
<label className={labelCls}>Utilisateur</label>
<input
type="text"
value={config.smtp_user ?? ""}
onChange={(e) => set("smtp_user", e.target.value)}
className={inputCls}
/>
</div>
<div>
<label className={labelCls}>Mot de passe</label>
<input
type="password"
value={config.smtp_pass ?? ""}
onChange={(e) => set("smtp_pass", e.target.value)}
className={inputCls}
/>
</div>
<div>
<label className={labelCls}>Adresse expéditeur</label>
<input
type="text"
value={config.smtp_from ?? ""}
onChange={(e) => set("smtp_from", e.target.value)}
className={inputCls}
placeholder="Grow <noreply@exemple.com>"
/>
</div>
</>
)}
</Section>
{/* Pushover */}
<Section title="Notifications push">
<div>
<label className={labelCls}>Token application Pushover</label>
<input
type="password"
value={config.pushover_app_token ?? ""}
onChange={(e) => set("pushover_app_token", e.target.value)}
className={inputCls}
placeholder="a••••••••••••••••••••••••••••••"
/>
<p className="mt-1.5 text-xs text-slate-400 dark:text-slate-500">
Créez une application sur pushover.net pour obtenir ce token.
</p>
</div>
</Section>
{/* Tests */}
<Section title="Tests">
<div className="space-y-3">
<div>
<button
type="button"
onClick={handleTestPush}
disabled={testingPush}
className="px-4 py-2 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 text-sm rounded-lg font-medium transition disabled:opacity-50"
>
{testingPush ? "Envoi…" : "Tester Pushover"}
</button>
{testPushMsg && <p className={inlineMsgCls(testPushMsg.type)}>{testPushMsg.text}</p>}
</div>
<div>
<button
type="button"
onClick={handleTestEmail}
disabled={testingEmail}
className="px-4 py-2 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 text-sm rounded-lg font-medium transition disabled:opacity-50"
>
{testingEmail ? "Envoi…" : "Tester l'email"}
</button>
{testEmailMsg && <p className={inlineMsgCls(testEmailMsg.type)}>{testEmailMsg.text}</p>}
</div>
</div>
</Section>
{/* Utilisateurs */}
<Section title="Utilisateurs">
{usersError ? (
<p className="text-sm text-red-500">{usersError}</p>
) : users.length === 0 ? (
<p className="text-sm text-slate-400 dark:text-slate-500">Chargement</p>
) : (
<div className="overflow-x-auto -mx-4 px-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 dark:border-slate-700">
<th className="text-left pb-2 pr-3 text-xs font-medium text-slate-400 dark:text-slate-500">Nom</th>
<th className="text-left pb-2 pr-3 text-xs font-medium text-slate-400 dark:text-slate-500">Email</th>
<th className="text-left pb-2 pr-3 text-xs font-medium text-slate-400 dark:text-slate-500">Famille</th>
<th className="text-left pb-2 pr-3 text-xs font-medium text-slate-400 dark:text-slate-500">Rôle</th>
<th className="pb-2 text-xs font-medium text-slate-400 dark:text-slate-500" />
</tr>
</thead>
<tbody className="divide-y divide-slate-50 dark:divide-slate-800">
{users.map((u) => (
<tr key={u.id}>
<td className="py-2 pr-3 font-medium text-slate-800 dark:text-slate-100 whitespace-nowrap">
{u.name}
{u.isSuperAdmin && (
<span className="ml-1.5 text-xs bg-indigo-100 dark:bg-indigo-900 text-indigo-600 dark:text-indigo-400 px-1.5 py-0.5 rounded font-medium">
superadmin
</span>
)}
</td>
<td className="py-2 pr-3 text-slate-500 dark:text-slate-400 whitespace-nowrap">{u.email}</td>
<td className="py-2 pr-3 text-slate-500 dark:text-slate-400 whitespace-nowrap">
{u.family?.name ?? <span className="text-slate-300 dark:text-slate-600"></span>}
</td>
<td className="py-2 pr-3">
{u.isSuperAdmin ? (
<span className="text-slate-400 dark:text-slate-500 text-xs"></span>
) : (
<select
value={u.role}
onChange={(e) => handleRoleChange(u.id, e.target.value)}
className="px-2 py-1 rounded-md border border-slate-200 dark:border-slate-600 text-xs bg-white dark:bg-slate-800 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400"
>
<option value="PARENT">Parent</option>
<option value="READONLY">Lecture seule</option>
<option value="DOCTOR">Médecin</option>
</select>
)}
</td>
<td className="py-2 text-right">
{!u.isSuperAdmin && (
<div className="flex items-center justify-end gap-1.5">
{forceLogoutMsgs[u.id] ? (
<span
className={`text-xs px-1.5 py-0.5 rounded font-medium ${
forceLogoutMsgs[u.id].type === "ok"
? "text-green-600 dark:text-green-400"
: "text-red-500 dark:text-red-400"
}`}
>
{forceLogoutMsgs[u.id].text}
</span>
) : (
<button
type="button"
onClick={() => handleForceLogout(u.id)}
className="px-2 py-1 text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-950 rounded transition"
>
Déconnecter
</button>
)}
<button
type="button"
onClick={() => handleDeleteUser(u.id, u.name)}
className="px-2 py-1 text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 rounded transition"
>
Supprimer
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
{/* Familles */}
<Section title="Familles">
{familiesError ? (
<p className="text-sm text-red-500">{familiesError}</p>
) : families.length === 0 ? (
<p className="text-sm text-slate-400 dark:text-slate-500">Chargement</p>
) : (
<div className="overflow-x-auto -mx-4 px-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 dark:border-slate-700">
<th className="text-left pb-2 pr-3 text-xs font-medium text-slate-400 dark:text-slate-500">Nom</th>
<th className="text-left pb-2 pr-3 text-xs font-medium text-slate-400 dark:text-slate-500">Membres</th>
<th className="text-left pb-2 pr-3 text-xs font-medium text-slate-400 dark:text-slate-500">Bébés</th>
<th className="text-left pb-2 pr-3 text-xs font-medium text-slate-400 dark:text-slate-500">Code d&apos;invitation</th>
<th className="pb-2 text-xs font-medium text-slate-400 dark:text-slate-500" />
</tr>
</thead>
<tbody className="divide-y divide-slate-50 dark:divide-slate-800">
{families.map((f) => (
<tr key={f.id}>
<td className="py-2 pr-3 font-medium text-slate-800 dark:text-slate-100 whitespace-nowrap">{f.name}</td>
<td className="py-2 pr-3 text-slate-500 dark:text-slate-400">{f._count.users}</td>
<td className="py-2 pr-3 text-slate-500 dark:text-slate-400">{f._count.babies}</td>
<td className="py-2 pr-3">
<span className="font-mono text-xs text-slate-500 dark:text-slate-400 truncate max-w-[10rem] inline-block align-bottom">
{f.inviteCode}
</span>
</td>
<td className="py-2 text-right whitespace-nowrap">
{resetInviteMsgs[f.id] ? (
<span
className={`text-xs px-1.5 py-0.5 rounded font-medium ${
resetInviteMsgs[f.id].type === "ok"
? "text-green-600 dark:text-green-400"
: "text-red-500 dark:text-red-400"
}`}
>
{resetInviteMsgs[f.id].text}
</span>
) : (
<button
type="button"
onClick={() => handleResetInvite(f.id)}
className="px-2 py-1 text-xs text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300 hover:bg-indigo-50 dark:hover:bg-indigo-950 rounded transition"
>
Réinitialiser le lien
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
{/* Sauvegarde */}
<Section title="Sauvegarde">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">Exporter les données</p>
<p className="text-xs text-slate-400 dark:text-slate-500">Télécharger une sauvegarde JSON complète</p>
</div>
<a
href="/api/admin/backup"
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 text-sm rounded-lg font-medium transition"
>
Télécharger
</a>
</div>
</Section>
{msg && (
<div
className={`mb-4 px-4 py-3 rounded-lg text-sm font-medium ${
msg.type === "ok"
? "bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-400 border border-green-200 dark:border-green-800"
: "bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-800"
}`}
>
{msg.text}
</div>
)}
<button
type="submit"
disabled={saving}
className="w-full py-2.5 bg-indigo-600 text-white text-sm rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 transition"
>
{saving ? "Sauvegarde..." : "Sauvegarder"}
</button>
</form>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import AdminConfigForm from "./AdminConfigForm";
export default async function AdminPage() {
const session = await auth();
if (!session?.user?.email) redirect("/auth/login");
if (session.user.email !== process.env.SUPERADMIN_EMAIL) redirect("/dashboard");
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-1">Administration</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-8">Configuration système</p>
<AdminConfigForm />
</div>
);
}
+238
View File
@@ -0,0 +1,238 @@
"use client";
import { useState, useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { EVENT_CONFIG, EventType, formatTimeAgo, formatDuration } from "@/lib/event-config";
import { EventModal } from "@/components/event-modal";
import { EventIcon } from "@/components/event-icon";
import { useBaby } from "@/contexts/baby-context";
import { checkFeedAlert, getThresholds } from "@/lib/notifications";
import { format, isToday } from "date-fns";
import { fr } from "date-fns/locale";
const QUICK_LOG_TYPES: EventType[] = [
"BREASTFEED",
"BOTTLE",
"DIAPER_WET",
"DIAPER_STOOL",
"NAP",
"NIGHT_SLEEP",
"MEDICATION",
"TEMPERATURE",
];
interface Event {
id: string;
type: EventType;
startedAt: string;
endedAt?: string;
notes?: string;
metadata?: Record<string, unknown>;
user: { id: string; name: string };
}
function getBabyAge(birthDate: string): string {
const birth = new Date(birthDate);
const now = new Date();
const days = Math.floor((now.getTime() - birth.getTime()) / 86400000);
if (days < 7) return `${days} jour${days !== 1 ? "s" : ""}`;
const weeks = Math.floor(days / 7);
if (weeks < 8) return `${weeks} semaine${weeks !== 1 ? "s" : ""}`;
const months = Math.floor(days / 30);
return `${months} mois`;
}
function getEventSummary(event: Event): string {
const meta = event.metadata ?? {};
if (event.type === "BREASTFEED") {
const side = meta.breastSide === "left" ? "gauche" : meta.breastSide === "right" ? "droite" : "les deux";
if (event.endedAt) return `${formatDuration(new Date(event.startedAt), new Date(event.endedAt))} · ${side}`;
return side;
}
if (event.type === "BOTTLE") {
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 === "TEMPERATURE") return meta.value ? `${meta.value}°${meta.unit ?? "C"}` : "";
if (event.type === "MEDICATION") return String(meta.name ?? "");
if ((event.type === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) {
return formatDuration(new Date(event.startedAt), new Date(event.endedAt));
}
return "";
}
export default function DashboardPage() {
const qc = useQueryClient();
const [activeModal, setActiveModal] = useState<EventType | null>(null);
const { selectedBaby, isLoading: familyLoading } = useBaby();
const { data: eventsData } = useQuery({
queryKey: ["events", selectedBaby?.id],
queryFn: () => fetch(`/api/events?babyId=${selectedBaby!.id}&limit=50`).then((r) => r.json()),
enabled: !!selectedBaby?.id,
refetchInterval: 10_000,
});
const events: Event[] = eventsData?.events ?? [];
useEffect(() => {
const lastFeed = events.find((e) => e.type === "BREASTFEED" || e.type === "BOTTLE");
checkFeedAlert(lastFeed ? new Date(lastFeed.startedAt) : null, getThresholds());
const id = setInterval(() => {
checkFeedAlert(lastFeed ? new Date(lastFeed.startedAt) : null, getThresholds());
}, 5 * 60 * 1000);
return () => clearInterval(id);
}, [events]);
const lastByType: Partial<Record<EventType, Event>> = {};
for (const ev of events) {
if (!lastByType[ev.type]) lastByType[ev.type] = ev;
}
const todayEvents = events.filter((e) => isToday(new Date(e.startedAt)));
const todayCounts: Partial<Record<EventType, number>> = {};
for (const ev of todayEvents) todayCounts[ev.type] = (todayCounts[ev.type] ?? 0) + 1;
if (familyLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!selectedBaby) {
return (
<div className="flex flex-col items-center justify-center min-h-screen p-8 text-center">
<p className="text-slate-500 mb-4">Aucun bébé enregistré.</p>
<a href="/setup" className="bg-indigo-600 text-white px-5 py-2.5 rounded-lg font-medium hover:bg-indigo-700 transition text-sm">
Ajouter un bébé
</a>
</div>
);
}
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
{/* Page header */}
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">{selectedBaby.name}</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-0.5">{getBabyAge(selectedBaby.birthDate)}</p>
</div>
<div className="text-right">
<p className="text-slate-400 dark:text-slate-500 text-xs">Aujourd&apos;hui</p>
<p className="text-slate-700 dark:text-slate-200 font-medium text-sm capitalize">
{format(new Date(), "EEEE d MMMM", { locale: fr })}
</p>
</div>
</div>
{/* Today's summary counters */}
<div className="grid grid-cols-4 gap-3 mb-6">
{(["BREASTFEED", "BOTTLE", "DIAPER_WET", "DIAPER_STOOL"] as EventType[]).map((t) => {
const cfg = EVENT_CONFIG[t];
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">
<EventIcon name={cfg.icon} className={`w-5 h-5 mx-auto mb-1.5 ${cfg.colorClass}`} />
<div className="text-2xl font-bold text-slate-900 dark:text-slate-100">{todayCounts[t] ?? 0}</div>
<div className="text-xs text-slate-400 dark:text-slate-500 mt-0.5 leading-tight">{cfg.label.split(" ")[0]}</div>
</div>
);
})}
</div>
<div className="grid md:grid-cols-2 gap-6">
{/* Last events */}
<div>
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">Dernières activités</h2>
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
{(["BREASTFEED", "BOTTLE", "DIAPER_WET", "DIAPER_STOOL", "NAP"] as EventType[]).map((t) => {
const last = lastByType[t];
const cfg = EVENT_CONFIG[t];
return (
<div key={t} className="flex items-center gap-3 px-4 py-3">
<div className={`w-8 h-8 rounded-lg ${cfg.bgClass} flex items-center justify-center flex-shrink-0`}>
<EventIcon name={cfg.icon} className={`w-4 h-4 ${cfg.colorClass}`} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-slate-700 dark:text-slate-200">{cfg.label}</div>
{last && getEventSummary(last) && (
<div className="text-xs text-slate-400 dark:text-slate-500 truncate">{getEventSummary(last)}</div>
)}
</div>
<div className="text-xs text-slate-400 dark:text-slate-500 flex-shrink-0">
{last ? formatTimeAgo(new Date(last.startedAt)) : "—"}
</div>
</div>
);
})}
</div>
</div>
{/* Quick log */}
<div>
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">Enregistrer</h2>
<div className="grid grid-cols-4 md:grid-cols-4 gap-2">
{QUICK_LOG_TYPES.map((type) => {
const cfg = EVENT_CONFIG[type];
return (
<button
key={type}
onClick={() => setActiveModal(type)}
className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 hover:border-slate-300 hover:shadow-sm rounded-xl p-3 flex flex-col items-center gap-2 transition active:scale-95"
>
<div className={`w-9 h-9 rounded-lg ${cfg.bgClass} flex items-center justify-center`}>
<EventIcon name={cfg.icon} className={`w-5 h-5 ${cfg.colorClass}`} />
</div>
<span className="text-[10px] font-medium text-slate-600 dark:text-slate-300 text-center leading-tight">
{cfg.label}
</span>
</button>
);
})}
</div>
</div>
</div>
{/* Today's timeline */}
{todayEvents.length > 0 && (
<div className="mt-6">
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">
Aujourd&apos;hui · {todayEvents.length} événement{todayEvents.length > 1 ? "s" : ""}
</h2>
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
{todayEvents.slice(0, 10).map((ev) => {
const cfg = EVENT_CONFIG[ev.type];
return (
<div key={ev.id} className="flex items-center gap-3 px-4 py-3">
<div className={`w-8 h-8 rounded-lg ${cfg.bgClass} flex items-center justify-center flex-shrink-0`}>
<EventIcon name={cfg.icon} className={`w-4 h-4 ${cfg.colorClass}`} />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{cfg.label}</span>
{getEventSummary(ev) && (
<span className="text-xs text-slate-400 dark:text-slate-500 ml-2">{getEventSummary(ev)}</span>
)}
</div>
<span className="text-xs text-slate-400 dark:text-slate-500 flex-shrink-0 font-mono">
{format(new Date(ev.startedAt), "HH:mm")}
</span>
</div>
);
})}
</div>
</div>
)}
{activeModal && (
<EventModal
type={activeModal}
babyId={selectedBaby.id}
onClose={() => setActiveModal(null)}
onSaved={() => qc.invalidateQueries({ queryKey: ["events"] })}
/>
)}
</div>
);
}
+223
View File
@@ -0,0 +1,223 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { useBaby } from "@/contexts/baby-context";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Trash2, Stethoscope, Plus, Loader2 } from "lucide-react";
interface DoctorNote {
id: string;
content: string;
date: string;
createdAt: string;
userId: string;
user: { id: string; name: string };
}
export default function DoctorNotesPage() {
const { selectedBaby, isLoading: familyLoading } = useBaby();
const { data: session } = useSession();
const qc = useQueryClient();
const userRole = (session?.user as { role?: string })?.role;
const userId = session?.user?.id;
const isDoctor = userRole === "DOCTOR";
const today = new Date().toISOString().slice(0, 10);
const [content, setContent] = useState("");
const [date, setDate] = useState(today);
const [formError, setFormError] = useState<string | null>(null);
const { data: notes = [], isLoading: notesLoading } = useQuery<DoctorNote[]>({
queryKey: ["doctor-notes", selectedBaby?.id],
queryFn: () =>
fetch(`/api/doctor-notes?babyId=${selectedBaby!.id}`).then((r) => r.json()),
enabled: !!selectedBaby?.id,
});
const createMutation = useMutation({
mutationFn: (body: { babyId: string; content: string; date: string }) =>
fetch("/api/doctor-notes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(async (r) => {
if (!r.ok) {
const data = await r.json();
throw new Error(data.error ?? "Erreur lors de la création");
}
return r.json();
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["doctor-notes", selectedBaby?.id] });
setContent("");
setDate(today);
setFormError(null);
},
onError: (err: Error) => {
setFormError(err.message);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) =>
fetch(`/api/doctor-notes/${id}`, { method: "DELETE" }).then(async (r) => {
if (!r.ok) {
const data = await r.json();
throw new Error(data.error ?? "Erreur lors de la suppression");
}
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["doctor-notes", selectedBaby?.id] });
},
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!selectedBaby?.id || !content.trim() || !date) return;
createMutation.mutate({ babyId: selectedBaby.id, content: content.trim(), date });
}
if (familyLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!selectedBaby) {
return (
<div className="flex flex-col items-center justify-center min-h-screen p-8 text-center">
<p className="text-slate-500 dark:text-slate-400 mb-4">Aucun bébé enregistré.</p>
<a
href="/setup"
className="bg-indigo-600 text-white px-5 py-2.5 rounded-lg font-medium hover:bg-indigo-700 transition text-sm"
>
Ajouter un bébé
</a>
</div>
);
}
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="w-9 h-9 bg-emerald-100 dark:bg-emerald-900 rounded-xl flex items-center justify-center flex-shrink-0">
<Stethoscope className="w-5 h-5 text-emerald-600 dark:text-emerald-400" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Observations médicales
</h1>
</div>
{/* Add form — DOCTOR only */}
{isDoctor && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-6">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-3">
Ajouter une observation
</h2>
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">
Date
</label>
<input
type="date"
value={date}
max={today}
onChange={(e) => setDate(e.target.value)}
required
className="w-full rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-slate-100 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:focus:ring-emerald-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">
Observation
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Décrivez votre observation médicale…"
required
rows={4}
className="w-full rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-slate-100 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:focus:ring-emerald-400 resize-none"
/>
</div>
{formError && (
<p className="text-xs text-red-600 dark:text-red-400">{formError}</p>
)}
<div className="flex justify-end">
<button
type="submit"
disabled={createMutation.isPending || !content.trim()}
className="inline-flex items-center gap-2 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium px-4 py-2 rounded-lg transition"
>
{createMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Plus className="w-4 h-4" />
)}
Enregistrer
</button>
</div>
</form>
</div>
)}
{/* Notes list */}
{notesLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-6 h-6 border-2 border-emerald-600 border-t-transparent rounded-full animate-spin" />
</div>
) : notes.length === 0 ? (
<div className="text-center py-12">
<div className="w-12 h-12 bg-slate-100 dark:bg-slate-800 rounded-xl flex items-center justify-center mx-auto mb-3">
<Stethoscope className="w-6 h-6 text-slate-400 dark:text-slate-500" />
</div>
<p className="text-slate-500 dark:text-slate-400 text-sm">
Aucune observation médicale enregistrée
</p>
</div>
) : (
<div className="flex flex-col gap-3">
{notes.map((note) => (
<div
key={note.id}
className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4"
>
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">
{format(new Date(note.date), "d MMMM yyyy", { locale: fr })}
</span>
<span className="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full border bg-emerald-50 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800">
<Stethoscope className="w-3 h-3" />
{note.user.name ?? "Médecin"}
</span>
</div>
{isDoctor && note.userId === userId && (
<button
onClick={() => deleteMutation.mutate(note.id)}
disabled={deleteMutation.isPending}
title="Supprimer"
className="text-slate-400 dark:text-slate-500 hover:text-red-500 dark:hover:text-red-400 transition flex-shrink-0 disabled:opacity-40"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<p className="text-sm text-slate-600 dark:text-slate-300 whitespace-pre-wrap leading-relaxed">
{note.content}
</p>
</div>
))}
</div>
)}
</div>
);
}
+478
View File
@@ -0,0 +1,478 @@
"use client";
import { useState, useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
} from "recharts";
import {
WHO_WEIGHT_PERCENTILES, PERCENTILE_LABELS, getAgeInWeeks, interpolatePercentile,
} from "@/lib/who-percentiles";
import { useBaby } from "@/contexts/baby-context";
import { format, subDays } from "date-fns";
import { fr } from "date-fns/locale";
import { Plus, Pencil, Trash2 } from "lucide-react";
interface GrowthLog {
id: string;
date: string;
weight?: number;
height?: number;
headCirc?: number;
notes?: string;
}
interface BabyShape {
id: string;
name: string;
birthDate: string;
}
const WHO_CURVE_KEYS = ["p3", "p10", "p25", "p50", "p75", "p90", "p97"];
const WHO_COLORS = ["#e2e8f0", "#cbd5e1", "#94a3b8", "#475569", "#94a3b8", "#cbd5e1", "#e2e8f0"];
function useDarkMode() {
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();
}, []);
return isDark;
}
type TimeRange = "7j" | "4sem" | "3mois" | "tout";
function filterLogsByRange(logs: GrowthLog[], range: TimeRange): GrowthLog[] {
if (range === "tout") return logs;
const now = new Date();
let cutoff: Date;
if (range === "7j") cutoff = subDays(now, 7);
else if (range === "4sem") cutoff = subDays(now, 28);
else cutoff = subDays(now, 90);
return logs.filter((l) => new Date(l.date) >= cutoff);
}
function buildWeightChartData(logs: GrowthLog[], baby: BabyShape, allLogs: GrowthLog[]) {
const actualPoints = logs
.filter((l) => l.weight)
.map((l) => ({
ageWeeks: getAgeInWeeks(new Date(baby.birthDate), new Date(l.date)),
weight: l.weight! / 1000,
}));
if (actualPoints.length === 0) return [];
const maxWeek = Math.max(...actualPoints.map((p) => p.ageWeeks));
// minWeek: either earliest in filtered set, or 0 if showing all
const allPoints = allLogs.filter((l) => l.weight).map((l) =>
getAgeInWeeks(new Date(baby.birthDate), new Date(l.date))
);
const globalMin = allPoints.length ? Math.min(...allPoints) : 0;
const localMin = Math.min(...actualPoints.map((p) => p.ageWeeks));
// Only clip if the range is meaningfully narrower than global
const minWeek = localMin > globalMin ? Math.max(0, localMin - 1) : 0;
const whoAges = Object.keys(WHO_WEIGHT_PERCENTILES)
.map(Number)
.filter((a) => a >= minWeek && a <= maxWeek + 2)
.sort((a, b) => a - b);
const mergedMap: Record<number, Record<string, number | null>> = {};
for (const ageWeeks of whoAges) {
const row: Record<string, number | null> = { ageWeeks };
PERCENTILE_LABELS.forEach((label, i) => { row[`p${label.slice(1)}`] = interpolatePercentile(ageWeeks, i); });
mergedMap[ageWeeks] = row;
}
for (const pt of actualPoints) {
mergedMap[pt.ageWeeks] = { ...(mergedMap[pt.ageWeeks] ?? { ageWeeks: pt.ageWeeks }), actualWeight: pt.weight };
}
return Object.values(mergedMap).sort((a, b) => (a.ageWeeks as number) - (b.ageWeeks as number));
}
function buildSimpleChartData(logs: GrowthLog[], baby: BabyShape, field: "height" | "headCirc") {
return logs
.filter((l) => l[field])
.map((l) => ({ ageWeeks: getAgeInWeeks(new Date(baby.birthDate), new Date(l.date)), value: l[field]! }))
.sort((a, b) => a.ageWeeks - b.ageWeeks);
}
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 WeightTooltip(props: Record<string, unknown> & { style: React.CSSProperties }) {
const { active, payload, label, style } = props as {
active?: boolean;
payload?: { dataKey: string; value: number }[];
label?: number;
style: React.CSSProperties;
};
if (!active || !payload) return null;
const baby = payload.find((p) => p.dataKey === "actualWeight");
if (!baby) return null;
return (
<div style={style} className="px-3 py-2 rounded-lg text-xs">
<p className="text-slate-400 mb-1">Semaine {label}</p>
<p className="font-semibold text-indigo-500">Bébé : {baby.value.toFixed(3)} kg</p>
</div>
);
}
export default function GrowthPage() {
const qc = useQueryClient();
const { selectedBaby: baby } = useBaby();
const isDark = useDarkMode();
const tooltipStyle = {
borderRadius: 8,
border: `1px solid ${isDark ? "#334155" : "#e2e8f0"}`,
background: isDark ? "#1e293b" : "#fff",
color: isDark ? "#f1f5f9" : "#0f172a",
boxShadow: "0 4px 16px rgba(0,0,0,0.12)",
fontSize: 12,
};
const gridStroke = isDark ? "#1e293b" : "#f1f5f9";
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ date: "", weight: "", height: "", headCirc: "", notes: "" });
function openAddForm() {
const last = logs.at(-1);
setForm({
date: "",
weight: last?.weight ? (last.weight / 1000).toFixed(3) : "",
height: last?.height ? String(last.height) : "",
headCirc: last?.headCirc ? String(last.headCirc) : "",
notes: "",
});
setShowForm(true);
}
const [saving, setSaving] = useState(false);
const [timeRange, setTimeRange] = useState<TimeRange>("tout");
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState({ date: "", weight: "", height: "", headCirc: "", notes: "" });
const [editSaving, setEditSaving] = useState(false);
const { data: logs = [] } = useQuery<GrowthLog[]>({
queryKey: ["growth", baby?.id],
queryFn: () => fetch(`/api/growth?babyId=${baby!.id}`).then((r) => r.json()),
enabled: !!baby?.id,
});
const filteredLogs = filterLogsByRange(logs, timeRange);
const weightData = baby ? buildWeightChartData(filteredLogs, baby, logs) : [];
const heightData = baby ? buildSimpleChartData(filteredLogs, baby, "height") : [];
const headData = baby ? buildSimpleChartData(filteredLogs, baby, "headCirc") : [];
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
await fetch("/api/growth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
babyId: baby!.id,
date: new Date(form.date).toISOString(),
weight: form.weight ? parseFloat(form.weight) * 1000 : null,
height: form.height ? parseFloat(form.height) : null,
headCirc: form.headCirc ? parseFloat(form.headCirc) : null,
notes: form.notes || null,
}),
});
qc.invalidateQueries({ queryKey: ["growth"] });
setSaving(false);
setShowForm(false);
setForm({ date: "", weight: "", height: "", headCirc: "", notes: "" });
}
function startEdit(log: GrowthLog) {
setEditingId(log.id);
setEditForm({
date: format(new Date(log.date), "yyyy-MM-dd"),
weight: log.weight ? (log.weight / 1000).toFixed(3) : "",
height: log.height ? String(log.height) : "",
headCirc: log.headCirc ? String(log.headCirc) : "",
notes: log.notes ?? "",
});
}
async function handleEditSave(e: React.FormEvent, logId: string) {
e.preventDefault();
setEditSaving(true);
await fetch(`/api/growth/${logId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
date: new Date(editForm.date).toISOString(),
weight: editForm.weight ? parseFloat(editForm.weight) * 1000 : null,
height: editForm.height ? parseFloat(editForm.height) : null,
headCirc: editForm.headCirc ? parseFloat(editForm.headCirc) : null,
notes: editForm.notes || null,
}),
});
qc.invalidateQueries({ queryKey: ["growth"] });
setEditSaving(false);
setEditingId(null);
}
async function handleDelete(logId: string) {
if (!confirm("Supprimer cette mesure ?")) return;
await fetch(`/api/growth/${logId}`, { method: "DELETE" });
qc.invalidateQueries({ queryKey: ["growth"] });
}
const latest = logs.at(-1);
const rangeOptions: { label: string; value: TimeRange }[] = [
{ label: "7j", value: "7j" },
{ label: "4 sem", value: "4sem" },
{ label: "3 mois", value: "3mois" },
{ label: "Tout", value: "tout" },
];
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Courbes de croissance</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{baby?.name}</p>
</div>
<button
onClick={() => showForm ? setShowForm(false) : openAddForm()}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition"
>
<Plus className="w-4 h-4" />
Mesure
</button>
</div>
{/* Add measurement form */}
{showForm && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 mb-5">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Nouvelle mesure</h3>
<form onSubmit={handleSave} className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Date</label>
<input type="date" value={form.date} onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))} required className={inputCls} />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Poids (kg)</label>
<input type="number" value={form.weight} onChange={(e) => setForm((f) => ({ ...f, weight: e.target.value }))}
step="0.001" min="1" max="30" placeholder="3.500" className={inputCls} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Taille (cm)</label>
<input type="number" value={form.height} onChange={(e) => setForm((f) => ({ ...f, height: e.target.value }))}
step="0.1" min="30" max="120" placeholder="50" className={inputCls} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">PC (cm)</label>
<input type="number" value={form.headCirc} onChange={(e) => setForm((f) => ({ ...f, headCirc: e.target.value }))}
step="0.1" min="20" max="60" placeholder="34" className={inputCls} />
</div>
</div>
<input type="text" value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
placeholder="RDV pédiatre, contexte..." className={inputCls} />
<div className="flex gap-2">
<button type="button" onClick={() => setShowForm(false)} className="flex-1 py-2.5 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-300">Annuler</button>
<button type="submit" disabled={saving} className="flex-1 py-2.5 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition">
{saving ? "..." : "Enregistrer"}
</button>
</div>
</form>
</div>
)}
{/* Latest values */}
{latest && (
<div className="grid grid-cols-3 gap-3 mb-5">
{latest.weight && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 text-center">
<p className="text-xs text-slate-400 dark:text-slate-500 font-medium mb-1">Poids</p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{(latest.weight / 1000).toFixed(3).replace(".", ",")} kg</p>
</div>
)}
{latest.height && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 text-center">
<p className="text-xs text-slate-400 dark:text-slate-500 font-medium mb-1">Taille</p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{latest.height} cm</p>
</div>
)}
{latest.headCirc && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 text-center">
<p className="text-xs text-slate-400 dark:text-slate-500 font-medium mb-1">Périmètre crânien</p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{latest.headCirc} cm</p>
</div>
)}
</div>
)}
{/* Time range selector */}
{logs.length > 0 && (
<div className="flex gap-1.5 mb-5">
{rangeOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setTimeRange(opt.value)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition ${
timeRange === opt.value
? "bg-indigo-600 text-white"
: "bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600"
}`}
>
{opt.label}
</button>
))}
</div>
)}
<div className="space-y-4">
{/* Weight + WHO */}
{weightData.length > 1 && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">Poids (kg)</h3>
<div className="flex items-center gap-3 text-xs text-slate-400 dark:text-slate-500">
<span className="flex items-center gap-1"><span className="inline-block w-3 h-0.5 bg-indigo-600 rounded" />Bébé</span>
<span className="flex items-center gap-1"><span className="inline-block w-3 h-0.5 bg-slate-300 rounded" />OMS P3P97</span>
</div>
</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={weightData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="ageWeeks" tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={(v) => `S${v}`} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
<Tooltip content={(props) => <WeightTooltip {...props} style={tooltipStyle} />} />
{WHO_CURVE_KEYS.map((key, i) => (
<Line key={key} dataKey={key} stroke={WHO_COLORS[i]} strokeWidth={key === "p50" ? 1.5 : 1} dot={false} strokeDasharray={key === "p50" ? undefined : "3 2"} />
))}
<Line dataKey="actualWeight" stroke="#4f46e5" strokeWidth={2.5} dot={{ r: 4, fill: "#4f46e5", strokeWidth: 2, stroke: "#fff" }} connectNulls={false} />
</LineChart>
</ResponsiveContainer>
</div>
)}
<div className="grid md:grid-cols-2 gap-4">
{/* Height */}
{heightData.length > 1 && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Taille (cm)</h3>
<ResponsiveContainer width="100%" height={160}>
<LineChart data={heightData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="ageWeeks" tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={(v) => `S${v}`} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} domain={["auto", "auto"]} />
<Tooltip formatter={(val) => [`${val} cm`, "Taille"]} contentStyle={tooltipStyle} />
<Line dataKey="value" stroke="#0ea5e9" strokeWidth={2.5} dot={{ r: 4, fill: "#0ea5e9", strokeWidth: 2, stroke: "#fff" }} />
</LineChart>
</ResponsiveContainer>
</div>
)}
{/* Head circumference */}
{headData.length > 1 && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Périmètre crânien (cm)</h3>
<ResponsiveContainer width="100%" height={160}>
<LineChart data={headData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="ageWeeks" tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={(v) => `S${v}`} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} domain={["auto", "auto"]} />
<Tooltip formatter={(val) => [`${val} cm`, "PC"]} contentStyle={tooltipStyle} />
<Line dataKey="value" stroke="#10b981" strokeWidth={2.5} dot={{ r: 4, fill: "#10b981", strokeWidth: 2, stroke: "#fff" }} />
</LineChart>
</ResponsiveContainer>
</div>
)}
</div>
{/* History */}
{logs.length > 0 && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-slate-100 dark:border-slate-700">
<h3 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">Historique</h3>
</div>
<div className="divide-y divide-slate-100 dark:divide-slate-700">
{[...logs].reverse().map((log) => (
<div key={log.id}>
{editingId === log.id ? (
<div className="px-4 py-4 bg-slate-50 dark:bg-slate-900">
<form onSubmit={(e) => handleEditSave(e, log.id)} className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Date</label>
<input type="date" value={editForm.date} onChange={(e) => setEditForm((f) => ({ ...f, date: e.target.value }))} required className={inputCls} />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Poids (kg)</label>
<input type="number" value={editForm.weight} onChange={(e) => setEditForm((f) => ({ ...f, weight: e.target.value }))}
step="0.001" min="1" max="30" placeholder="3.500" className={inputCls} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Taille (cm)</label>
<input type="number" value={editForm.height} onChange={(e) => setEditForm((f) => ({ ...f, height: e.target.value }))}
step="0.1" min="30" max="120" placeholder="50" className={inputCls} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">PC (cm)</label>
<input type="number" value={editForm.headCirc} onChange={(e) => setEditForm((f) => ({ ...f, headCirc: e.target.value }))}
step="0.1" min="20" max="60" placeholder="34" className={inputCls} />
</div>
</div>
<input type="text" value={editForm.notes} onChange={(e) => setEditForm((f) => ({ ...f, notes: e.target.value }))}
placeholder="RDV pédiatre, contexte..." className={inputCls} />
<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-300">Annuler</button>
<button type="submit" disabled={editSaving} className="flex-1 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition">
{editSaving ? "..." : "Enregistrer"}
</button>
</div>
</form>
</div>
) : (
<div className="px-4 py-3 flex items-center justify-between group">
<span className="text-sm text-slate-600 dark:text-slate-300">
{format(new Date(log.date), "d MMMM yyyy", { locale: fr })}
</span>
<div className="flex items-center gap-3">
<div className="flex gap-4 text-xs text-slate-500 dark:text-slate-400 font-mono">
{log.weight && <span>{(log.weight / 1000).toFixed(3)} kg</span>}
{log.height && <span>{log.height} cm</span>}
{log.headCirc && <span>PC {log.headCirc} cm</span>}
</div>
<div className="flex gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<button
onClick={() => startEdit(log)}
className="p-1.5 rounded-md text-slate-400 dark:text-slate-500 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-950 transition"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDelete(log.id)}
className="p-1.5 rounded-md text-slate-400 dark:text-slate-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{logs.length === 0 && !showForm && (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
<p className="text-sm">Aucune mesure enregistrée</p>
<button onClick={openAddForm} className="mt-3 text-indigo-600 font-medium text-sm hover:underline">
Ajouter la première mesure
</button>
</div>
)}
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { SidebarNav, BottomNav } from "@/components/nav";
import { BabyProvider } from "@/contexts/baby-context";
import { QuickAddFab } from "@/components/quick-add-fab";
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
if (!session?.user) redirect("/auth/login");
return (
<BabyProvider>
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<SidebarNav />
<main className="app-main min-h-screen">
{children}
</main>
<BottomNav />
<QuickAddFab />
</div>
</BabyProvider>
);
}
+440
View File
@@ -0,0 +1,440 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { useBaby } from "@/contexts/baby-context";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import {
Plus,
Pencil,
Trash2,
Star,
Smile,
MessageCircle,
Baby,
Footprints,
Cake,
Droplets,
Eye,
Hand,
Moon,
type LucideIcon,
} from "lucide-react";
interface Milestone {
id: string;
babyId: string;
title: string;
date: string;
notes: string | null;
icon: string | null;
createdAt: string;
}
const ICON_MAP: Record<string, LucideIcon> = {
Smile,
MessageCircle,
Baby,
Footprints,
Cake,
Droplets,
Eye,
Hand,
Moon,
Star,
};
const PRESET_ICONS: { key: string; Icon: LucideIcon; label: string }[] = [
{ key: "Smile", Icon: Smile, label: "Premier sourire" },
{ key: "MessageCircle", Icon: MessageCircle, label: "Premier mot" },
{ key: "Baby", Icon: Baby, label: "Tient assis" },
{ key: "Footprints", Icon: Footprints, label: "Premier pas" },
{ key: "Cake", Icon: Cake, label: "Anniversaire" },
{ key: "Droplets", Icon: Droplets, label: "Boit seul" },
{ key: "Eye", Icon: Eye, label: "Suit du regard" },
{ key: "Hand", Icon: Hand, label: "Saisit objet" },
{ key: "Moon", Icon: Moon, label: "Fait ses nuits" },
{ key: "Star", Icon: Star, label: "Autre" },
];
function MilestoneIcon({ icon }: { icon: string | null; className?: string }) {
const Icon = icon && ICON_MAP[icon] ? ICON_MAP[icon] : Star;
return <Icon className="w-5 h-5 text-indigo-500 dark:text-indigo-400" />;
}
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 emptyForm = { icon: "", title: "", date: "", notes: "" };
type FormState = typeof emptyForm;
function groupByMonth(milestones: Milestone[]) {
const groups: { key: string; items: Milestone[] }[] = [];
const seen: Record<string, number> = {};
for (const m of milestones) {
const key = format(new Date(m.date), "MMMM yyyy", { locale: fr });
if (seen[key] === undefined) {
seen[key] = groups.length;
groups.push({ key, items: [] });
}
groups[seen[key]].items.push(m);
}
return groups;
}
function IconPicker({
value,
onChange,
}: {
value: string;
onChange: (key: string, label: string) => void;
}) {
return (
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-2">
Icône
</label>
<div className="grid grid-cols-2 gap-2">
{PRESET_ICONS.map(({ key, Icon, label }) => (
<button
key={key}
type="button"
onClick={() => onChange(key, label)}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg border text-sm transition ${
value === key
? "border-indigo-500 bg-indigo-50 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300"
: "border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:border-indigo-300 dark:hover:border-indigo-700 hover:bg-slate-50 dark:hover:bg-slate-800"
}`}
>
<span className="w-5 h-5 flex items-center justify-center flex-shrink-0">
<Icon
className={`w-4 h-4 ${
value === key
? "text-indigo-600 dark:text-indigo-400"
: "text-slate-400 dark:text-slate-500"
}`}
/>
</span>
<span className="text-xs font-medium truncate">{label}</span>
</button>
))}
</div>
</div>
);
}
function MilestoneFormCard({
form,
setForm,
onSubmit,
onCancel,
saving,
heading,
}: {
form: FormState;
setForm: (fn: (f: FormState) => FormState) => void;
onSubmit: (e: React.FormEvent) => void;
onCancel: () => void;
saving: boolean;
heading: string;
}) {
return (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200 mb-4">
{heading}
</h3>
<form onSubmit={onSubmit} className="space-y-4">
<IconPicker
value={form.icon}
onChange={(key, label) =>
setForm((f) => ({ ...f, icon: key, title: f.title || label }))
}
/>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Titre <span className="text-red-400">*</span>
</label>
<input
type="text"
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
required
placeholder="Ex : Premier sourire"
className={inputCls}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Date <span className="text-red-400">*</span>
</label>
<input
type="date"
value={form.date}
onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))}
required
className={inputCls}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Notes{" "}
<span className="text-slate-400 font-normal">(optionnel)</span>
</label>
<textarea
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
rows={3}
placeholder="Un souvenir à garder…"
className={inputCls + " resize-none"}
/>
</div>
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={onCancel}
className="flex-1 py-2.5 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition"
>
Annuler
</button>
<button
type="submit"
disabled={saving}
className="flex-1 py-2.5 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition disabled:opacity-60"
>
{saving ? "Enregistrement…" : "Enregistrer"}
</button>
</div>
</form>
</div>
);
}
export default function MilestonesPage() {
const qc = useQueryClient();
const { selectedBaby: baby } = useBaby();
const { data: session } = useSession();
const isReadonly =
(session?.user as { role?: string } | undefined)?.role === "READONLY";
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState(emptyForm);
const [editSaving, setEditSaving] = useState(false);
const { data: milestones = [] } = useQuery<Milestone[]>({
queryKey: ["milestones", baby?.id],
queryFn: () =>
fetch(`/api/milestones?babyId=${baby!.id}`).then((r) => r.json()),
enabled: !!baby?.id,
});
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!baby) return;
setSaving(true);
await fetch("/api/milestones", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
babyId: baby.id,
title: form.title,
date: new Date(form.date).toISOString(),
notes: form.notes || null,
icon: form.icon || null,
}),
});
qc.invalidateQueries({ queryKey: ["milestones", baby.id] });
setSaving(false);
setShowForm(false);
setForm(emptyForm);
}
function startEdit(m: Milestone) {
setShowForm(false);
setEditingId(m.id);
setEditForm({
icon: m.icon ?? "",
title: m.title,
date: format(new Date(m.date), "yyyy-MM-dd"),
notes: m.notes ?? "",
});
}
async function handleEditSave(e: React.FormEvent) {
e.preventDefault();
if (!baby || !editingId) return;
setEditSaving(true);
await fetch(`/api/milestones/${editingId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: editForm.title,
date: new Date(editForm.date).toISOString(),
notes: editForm.notes || null,
icon: editForm.icon || null,
}),
});
qc.invalidateQueries({ queryKey: ["milestones", baby.id] });
setEditSaving(false);
setEditingId(null);
}
async function handleDelete(id: string) {
if (!baby) return;
if (!confirm("Supprimer cette étape ?")) return;
await fetch(`/api/milestones/${id}`, { method: "DELETE" });
qc.invalidateQueries({ queryKey: ["milestones", baby.id] });
}
const groups = groupByMonth(milestones);
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Étapes clés
</h1>
{baby && (
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
{baby.name}
</p>
)}
</div>
{!isReadonly && (
<button
onClick={() => {
setShowForm((v) => !v);
setForm(emptyForm);
setEditingId(null);
}}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition"
>
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">Ajouter une étape</span>
<span className="sm:hidden">Ajouter</span>
</button>
)}
</div>
{/* Add form */}
{showForm && !isReadonly && (
<div className="mb-6">
<MilestoneFormCard
form={form}
setForm={setForm}
onSubmit={handleSave}
onCancel={() => setShowForm(false)}
saving={saving}
heading="Nouvelle étape"
/>
</div>
)}
{/* Empty state */}
{milestones.length === 0 && !showForm && (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-16 h-16 bg-indigo-50 dark:bg-indigo-950 rounded-2xl flex items-center justify-center mb-4">
<Star className="w-8 h-8 text-indigo-300 dark:text-indigo-600" />
</div>
<p className="text-base font-medium text-slate-700 dark:text-slate-300 mb-1">
Aucune étape enregistrée
</p>
<p className="text-sm text-slate-400 dark:text-slate-500 mb-4 max-w-xs">
Capturez les grands moments premier sourire, premiers pas,
premier mot
</p>
{!isReadonly && (
<button
onClick={() => setShowForm(true)}
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:underline"
>
Ajouter la première étape
</button>
)}
</div>
)}
{/* Milestone list grouped by month */}
{milestones.length > 0 && (
<div className="space-y-8">
{groups.map(({ key, items }) => (
<div key={key}>
<div className="flex items-center gap-3 mb-3">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500 capitalize">
{key}
</span>
<div className="flex-1 h-px bg-slate-100 dark:bg-slate-800" />
</div>
<div className="space-y-3">
{items.map((m) =>
editingId === m.id ? (
<MilestoneFormCard
key={m.id}
form={editForm}
setForm={setEditForm}
onSubmit={handleEditSave}
onCancel={() => setEditingId(null)}
saving={editSaving}
heading="Modifier l'étape"
/>
) : (
<div
key={m.id}
className="group flex items-start gap-4 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl px-4 py-4 hover:border-indigo-200 dark:hover:border-indigo-800 transition-colors"
>
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-indigo-50 dark:bg-indigo-950 flex items-center justify-center">
<MilestoneIcon icon={m.icon} />
</div>
<div className="flex-1 min-w-0 pt-0.5">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{m.title}
</p>
<p className="text-xs text-indigo-500 dark:text-indigo-400 font-medium mt-0.5 capitalize">
{format(new Date(m.date), "EEEE d MMMM yyyy", {
locale: fr,
})}
</p>
{m.notes && (
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400 leading-relaxed">
{m.notes}
</p>
)}
</div>
{!isReadonly && (
<div className="flex gap-1 flex-shrink-0 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity pt-0.5">
<button
onClick={() => startEdit(m)}
className="p-1.5 rounded-md text-slate-400 dark:text-slate-500 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-950 transition"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(m.id)}
className="p-1.5 rounded-md text-slate-400 dark:text-slate-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 transition"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</div>
)
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
+354
View File
@@ -0,0 +1,354 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { useBaby } from "@/contexts/baby-context";
import {
format,
subDays,
addDays,
isToday,
isYesterday,
isTomorrow,
isFuture,
startOfDay,
parseISO,
} from "date-fns";
import { fr } from "date-fns/locale";
import { ChevronLeft, ChevronRight, CalendarDays, Save, Trash2 } from "lucide-react";
interface JournalNote {
id: string;
babyId: string;
userId: string;
date: string;
content: string;
createdAt: string;
updatedAt: string;
user: { id: string; name: string };
}
function formatDateLabel(date: Date): string {
if (isToday(date)) return "Aujourd'hui";
if (isYesterday(date)) return "Hier";
if (isTomorrow(date)) return "Demain";
return format(date, "d MMMM yyyy", { locale: fr });
}
function toDateString(date: Date): string {
return format(date, "yyyy-MM-dd");
}
export default function NotesPage() {
const { data: session } = useSession();
const { selectedBaby } = useBaby();
const qc = useQueryClient();
const [currentDate, setCurrentDate] = useState<Date>(startOfDay(new Date()));
const [draftContent, setDraftContent] = useState<string>("");
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const babyId = selectedBaby?.id;
const dateStr = toDateString(currentDate);
const userRole = (session?.user as { role?: string } | undefined)?.role;
const isReadonly = userRole === "READONLY";
// Query for current day note
const { data: dayNotes, isLoading: isDayLoading } = useQuery<JournalNote[]>({
queryKey: ["journal", babyId, dateStr],
queryFn: () =>
fetch(`/api/journal?babyId=${babyId}&date=${dateStr}`).then((r) => r.json()),
enabled: !!babyId,
});
const currentNote = dayNotes?.[0] ?? null;
// Query for recent notes (last 10 days that have notes)
const { data: recentNotes } = useQuery<JournalNote[]>({
queryKey: ["journal-recent", babyId],
queryFn: () =>
fetch(`/api/journal?babyId=${babyId}`).then((r) => r.json()),
enabled: !!babyId,
staleTime: 30_000,
});
// Build list of last 10 unique days with notes, excluding current date
const recentDays: { dateStr: string; note: JournalNote }[] = (() => {
if (!recentNotes) return [];
const seen = new Set<string>();
const result: { dateStr: string; note: JournalNote }[] = [];
for (const note of recentNotes) {
const ds = toDateString(new Date(note.date));
if (ds === dateStr) continue;
if (!seen.has(ds)) {
seen.add(ds);
result.push({ dateStr: ds, note });
}
if (result.length >= 10) break;
}
return result;
})();
// Sync draft content when note or date changes
useEffect(() => {
if (isDayLoading) return;
setDraftContent(currentNote?.content ?? "");
setSaveError(null);
}, [currentNote?.id, dateStr, isDayLoading]);
function goToPrevDay() {
setCurrentDate((d) => startOfDay(subDays(d, 1)));
}
function goToNextDay() {
const next = startOfDay(addDays(currentDate, 1));
if (isFuture(next)) return;
setCurrentDate(next);
}
function goToToday() {
setCurrentDate(startOfDay(new Date()));
}
const canGoNext = !isToday(currentDate);
async function handleSave() {
if (!babyId || isReadonly) return;
const trimmed = draftContent.trim();
setIsSaving(true);
setSaveError(null);
try {
if (currentNote) {
const res = await fetch(`/api/journal/${currentNote.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: trimmed }),
});
if (!res.ok) throw new Error("Erreur lors de la sauvegarde");
} else {
if (!trimmed) return;
const res = await fetch("/api/journal", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ babyId, date: dateStr, content: trimmed }),
});
if (!res.ok) throw new Error("Erreur lors de la sauvegarde");
}
qc.invalidateQueries({ queryKey: ["journal", babyId, dateStr] });
qc.invalidateQueries({ queryKey: ["journal-recent", babyId] });
} catch (e) {
setSaveError(e instanceof Error ? e.message : "Erreur inconnue");
} finally {
setIsSaving(false);
}
}
async function handleBlur() {
if (isReadonly) return;
const trimmed = draftContent.trim();
// Only auto-save if content changed from what's stored
const stored = currentNote?.content ?? "";
if (trimmed === stored) return;
// Don't auto-save empty content if there's no existing note
if (!trimmed && !currentNote) return;
await handleSave();
}
async function handleDelete() {
if (!currentNote || isReadonly) return;
setIsDeleting(true);
try {
await fetch(`/api/journal/${currentNote.id}`, { method: "DELETE" });
setDraftContent("");
qc.invalidateQueries({ queryKey: ["journal", babyId, dateStr] });
qc.invalidateQueries({ queryKey: ["journal-recent", babyId] });
} finally {
setIsDeleting(false);
}
}
function jumpToDate(ds: string) {
setCurrentDate(startOfDay(parseISO(ds)));
}
const hasUnsavedChanges = draftContent.trim() !== (currentNote?.content ?? "").trim();
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Journal
</h1>
{selectedBaby && (
<p className="text-sm text-slate-400 dark:text-slate-500 mt-1">
Notes quotidiennes pour {selectedBaby.name}
</p>
)}
</div>
{!selectedBaby ? (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
Sélectionnez un bébé pour voir le journal.
</div>
) : (
<>
{/* Date Navigation */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4">
<div className="flex items-center justify-between gap-2">
<button
onClick={goToPrevDay}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400 transition-colors"
aria-label="Jour précédent"
>
<ChevronLeft className="w-5 h-5" />
</button>
<div className="flex-1 flex flex-col items-center gap-1">
<span className="text-lg font-semibold text-slate-900 dark:text-slate-100 capitalize">
{formatDateLabel(currentDate)}
</span>
{!isToday(currentDate) && (
<span className="text-xs text-slate-400 dark:text-slate-500">
{format(currentDate, "EEEE d MMMM yyyy", { locale: fr })}
</span>
)}
</div>
<div className="flex items-center gap-1">
{!isToday(currentDate) && (
<button
onClick={goToToday}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-indigo-500 dark:text-indigo-400 transition-colors"
aria-label="Aujourd'hui"
title="Aujourd'hui"
>
<CalendarDays className="w-5 h-5" />
</button>
)}
<button
onClick={goToNextDay}
disabled={!canGoNext}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
aria-label="Jour suivant"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
</div>
{/* Note Editor / Viewer */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-6">
{isDayLoading ? (
<div className="h-32 flex items-center justify-center">
<div className="w-5 h-5 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
</div>
) : isReadonly ? (
<div className="min-h-[8rem]">
{currentNote ? (
<p className="text-slate-700 dark:text-slate-300 whitespace-pre-wrap leading-relaxed">
{currentNote.content}
</p>
) : (
<p className="text-slate-400 dark:text-slate-500 italic">
Aucune note pour ce jour.
</p>
)}
</div>
) : (
<>
<textarea
ref={textareaRef}
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
onBlur={handleBlur}
placeholder="Notes du jour..."
rows={6}
className="w-full resize-none bg-transparent text-slate-900 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 text-sm leading-relaxed focus:outline-none"
/>
<div className="flex items-center justify-between mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-2">
{currentNote && (
<button
onClick={handleDelete}
disabled={isDeleting}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg transition-colors disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
Supprimer
</button>
)}
</div>
<div className="flex items-center gap-2">
{saveError && (
<span className="text-xs text-red-500">{saveError}</span>
)}
<button
onClick={handleSave}
disabled={isSaving || !hasUnsavedChanges || (!draftContent.trim() && !currentNote)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
<Save className="w-3.5 h-3.5" />
{isSaving ? "Sauvegarde…" : "Sauvegarder"}
</button>
</div>
</div>
{currentNote && (
<p className="text-xs text-slate-400 dark:text-slate-500 mt-2">
Dernière modification le{" "}
{format(new Date(currentNote.updatedAt), "d MMM yyyy à HH:mm", { locale: fr })}
{currentNote.user?.name ? ` par ${currentNote.user.name}` : ""}
</p>
)}
</>
)}
</div>
{/* Recent Notes */}
{recentDays.length > 0 && (
<div>
<h2 className="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-3">
Notes récentes
</h2>
<div className="flex flex-col gap-3">
{recentDays.map(({ dateStr: ds, note }) => {
const d = parseISO(ds);
return (
<button
key={ds}
onClick={() => jumpToDate(ds)}
className="text-left bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 hover:border-indigo-300 dark:hover:border-indigo-600 hover:shadow-sm transition-all group"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-indigo-600 dark:text-indigo-400 mb-1 capitalize">
{formatDateLabel(d)}
{!isYesterday(d) && !isToday(d) && (
<span className="text-slate-400 dark:text-slate-500 font-normal ml-1">
· {format(d, "d MMMM", { locale: fr })}
</span>
)}
</p>
<p className="text-sm text-slate-700 dark:text-slate-300 line-clamp-2 leading-relaxed">
{note.content}
</p>
</div>
<ChevronRight className="w-4 h-4 text-slate-300 dark:text-slate-600 group-hover:text-indigo-400 flex-shrink-0 mt-0.5 transition-colors" />
</div>
</button>
);
})}
</div>
</div>
)}
</>
)}
</div>
);
}
+493
View File
@@ -0,0 +1,493 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { signOut } from "next-auth/react";
import { useState, useEffect } from "react";
import { useBaby } from "@/contexts/baby-context";
import { getThresholds, saveThresholds, requestPermission } from "@/lib/notifications";
import { Copy, Check, Download, LogOut, Bell, Baby, Users, ChevronRight, FileText, Plus } from "lucide-react";
interface Family {
id: string;
name: string;
inviteCode: string;
babies: { id: string; name: string; birthDate: string; gender?: string }[];
users: { id: string; name: string; email: string; role: string }[];
}
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 dark:text-slate-100";
export default function SettingsPage() {
const qc = useQueryClient();
const { selectedBaby, setSelectedBabyId } = useBaby();
const [copiedRole, setCopiedRole] = useState<string | null>(null);
const [exportLoading, setExportLoading] = useState(false);
const [pdfLoading, setPdfLoading] = useState(false);
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [editingBaby, setEditingBaby] = useState(false);
const [babyForm, setBabyForm] = useState({ name: "", birthDate: "", gender: "" });
const [savingBaby, setSavingBaby] = useState(false);
const [thresholds, setThresholdsState] = useState(getThresholds);
const [notifPermission, setNotifPermission] = useState<boolean>(
typeof window !== "undefined" && Notification.permission === "granted"
);
const [pushoverKey, setPushoverKey] = useState("");
const [savingPushover, setSavingPushover] = useState(false);
const [pushoverMsg, setPushoverMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
useEffect(() => {
fetch("/api/user/pushover")
.then((r) => r.json())
.then((data) => { if (data.pushoverUserKey) setPushoverKey(data.pushoverUserKey); });
}, []);
const { data: family } = useQuery<Family>({
queryKey: ["family"],
queryFn: () => fetch("/api/baby").then((r) => r.json()),
});
const origin = typeof window !== "undefined" ? window.location.origin : "";
async function copyInvite(role: string) {
if (!family) return;
const url = `${origin}/auth/invite/${family.inviteCode}?role=${role}`;
await navigator.clipboard.writeText(url);
setCopiedRole(role);
setTimeout(() => setCopiedRole(null), 2000);
}
async function handlePdfExport() {
if (!selectedBaby) return;
setPdfLoading(true);
const { default: jsPDF } = await import("jspdf");
const params = new URLSearchParams({ babyId: selectedBaby.id, limit: "500" });
if (dateFrom) params.set("dateFrom", new Date(dateFrom).toISOString());
if (dateTo) params.set("dateTo", new Date(dateTo).toISOString());
const res = await fetch(`/api/export?${params}&format=json`);
const events: Array<{ type: string; startedAt: string; endedAt?: string; notes?: string }> = await res.json().catch(() => []);
const doc = new jsPDF({ unit: "mm", format: "a4" });
const pageW = doc.internal.pageSize.getWidth();
let y = 20;
doc.setFontSize(20);
doc.setFont("helvetica", "bold");
doc.text("Grow — Rapport", 20, y);
y += 8;
doc.setFontSize(11);
doc.setFont("helvetica", "normal");
doc.setTextColor(100, 116, 139);
const rangeLabel = dateFrom || dateTo
? `${dateFrom ? new Date(dateFrom).toLocaleDateString("fr-FR") : "début"}${dateTo ? new Date(dateTo).toLocaleDateString("fr-FR") : "aujourd'hui"}`
: "Toutes les données";
doc.text(`${selectedBaby.name} · ${rangeLabel}`, 20, y);
y += 12;
doc.setDrawColor(226, 232, 240);
doc.line(20, y, pageW - 20, y);
y += 8;
doc.setFontSize(9);
doc.setTextColor(15, 23, 42);
doc.setFont("helvetica", "bold");
doc.text("Date & Heure", 20, y);
doc.text("Type", 75, y);
doc.text("Détails", 115, y);
y += 5;
doc.setDrawColor(226, 232, 240);
doc.line(20, y, pageW - 20, y);
y += 4;
doc.setFont("helvetica", "normal");
const TYPE_LABELS: Record<string, string> = {
BREASTFEED: "Allaitement", BOTTLE: "Biberon", DIAPER_WET: "Couche urine",
DIAPER_STOOL: "Couche selles", NAP: "Sieste", NIGHT_SLEEP: "Nuit",
MEDICATION: "Traitement", TEMPERATURE: "Température",
};
const rows = Array.isArray(events) ? events : [];
for (const ev of rows) {
if (y > 275) { doc.addPage(); y = 20; }
const dt = new Date(ev.startedAt).toLocaleString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
const label = TYPE_LABELS[ev.type] ?? ev.type;
const detail = ev.notes ?? "";
doc.setTextColor(100, 116, 139);
doc.text(dt, 20, y);
doc.setTextColor(15, 23, 42);
doc.text(label, 75, y);
if (detail) doc.text(detail.slice(0, 40), 115, y);
y += 5.5;
}
doc.save(`grow-${selectedBaby.name}-rapport.pdf`);
setPdfLoading(false);
}
async function handleExport() {
if (!selectedBaby) return;
setExportLoading(true);
const params = new URLSearchParams({ babyId: selectedBaby.id });
if (dateFrom) params.set("dateFrom", new Date(dateFrom).toISOString());
if (dateTo) params.set("dateTo", new Date(dateTo).toISOString());
const res = await fetch(`/api/export?${params}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `grow-export-${selectedBaby.name}.csv`;
a.click();
URL.revokeObjectURL(url);
setExportLoading(false);
}
function startEditBaby() {
if (!selectedBaby) return;
setBabyForm({
name: selectedBaby.name,
birthDate: selectedBaby.birthDate.slice(0, 10),
gender: selectedBaby.gender ?? "",
});
setEditingBaby(true);
}
async function saveBaby(e: React.FormEvent) {
e.preventDefault();
if (!selectedBaby) return;
setSavingBaby(true);
await fetch(`/api/baby/${selectedBaby.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(babyForm),
});
qc.invalidateQueries({ queryKey: ["family"] });
setSavingBaby(false);
setEditingBaby(false);
}
async function enableNotifications() {
const granted = await requestPermission();
setNotifPermission(granted);
if (granted) {
const updated = { ...thresholds, enabled: true };
setThresholdsState(updated);
saveThresholds(updated);
}
}
function updateThreshold(hours: number) {
const updated = { ...thresholds, feedAlertHours: hours };
setThresholdsState(updated);
saveThresholds(updated);
}
function toggleNotifications(enabled: boolean) {
const updated = { ...thresholds, enabled };
setThresholdsState(updated);
saveThresholds(updated);
}
async function savePushoverKey() {
setSavingPushover(true);
setPushoverMsg(null);
try {
const res = await fetch("/api/user/pushover", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pushoverUserKey: pushoverKey }),
});
setPushoverMsg(res.ok ? { type: "ok", text: "Clé sauvegardée." } : { type: "err", text: "Erreur lors de la sauvegarde." });
} catch {
setPushoverMsg({ type: "err", text: "Erreur réseau." });
}
setSavingPushover(false);
}
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-slate-100 dark:border-slate-700">
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">{title}</h2>
</div>
{children}
</div>
);
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-6">Réglages</h1>
<div className="space-y-4">
{/* Baby info */}
{selectedBaby && (
<Section title="Bébé">
{editingBaby ? (
<form onSubmit={saveBaby} className="p-4 space-y-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Prénom</label>
<input type="text" value={babyForm.name} onChange={(e) => setBabyForm((f) => ({ ...f, name: e.target.value }))}
required className={inputCls} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Date de naissance</label>
<input type="date" value={babyForm.birthDate} onChange={(e) => setBabyForm((f) => ({ ...f, birthDate: e.target.value }))}
required className={inputCls} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Genre</label>
<select value={babyForm.gender} onChange={(e) => setBabyForm((f) => ({ ...f, gender: e.target.value }))} className={inputCls}>
<option value="">Non précisé</option>
<option value="boy">Garçon</option>
<option value="girl">Fille</option>
</select>
</div>
<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">Annuler</button>
<button type="submit" disabled={savingBaby} className="flex-1 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium">
{savingBaby ? "..." : "Enregistrer"}
</button>
</div>
</form>
) : (
<button onClick={startEditBaby} className="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-800 transition text-left">
<div className="w-10 h-10 bg-indigo-50 dark:bg-indigo-950 rounded-xl flex items-center justify-center flex-shrink-0">
<Baby className="w-5 h-5 text-indigo-600 dark:text-indigo-400" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-slate-800 dark:text-slate-100">{selectedBaby.name}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{new Date(selectedBaby.birthDate).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" })}
</p>
</div>
<ChevronRight className="w-4 h-4 text-slate-400 dark:text-slate-500" />
</button>
)}
</Section>
)}
{/* Add baby */}
<Section title="Bébés">
<div className="divide-y divide-slate-100 dark:divide-slate-700">
{family?.babies.map((b) => (
<div key={b.id} className="flex items-center gap-3 px-4 py-3">
<div className="w-8 h-8 bg-indigo-50 dark:bg-indigo-950 rounded-full flex items-center justify-center">
<Baby className="w-4 h-4 text-indigo-600 dark:text-indigo-400" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">{b.name}</p>
<p className="text-xs text-slate-400 dark:text-slate-500">
{new Date(b.birthDate).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" })}
</p>
</div>
</div>
))}
<div className="px-4 py-3">
<a
href="/setup"
className="flex items-center gap-2 text-xs text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 font-medium transition"
>
<Plus className="w-3.5 h-3.5" />
Ajouter un bébé
</a>
</div>
</div>
</Section>
{/* Family */}
{family && (
<Section title={`Famille · ${family.users.length} membre${family.users.length > 1 ? "s" : ""}`}>
{family.users.map((u) => (
<div key={u.id} className="flex items-center gap-3 px-4 py-3 border-b border-slate-100 dark:border-slate-700 last:border-0">
<div className="w-8 h-8 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center text-xs font-bold text-slate-600 dark:text-slate-300">
{u.name[0].toUpperCase()}
</div>
<div className="flex-1">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">{u.name}</p>
<p className="text-xs text-slate-400 dark:text-slate-500">{u.email}</p>
</div>
{u.role && (
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-medium">
{u.role === "PARENT" ? "Parent" : u.role === "READONLY" ? "Lecture" : u.role === "DOCTOR" ? "Médecin" : u.role}
</span>
)}
</div>
))}
<div className="px-4 py-3 border-t border-slate-100 dark:border-slate-700">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-3">Inviter un membre</p>
<div className="space-y-2">
{([
{
role: "PARENT",
label: "Parent",
desc: "Accès complet — enregistrer, modifier, supprimer tous les événements",
color: "border-indigo-200 dark:border-indigo-800 bg-indigo-50 dark:bg-indigo-950",
textColor: "text-indigo-700 dark:text-indigo-300",
},
{
role: "READONLY",
label: "Lecture seule",
desc: "Voir le journal et les courbes, sans pouvoir modifier quoi que ce soit",
color: "border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800",
textColor: "text-slate-700 dark:text-slate-300",
},
{
role: "DOCTOR",
label: "Médecin",
desc: "Voir toutes les données + ajouter des notes et observations cliniques",
color: "border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-950",
textColor: "text-emerald-700 dark:text-emerald-300",
},
] as const).map(({ role, label, desc, color, textColor }) => (
<button
key={role}
onClick={() => copyInvite(role)}
className={`w-full flex items-center gap-3 px-3 py-2.5 border rounded-xl transition hover:opacity-90 active:scale-[0.99] text-left ${color}`}
>
<div className="flex-1 min-w-0">
<p className={`text-xs font-semibold ${textColor}`}>{label}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 leading-snug">{desc}</p>
</div>
<div className={`flex-shrink-0 flex items-center gap-1.5 text-xs font-medium ${textColor}`}>
{copiedRole === role
? <><Check className="w-3.5 h-3.5" />Copié</>
: <><Copy className="w-3.5 h-3.5" />Copier</>}
</div>
</button>
))}
</div>
</div>
</Section>
)}
{/* Notifications */}
<Section title="Notifications">
<div className="p-4 space-y-4">
{!notifPermission ? (
<div className="flex items-start gap-3">
<Bell className="w-5 h-5 text-slate-400 dark:text-slate-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-slate-700 dark:text-slate-200 font-medium mb-1">Activer les notifications</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-3">Recevez une alerte si bébé n&apos;a pas mangé depuis X heures.</p>
<button onClick={enableNotifications}
className="px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg font-medium hover:bg-indigo-700 transition">
Activer
</button>
</div>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">Alertes activées</p>
<p className="text-xs text-slate-400 dark:text-slate-500">Rappel si pas de repas depuis</p>
</div>
<button
onClick={() => toggleNotifications(!thresholds.enabled)}
className={`w-10 h-5 rounded-full transition relative overflow-hidden ${thresholds.enabled ? "bg-indigo-600" : "bg-slate-200 dark:bg-slate-700"}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${thresholds.enabled ? "translate-x-[18px]" : "translate-x-0"}`} />
</button>
</div>
{thresholds.enabled && (
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Délai (heures)</label>
<select
value={thresholds.feedAlertHours}
onChange={(e) => updateThreshold(parseInt(e.target.value))}
className={inputCls}
>
{[2, 2.5, 3, 3.5, 4, 5].map((h) => (
<option key={h} value={h}>{h}h</option>
))}
</select>
</div>
)}
</div>
)}
<div className="pt-2 border-t border-slate-100 dark:border-slate-700">
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">Pushover</p>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Votre clé utilisateur Pushover</label>
<div className="flex gap-2">
<input
type="password"
value={pushoverKey}
onChange={(e) => setPushoverKey(e.target.value)}
placeholder="u••••••••••••••••••••••••••••••"
className={inputCls}
/>
<button
type="button"
onClick={savePushoverKey}
disabled={savingPushover}
className="flex-shrink-0 px-3 py-2 bg-indigo-600 text-white text-sm rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 transition"
>
{savingPushover ? "..." : "Sauvegarder"}
</button>
</div>
<p className="mt-1.5 text-xs text-slate-400 dark:text-slate-500">Obtenez votre clé sur pushover.net</p>
{pushoverMsg && (
<p className={`mt-2 text-xs font-medium ${pushoverMsg.type === "ok" ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}`}>
{pushoverMsg.text}
</p>
)}
</div>
</div>
</div>
</Section>
{/* Export */}
{selectedBaby && (
<Section title="Exporter les données">
<div className="p-4 space-y-3">
<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">Depuis</label>
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} className={inputCls} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Jusqu&apos;au</label>
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} className={inputCls} />
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={handleExport}
disabled={exportLoading}
className="flex items-center gap-2 justify-center py-2.5 bg-slate-800 dark:bg-slate-700 text-white text-sm rounded-lg font-medium hover:bg-slate-900 dark:hover:bg-slate-600 disabled:opacity-50 transition"
>
<Download className="w-4 h-4" />
{exportLoading ? "..." : "CSV"}
</button>
<button
onClick={handlePdfExport}
disabled={pdfLoading}
className="flex items-center gap-2 justify-center py-2.5 bg-indigo-600 text-white text-sm rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 transition"
>
<FileText className="w-4 h-4" />
{pdfLoading ? "..." : "PDF"}
</button>
</div>
</div>
</Section>
)}
{/* Sign out */}
<button
onClick={() => signOut({ callbackUrl: "/auth/login" })}
className="flex items-center gap-2 w-full px-4 py-3 border border-red-200 dark:border-red-900 text-red-600 dark:text-red-400 rounded-xl text-sm font-medium hover:bg-red-50 dark:hover:bg-red-950 transition"
>
<LogOut className="w-4 h-4" />
Déconnexion
</button>
<p className="text-center text-xs text-slate-400 dark:text-slate-600">Grow · Suivi nouveau-</p>
</div>
</div>
);
}
+249
View File
@@ -0,0 +1,249 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { Leaf, Baby, Ruler } from "lucide-react";
const inputCls = "w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm";
const labelCls = "block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5";
export default function SetupPage() {
const router = useRouter();
const qc = useQueryClient();
const [step, setStep] = useState<"baby" | "measurements">("baby");
const [babyId, setBabyId] = useState("");
const [birthDate, setBirthDate] = useState("");
const [form, setForm] = useState({ name: "", birthDate: "", gender: "" });
const [measurements, setMeasurements] = useState({ weight: "", height: "", headCirc: "" });
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleBabySubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const res = await fetch("/api/baby", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!res.ok) {
const data = await res.json();
setError(data.error ?? "Erreur");
setLoading(false);
return;
}
const baby = await res.json();
setBabyId(baby.id);
setBirthDate(form.birthDate);
setLoading(false);
setStep("measurements");
}
async function handleMeasurementsSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const hasAny = measurements.weight || measurements.height || measurements.headCirc;
if (hasAny) {
await fetch("/api/growth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
babyId,
date: birthDate,
weight: measurements.weight ? parseFloat(measurements.weight) * 1000 : undefined,
height: measurements.height ? parseFloat(measurements.height) : undefined,
headCirc: measurements.headCirc ? parseFloat(measurements.headCirc) : undefined,
notes: "Mesures à la naissance",
}),
});
}
await qc.invalidateQueries({ queryKey: ["family"] });
router.push("/dashboard");
}
function skipMeasurements() {
qc.invalidateQueries({ queryKey: ["family"] });
router.push("/dashboard");
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-12 h-12 bg-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Leaf className="w-6 h-6 text-white" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Bienvenue !</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
{step === "baby" ? "Ajoutons votre bébé pour commencer" : "Mesures initiales (optionnel)"}
</p>
</div>
{/* Step indicator */}
<div className="flex items-center gap-2 justify-center mb-6">
<div className={`w-2 h-2 rounded-full transition-colors ${step === "baby" ? "bg-indigo-600" : "bg-indigo-600"}`} />
<div className={`w-8 h-0.5 transition-colors ${step === "measurements" ? "bg-indigo-600" : "bg-slate-200 dark:bg-slate-700"}`} />
<div className={`w-2 h-2 rounded-full transition-colors ${step === "measurements" ? "bg-indigo-600" : "bg-slate-200 dark:bg-slate-700"}`} />
</div>
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm p-8">
{step === "baby" && (
<>
<div className="flex items-center gap-3 mb-6">
<div className="w-9 h-9 bg-indigo-50 dark:bg-indigo-950 rounded-xl flex items-center justify-center">
<Baby className="w-5 h-5 text-indigo-600" />
</div>
<h2 className="text-base font-semibold text-slate-800 dark:text-slate-100">Informations bébé</h2>
</div>
<form onSubmit={handleBabySubmit} className="space-y-4">
<div>
<label className={labelCls}>Prénom de bébé</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
className={inputCls}
placeholder="Léo"
/>
</div>
<div>
<label className={labelCls}>Date de naissance</label>
<input
type="date"
value={form.birthDate}
onChange={(e) => setForm({ ...form, birthDate: e.target.value })}
required
className={inputCls}
/>
</div>
<div>
<label className={labelCls}>Genre</label>
<div className="grid grid-cols-3 gap-2">
{[
{ value: "boy", label: "Garçon" },
{ value: "girl", label: "Fille" },
{ value: "", label: "Non précisé" },
].map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => setForm({ ...form, gender: value })}
className={`py-2.5 px-3 rounded-xl border text-xs font-medium transition ${
form.gender === value
? "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-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-500"
}`}
>
{label}
</button>
))}
</div>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 px-4 py-3 rounded-xl text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 text-white font-semibold py-3 rounded-xl transition text-sm"
>
{loading ? "Enregistrement..." : "Continuer"}
</button>
</form>
</>
)}
{step === "measurements" && (
<>
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 bg-indigo-50 dark:bg-indigo-950 rounded-xl flex items-center justify-center">
<Ruler className="w-5 h-5 text-indigo-600" />
</div>
<h2 className="text-base font-semibold text-slate-800 dark:text-slate-100">Mesures à la naissance</h2>
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-6">
Ces données seront enregistrées comme premier point sur la courbe de croissance.
</p>
<form onSubmit={handleMeasurementsSubmit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<div>
<label className={labelCls}>Poids (kg)</label>
<input
type="number"
step="0.001"
min="0"
max="8"
value={measurements.weight}
onChange={(e) => setMeasurements({ ...measurements, weight: e.target.value })}
className={inputCls}
placeholder="3,400"
/>
</div>
<div>
<label className={labelCls}>Taille (cm)</label>
<input
type="number"
step="0.1"
min="0"
max="70"
value={measurements.height}
onChange={(e) => setMeasurements({ ...measurements, height: e.target.value })}
className={inputCls}
placeholder="50"
/>
</div>
<div>
<label className={labelCls}>PC (cm)</label>
<input
type="number"
step="0.1"
min="0"
max="50"
value={measurements.headCirc}
onChange={(e) => setMeasurements({ ...measurements, headCirc: e.target.value })}
className={inputCls}
placeholder="34"
/>
</div>
</div>
<p className="text-xs text-slate-400 dark:text-slate-500">
PC = périmètre crânien. Tous les champs sont optionnels.
</p>
<div className="flex gap-2">
<button
type="button"
onClick={skipMeasurements}
className="flex-1 py-3 border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-400 rounded-xl text-sm font-medium hover:bg-slate-50 dark:hover:bg-slate-700 transition"
>
Passer
</button>
<button
type="submit"
disabled={loading}
className="flex-1 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 text-white font-semibold py-3 rounded-xl transition text-sm"
>
{loading ? "..." : "Commencer le suivi"}
</button>
</div>
</form>
</>
)}
</div>
</div>
</div>
);
}
+303
View File
@@ -0,0 +1,303 @@
"use client";
import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
LineChart, Line, CartesianGrid,
} from "recharts";
import { useBaby } from "@/contexts/baby-context";
import { format, subDays, startOfDay, endOfDay } from "date-fns";
import { fr } from "date-fns/locale";
import { EventType } from "@/lib/event-config";
interface Event {
id: string;
type: EventType;
startedAt: string;
endedAt?: string;
}
function buildWeeklyData(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 start = startOfDay(d).getTime();
const end = endOfDay(d).getTime();
const dayEvents = events.filter((e) => {
const t = new Date(e.startedAt).getTime();
return t >= start && t <= end;
});
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 sleepMins = dayEvents
.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);
return { label: format(d, labelFormat, { locale: fr }), feeds, diapers, sleep: Math.round(sleepMins / 60 * 10) / 10 };
});
}
function formatDuration(totalMins: number): string {
const h = Math.floor(totalMins / 60);
const m = Math.round(totalMins % 60);
return `${h}h ${m.toString().padStart(2, "0")}min`;
}
interface SleepStats {
totalHours: number;
avgPerDay: number;
longestStretchMins: number;
}
function computeSleepStats(events: Event[], days: number): SleepStats {
const sleepEvents = events.filter(
(e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt
);
const durations = sleepEvents.map(
(e) => (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime()) / 60000
);
const totalMins = durations.reduce((acc, d) => acc + d, 0);
const longestStretchMins = durations.length > 0 ? Math.max(...durations) : 0;
return {
totalHours: Math.round((totalMins / 60) * 10) / 10,
avgPerDay: Math.round((totalMins / 60 / days) * 10) / 10,
longestStretchMins: Math.round(longestStretchMins),
};
}
interface FeedingStats {
breastfeedMinutes: number;
bottleCount: number;
}
function computeFeedingStats(events: Event[]): FeedingStats {
const breastfeedEvents = events.filter(
(e) => e.type === "BREASTFEED" && e.endedAt
);
const breastfeedMinutes = Math.round(
breastfeedEvents.reduce(
(acc, e) =>
acc + (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime()) / 60000,
0
)
);
const bottleCount = events.filter((e) => e.type === "BOTTLE").length;
return { breastfeedMinutes, bottleCount };
}
function computeFeedInterval(events: Event[]): string {
const feeds = events
.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE")
.map((e) => new Date(e.startedAt).getTime())
.sort((a, b) => a - b);
if (feeds.length < 2) return "—";
const intervals = feeds.slice(1).map((t, i) => t - feeds[i]);
const avgMs = intervals.reduce((a, b) => a + b, 0) / intervals.length;
const hours = Math.floor(avgMs / 3600000);
const mins = Math.round((avgMs % 3600000) / 60000);
return `${hours}h${mins.toString().padStart(2, "0")}`;
}
function useDarkMode() {
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();
}, []);
return isDark;
}
const DAY_OPTIONS = [7, 14, 30, 90] as const;
type DaysOption = typeof DAY_OPTIONS[number];
function daysLabel(days: DaysOption): string {
if (days === 7) return "7j";
if (days === 14) return "14j";
if (days === 30) return "30j";
return "90j";
}
function periodSubtitle(days: DaysOption): string {
if (days === 7) return "7 derniers jours";
if (days === 14) return "14 derniers jours";
if (days === 30) return "30 derniers jours";
return "90 derniers jours";
}
export default function StatsPage() {
const { selectedBaby: baby } = useBaby();
const isDark = useDarkMode();
const [days, setDays] = useState<DaysOption>(7);
const tooltipStyle = {
borderRadius: 8,
border: `1px solid ${isDark ? "#334155" : "#e2e8f0"}`,
background: isDark ? "#1e293b" : "#fff",
color: isDark ? "#f1f5f9" : "#0f172a",
boxShadow: "0 4px 16px rgba(0,0,0,0.12)",
fontSize: 12,
};
const cursorFill = isDark ? "rgba(255,255,255,0.04)" : "#f8fafc";
const { data: eventsData } = useQuery({
queryKey: ["events", baby?.id, "stats", days],
queryFn: () =>
fetch(`/api/events?babyId=${baby!.id}&limit=500&dateFrom=${subDays(new Date(), days - 1).toISOString()}`).then((r) => r.json()),
enabled: !!baby?.id,
});
const events: Event[] = eventsData?.events ?? [];
const weeklyData = buildWeeklyData(events, days);
const feedInterval = computeFeedInterval(events);
const sleepStats = computeSleepStats(events, days);
const feedingStats = computeFeedingStats(events);
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 todayDiapers = today.filter((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL").length;
const todaySleepMins = today
.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);
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
<div className="flex items-center justify-between mb-5">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Statistiques</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{periodSubtitle(days)}</p>
</div>
<div className="flex gap-1.5">
{DAY_OPTIONS.map((opt) => (
<button
key={opt}
onClick={() => setDays(opt)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition ${
days === opt
? "bg-indigo-600 text-white"
: "bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600"
}`}
>
{daysLabel(opt)}
</button>
))}
</div>
</div>
{/* Today summary */}
<div className="grid grid-cols-3 gap-3 mb-6">
{[
{ label: "Repas", value: todayFeeds, color: "text-rose-600", bg: "bg-rose-50 dark:bg-rose-950", border: "border-rose-100 dark:border-rose-900" },
{ label: "Couches", value: todayDiapers, color: "text-amber-600", bg: "bg-amber-50 dark:bg-amber-950", border: "border-amber-100 dark:border-amber-900" },
{ label: "Sommeil", value: `${Math.round(todaySleepMins / 60 * 10) / 10}h`, color: "text-violet-600", bg: "bg-violet-50 dark:bg-violet-950", border: "border-violet-100 dark:border-violet-900" },
].map(({ label, value, color, bg, border }) => (
<div key={label} className={`${bg} border ${border} rounded-xl p-4 text-center`}>
<div className={`text-2xl font-bold ${color}`}>{value}</div>
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">{label} · aujourd&apos;hui</div>
</div>
))}
</div>
{/* Feed interval */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">Intervalle moyen entre les repas</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{periodSubtitle(days)}</p>
</div>
<span className="text-2xl font-bold text-indigo-600 font-mono">{feedInterval}</span>
</div>
{/* Sleep summary */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 mb-3">Résumé du sommeil</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">{periodSubtitle(days)}</p>
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Total</p>
<p className="text-lg font-bold text-violet-600 leading-tight">
{formatDuration(sleepStats.totalHours * 60)}
</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Moy. / jour</p>
<p className="text-lg font-bold text-violet-600 leading-tight">
{formatDuration(sleepStats.avgPerDay * 60)}
</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Plus longue nuit</p>
<p className="text-lg font-bold text-violet-600 leading-tight">
{sleepStats.longestStretchMins > 0 ? formatDuration(sleepStats.longestStretchMins) : "—"}
</p>
</div>
</div>
</div>
{/* Feeding summary */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 mb-3">Résumé des repas</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">{periodSubtitle(days)}</p>
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Durée allaitement</p>
<p className="text-lg font-bold text-rose-600 leading-tight">
{feedingStats.breastfeedMinutes > 0 ? formatDuration(feedingStats.breastfeedMinutes) : "—"}
</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Biberons</p>
<p className="text-lg font-bold text-rose-600 leading-tight">{feedingStats.bottleCount}</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Intervalle moy.</p>
<p className="text-lg font-bold text-rose-600 leading-tight font-mono">{feedInterval}</p>
</div>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
{/* Feeds */}
<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-4">Repas par jour</h3>
<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} />
<Tooltip formatter={(val) => [val, "Repas"]} contentStyle={tooltipStyle} cursor={{ fill: cursorFill }} />
<Bar dataKey="feeds" fill="#4f46e5" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
{/* Diapers */}
<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-4">Couches par jour</h3>
<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} />
<Tooltip formatter={(val) => [val, "Couches"]} contentStyle={tooltipStyle} cursor={{ fill: cursorFill }} />
<Bar dataKey="diapers" fill="#f59e0b" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
{/* 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">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Sommeil par jour (heures)</h3>
<ResponsiveContainer width="100%" height={150}>
<LineChart data={weeklyData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<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}h`, "Sommeil"]} contentStyle={tooltipStyle} />
<Line dataKey="sleep" stroke="#7c3aed" strokeWidth={2} dot={{ r: 4, fill: "#7c3aed", strokeWidth: 2, stroke: "#fff" }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}
+257
View File
@@ -0,0 +1,257 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { EVENT_CONFIG, EventType, formatDuration } from "@/lib/event-config";
import { EventIcon } from "@/components/event-icon";
import { useBaby } from "@/contexts/baby-context";
import { format, isToday, isYesterday } from "date-fns";
import { fr } from "date-fns/locale";
import { Trash2, StickyNote } from "lucide-react";
interface Event {
id: string;
type: EventType;
startedAt: string;
endedAt?: string;
notes?: string;
metadata?: Record<string, unknown>;
user: { id: string; name: string };
}
const PAGE_SIZE = 50;
function groupByDay(events: Event[]): Record<string, Event[]> {
const groups: Record<string, Event[]> = {};
for (const ev of events) {
const key = format(new Date(ev.startedAt), "yyyy-MM-dd");
if (!groups[key]) groups[key] = [];
groups[key].push(ev);
}
return groups;
}
function dayLabel(key: string): string {
const d = new Date(key);
if (isToday(d)) return "Aujourd'hui";
if (isYesterday(d)) return "Hier";
return format(d, "EEEE d MMMM", { locale: fr });
}
function getEventSummary(event: Event): string {
const meta = event.metadata ?? {};
if (event.type === "BREASTFEED") {
const side = meta.breastSide === "left" ? "gauche" : meta.breastSide === "right" ? "droite" : "les deux";
if (event.endedAt) return `${formatDuration(new Date(event.startedAt), new Date(event.endedAt))} · ${side}`;
return side;
}
if (event.type === "BOTTLE") 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 === "TEMPERATURE") return meta.value ? `${meta.value}°${meta.unit ?? "C"}` : "";
if (event.type === "MEDICATION") return String(meta.name ?? "");
if ((event.type === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) {
return formatDuration(new Date(event.startedAt), new Date(event.endedAt));
}
return "";
}
export default function TimelinePage() {
const qc = useQueryClient();
const { selectedBaby } = useBaby();
const [expandedId, setExpandedId] = useState<string | null>(null);
const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null);
const [filterType, setFilterType] = useState<EventType | "">("");
const [page, setPage] = useState(0);
const { data: eventsData, isLoading } = useQuery({
queryKey: ["events", selectedBaby?.id, filterType, page],
queryFn: () =>
fetch(`/api/events?babyId=${selectedBaby!.id}&limit=${PAGE_SIZE}&offset=${page * PAGE_SIZE}${filterType ? `&type=${filterType}` : ""}`).then(
(r) => r.json()
),
enabled: !!selectedBaby?.id,
refetchInterval: 10_000,
});
const events: Event[] = eventsData?.events ?? [];
const total: number = eventsData?.total ?? 0;
const hasMore = (page + 1) * PAGE_SIZE < total;
const grouped = groupByDay(events);
const days = Object.keys(grouped).sort((a, b) => b.localeCompare(a));
async function deleteEvent(id: string) {
await fetch(`/api/events/${id}`, { method: "DELETE" });
qc.invalidateQueries({ queryKey: ["events"] });
}
async function saveNotes(id: string, notes: string) {
await fetch(`/api/events/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notes }),
});
qc.invalidateQueries({ queryKey: ["events"] });
setEditingNotes(null);
}
function handleFilterChange(t: EventType | "") {
setFilterType(t);
setPage(0);
}
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-4">
Journal{selectedBaby ? `${selectedBaby.name}` : ""}
</h1>
{/* Filter chips */}
<div className="flex gap-2 overflow-x-auto pb-1 no-scrollbar">
<button
onClick={() => handleFilterChange("")}
className={`flex-shrink-0 px-3 py-1.5 rounded-full text-xs font-medium transition border ${
filterType === "" ? "bg-indigo-600 text-white border-indigo-600" : "bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-slate-300 dark:hover:border-slate-500"
}`}
>
Tout {total > 0 && <span className="opacity-70 ml-0.5">({total})</span>}
</button>
{(Object.keys(EVENT_CONFIG) as EventType[]).map((t) => (
<button
key={t}
onClick={() => handleFilterChange(filterType === t ? "" : t)}
className={`flex-shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition border ${
filterType === t ? "bg-indigo-600 text-white border-indigo-600" : "bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-slate-300 dark:hover:border-slate-500"
}`}
>
<EventIcon name={EVENT_CONFIG[t].icon} className={`w-3 h-3 ${filterType === t ? "text-white" : EVENT_CONFIG[t].colorClass}`} />
{EVENT_CONFIG[t].label}
</button>
))}
</div>
</div>
{isLoading && (
<div className="flex justify-center py-16">
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
</div>
)}
<div className="space-y-6">
{days.map((day) => (
<div key={day}>
<div className="flex items-center gap-3 mb-3">
<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" />
<span className="text-xs text-slate-400 dark:text-slate-500">{grouped[day].length}</span>
</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">
{grouped[day].map((ev) => {
const cfg = EVENT_CONFIG[ev.type];
const isExpanded = expandedId === ev.id;
const summary = getEventSummary(ev);
return (
<div key={ev.id}>
<button
className="w-full text-left flex items-center gap-3 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition"
onClick={() => setExpandedId(isExpanded ? null : ev.id)}
>
<div className={`w-8 h-8 rounded-lg ${cfg.bgClass} flex items-center justify-center flex-shrink-0`}>
<EventIcon name={cfg.icon} className={`w-4 h-4 ${cfg.colorClass}`} />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{cfg.label}</span>
{summary && <span className="text-xs text-slate-400 dark:text-slate-500 ml-2">{summary}</span>}
{ev.notes && <p className="text-xs text-slate-400 dark:text-slate-500 truncate mt-0.5 italic">{ev.notes}</p>}
</div>
<span className="text-xs text-slate-400 dark:text-slate-500 flex-shrink-0 font-mono">
{format(new Date(ev.startedAt), "HH:mm")}
</span>
</button>
{isExpanded && (
<div className="px-4 pb-4 bg-slate-50 dark:bg-slate-800/60 border-t border-slate-100 dark:border-slate-700 space-y-3 pt-3">
<div className="grid grid-cols-3 gap-4 text-xs">
<div>
<p className="text-slate-400 dark:text-slate-500 mb-0.5">Début</p>
<p className="font-mono font-medium text-slate-700 dark:text-slate-200">{format(new Date(ev.startedAt), "HH:mm")}</p>
</div>
{ev.endedAt && (
<div>
<p className="text-slate-400 dark:text-slate-500 mb-0.5">Fin</p>
<p className="font-mono font-medium text-slate-700 dark:text-slate-200">{format(new Date(ev.endedAt), "HH:mm")}</p>
</div>
)}
<div>
<p className="text-slate-400 dark:text-slate-500 mb-0.5">Noté par</p>
<p className="font-medium text-slate-700 dark:text-slate-200">{ev.user.name}</p>
</div>
</div>
{editingNotes?.id === ev.id ? (
<div className="space-y-2">
<textarea
value={editingNotes.notes}
onChange={(e) => setEditingNotes({ id: ev.id, notes: e.target.value })}
rows={2}
className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 resize-none bg-white dark:bg-slate-700 dark:text-slate-100"
placeholder="Observations..."
autoFocus
/>
<div className="flex gap-2">
<button onClick={() => saveNotes(ev.id, editingNotes.notes)}
className="flex-1 py-1.5 bg-indigo-600 text-white text-xs rounded-lg font-medium">
Sauvegarder
</button>
<button onClick={() => setEditingNotes(null)}
className="py-1.5 px-3 border border-slate-200 dark:border-slate-600 text-xs rounded-lg text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-700">
Annuler
</button>
</div>
</div>
) : (
<button
onClick={() => setEditingNotes({ id: ev.id, notes: ev.notes ?? "" })}
className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition"
>
<StickyNote className="w-3.5 h-3.5" />
<span>{ev.notes ? ev.notes : "Ajouter une note..."}</span>
</button>
)}
<button
onClick={() => { if (confirm("Supprimer cet événement ?")) deleteEvent(ev.id); }}
className="flex items-center gap-1.5 text-xs text-red-400 hover:text-red-600 transition"
>
<Trash2 className="w-3.5 h-3.5" />
Supprimer
</button>
</div>
)}
</div>
);
})}
</div>
</div>
))}
{hasMore && (
<button
onClick={() => setPage((p) => p + 1)}
className="w-full py-2.5 border border-slate-200 dark:border-slate-700 rounded-xl text-sm text-slate-600 dark:text-slate-300 hover:border-indigo-300 dark:hover:border-indigo-500 hover:text-indigo-600 dark:hover:text-indigo-400 bg-white dark:bg-slate-800 transition"
>
Charger plus · {total - (page + 1) * PAGE_SIZE} restants
</button>
)}
{!isLoading && events.length === 0 && (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
<p className="text-sm">Aucun événement enregistré</p>
</div>
)}
</div>
</div>
);
}
+518
View File
@@ -0,0 +1,518 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { useBaby } from "@/contexts/baby-context";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Plus, Pencil, Trash2, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle } from "lucide-react";
interface Vaccination {
id: string;
babyId: string;
name: string;
date: string | null;
dueDate: string | null;
notes: string | null;
done: boolean;
createdAt: string;
}
const PRESET_VACCINES = [
"BCG",
"Hépatite B",
"DTPolio",
"Pentavalent",
"Pneumocoque",
"Méningocoque C",
"ROR",
"Varicelle",
"Hépatite A",
];
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 emptyForm = { name: "", dueDate: "", date: "", notes: "" };
function formatDate(dateStr: string) {
return format(new Date(dateStr), "d MMM yyyy", { locale: fr });
}
function isOverdue(v: Vaccination): boolean {
if (v.done || !v.dueDate) return false;
return new Date(v.dueDate) < new Date(new Date().toDateString());
}
export default function VaccinationsPage() {
const qc = useQueryClient();
const { selectedBaby: baby } = useBaby();
const { data: session } = useSession();
const isReadonly = (session?.user as { role?: string } | undefined)?.role === "READONLY";
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState(emptyForm);
const [editSaving, setEditSaving] = useState(false);
const [showDone, setShowDone] = useState(false);
const [togglingId, setTogglingId] = useState<string | null>(null);
const { data: vaccinations = [] } = useQuery<Vaccination[]>({
queryKey: ["vaccinations", baby?.id],
queryFn: () =>
fetch(`/api/vaccinations?babyId=${baby!.id}`).then((r) => r.json()),
enabled: !!baby?.id,
});
const upcoming = vaccinations.filter((v) => !v.done);
const done = vaccinations.filter((v) => v.done);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!baby) return;
setSaving(true);
await fetch("/api/vaccinations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
babyId: baby.id,
name: form.name,
dueDate: form.dueDate ? new Date(form.dueDate).toISOString() : null,
date: form.date ? new Date(form.date).toISOString() : null,
notes: form.notes || null,
}),
});
qc.invalidateQueries({ queryKey: ["vaccinations", baby.id] });
setSaving(false);
setShowForm(false);
setForm(emptyForm);
}
function startEdit(v: Vaccination) {
setEditingId(v.id);
setEditForm({
name: v.name,
dueDate: v.dueDate ? format(new Date(v.dueDate), "yyyy-MM-dd") : "",
date: v.date ? format(new Date(v.date), "yyyy-MM-dd") : "",
notes: v.notes ?? "",
});
}
async function handleEditSave(e: React.FormEvent, id: string) {
e.preventDefault();
if (!baby) return;
setEditSaving(true);
await fetch(`/api/vaccinations/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: editForm.name,
dueDate: editForm.dueDate ? new Date(editForm.dueDate).toISOString() : null,
date: editForm.date ? new Date(editForm.date).toISOString() : null,
notes: editForm.notes || null,
}),
});
qc.invalidateQueries({ queryKey: ["vaccinations", baby.id] });
setEditSaving(false);
setEditingId(null);
}
async function handleDelete(id: string) {
if (!baby) return;
if (!confirm("Supprimer ce vaccin ?")) return;
await fetch(`/api/vaccinations/${id}`, { method: "DELETE" });
qc.invalidateQueries({ queryKey: ["vaccinations", baby.id] });
}
async function handleToggleDone(v: Vaccination) {
if (!baby || isReadonly) return;
setTogglingId(v.id);
const nowDone = !v.done;
await fetch(`/api/vaccinations/${v.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
done: nowDone,
date: nowDone && !v.date ? new Date().toISOString() : v.date ?? undefined,
}),
});
qc.invalidateQueries({ queryKey: ["vaccinations", baby.id] });
setTogglingId(null);
}
function VaccinCard({ v }: { v: Vaccination }) {
const overdue = isOverdue(v);
const toggling = togglingId === v.id;
if (editingId === v.id) {
return (
<div className="bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
<form onSubmit={(e) => handleEditSave(e, v.id)} className="space-y-3">
{/* Preset chips */}
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-2">
Vaccin
</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{PRESET_VACCINES.map((preset) => (
<button
key={preset}
type="button"
onClick={() => setEditForm((f) => ({ ...f, name: preset }))}
className={`px-2.5 py-1 rounded-full text-xs font-medium border transition ${
editForm.name === preset
? "bg-indigo-600 text-white border-indigo-600"
: "border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-indigo-400 dark:hover:border-indigo-500"
}`}
>
{preset}
</button>
))}
</div>
<input
type="text"
value={editForm.name}
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
required
placeholder="Nom du vaccin"
className={inputCls}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Date prévue
</label>
<input
type="date"
value={editForm.dueDate}
onChange={(e) => setEditForm((f) => ({ ...f, dueDate: e.target.value }))}
className={inputCls}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Date d&apos;administration
</label>
<input
type="date"
value={editForm.date}
onChange={(e) => setEditForm((f) => ({ ...f, date: e.target.value }))}
className={inputCls}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Notes <span className="text-slate-400 font-normal">(optionnel)</span>
</label>
<textarea
value={editForm.notes}
onChange={(e) => setEditForm((f) => ({ ...f, notes: e.target.value }))}
rows={2}
className={inputCls + " resize-none"}
/>
</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-300 hover:bg-slate-100 dark:hover:bg-slate-700 transition"
>
Annuler
</button>
<button
type="submit"
disabled={editSaving}
className="flex-1 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition disabled:opacity-60"
>
{editSaving ? "..." : "Enregistrer"}
</button>
</div>
</form>
</div>
);
}
return (
<div
className={`bg-white dark:bg-slate-800 border rounded-xl px-4 py-3.5 flex items-start gap-3 group transition ${
overdue
? "border-red-200 dark:border-red-800"
: "border-slate-200 dark:border-slate-700"
}`}
>
{/* Done toggle */}
{!isReadonly ? (
<button
onClick={() => handleToggleDone(v)}
disabled={toggling}
className="mt-0.5 flex-shrink-0 text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 disabled:opacity-50 transition"
aria-label={v.done ? "Marquer non fait" : "Marquer fait"}
>
{v.done ? (
<CheckSquare className="w-5 h-5" />
) : (
<Square className="w-5 h-5" />
)}
</button>
) : (
<span className="mt-0.5 flex-shrink-0 text-slate-300 dark:text-slate-600">
{v.done ? (
<CheckSquare className="w-5 h-5" />
) : (
<Square className="w-5 h-5" />
)}
</span>
)}
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{v.name}
</p>
{overdue && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400">
<AlertCircle className="w-3 h-3" />
En retard
</span>
)}
</div>
{v.dueDate && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Prévu le{" "}
<span className={overdue ? "text-red-500 dark:text-red-400 font-medium" : ""}>
{formatDate(v.dueDate)}
</span>
</p>
)}
{v.date && (
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-0.5">
Administré le {formatDate(v.date)}
</p>
)}
{v.notes && (
<p className="mt-1.5 text-sm text-slate-500 dark:text-slate-400 leading-relaxed">
{v.notes}
</p>
)}
</div>
{/* Actions */}
{!isReadonly && (
<div className="flex gap-1 flex-shrink-0 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<button
onClick={() => startEdit(v)}
className="p-1.5 rounded-md text-slate-400 dark:text-slate-500 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-950 transition"
aria-label="Modifier"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDelete(v.id)}
className="p-1.5 rounded-md text-slate-400 dark:text-slate-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 transition"
aria-label="Supprimer"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
);
}
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Vaccinations
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
{baby?.name}
</p>
</div>
{!isReadonly && (
<button
onClick={() => {
setShowForm(!showForm);
setForm(emptyForm);
}}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition"
>
<Plus className="w-4 h-4" />
Ajouter un vaccin
</button>
)}
</div>
{/* Add form */}
{showForm && !isReadonly && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 mb-6">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">
Nouveau vaccin
</h3>
<form onSubmit={handleSave} className="space-y-4">
{/* Preset chips */}
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-2">
Vaccin <span className="text-red-400">*</span>
</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{PRESET_VACCINES.map((preset) => (
<button
key={preset}
type="button"
onClick={() => setForm((f) => ({ ...f, name: preset }))}
className={`px-2.5 py-1 rounded-full text-xs font-medium border transition ${
form.name === preset
? "bg-indigo-600 text-white border-indigo-600"
: "border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-indigo-400 dark:hover:border-indigo-500"
}`}
>
{preset}
</button>
))}
</div>
<input
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
placeholder="Ou saisir un nom personnalisé"
className={inputCls}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Date prévue
</label>
<input
type="date"
value={form.dueDate}
onChange={(e) => setForm((f) => ({ ...f, dueDate: e.target.value }))}
className={inputCls}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Date d&apos;administration
</label>
<input
type="date"
value={form.date}
onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))}
className={inputCls}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Notes <span className="text-slate-400 font-normal">(optionnel)</span>
</label>
<textarea
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
rows={3}
placeholder="Réactions, lot, remarques..."
className={inputCls + " resize-none"}
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowForm(false)}
className="flex-1 py-2.5 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition"
>
Annuler
</button>
<button
type="submit"
disabled={saving}
className="flex-1 py-2.5 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition disabled:opacity-60"
>
{saving ? "Enregistrement..." : "Enregistrer"}
</button>
</div>
</form>
</div>
)}
{/* Empty state */}
{vaccinations.length === 0 && !showForm && (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
<p className="text-4xl mb-3">💉</p>
<p className="text-sm">Aucun vaccin enregistré</p>
{!isReadonly && (
<button
onClick={() => setShowForm(true)}
className="mt-3 text-indigo-600 font-medium text-sm hover:underline"
>
Ajouter le premier vaccin
</button>
)}
</div>
)}
{/* À venir section */}
{upcoming.length > 0 && (
<div className="mb-6">
<h2 className="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">
À venir
<span className="ml-2 text-slate-300 dark:text-slate-600 font-normal normal-case tracking-normal">
({upcoming.length})
</span>
</h2>
<div className="space-y-2">
{upcoming.map((v) => (
<VaccinCard key={v.id} v={v} />
))}
</div>
</div>
)}
{/* Faits section */}
{done.length > 0 && (
<div>
<button
onClick={() => setShowDone((s) => !s)}
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3 hover:text-slate-600 dark:hover:text-slate-300 transition"
>
{showDone ? (
<ChevronUp className="w-3.5 h-3.5" />
) : (
<ChevronDown className="w-3.5 h-3.5" />
)}
Faits
<span className="font-normal normal-case tracking-normal text-slate-300 dark:text-slate-600">
({done.length})
</span>
</button>
{showDone && (
<div className="space-y-2">
{done.map((v) => (
<VaccinCard key={v.id} v={v} />
))}
</div>
)}
</div>
)}
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function GET() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const [families, babies, events, growthLogs, journalNotes, milestones] = await Promise.all([
prisma.family.findMany({
include: { users: { select: { id: true, name: true, email: true, role: true, isSuperAdmin: true, createdAt: true } } },
}),
prisma.baby.findMany(),
prisma.event.findMany({ orderBy: { createdAt: "desc" }, take: 10000 }),
prisma.growthLog.findMany(),
prisma.journalNote.findMany(),
prisma.milestone.findMany(),
]);
const payload = {
exportedAt: new Date().toISOString(),
families,
babies,
events,
growthLogs,
journalNotes,
milestones,
};
const filename = `grow-backup-${new Date().toISOString().slice(0, 10)}.json`;
return new NextResponse(JSON.stringify(payload, null, 2), {
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
+60
View File
@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
const ALLOWED_KEYS = [
"email_provider",
"mailgun_api_key",
"mailgun_domain",
"mailgun_from",
"smtp_host",
"smtp_port",
"smtp_user",
"smtp_pass",
"smtp_from",
"pushover_app_token",
"allow_registration",
"maintenance_mode",
];
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function GET() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const configs = await prisma.systemConfig.findMany();
const result: Record<string, string> = {};
for (const c of configs) result[c.key] = c.value;
return NextResponse.json(result);
}
export async function POST(req: Request) {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const body: Record<string, string> = await req.json();
await Promise.all(
Object.entries(body)
.filter(([key]) => ALLOWED_KEYS.includes(key))
.map(([key, value]) =>
prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, value },
})
)
);
return NextResponse.json({ ok: true });
}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function GET() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const families = await prisma.family.findMany({
select: {
id: true,
name: true,
inviteCode: true,
createdAt: true,
_count: {
select: {
users: true,
babies: true,
},
},
},
orderBy: { createdAt: "asc" },
});
return NextResponse.json(families);
}
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
function generateInviteCode(): string {
return crypto.randomUUID().replace(/-/g, "").slice(0, 25);
}
export async function POST(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const { id } = await params;
const inviteCode = generateInviteCode();
await prisma.family.update({
where: { id },
data: { inviteCode },
});
return NextResponse.json({ inviteCode });
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function GET() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const [userCount, familyCount, babyCount, eventCount, growthLogCount] = await Promise.all([
prisma.user.count(),
prisma.family.count(),
prisma.baby.count(),
prisma.event.count(),
prisma.growthLog.count(),
]);
return NextResponse.json({ userCount, familyCount, babyCount, eventCount, growthLogCount });
}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { sendEmail } from "@/lib/email";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function POST() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
if (!session.user?.email) {
return NextResponse.json({ error: "Email de l'administrateur introuvable" }, { status: 400 });
}
try {
await sendEmail({
to: session.user!.email as string,
subject: "Test email — Grow",
html: "<p>La configuration email fonctionne correctement.</p>",
text: "La configuration email fonctionne correctement.",
});
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
return NextResponse.json({ error: message }, { status: 502 });
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user!.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function POST() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const [appTokenRow, user] = await Promise.all([
prisma.systemConfig.findUnique({ where: { key: "pushover_app_token" } }),
prisma.user.findUnique({ where: { id: session.user!.id }, select: { pushoverUserKey: true } }),
]);
const appToken = appTokenRow?.value ?? process.env.PUSHOVER_APP_TOKEN ?? "";
const userKey = user?.pushoverUserKey ?? "";
if (!appToken) {
return NextResponse.json({ error: "Token application Pushover non configuré" }, { status: 400 });
}
if (!userKey) {
return NextResponse.json({ error: "Clé utilisateur Pushover non configurée" }, { status: 400 });
}
const formData = new FormData();
formData.append("token", appToken);
formData.append("user", userKey);
formData.append("title", "Grow — Test de notification");
formData.append("message", "La configuration Pushover fonctionne correctement.");
const res = await fetch("https://api.pushover.net/1/messages.json", {
method: "POST",
body: formData,
});
if (!res.ok) {
const text = await res.text();
return NextResponse.json({ error: `Erreur Pushover : ${text}` }, { status: 502 });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function POST(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const { id } = await params;
if (id === session.user?.id) {
return NextResponse.json({ error: "Impossible de vous déconnecter vous-même" }, { status: 400 });
}
await prisma.user.update({
where: { id },
data: { forcedLogoutAt: new Date() },
});
return NextResponse.json({ ok: true });
}
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
const VALID_ROLES = ["PARENT", "READONLY", "DOCTOR"] as const;
type ValidRole = typeof VALID_ROLES[number];
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const { id } = await params;
const body: { role?: string } = await req.json();
if (!body.role || !(VALID_ROLES as readonly string[]).includes(body.role)) {
return NextResponse.json({ error: "Rôle invalide" }, { status: 400 });
}
const updated = await prisma.user.update({
where: { id },
data: { role: body.role as ValidRole },
select: { id: true, name: true, email: true, role: true },
});
return NextResponse.json(updated);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const { id } = await params;
if (id === session.user?.id) {
return NextResponse.json({ error: "Impossible de supprimer votre propre compte" }, { status: 400 });
}
await prisma.user.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function GET() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
role: true,
isSuperAdmin: true,
familyId: true,
family: { select: { name: true } },
createdAt: true,
},
orderBy: { createdAt: "asc" },
});
return NextResponse.json(users);
}
+3
View File
@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { sendEmail, resetPasswordEmail } from "@/lib/email";
export async function POST(req: Request) {
const { email } = await req.json();
if (!email) return NextResponse.json({ error: "Email requis" }, { status: 400 });
const user = await prisma.user.findUnique({ where: { email } });
// Always return success to prevent user enumeration
if (!user) return NextResponse.json({ ok: true });
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
const reset = await prisma.passwordResetToken.create({
data: { userId: user.id, expiresAt },
});
const origin = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const resetUrl = `${origin}/auth/reset-password?token=${reset.token}`;
try {
const { html, text } = resetPasswordEmail(user.name, resetUrl);
await sendEmail({ to: user.email, subject: "Réinitialisation de votre mot de passe — Grow", html, text });
} catch (err) {
console.error("Email send failed:", err);
// Still succeed silently to avoid leaking info
}
return NextResponse.json({ ok: true });
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export async function POST(req: Request) {
const { token, password } = await req.json();
if (!token || !password || password.length < 8) {
return NextResponse.json({ error: "Données invalides" }, { status: 400 });
}
const reset = await prisma.passwordResetToken.findUnique({ where: { token } });
if (!reset || reset.used || reset.expiresAt < new Date()) {
return NextResponse.json({ error: "Lien expiré ou invalide" }, { status: 400 });
}
const hashed = await bcrypt.hash(password, 12);
await Promise.all([
prisma.user.update({ where: { id: reset.userId }, data: { password: hashed } }),
prisma.passwordResetToken.update({ where: { id: reset.id }, data: { used: true } }),
]);
return NextResponse.json({ ok: true });
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { id } = await params;
const { name, birthDate, gender } = await req.json();
const baby = await prisma.baby.update({
where: { id },
data: {
...(name ? { name } : {}),
...(birthDate ? { birthDate: new Date(birthDate) } : {}),
...(gender !== undefined ? { gender } : {}),
},
});
return NextResponse.json(baby);
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET() {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: {
family: {
include: {
babies: true,
users: { select: { id: true, name: true, email: true, role: true } },
},
},
},
});
return NextResponse.json(user?.family ?? null);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { name, birthDate, gender } = await req.json();
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.familyId) return NextResponse.json({ error: "Aucune famille" }, { status: 400 });
const baby = await prisma.baby.create({
data: { name, birthDate: new Date(birthDate), gender, familyId: user.familyId },
});
return NextResponse.json(baby);
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const userRole = (session.user as { role?: string }).role;
if (userRole !== "DOCTOR") {
return NextResponse.json({ error: "Réservé aux médecins" }, { status: 403 });
}
const { id } = await params;
const note = await prisma.doctorNote.findUnique({ where: { id } });
if (!note) {
return NextResponse.json({ error: "Observation introuvable" }, { status: 404 });
}
if (note.userId !== session.user.id) {
return NextResponse.json({ error: "Vous ne pouvez supprimer que vos propres observations" }, { status: 403 });
}
await prisma.doctorNote.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+53
View File
@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const notes = await prisma.doctorNote.findMany({
where: { babyId },
orderBy: { date: "desc" },
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(notes);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const userRole = (session.user as { role?: string }).role;
if (userRole !== "DOCTOR") {
return NextResponse.json({ error: "Réservé aux médecins" }, { status: 403 });
}
const body = await req.json();
const { babyId, content, date } = body;
if (!babyId || !content || !date) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const noteDate = new Date(date);
noteDate.setHours(12, 0, 0, 0);
const note = await prisma.doctorNote.create({
data: {
babyId,
userId: session.user.id,
content,
date: noteDate,
},
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(note, { status: 201 });
}
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { id } = await params;
const body = await req.json();
const event = await prisma.event.update({
where: { id },
data: {
...(body.notes !== undefined ? { notes: body.notes } : {}),
...(body.startedAt ? { startedAt: new Date(body.startedAt) } : {}),
...(body.endedAt !== undefined
? { endedAt: body.endedAt ? new Date(body.endedAt) : null }
: {}),
...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
},
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(event);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { id } = await params;
await prisma.event.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+67
View File
@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
const limit = parseInt(searchParams.get("limit") ?? "50");
const offset = parseInt(searchParams.get("offset") ?? "0");
const type = searchParams.get("type");
const dateFrom = searchParams.get("dateFrom");
const dateTo = searchParams.get("dateTo");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const where: Record<string, unknown> = { babyId };
if (type) where.type = type;
if (dateFrom || dateTo) {
where.startedAt = {
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
...(dateTo ? { lte: new Date(dateTo) } : {}),
};
}
const [events, total] = await Promise.all([
prisma.event.findMany({
where,
orderBy: { startedAt: "desc" },
take: limit,
skip: offset,
include: { user: { select: { id: true, name: true } } },
}),
prisma.event.count({ where }),
]);
return NextResponse.json({ events, total });
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const body = await req.json();
const { babyId, type, startedAt, endedAt, notes, metadata } = body;
if (!babyId || !type || !startedAt) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const event = await prisma.event.create({
data: {
babyId,
userId: session.user.id,
type,
startedAt: new Date(startedAt),
endedAt: endedAt ? new Date(endedAt) : null,
notes: notes ?? null,
metadata: metadata ?? null,
},
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(event, { status: 201 });
}
+71
View File
@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { EVENT_CONFIG, EventType } from "@/lib/event-config";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
const dateFrom = searchParams.get("dateFrom");
const dateTo = searchParams.get("dateTo");
const outputFormat = searchParams.get("format");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const where: Record<string, unknown> = { babyId };
if (dateFrom || dateTo) {
where.startedAt = {
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
...(dateTo ? { lte: new Date(dateTo) } : {}),
};
}
const events = await prisma.event.findMany({
where,
orderBy: { startedAt: "asc" },
include: { user: { select: { name: true } } },
});
if (outputFormat === "json") {
return NextResponse.json(events.map((e) => ({
type: e.type,
startedAt: e.startedAt,
endedAt: e.endedAt,
notes: e.notes,
metadata: e.metadata,
user: e.user.name,
})));
}
const rows = [
["Date", "Heure", "Type", "Durée (min)", "Noté par", "Notes", "Détails"].join(","),
...events.map((e) => {
const duration =
e.endedAt
? Math.round((e.endedAt.getTime() - e.startedAt.getTime()) / 60000)
: "";
const details = e.metadata ? JSON.stringify(e.metadata).replace(/"/g, "'") : "";
return [
format(e.startedAt, "dd/MM/yyyy", { locale: fr }),
format(e.startedAt, "HH:mm"),
EVENT_CONFIG[e.type as EventType]?.label ?? e.type,
duration,
e.user.name,
`"${(e.notes ?? "").replace(/"/g, "'")}"`,
`"${details}"`,
].join(",");
}),
].join("\n");
return new NextResponse(rows, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="grow-export-${format(new Date(), "yyyy-MM-dd")}.csv"`,
},
});
}
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const role = (session.user as { role?: string }).role;
if (role === "READONLY") return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
const { id } = await params;
const { date, weight, height, headCirc, notes } = await req.json();
const log = await prisma.growthLog.update({
where: { id },
data: {
...(date ? { date: new Date(date) } : {}),
...(weight !== undefined ? { weight: weight ? weight * 1000 : null } : {}),
...(height !== undefined ? { height: height ?? null } : {}),
...(headCirc !== undefined ? { headCirc: headCirc ?? null } : {}),
...(notes !== undefined ? { notes: notes || null } : {}),
},
});
return NextResponse.json(log);
}
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const role = (session.user as { role?: string }).role;
if (role === "READONLY") return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
const { id } = await params;
await prisma.growthLog.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const logs = await prisma.growthLog.findMany({
where: { babyId },
orderBy: { date: "asc" },
});
return NextResponse.json(logs);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { babyId, date, weight, height, headCirc, notes } = await req.json();
if (!babyId || !date) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const log = await prisma.growthLog.create({
data: {
babyId,
date: new Date(date),
weight: weight ?? null,
height: height ?? null,
headCirc: headCirc ?? null,
notes: notes ?? null,
},
});
return NextResponse.json(log, { status: 201 });
}
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
const VALID_ROLES = ["PARENT", "READONLY", "DOCTOR"] as const;
type Role = (typeof VALID_ROLES)[number];
export async function POST(req: Request) {
const { name, email, password, inviteCode, role } = await req.json();
if (!name || !email || !password || !inviteCode) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const assignedRole: Role = VALID_ROLES.includes(role) ? role : "PARENT";
const family = await prisma.family.findUnique({ where: { inviteCode } });
if (!family) {
return NextResponse.json({ error: "Code d'invitation invalide" }, { status: 404 });
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json({ error: "Email déjà utilisé" }, { status: 409 });
}
const hashed = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { name, email, password: hashed, familyId: family.id, role: assignedRole },
});
return NextResponse.json({ id: user.id, email: user.email, name: user.name, role: user.role });
}
+61
View File
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { id } = await params;
const body = await req.json();
if (body.content === undefined) {
return NextResponse.json({ error: "content requis" }, { status: 400 });
}
const existing = await prisma.journalNote.findUnique({ where: { id } });
if (!existing) return NextResponse.json({ error: "Note introuvable" }, { status: 404 });
const userRole = (session.user as { role?: string }).role;
const isOwner = existing.userId === session.user.id;
const isParent = userRole === "PARENT";
if (!isOwner && !isParent) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const note = await prisma.journalNote.update({
where: { id },
data: { content: body.content },
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(note);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { id } = await params;
const existing = await prisma.journalNote.findUnique({ where: { id } });
if (!existing) return NextResponse.json({ error: "Note introuvable" }, { status: 404 });
const userRole = (session.user as { role?: string }).role;
const isOwner = existing.userId === session.user.id;
const isParent = userRole === "PARENT";
if (!isOwner && !isParent) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
await prisma.journalNote.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+64
View File
@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
const date = searchParams.get("date");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const where: Record<string, unknown> = { babyId };
if (date) {
const start = new Date(date);
start.setHours(0, 0, 0, 0);
const end = new Date(date);
end.setHours(23, 59, 59, 999);
where.date = { gte: start, lte: end };
}
const notes = await prisma.journalNote.findMany({
where,
orderBy: { date: "desc" },
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(notes);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const userRole = (session.user as { role?: string }).role;
if (userRole === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const body = await req.json();
const { babyId, date, content } = body;
if (!babyId || !date || !content) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const noteDate = new Date(date);
noteDate.setHours(12, 0, 0, 0);
const note = await prisma.journalNote.create({
data: {
babyId,
userId: session.user.id,
date: noteDate,
content,
},
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(note, { status: 201 });
}
+48
View File
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const role = (session.user as { role?: string }).role;
if (role === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const { id } = await params;
const body = await req.json();
const milestone = await prisma.milestone.update({
where: { id },
data: {
...(body.title !== undefined ? { title: body.title } : {}),
...(body.date !== undefined ? { date: new Date(body.date) } : {}),
...(body.notes !== undefined ? { notes: body.notes } : {}),
...(body.icon !== undefined ? { icon: body.icon } : {}),
},
});
return NextResponse.json(milestone);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const role = (session.user as { role?: string }).role;
if (role === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const { id } = await params;
await prisma.milestone.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const milestones = await prisma.milestone.findMany({
where: { babyId },
orderBy: { date: "desc" },
});
return NextResponse.json(milestones);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const role = (session.user as { role?: string }).role;
if (role === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const body = await req.json();
const { babyId, title, date, notes, icon } = body;
if (!babyId || !title || !date) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const milestone = await prisma.milestone.create({
data: {
babyId,
title,
date: new Date(date),
notes: notes ?? null,
icon: icon ?? null,
},
});
return NextResponse.json(milestone, { status: 201 });
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { notifyFamilyFeedAlert } from "@/lib/push-notify";
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { babyId, thresholdHours } = await req.json();
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const baby = await prisma.baby.findUnique({
where: { id: babyId },
select: { name: true, familyId: true },
});
if (!baby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 });
const lastFeed = await prisma.event.findFirst({
where: { babyId, type: { in: ["BREASTFEED", "BOTTLE"] } },
orderBy: { startedAt: "desc" },
});
if (!lastFeed) {
return NextResponse.json({ sent: false, reason: "no_feed_data" });
}
const hoursAgo = (Date.now() - lastFeed.startedAt.getTime()) / 3600000;
const threshold = thresholdHours ?? 3;
if (hoursAgo < threshold) {
return NextResponse.json({ sent: false, reason: "threshold_not_reached", hoursAgo });
}
const hStr = Math.floor(hoursAgo);
const mStr = Math.round((hoursAgo % 1) * 60);
const message = `${baby.name} n'a pas mangé depuis ${hStr}h${mStr.toString().padStart(2, "0")} — pensez à la prochaine tétée.`;
try {
await notifyFamilyFeedAlert(baby.familyId, message);
return NextResponse.json({ sent: true, hoursAgo });
} catch (err) {
return NextResponse.json({ sent: false, error: String(err) }, { status: 500 });
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
export async function POST(req: Request) {
const { name, email, password } = await req.json();
if (!name || !email || !password) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
// Check if registration is disabled (superadmin can always register)
const isSuperAdminEmail = !!process.env.SUPERADMIN_EMAIL && email === process.env.SUPERADMIN_EMAIL;
if (!isSuperAdminEmail) {
const regConfig = await prisma.systemConfig.findUnique({ where: { key: "allow_registration" } });
if (regConfig && regConfig.value === "false") {
return NextResponse.json({ error: "Les inscriptions sont désactivées." }, { status: 403 });
}
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json({ error: "Email déjà utilisé" }, { status: 409 });
}
const hashed = await bcrypt.hash(password, 12);
const family = await prisma.family.create({
data: { name: `Famille de ${name}` },
});
const isSuperAdmin = !!process.env.SUPERADMIN_EMAIL && email === process.env.SUPERADMIN_EMAIL;
const user = await prisma.user.create({
data: { name, email, password: hashed, familyId: family.id, isSuperAdmin },
});
return NextResponse.json({ id: user.id, email: user.email, name: user.name });
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET() {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { pushoverUserKey: true } });
return NextResponse.json({ pushoverUserKey: user?.pushoverUserKey ?? "" });
}
export async function PATCH(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { pushoverUserKey } = await req.json();
await prisma.user.update({ where: { id: session.user.id }, data: { pushoverUserKey: pushoverUserKey || null } });
return NextResponse.json({ ok: true });
}
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const role = (session.user as { role?: string }).role;
if (role === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const { id } = await params;
const body = await req.json();
const vaccination = await prisma.vaccination.update({
where: { id },
data: {
...(body.name !== undefined ? { name: body.name } : {}),
...(body.date !== undefined
? { date: body.date ? new Date(body.date) : null }
: {}),
...(body.dueDate !== undefined
? { dueDate: body.dueDate ? new Date(body.dueDate) : null }
: {}),
...(body.notes !== undefined ? { notes: body.notes } : {}),
...(body.done !== undefined ? { done: body.done } : {}),
},
});
return NextResponse.json(vaccination);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const role = (session.user as { role?: string }).role;
if (role === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const { id } = await params;
await prisma.vaccination.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
if (!babyId)
return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const vaccinations = await prisma.vaccination.findMany({
where: { babyId },
orderBy: [{ dueDate: "asc" }, { date: "asc" }],
});
// Move nulls last for dueDate
const withDue = vaccinations.filter((v) => v.dueDate !== null);
const withoutDue = vaccinations.filter((v) => v.dueDate === null);
return NextResponse.json([...withDue, ...withoutDue]);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const role = (session.user as { role?: string }).role;
if (role === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const body = await req.json();
const { babyId, name, date, dueDate, notes } = body;
if (!babyId || !name) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const vaccination = await prisma.vaccination.create({
data: {
babyId,
name,
date: date ? new Date(date) : null,
dueDate: dueDate ? new Date(dueDate) : null,
notes: notes ?? null,
},
});
return NextResponse.json(vaccination, { status: 201 });
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Leaf } from "lucide-react";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
await fetch("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
setSent(true);
setLoading(false);
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-12 h-12 bg-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Leaf className="w-6 h-6 text-white" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Grow</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">Réinitialisation du mot de passe</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm p-8">
{sent ? (
<div className="text-center space-y-4">
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center mx-auto">
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-slate-700 dark:text-slate-300 text-sm">
Si un compte existe pour cet email, vous recevrez un lien de réinitialisation dans quelques minutes.
</p>
<Link href="/auth/login" className="inline-block text-indigo-600 dark:text-indigo-400 font-medium hover:underline text-sm">
Retour à la connexion
</Link>
</div>
) : (
<>
<p className="text-slate-500 dark:text-slate-400 text-sm mb-6">
Entrez votre adresse email et nous vous enverrons un lien pour réinitialiser votre mot de passe.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
className="w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm"
placeholder="vous@exemple.com"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 text-white font-semibold py-3 rounded-xl transition text-sm"
>
{loading ? "Envoi..." : "Envoyer le lien"}
</button>
</form>
<p className="text-center text-sm text-slate-500 dark:text-slate-400 mt-6">
<Link href="/auth/login" className="text-indigo-600 dark:text-indigo-400 font-medium hover:underline">
Retour à la connexion
</Link>
</p>
</>
)}
</div>
</div>
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
"use client";
import { useState, use, Suspense } from "react";
import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Leaf, Check } from "lucide-react";
const ROLE_META: Record<string, { label: string; description: string; color: string }> = {
PARENT: {
label: "Accès parent",
description: "toutes les fonctionnalités",
color: "bg-indigo-50 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300 border-indigo-200 dark:border-indigo-800",
},
READONLY: {
label: "Accès consultation",
description: "lecture seule",
color: "bg-slate-50 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-700",
},
DOCTOR: {
label: "Accès médecin",
description: "consultation + notes cliniques",
color: "bg-emerald-50 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800",
},
};
const inputCls =
"w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm";
function InviteForm({ code }: { code: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const role = searchParams.get("role") ?? "PARENT";
const [form, setForm] = useState({ name: "", email: "", password: "" });
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const meta = ROLE_META[role] ?? ROLE_META.PARENT;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const res = await fetch("/api/invite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...form, inviteCode: code, role }),
});
if (!res.ok) {
const data = await res.json();
setError(data.error ?? "Erreur lors de l'inscription");
setLoading(false);
return;
}
await signIn("credentials", {
email: form.email,
password: form.password,
redirect: false,
});
router.push("/dashboard");
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-4">
<div className="w-full max-w-md">
{/* Brand */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-12 h-12 bg-indigo-600 rounded-2xl mb-4">
<Leaf className="w-6 h-6 text-white" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Grow</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">Suivi nouveau-</p>
</div>
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 p-8">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-1">
Rejoindre la famille
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-5">
Créez votre compte pour accéder à l&apos;espace partagé.
</p>
{/* Role badge */}
<div className={`flex items-center gap-2 px-3 py-2.5 rounded-xl border mb-6 ${meta.color}`}>
<Check className="w-4 h-4 flex-shrink-0" />
<span className="text-sm font-medium">
{meta.label} <span className="font-normal opacity-75">· {meta.description}</span>
</span>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
Prénom
</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
className={inputCls}
placeholder="Thomas"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
Email
</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
required
className={inputCls}
placeholder="vous@exemple.com"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
Mot de passe
</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
required
minLength={8}
className={inputCls}
placeholder="8 caractères minimum"
/>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-900 px-4 py-3 rounded-xl text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 dark:disabled:bg-indigo-900 text-white font-semibold py-3 rounded-xl transition text-sm"
>
{loading ? "Rejoindre..." : "Rejoindre la famille"}
</button>
</form>
<p className="text-center text-sm text-slate-500 dark:text-slate-400 mt-6">
Déjà un compte ?{" "}
<Link href="/auth/login" className="text-indigo-600 dark:text-indigo-400 font-medium hover:underline">
Se connecter
</Link>
</p>
</div>
</div>
</div>
);
}
export default function InvitePage({ params }: { params: Promise<{ code: string }> }) {
const { code } = use(params);
return (
<Suspense fallback={null}>
<InviteForm code={code} />
</Suspense>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Leaf } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) {
setError("Email ou mot de passe incorrect");
setLoading(false);
} else {
router.push("/dashboard");
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-12 h-12 bg-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Leaf className="w-6 h-6 text-white" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Grow</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">Suivi de votre nouveau-</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm p-8">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
className="w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm"
placeholder="vous@exemple.com"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400">
Mot de passe
</label>
<Link href="/auth/forgot-password" className="text-xs text-slate-400 dark:text-slate-500 hover:text-indigo-600">
Mot de passe oublié ?
</Link>
</div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm"
placeholder="••••••••"
/>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800 px-4 py-3 rounded-xl text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 text-white font-semibold py-3 rounded-xl transition text-sm"
>
{loading ? "Connexion..." : "Se connecter"}
</button>
</form>
<p className="text-center text-sm text-slate-500 dark:text-slate-400 mt-6">
Pas encore de compte ?{" "}
<Link href="/auth/register" className="text-indigo-600 dark:text-indigo-400 font-medium hover:underline">
Créer un compte
</Link>
</p>
</div>
</div>
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Leaf } from "lucide-react";
export default function RegisterPage() {
const router = useRouter();
const [form, setForm] = useState({ name: "", email: "", password: "" });
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!res.ok) {
const data = await res.json();
setError(data.error ?? "Erreur lors de l'inscription");
setLoading(false);
return;
}
await signIn("credentials", {
email: form.email,
password: form.password,
redirect: false,
});
router.push("/setup");
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-12 h-12 bg-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Leaf className="w-6 h-6 text-white" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Grow</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">Créez votre compte</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm p-8">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Prénom</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
className="w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm"
placeholder="Marie"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Email</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
required
className="w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm"
placeholder="vous@exemple.com"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Mot de passe</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
required
minLength={8}
className="w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm"
placeholder="8 caractères minimum"
/>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800 px-4 py-3 rounded-xl text-sm">{error}</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 text-white font-semibold py-3 rounded-xl transition text-sm"
>
{loading ? "Création..." : "Créer mon compte"}
</button>
</form>
<p className="text-center text-sm text-slate-500 dark:text-slate-400 mt-6">
Déjà un compte ?{" "}
<Link href="/auth/login" className="text-indigo-600 dark:text-indigo-400 font-medium hover:underline">
Se connecter
</Link>
</p>
</div>
</div>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
"use client";
import { Suspense, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import Link from "next/link";
import { Leaf } from "lucide-react";
function ResetForm() {
const params = useSearchParams();
const token = params.get("token") ?? "";
const router = useRouter();
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (password !== confirm) {
setError("Les mots de passe ne correspondent pas");
return;
}
if (password.length < 8) {
setError("8 caractères minimum");
return;
}
setLoading(true);
setError("");
const res = await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error);
setLoading(false);
return;
}
setDone(true);
}
if (done) {
return (
<div className="text-center space-y-4">
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center mx-auto">
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-slate-700 dark:text-slate-300 text-sm">
Votre mot de passe a é réinitialisé avec succès.
</p>
<Link href="/auth/login" className="inline-block text-indigo-600 dark:text-indigo-400 font-medium hover:underline text-sm">
Se connecter
</Link>
</div>
);
}
if (!token) {
return (
<div className="text-center space-y-4">
<div className="w-12 h-12 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center mx-auto">
<svg className="w-6 h-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-slate-700 dark:text-slate-300 text-sm">
Ce lien de réinitialisation est invalide ou a expiré.
</p>
<Link href="/auth/forgot-password" className="inline-block text-indigo-600 dark:text-indigo-400 font-medium hover:underline text-sm">
Demander un nouveau lien
</Link>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
Nouveau mot de passe
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm"
placeholder="8 caractères minimum"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
Confirmer le mot de passe
</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
autoComplete="new-password"
className="w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm"
placeholder="••••••••"
/>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800 px-4 py-3 rounded-xl text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 text-white font-semibold py-3 rounded-xl transition text-sm"
>
{loading ? "Réinitialisation..." : "Réinitialiser le mot de passe"}
</button>
<p className="text-center text-sm text-slate-500 dark:text-slate-400 mt-2">
<Link href="/auth/login" className="text-indigo-600 dark:text-indigo-400 font-medium hover:underline">
Retour à la connexion
</Link>
</p>
</form>
);
}
export default function ResetPasswordPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-12 h-12 bg-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Leaf className="w-6 h-6 text-white" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Grow</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">Nouveau mot de passe</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm p-8">
<Suspense
fallback={
<div className="flex justify-center py-8">
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
</div>
}
>
<ResetForm />
</Suspense>
</div>
</div>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+67
View File
@@ -0,0 +1,67 @@
@import "tailwindcss";
@variant dark (&:is(.dark *));
:root {
--bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-light: #f1f5f9;
--text: #0f172a;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--primary: #4f46e5;
--primary-light: #eef2ff;
--primary-fg: #ffffff;
--sidebar-width: 220px;
}
@theme inline {
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-border: var(--border);
--color-primary: var(--primary);
}
* {
box-sizing: border-box;
}
html {
color-scheme: light dark;
}
.dark {
--bg: #0f172a;
--surface: #1e293b;
--border: #334155;
--border-light: #1e293b;
--text: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--primary: #6366f1;
--primary-light: #1e1b4b;
--primary-fg: #ffffff;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 14px;
transition: background-color 0.15s, color 0.15s;
}
button, a {
-webkit-tap-highlight-color: transparent;
}
@media (min-width: 768px) {
.app-main { margin-left: var(--sidebar-width); }
}
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
+35
View File
@@ -0,0 +1,35 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { Providers } from "@/components/providers";
export const metadata: Metadata = {
title: "Grow — Suivi bébé",
description: "Suivez les activités de votre nouveau-né",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Grow",
},
};
export const viewport: Viewport = {
themeColor: "#4f46e5",
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: `try{var t=localStorage.getItem('grow_theme')||'auto';var d=t==='dark'||(t==='auto'&&window.matchMedia('(prefers-color-scheme: dark)').matches);if(d)document.documentElement.classList.add('dark')}catch(e){}` }} />
</head>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
export default async function Home() {
const session = await auth();
if (session?.user) {
redirect("/dashboard");
}
redirect("/auth/login");
}
+32
View File
@@ -0,0 +1,32 @@
import {
Heart,
GlassWater,
Droplets,
CircleDot,
Sunset,
Moon,
Pill,
Thermometer,
LucideProps,
} from "lucide-react";
const ICONS: Record<string, React.FC<LucideProps>> = {
Heart,
GlassWater,
Droplets,
CircleDot,
Sunset,
Moon,
Pill,
Thermometer,
};
interface EventIconProps extends LucideProps {
name: string;
}
export function EventIcon({ name, ...props }: EventIconProps) {
const Icon = ICONS[name];
if (!Icon) return null;
return <Icon {...props} />;
}
+356
View File
@@ -0,0 +1,356 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { EVENT_CONFIG, EventType } from "@/lib/event-config";
import { EventIcon } from "@/components/event-icon";
import { X } from "lucide-react";
interface Props {
type: EventType;
babyId: string;
onClose: () => void;
onSaved: () => void;
}
interface FormData {
startedAt: string;
endedAt: string;
notes: string;
breastSide?: "left" | "right" | "both";
volume?: string;
bottleType?: "maternal" | "formula";
stoolColor?: string;
stoolAmount?: "small" | "medium" | "large";
medName?: string;
medDose?: string;
medUnit?: string;
temperature?: string;
tempUnit?: "C" | "F";
}
const STOOL_COLORS = [
{ value: "jaune", label: "Jaune", bg: "#fef3c7", dot: "#f59e0b" },
{ value: "vert", label: "Vert", bg: "#d1fae5", dot: "#10b981" },
{ value: "marron", label: "Marron", bg: "#fef3c7", dot: "#92400e" },
{ value: "orange", label: "Orange", bg: "#ffedd5", dot: "#f97316" },
{ value: "noir", label: "Méconium", bg: "#f1f5f9", dot: "#1e293b" },
{ value: "rouge", label: "Rouge", bg: "#fee2e2", dot: "#ef4444" },
];
function localNowString() {
const now = new Date();
return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
}
export function EventModal({ type, babyId, onClose, onSaved }: Props) {
const config = EVENT_CONFIG[type];
const TIMER_KEY = `grow_timer_${type}_${babyId}`;
const [form, setForm] = useState<FormData>({
startedAt: localNowString(),
endedAt: "",
notes: "",
breastSide: "left",
bottleType: "maternal",
stoolColor: "jaune",
stoolAmount: "medium",
tempUnit: "C",
});
const [timerRunning, setTimerRunning] = useState(false);
const [timerSeconds, setTimerSeconds] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startTimeRef = useRef<Date | null>(null);
const [loading, setLoading] = useState(false);
const [validationError, setValidationError] = useState("");
useEffect(() => {
if (!config.hasTimer) return;
const saved = localStorage.getItem(TIMER_KEY);
if (saved) {
const savedStart = new Date(saved);
const elapsed = Math.floor((Date.now() - savedStart.getTime()) / 1000);
startTimeRef.current = savedStart;
setTimerSeconds(elapsed);
setTimerRunning(true);
timerRef.current = setInterval(() => setTimerSeconds((s) => s + 1), 1000);
}
return () => { if (timerRef.current) clearInterval(timerRef.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function startTimer() {
const start = new Date();
startTimeRef.current = start;
localStorage.setItem(TIMER_KEY, start.toISOString());
setTimerRunning(true);
timerRef.current = setInterval(() => setTimerSeconds((s) => s + 1), 1000);
}
function stopTimer() {
if (timerRef.current) clearInterval(timerRef.current);
localStorage.removeItem(TIMER_KEY);
setTimerRunning(false);
if (startTimeRef.current) {
const endedAt = new Date();
const toLocal = (d: Date) => new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
setForm((f) => ({ ...f, startedAt: toLocal(startTimeRef.current!), endedAt: toLocal(endedAt) }));
}
}
function formatTimer(s: number) {
return `${Math.floor(s / 60).toString().padStart(2, "0")}:${(s % 60).toString().padStart(2, "0")}`;
}
async function handleSave() {
setValidationError("");
if (type === "TEMPERATURE" && form.temperature) {
const t = parseFloat(form.temperature);
const min = form.tempUnit === "C" ? 35 : 95;
const max = form.tempUnit === "C" ? 42 : 107.6;
if (t < min || t > max) {
setValidationError(`Température hors plage (${min}${max}°${form.tempUnit})`);
return;
}
}
if (type === "BOTTLE" && form.volume) {
const v = parseFloat(form.volume);
if (v < 0 || v > 500) { setValidationError("Volume entre 0 et 500 mL"); return; }
}
setLoading(true);
const metadata: Record<string, unknown> = {};
if (type === "BREASTFEED") metadata.breastSide = form.breastSide;
if (type === "BOTTLE") { metadata.volume = form.volume ? parseFloat(form.volume) : null; metadata.bottleType = form.bottleType; }
if (type === "DIAPER_STOOL") { metadata.color = form.stoolColor; metadata.amount = form.stoolAmount; }
if (type === "MEDICATION") { metadata.name = form.medName; metadata.dose = form.medDose; metadata.unit = form.medUnit; }
if (type === "TEMPERATURE") { metadata.value = form.temperature ? parseFloat(form.temperature) : null; metadata.unit = form.tempUnit; }
const toUTC = (local: string) => local ? new Date(local).toISOString() : null;
localStorage.removeItem(TIMER_KEY);
await fetch("/api/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
babyId,
type,
startedAt: toUTC(form.startedAt),
endedAt: form.endedAt ? toUTC(form.endedAt) : null,
notes: form.notes || null,
metadata: Object.keys(metadata).length > 0 ? metadata : null,
}),
});
setLoading(false);
onSaved();
onClose();
}
const inputCls = "w-full px-3 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:border-transparent transition bg-white dark:bg-slate-700 dark:text-slate-100";
const segmentBase = "flex-1 py-2 text-xs font-medium rounded-lg border transition text-center";
const segmentActive = "border-indigo-500 bg-indigo-50 text-indigo-700";
const segmentInactive = "border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:border-slate-300";
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4">
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-md bg-white dark:bg-slate-800 rounded-2xl shadow-2xl z-10 max-h-[92vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-xl ${config.bgClass} flex items-center justify-center`}>
<EventIcon name={config.icon} className={`w-5 h-5 ${config.colorClass}`} />
</div>
<h2 className="text-base font-semibold text-slate-800 dark:text-slate-100">{config.label}</h2>
</div>
<button onClick={onClose} className="w-8 h-8 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 flex items-center justify-center text-slate-400 transition">
<X className="w-4 h-4" />
</button>
</div>
<div className="px-5 py-4 space-y-4">
{/* Timer */}
{config.hasTimer && (
<div className="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4 text-center">
<div className="text-4xl font-mono font-bold text-slate-800 dark:text-slate-100 mb-3 tabular-nums">
{formatTimer(timerSeconds)}
</div>
<button
type="button"
onClick={timerRunning ? stopTimer : startTimer}
className={`px-8 py-2.5 rounded-lg font-medium text-sm text-white transition ${
timerRunning ? "bg-red-500 hover:bg-red-600" : "bg-indigo-600 hover:bg-indigo-700"
}`}
>
{timerRunning ? "Arrêter" : "Démarrer"}
</button>
</div>
)}
{/* Breastfeed: side */}
{type === "BREASTFEED" && (
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Sein</label>
<div className="flex gap-2">
{(["left", "right", "both"] as const).map((side) => (
<button key={side} type="button" onClick={() => setForm((f) => ({ ...f, breastSide: side }))}
className={`${segmentBase} ${form.breastSide === side ? segmentActive : segmentInactive}`}>
{side === "left" ? "Gauche" : side === "right" ? "Droite" : "Les deux"}
</button>
))}
</div>
</div>
)}
{/* Bottle */}
{type === "BOTTLE" && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Volume (mL)</label>
<input type="number" value={form.volume ?? ""} onChange={(e) => setForm((f) => ({ ...f, volume: e.target.value }))}
className={inputCls} placeholder="80" min="0" max="300" />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Type de lait</label>
<div className="flex gap-2">
{(["maternal", "formula"] as const).map((bt) => (
<button key={bt} type="button" onClick={() => setForm((f) => ({ ...f, bottleType: bt }))}
className={`${segmentBase} ${form.bottleType === bt ? segmentActive : segmentInactive}`}>
{bt === "maternal" ? "Maternel" : "Artificiel"}
</button>
))}
</div>
</div>
</div>
)}
{/* Diaper stool */}
{type === "DIAPER_STOOL" && (
<div className="space-y-3">
<div>
<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">
{STOOL_COLORS.map(({ value, label, bg, 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 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: 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>
)}
{/* Medication */}
{type === "MEDICATION" && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Traitement</label>
<input type="text" value={form.medName ?? ""} onChange={(e) => setForm((f) => ({ ...f, medName: e.target.value }))}
className={inputCls} placeholder="Doliprane" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Dose</label>
<input type="text" value={form.medDose ?? ""} onChange={(e) => setForm((f) => ({ ...f, medDose: e.target.value }))}
className={inputCls} placeholder="2.4" />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Unité</label>
<select value={form.medUnit ?? "mL"} onChange={(e) => setForm((f) => ({ ...f, medUnit: e.target.value }))}
className={inputCls}>
<option value="mL">mL</option>
<option value="mg">mg</option>
<option value="gouttes">gouttes</option>
</select>
</div>
</div>
</div>
)}
{/* Temperature */}
{type === "TEMPERATURE" && (
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Température</label>
<div className="flex gap-2">
<input type="number" value={form.temperature ?? ""} onChange={(e) => setForm((f) => ({ ...f, temperature: e.target.value }))}
className={`${inputCls} flex-1`} placeholder="37.5" step="0.1" min="35" max="42" />
<div className="flex gap-1">
{(["C", "F"] as const).map((u) => (
<button key={u} type="button" onClick={() => setForm((f) => ({ ...f, tempUnit: u }))}
className={`px-3 py-2.5 rounded-lg border text-sm font-semibold transition ${
form.tempUnit === u ? segmentActive : segmentInactive
}`}>
°{u}
</button>
))}
</div>
</div>
</div>
)}
{/* Time fields */}
<div className={`grid ${config.hasDuration ? "grid-cols-2" : "grid-cols-1"} gap-3`}>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
{config.hasDuration ? "Début" : "Heure"}
</label>
<input type="datetime-local" value={form.startedAt}
onChange={(e) => setForm((f) => ({ ...f, startedAt: e.target.value }))}
className={inputCls} />
</div>
{config.hasDuration && (
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Fin</label>
<input type="datetime-local" value={form.endedAt}
onChange={(e) => setForm((f) => ({ ...f, endedAt: e.target.value }))}
className={inputCls} />
</div>
)}
</div>
{/* Notes */}
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Notes <span className="text-slate-400 font-normal">(optionnel)</span>
</label>
<textarea value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
rows={2} className={`${inputCls} resize-none`} placeholder="Observations..." />
</div>
{validationError && (
<div className="px-3 py-2 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg text-xs text-red-600 dark:text-red-400">
{validationError}
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-1 pb-1">
<button type="button" onClick={onClose}
className="flex-1 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 text-sm font-medium hover:bg-slate-50 dark:hover:bg-slate-700 transition">
Annuler
</button>
<button type="button" onClick={handleSave} disabled={loading}
className="flex-[2] py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 text-white text-sm font-medium transition">
{loading ? "Enregistrement..." : "Enregistrer"}
</button>
</div>
</div>
</div>
</div>
);
}
+278
View File
@@ -0,0 +1,278 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSession, signOut } from "next-auth/react";
import { useBaby } from "@/contexts/baby-context";
import {
LayoutGrid,
ScrollText,
BarChart3,
TrendingUp,
Settings,
Leaf,
Sun,
Moon,
ShieldCheck,
LogOut,
ChevronDown,
Star,
NotebookPen,
Stethoscope,
Syringe,
MoreHorizontal,
X,
} from "lucide-react";
const LINKS = [
{ href: "/dashboard", label: "Accueil", Icon: LayoutGrid },
{ href: "/timeline", label: "Journal", Icon: ScrollText },
{ href: "/stats", label: "Statistiques", Icon: BarChart3 },
{ href: "/growth", label: "Courbes", Icon: TrendingUp },
{ href: "/milestones", label: "Étapes", Icon: Star },
{ href: "/notes", label: "Notes", Icon: NotebookPen },
{ href: "/doctor-notes", label: "Médecin", Icon: Stethoscope },
{ href: "/vaccinations", label: "Vaccinations", Icon: Syringe },
{ href: "/settings", label: "Réglages", Icon: Settings },
];
const PRIMARY_HREFS = ["/dashboard", "/timeline", "/growth", "/settings"];
const PRIMARY_LINKS = LINKS.filter((l) => PRIMARY_HREFS.includes(l.href));
const SECONDARY_LINKS = LINKS.filter((l) => !PRIMARY_HREFS.includes(l.href));
type ThemeMode = "auto" | "light" | "dark";
function applyTheme(mode: ThemeMode) {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const isDark = mode === "dark" || (mode === "auto" && prefersDark);
document.documentElement.classList.toggle("dark", isDark);
}
function ThemeToggle() {
const [mode, setMode] = useState<ThemeMode>("auto");
useEffect(() => {
const stored = (localStorage.getItem("grow_theme") ?? "auto") as ThemeMode;
setMode(stored);
applyTheme(stored);
// Re-apply when system preference changes (only matters in auto mode)
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => {
const current = (localStorage.getItem("grow_theme") ?? "auto") as ThemeMode;
if (current === "auto") applyTheme("auto");
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
function cycle() {
const next: ThemeMode = mode === "auto" ? "light" : mode === "light" ? "dark" : "auto";
setMode(next);
if (next === "auto") localStorage.removeItem("grow_theme");
else localStorage.setItem("grow_theme", next);
applyTheme(next);
}
return (
<button
onClick={cycle}
title={mode === "auto" ? "Thème : automatique" : mode === "light" ? "Thème : clair" : "Thème : sombre"}
className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 transition"
>
{mode === "dark" ? <Sun className="w-4 h-4" /> : mode === "light" ? <Moon className="w-4 h-4" /> : <Sun className="w-4 h-4 opacity-50" />}
</button>
);
}
export function SidebarNav() {
const pathname = usePathname();
const { data: session } = useSession();
const isSuperAdmin = (session?.user as { isSuperAdmin?: boolean })?.isSuperAdmin;
const { babies, selectedBaby, setSelectedBabyId } = useBaby();
return (
<aside className="hidden md:flex flex-col w-[220px] border-r border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 fixed left-0 top-0 h-screen z-20">
{/* Brand */}
<div className="px-5 py-4 border-b border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-2">
<div className="w-7 h-7 bg-indigo-600 rounded-lg flex items-center justify-center flex-shrink-0">
<Leaf className="w-4 h-4 text-white" />
</div>
<span className="font-semibold text-slate-900 dark:text-slate-100 text-base">Grow</span>
</div>
</div>
{/* Baby switcher */}
{babies.length > 0 && (
<div className="px-3 py-2 border-b border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-2 px-2 py-1.5 rounded-lg">
<div className="w-6 h-6 bg-indigo-100 dark:bg-indigo-950 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-[10px] font-bold text-indigo-600 dark:text-indigo-400 leading-none">
{selectedBaby?.name[0]}
</span>
</div>
{babies.length === 1 ? (
<span className="text-sm font-medium text-slate-700 dark:text-slate-200 truncate">
{selectedBaby?.name}
</span>
) : (
<div className="flex items-center flex-1 min-w-0">
<select
value={selectedBaby?.id ?? ""}
onChange={(e) => setSelectedBabyId(e.target.value)}
className="flex-1 min-w-0 text-sm font-medium text-slate-700 dark:text-slate-200 bg-transparent border-none outline-none cursor-pointer appearance-none"
>
{babies.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
<ChevronDown className="w-3.5 h-3.5 text-slate-400 flex-shrink-0 pointer-events-none" />
</div>
)}
</div>
</div>
)}
<nav className="flex-1 px-3 py-4 space-y-0.5">
{LINKS.map(({ href, label, Icon }) => {
const active = pathname === href;
return (
<Link
key={href}
href={href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
active
? "bg-indigo-50 text-indigo-700 dark:bg-indigo-950 dark:text-indigo-300"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
}`}
>
<Icon className={`w-4 h-4 flex-shrink-0 ${active ? "text-indigo-600" : "text-slate-400"}`} />
{label}
</Link>
);
})}
{isSuperAdmin && (
<Link
href="/admin"
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
pathname === "/admin"
? "bg-indigo-50 text-indigo-700 dark:bg-indigo-950 dark:text-indigo-300"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
}`}
>
<ShieldCheck className={`w-4 h-4 flex-shrink-0 ${pathname === "/admin" ? "text-indigo-600" : "text-slate-400"}`} />
Admin
</Link>
)}
</nav>
<div className="px-3 py-3 border-t border-slate-200 dark:border-slate-700 flex items-center gap-2">
<ThemeToggle />
<button
onClick={() => signOut({ callbackUrl: "/auth/login" })}
title="Déconnexion"
className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-slate-700 dark:hover:text-slate-300 transition"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</aside>
);
}
export function BottomNav() {
const pathname = usePathname();
const { data: session } = useSession();
const isSuperAdmin = (session?.user as { isSuperAdmin?: boolean })?.isSuperAdmin;
const [drawerOpen, setDrawerOpen] = useState(false);
const secondaryLinks = isSuperAdmin
? [...SECONDARY_LINKS, { href: "/admin", label: "Admin", Icon: ShieldCheck }]
: SECONDARY_LINKS;
const secondaryActive = secondaryLinks.some((l) => pathname === l.href);
return (
<>
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-slate-900 border-t border-slate-200 dark:border-slate-700 z-50 pb-[env(safe-area-inset-bottom)]">
<div className="flex items-center justify-around px-2 pt-2 pb-2">
{PRIMARY_LINKS.map(({ href, label, Icon }) => {
const active = pathname === href;
return (
<Link
key={href}
href={href}
className={`flex flex-col items-center gap-1 px-2 py-1 rounded-lg min-w-[52px] transition-colors ${
active ? "text-indigo-600 dark:text-indigo-400" : "text-slate-400 dark:text-slate-500"
}`}
>
<Icon className="w-5 h-5" />
<span className="text-[10px] font-medium">{label}</span>
</Link>
);
})}
<button
onClick={() => setDrawerOpen(true)}
className={`flex flex-col items-center gap-1 px-2 py-1 rounded-lg min-w-[52px] transition-colors ${
secondaryActive ? "text-indigo-600 dark:text-indigo-400" : "text-slate-400 dark:text-slate-500"
}`}
>
<MoreHorizontal className="w-5 h-5" />
<span className="text-[10px] font-medium">Plus</span>
</button>
</div>
</nav>
{/* Drawer overlay */}
{drawerOpen && (
<div className="md:hidden fixed inset-0 z-50 flex flex-col justify-end">
{/* Semi-transparent backdrop */}
<div
className="absolute inset-0 bg-black/40"
onClick={() => setDrawerOpen(false)}
/>
{/* Drawer sheet */}
<div className="relative bg-white dark:bg-slate-900 rounded-t-2xl pb-[env(safe-area-inset-bottom)] z-10">
{/* Header */}
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">Plus</span>
<button
onClick={() => setDrawerOpen(false)}
className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Grid of secondary links */}
<div className="grid grid-cols-3 gap-2 px-4 pb-4">
{secondaryLinks.map(({ href, label, Icon }) => {
const active = pathname === href;
return (
<Link
key={href}
href={href}
onClick={() => setDrawerOpen(false)}
className={`flex flex-col items-center gap-1.5 px-2 py-3 rounded-xl transition-colors ${
active
? "bg-indigo-50 dark:bg-indigo-950 text-indigo-600 dark:text-indigo-400"
: "text-slate-500 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800"
}`}
>
<Icon className="w-5 h-5" />
<span className="text-[10px] font-medium text-center leading-tight">{label}</span>
</Link>
);
})}
</div>
</div>
</div>
)}
</>
);
}
// Legacy export for compatibility
export { BottomNav as Nav };
+24
View File
@@ -0,0 +1,24 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SessionProvider } from "next-auth/react";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 10 * 1000,
},
},
})
);
return (
<SessionProvider>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</SessionProvider>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useBaby } from "@/contexts/baby-context";
import { EVENT_CONFIG, EventType } from "@/lib/event-config";
import { EventIcon } from "@/components/event-icon";
import { EventModal } from "@/components/event-modal";
import { Plus, X } from "lucide-react";
const QUICK_TYPES: EventType[] = [
"BREASTFEED",
"BOTTLE",
"DIAPER_WET",
"DIAPER_STOOL",
"NAP",
"NIGHT_SLEEP",
"MEDICATION",
"TEMPERATURE",
];
export function QuickAddFab() {
const qc = useQueryClient();
const { selectedBaby } = useBaby();
const [sheetOpen, setSheetOpen] = useState(false);
const [activeModal, setActiveModal] = useState<EventType | null>(null);
if (!selectedBaby) return null;
function selectType(type: EventType) {
setSheetOpen(false);
setActiveModal(type);
}
return (
<>
{/* FAB — only on mobile, hidden on md+ (sidebar handles desktop) */}
<button
onClick={() => setSheetOpen((v) => !v)}
aria-label="Enregistrer une action"
className="md:hidden fixed bottom-[calc(env(safe-area-inset-bottom)+4.5rem)] right-4 z-40 w-14 h-14 rounded-full bg-indigo-600 text-white shadow-lg flex items-center justify-center active:scale-95 transition-transform"
>
{sheetOpen ? <X className="w-6 h-6" /> : <Plus className="w-6 h-6" />}
</button>
{/* Sheet */}
{sheetOpen && (
<div className="md:hidden fixed inset-0 z-30 flex flex-col justify-end">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40"
onClick={() => setSheetOpen(false)}
/>
{/* Panel */}
<div className="relative bg-white dark:bg-slate-900 rounded-t-2xl pb-[calc(env(safe-area-inset-bottom)+5rem)] z-10">
<div className="px-4 pt-4 pb-3 flex items-center justify-between">
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">
Enregistrer
</span>
<button
onClick={() => setSheetOpen(false)}
className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-4 gap-2 px-4 pb-2">
{QUICK_TYPES.map((type) => {
const cfg = EVENT_CONFIG[type];
return (
<button
key={type}
onClick={() => selectType(type)}
className="flex flex-col items-center gap-2 py-3 px-1 rounded-xl hover:bg-slate-50 dark:hover:bg-slate-800 active:scale-95 transition-transform"
>
<div
className={`w-10 h-10 rounded-xl ${cfg.bgClass} flex items-center justify-center`}
>
<EventIcon name={cfg.icon} className={`w-5 h-5 ${cfg.colorClass}`} />
</div>
<span className="text-[10px] font-medium text-slate-600 dark:text-slate-300 text-center leading-tight">
{cfg.label}
</span>
</button>
);
})}
</div>
</div>
</div>
)}
{/* Event modal (rendered outside sheet so it persists after sheet closes) */}
{activeModal && (
<EventModal
type={activeModal}
babyId={selectedBaby.id}
onClose={() => setActiveModal(null)}
onSaved={() => {
qc.invalidateQueries({ queryKey: ["events"] });
setActiveModal(null);
}}
/>
)}
</>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
interface Baby {
id: string;
name: string;
birthDate: string;
gender?: string;
}
interface Family {
id: string;
name: string;
inviteCode: string;
babies: Baby[];
users: { id: string; name: string; email: string }[];
}
interface BabyContextValue {
family: Family | null;
babies: Baby[];
selectedBaby: Baby | null;
setSelectedBabyId: (id: string) => void;
isLoading: boolean;
}
const BabyContext = createContext<BabyContextValue>({
family: null,
babies: [],
selectedBaby: null,
setSelectedBabyId: () => {},
isLoading: true,
});
export function BabyProvider({ children }: { children: ReactNode }) {
const [selectedBabyId, setSelectedBabyIdState] = useState<string | null>(null);
const { data: family, isLoading } = useQuery<Family>({
queryKey: ["family"],
queryFn: () => fetch("/api/baby").then((r) => r.json()),
staleTime: 30 * 1000,
});
const babies = family?.babies ?? [];
useEffect(() => {
if (babies.length === 0) return;
const saved = localStorage.getItem("grow_selected_baby");
const valid = babies.find((b) => b.id === saved);
if (valid) {
setSelectedBabyIdState(valid.id);
} else {
setSelectedBabyIdState(babies[0].id);
}
}, [babies]);
function setSelectedBabyId(id: string) {
setSelectedBabyIdState(id);
localStorage.setItem("grow_selected_baby", id);
}
const selectedBaby = babies.find((b) => b.id === selectedBabyId) ?? babies[0] ?? null;
return (
<BabyContext.Provider value={{ family: family ?? null, babies, selectedBaby, setSelectedBabyId, isLoading }}>
{children}
</BabyContext.Provider>
);
}
export function useBaby() {
return useContext(BabyContext);
}
+79
View File
@@ -0,0 +1,79 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
pages: {
signIn: "/auth/login",
},
providers: [
Credentials({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Mot de passe", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user?.password) return null;
const valid = await bcrypt.compare(
credentials.password as string,
user.password
);
if (!valid) return null;
return {
id: user.id,
email: user.email,
name: user.name,
familyId: user.familyId,
role: user.role,
isSuperAdmin: user.isSuperAdmin,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.familyId = (user as { familyId?: string | null }).familyId;
token.role = (user as { role?: string }).role ?? "PARENT";
token.isSuperAdmin = (user as { isSuperAdmin?: boolean }).isSuperAdmin ?? false;
}
if (!user && token.id) {
const dbUser = await prisma.user.findUnique({
where: { id: token.id as string },
select: { forcedLogoutAt: true },
});
if (dbUser?.forcedLogoutAt) {
const iat = typeof token.iat === "number" ? token.iat : 0;
if (dbUser.forcedLogoutAt.getTime() / 1000 > iat) {
return null;
}
}
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string;
(session.user as { familyId?: string | null }).familyId =
token.familyId as string | null;
(session.user as { role?: string }).role = token.role as string;
(session.user as { isSuperAdmin?: boolean }).isSuperAdmin = token.isSuperAdmin as boolean;
}
return session;
},
},
});
+78
View File
@@ -0,0 +1,78 @@
import { prisma } from "./prisma";
interface EmailPayload {
to: string;
subject: string;
html: string;
text?: string;
}
async function getConfig(key: string): Promise<string> {
const row = await prisma.systemConfig.findUnique({ where: { key } });
return row?.value ?? process.env[key.toUpperCase()] ?? "";
}
async function sendMailgun(payload: EmailPayload): Promise<void> {
const apiKey = await getConfig("mailgun_api_key");
const domain = await getConfig("mailgun_domain");
const from = await getConfig("mailgun_from");
if (!apiKey || !domain) throw new Error("Mailgun non configuré");
const formData = new FormData();
formData.append("from", from || `Grow <noreply@${domain}>`);
formData.append("to", payload.to);
formData.append("subject", payload.subject);
formData.append("html", payload.html);
if (payload.text) formData.append("text", payload.text);
const res = await fetch(`https://api.mailgun.net/v3/${domain}/messages`, {
method: "POST",
headers: { Authorization: `Basic ${Buffer.from(`api:${apiKey}`).toString("base64")}` },
body: formData,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Mailgun error: ${err}`);
}
}
async function sendSmtp(payload: EmailPayload): Promise<void> {
const host = await getConfig("smtp_host");
const port = parseInt(await getConfig("smtp_port") || "587");
const user = await getConfig("smtp_user");
const pass = await getConfig("smtp_pass");
const from = await getConfig("smtp_from");
if (!host) throw new Error("SMTP non configuré");
const nodemailer = await import("nodemailer");
const transport = nodemailer.default.createTransport({ host, port, auth: { user, pass } });
await transport.sendMail({ from, to: payload.to, subject: payload.subject, html: payload.html, text: payload.text });
}
export async function sendEmail(payload: EmailPayload): Promise<void> {
const provider = await getConfig("email_provider");
if (provider === "smtp") return sendSmtp(payload);
if (provider === "mailgun") return sendMailgun(payload);
// Fallback: check env vars directly
if (process.env.MAILGUN_API_KEY) return sendMailgun(payload);
if (process.env.SMTP_HOST) return sendSmtp(payload);
throw new Error("Aucun service email configuré");
}
export function resetPasswordEmail(name: string, resetUrl: string): { html: string; text: string } {
return {
text: `Bonjour ${name},\n\nCliquez sur ce lien pour réinitialiser votre mot de passe :\n${resetUrl}\n\nLien valide 1 heure.\n\nGrow`,
html: `
<div style="font-family:sans-serif;max-width:480px;margin:0 auto">
<h2 style="color:#4f46e5">Grow — Réinitialisation du mot de passe</h2>
<p>Bonjour ${name},</p>
<p>Cliquez sur le bouton ci-dessous pour réinitialiser votre mot de passe. Le lien est valide pendant <strong>1 heure</strong>.</p>
<a href="${resetUrl}" style="display:inline-block;margin:16px 0;padding:12px 24px;background:#4f46e5;color:#fff;border-radius:8px;text-decoration:none;font-weight:600">
Réinitialiser mon mot de passe
</a>
<p style="color:#64748b;font-size:13px">Si vous n'avez pas demandé cette réinitialisation, ignorez cet email.</p>
<p style="color:#64748b;font-size:13px">Ou copiez ce lien : ${resetUrl}</p>
</div>
`,
};
}
+127
View File
@@ -0,0 +1,127 @@
export type EventType =
| "BREASTFEED"
| "BOTTLE"
| "DIAPER_WET"
| "DIAPER_STOOL"
| "NAP"
| "NIGHT_SLEEP"
| "MEDICATION"
| "TEMPERATURE";
export const EVENT_CONFIG: Record<
EventType,
{
label: string;
icon: string; // lucide icon name
color: string; // hex for inline use
colorClass: string; // tailwind text color
bgClass: string; // tailwind bg (light)
borderClass: string; // tailwind border
hasDuration: boolean;
hasTimer: boolean;
}
> = {
BREASTFEED: {
label: "Allaitement",
icon: "Heart",
color: "#f43f5e",
colorClass: "text-rose-500",
bgClass: "bg-rose-50",
borderClass: "border-rose-200",
hasDuration: true,
hasTimer: true,
},
BOTTLE: {
label: "Biberon",
icon: "GlassWater",
color: "#3b82f6",
colorClass: "text-blue-500",
bgClass: "bg-blue-50",
borderClass: "border-blue-200",
hasDuration: false,
hasTimer: false,
},
DIAPER_WET: {
label: "Couche mouillée",
icon: "Droplets",
color: "#06b6d4",
colorClass: "text-cyan-500",
bgClass: "bg-cyan-50",
borderClass: "border-cyan-200",
hasDuration: false,
hasTimer: false,
},
DIAPER_STOOL: {
label: "Selles",
icon: "CircleDot",
color: "#d97706",
colorClass: "text-amber-600",
bgClass: "bg-amber-50",
borderClass: "border-amber-200",
hasDuration: false,
hasTimer: false,
},
NAP: {
label: "Sieste",
icon: "Sunset",
color: "#7c3aed",
colorClass: "text-violet-600",
bgClass: "bg-violet-50",
borderClass: "border-violet-200",
hasDuration: true,
hasTimer: true,
},
NIGHT_SLEEP: {
label: "Sommeil nocturne",
icon: "Moon",
color: "#4338ca",
colorClass: "text-indigo-700",
bgClass: "bg-indigo-50",
borderClass: "border-indigo-200",
hasDuration: true,
hasTimer: true,
},
MEDICATION: {
label: "Traitement",
icon: "Pill",
color: "#16a34a",
colorClass: "text-green-600",
bgClass: "bg-green-50",
borderClass: "border-green-200",
hasDuration: false,
hasTimer: false,
},
TEMPERATURE: {
label: "Température",
icon: "Thermometer",
color: "#dc2626",
colorClass: "text-red-600",
bgClass: "bg-red-50",
borderClass: "border-red-200",
hasDuration: false,
hasTimer: false,
},
};
export const ALL_EVENT_TYPES = Object.keys(EVENT_CONFIG) as EventType[];
export function formatDuration(startedAt: Date, endedAt: Date): string {
const ms = endedAt.getTime() - startedAt.getTime();
const totalMinutes = Math.floor(ms / 60000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours > 0) return `${hours}h${minutes.toString().padStart(2, "0")}`;
return `${minutes}min`;
}
export function formatTimeAgo(date: Date): string {
const now = new Date();
const ms = now.getTime() - date.getTime();
const minutes = Math.floor(ms / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `il y a ${days}j`;
if (hours > 0) return `il y a ${hours}h${(minutes % 60).toString().padStart(2, "0")}`;
if (minutes > 0) return `il y a ${minutes}min`;
return "à l'instant";
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
const STORAGE_KEY = "grow_notification_thresholds";
export interface NotificationThresholds {
feedAlertHours: number; // alert if no feed in X hours
enabled: boolean;
}
export function getThresholds(): NotificationThresholds {
if (typeof window === "undefined") return { feedAlertHours: 3, enabled: false };
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { feedAlertHours: 3, enabled: false };
return JSON.parse(raw);
}
export function saveThresholds(t: NotificationThresholds): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(t));
}
export async function requestPermission(): Promise<boolean> {
if (!("Notification" in window)) return false;
if (Notification.permission === "granted") return true;
const result = await Notification.requestPermission();
return result === "granted";
}
export function sendNotification(title: string, body: string): void {
if (typeof window === "undefined") return;
if (Notification.permission !== "granted") return;
new Notification(title, {
body,
icon: "/icon.svg",
badge: "/icon.svg",
tag: "grow-alert",
});
}
export function checkFeedAlert(lastFeedAt: Date | null, thresholds: NotificationThresholds): void {
if (!thresholds.enabled || !lastFeedAt) return;
const hoursSince = (Date.now() - lastFeedAt.getTime()) / 3600000;
if (hoursSince >= thresholds.feedAlertHours) {
const h = Math.floor(hoursSince);
const m = Math.floor((hoursSince - h) * 60);
sendNotification(
"🍼 Rappel repas — Grow",
`Dernier repas il y a ${h}h${m.toString().padStart(2, "0")}. Heure de nourrir bébé ?`
);
}
}
+16
View File
@@ -0,0 +1,16 @@
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
function createClient() {
const adapter = new PrismaPg(process.env.DATABASE_URL!);
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
}
export const prisma = globalForPrisma.prisma ?? createClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+31
View File
@@ -0,0 +1,31 @@
import { prisma } from "./prisma";
async function getConfig(key: string): Promise<string> {
const row = await prisma.systemConfig.findUnique({ where: { key } });
return row?.value ?? process.env[key.toUpperCase()] ?? "";
}
export async function sendPushover(userKey: string, message: string, title = "Grow"): Promise<void> {
const token = await getConfig("pushover_app_token");
if (!token) throw new Error("Pushover non configuré (PUSHOVER_APP_TOKEN manquant)");
const res = await fetch("https://api.pushover.net/1/messages.json", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, user: userKey, message, title }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(`Pushover error: ${JSON.stringify(err)}`);
}
}
export async function notifyFamilyFeedAlert(familyId: string, message: string): Promise<void> {
const users = await prisma.user.findMany({
where: { familyId, pushoverUserKey: { not: null } },
select: { pushoverUserKey: true },
});
await Promise.allSettled(
users.map((u) => sendPushover(u.pushoverUserKey!, message))
);
}
+53
View File
@@ -0,0 +1,53 @@
// WHO Child Growth Standards — Weight-for-age (girls & boys combined approximation)
// Values in kg, indexed by age in weeks (052)
export const WHO_WEIGHT_PERCENTILES: Record<number, number[]> = {
// [P3, P10, P25, P50, P75, P90, P97] in kg
0: [2.4, 2.6, 2.9, 3.3, 3.7, 4.0, 4.3],
1: [2.6, 2.9, 3.1, 3.5, 3.9, 4.3, 4.6],
2: [2.9, 3.2, 3.5, 4.0, 4.4, 4.8, 5.1],
4: [3.4, 3.8, 4.2, 4.8, 5.3, 5.8, 6.2],
6: [4.0, 4.4, 4.9, 5.5, 6.1, 6.6, 7.1],
8: [4.5, 5.0, 5.5, 6.2, 6.9, 7.5, 8.0],
10: [4.9, 5.4, 6.0, 6.7, 7.5, 8.1, 8.7],
12: [5.3, 5.8, 6.4, 7.2, 8.0, 8.7, 9.3],
16: [5.8, 6.4, 7.0, 7.8, 8.7, 9.4, 10.1],
20: [6.3, 6.9, 7.6, 8.5, 9.5, 10.2, 11.0],
24: [6.6, 7.3, 8.0, 9.0, 10.0, 10.9, 11.7],
28: [6.9, 7.6, 8.4, 9.4, 10.5, 11.4, 12.2],
32: [7.2, 7.9, 8.7, 9.8, 10.9, 11.8, 12.7],
36: [7.4, 8.1, 9.0, 10.1, 11.3, 12.3, 13.2],
40: [7.6, 8.3, 9.2, 10.4, 11.6, 12.7, 13.6],
44: [7.7, 8.5, 9.5, 10.7, 11.9, 13.0, 14.0],
48: [7.9, 8.7, 9.7, 10.9, 12.2, 13.3, 14.3],
52: [8.1, 8.9, 9.9, 11.2, 12.5, 13.6, 14.7],
};
export const PERCENTILE_LABELS = ["P3", "P10", "P25", "P50", "P75", "P90", "P97"];
export const PERCENTILE_COLORS = [
"#e2e8f0",
"#cbd5e1",
"#94a3b8",
"#64748b",
"#94a3b8",
"#cbd5e1",
"#e2e8f0",
];
export function getAgeInWeeks(birthDate: Date, measureDate: Date): number {
const ms = measureDate.getTime() - birthDate.getTime();
return Math.floor(ms / (7 * 24 * 60 * 60 * 1000));
}
export function interpolatePercentile(ageWeeks: number, percentileIndex: number): number | null {
const ages = Object.keys(WHO_WEIGHT_PERCENTILES).map(Number).sort((a, b) => a - b);
const lower = ages.filter((a) => a <= ageWeeks).at(-1);
const upper = ages.filter((a) => a >= ageWeeks)[0];
if (lower === undefined || upper === undefined) return null;
if (lower === upper) return WHO_WEIGHT_PERCENTILES[lower][percentileIndex];
const ratio = (ageWeeks - lower) / (upper - lower);
const lowerVal = WHO_WEIGHT_PERCENTILES[lower][percentileIndex];
const upperVal = WHO_WEIGHT_PERCENTILES[upper][percentileIndex];
return lowerVal + ratio * (upperVal - lowerVal);
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}