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.
@@ -0,0 +1,7 @@
|
||||
.next
|
||||
node_modules
|
||||
.git
|
||||
*.md
|
||||
.env*
|
||||
.DS_Store
|
||||
coverage
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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 (6–8 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 5–10s → 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)
|
||||
@@ -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 (P3–P97), 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.
|
||||
@@ -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:
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
allowBuilds:
|
||||
sharp: set this to true or false
|
||||
unrs-resolver: set this to true or false
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 P3–P97</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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-né</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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-né</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'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>
|
||||
);
|
||||
}
|
||||
@@ -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-né</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 été 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>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -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; }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
`,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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é ?`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// WHO Child Growth Standards — Weight-for-age (girls & boys combined approximation)
|
||||
// Values in kg, indexed by age in weeks (0–52)
|
||||
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);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||