"use client"; import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type TouchEvent as ReactTouchEvent, type WheelEvent as ReactWheelEvent, } from "react"; import Link from "next/link"; import { MainTabBar } from "@/features/predictions/components/main-tab-bar"; import styles from "@/features/predictions/styles/predictions.module.css"; import indicesStyles from "./page.module.css"; import { getProjectIndices } from "@/lib/indices-client"; import { getCurrentProjectId, getApiUrl, loadSession, PROJECT_CHANGED_EVENT } from "@/lib/auth"; import { getMyProjects } from "@/lib/projects-client"; import type { ParentType, ProjectIndicesResponse, Trimester } from "@/types/indices"; import type { ProjectSummary } from "@/types/projects"; const PARENT_LABELS: Record = { PAPA: "Papa", MAMAN: "Maman", }; const TRIMESTER_LABELS: Record = { DATATION: "Datation (1ère échographie)", T1: "1er trimestre", T2: "2ème trimestre", T3: "3ème trimestre", }; const TRIMESTER_ORDER: Trimester[] = ["DATATION", "T1", "T2", "T3"]; function hasTrimesterData(tri: { poids: number | null; taille: number | null; perimCranien: number | null; date: string | null; note: string | null; photos: Array<{ id: string; trimesterEntryId: string; url: string; sortOrder: number; createdAt: string }>; }) { return tri.poids != null || tri.taille != null || tri.perimCranien != null || Boolean(tri.date) || Boolean(tri.note) || tri.photos.length > 0; } function formatDate(value: string | null) { if (!value) return "—"; return new Date(value).toLocaleDateString("fr-FR", { day: "2-digit", month: "long", year: "numeric", }); } function StatRow({ label, value }: { label: string; value: string }) { return (
{label} {value}
); } function PhotoGallery({ urls, onOpen, }: { urls: string[]; onOpen: (images: string[], startIndex: number) => void; }) { if (urls.length === 0) return null; const images = urls.map((url) => `${getApiUrl()}${url}`); return (
{images.map((image, index) => ( ))}
); } export default function IndicesPage() { const MIN_ZOOM = 1; const MAX_ZOOM = 4; const [indices, setIndices] = useState(null); const [currentProject, setCurrentProject] = useState(null); const [loading, setLoading] = useState(true); const [message, setMessage] = useState(""); const [activeParentTab, setActiveParentTab] = useState("MAMAN"); const [activeTrimesterByBaby, setActiveTrimesterByBaby] = useState>({}); const [projectRefreshKey, setProjectRefreshKey] = useState(0); const [isAdmin, setIsAdmin] = useState(false); const [lightboxImages, setLightboxImages] = useState([]); const [lightboxIndex, setLightboxIndex] = useState(null); const [lightboxZoom, setLightboxZoom] = useState(1); const [lightboxOffset, setLightboxOffset] = useState({ x: 0, y: 0 }); const imageWrapRef = useRef(null); const activePointerIdRef = useRef(null); const lastPointerRef = useRef<{ x: number; y: number } | null>(null); const lastPinchDistanceRef = useRef(null); const lastPinchMidpointRef = useRef<{ x: number; y: number } | null>(null); const lastTouchPointRef = useRef<{ x: number; y: number } | null>(null); useEffect(() => { const session = loadSession(); if (session?.user.role === "ADMIN") setIsAdmin(true); }, []); useEffect(() => { const handler = () => setProjectRefreshKey((k) => k + 1); window.addEventListener(PROJECT_CHANGED_EVENT, handler); return () => window.removeEventListener(PROJECT_CHANGED_EVENT, handler); }, []); useEffect(() => { if (lightboxIndex == null) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setLightboxIndex(null); setLightboxImages([]); setLightboxZoom(1); setLightboxOffset({ x: 0, y: 0 }); return; } if (event.key === "+" || event.key === "=") { event.preventDefault(); setLightboxZoom((current) => Math.min(current + 0.25, MAX_ZOOM)); } if (event.key === "-") { event.preventDefault(); setLightboxZoom((current) => { const next = Math.max(current - 0.25, MIN_ZOOM); if (next === MIN_ZOOM) setLightboxOffset({ x: 0, y: 0 }); return next; }); } if (lightboxImages.length <= 1) return; if (event.key === "ArrowLeft") { setLightboxIndex((current) => { if (current == null) return current; return (current - 1 + lightboxImages.length) % lightboxImages.length; }); } if (event.key === "ArrowRight") { setLightboxIndex((current) => { if (current == null) return current; return (current + 1) % lightboxImages.length; }); } }; const previousOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; window.addEventListener("keydown", handleKeyDown); return () => { document.body.style.overflow = previousOverflow; window.removeEventListener("keydown", handleKeyDown); }; }, [lightboxImages.length, lightboxIndex, MAX_ZOOM, MIN_ZOOM]); useEffect(() => { if (lightboxZoom <= MIN_ZOOM) { setLightboxOffset({ x: 0, y: 0 }); return; } const wrap = imageWrapRef.current; if (!wrap) return; const maxX = ((lightboxZoom - 1) * wrap.clientWidth) / 2; const maxY = ((lightboxZoom - 1) * wrap.clientHeight) / 2; setLightboxOffset((current) => ({ x: Math.max(-maxX, Math.min(maxX, current.x)), y: Math.max(-maxY, Math.min(maxY, current.y)), })); }, [lightboxZoom, MIN_ZOOM]); const loadData = useCallback(async () => { setLoading(true); setMessage(""); try { const projects = await getMyProjects(); const currentProjectId = getCurrentProjectId(); const project = projects.find((p) => p.id === currentProjectId) ?? projects[0] ?? null; setCurrentProject(project); if (!project) { setIndices(null); return; } const data = await getProjectIndices(project.id); setIndices(data); } catch (err) { setMessage(err instanceof Error ? err.message : "Impossible de charger les indices"); } finally { setLoading(false); } }, []); useEffect(() => { void loadData(); }, [loadData, projectRefreshKey]); const setActiveTrimester = (babyId: string, trimester: Trimester) => { setActiveTrimesterByBaby((prev) => ({ ...prev, [babyId]: trimester })); }; const papaIndices = indices?.parentIndices.find((p) => p.parentType === "PAPA") ?? null; const mamanIndices = indices?.parentIndices.find((p) => p.parentType === "MAMAN") ?? null; const activeParent = activeParentTab === "PAPA" ? papaIndices : mamanIndices; const currentImage = lightboxIndex != null ? lightboxImages[lightboxIndex] : null; const openLightbox = (images: string[], startIndex: number) => { setLightboxImages(images); setLightboxIndex(startIndex); setLightboxZoom(1); setLightboxOffset({ x: 0, y: 0 }); }; const closeLightbox = () => { setLightboxIndex(null); setLightboxImages([]); setLightboxZoom(1); setLightboxOffset({ x: 0, y: 0 }); }; const showPreviousImage = () => { if (lightboxImages.length <= 1) return; setLightboxIndex((current) => { if (current == null) return current; return (current - 1 + lightboxImages.length) % lightboxImages.length; }); setLightboxZoom(1); setLightboxOffset({ x: 0, y: 0 }); }; const showNextImage = () => { if (lightboxImages.length <= 1) return; setLightboxIndex((current) => { if (current == null) return current; return (current + 1) % lightboxImages.length; }); setLightboxZoom(1); setLightboxOffset({ x: 0, y: 0 }); }; const applyPan = (deltaX: number, deltaY: number) => { const wrap = imageWrapRef.current; if (!wrap || lightboxZoom <= MIN_ZOOM) return; const maxX = ((lightboxZoom - 1) * wrap.clientWidth) / 2; const maxY = ((lightboxZoom - 1) * wrap.clientHeight) / 2; setLightboxOffset((current) => ({ x: Math.max(-maxX, Math.min(maxX, current.x + deltaX)), y: Math.max(-maxY, Math.min(maxY, current.y + deltaY)), })); }; const updateZoom = (targetZoom: number | ((current: number) => number)) => { setLightboxZoom((current) => { const requestedZoom = typeof targetZoom === "function" ? targetZoom(current) : targetZoom; const nextZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, requestedZoom)); if (nextZoom <= MIN_ZOOM) { setLightboxOffset({ x: 0, y: 0 }); } return nextZoom; }); }; const handleImageWheel = (event: ReactWheelEvent) => { event.preventDefault(); const step = event.deltaY < 0 ? 0.15 : -0.15; updateZoom((current) => current + step); }; const handleImagePointerDown = (event: ReactPointerEvent) => { if (event.pointerType === "touch") return; if (event.pointerType === "mouse" && event.button !== 0) return; if (event.target instanceof HTMLElement && event.target.closest("button")) return; if (lightboxZoom <= MIN_ZOOM) return; activePointerIdRef.current = event.pointerId; lastPointerRef.current = { x: event.clientX, y: event.clientY }; event.currentTarget.setPointerCapture(event.pointerId); }; const handleImagePointerMove = (event: ReactPointerEvent) => { if (activePointerIdRef.current !== event.pointerId || !lastPointerRef.current) return; const deltaX = event.clientX - lastPointerRef.current.x; const deltaY = event.clientY - lastPointerRef.current.y; lastPointerRef.current = { x: event.clientX, y: event.clientY }; applyPan(deltaX, deltaY); }; const handleImagePointerUp = (event: ReactPointerEvent) => { if (activePointerIdRef.current === event.pointerId) { activePointerIdRef.current = null; lastPointerRef.current = null; event.currentTarget.releasePointerCapture(event.pointerId); } }; const handleImageTouchStart = (event: ReactTouchEvent) => { if (event.target instanceof HTMLElement && event.target.closest("button")) { lastTouchPointRef.current = null; return; } if (event.touches.length === 1) { const touch = event.touches[0]; lastTouchPointRef.current = { x: touch.clientX, y: touch.clientY }; lastPinchDistanceRef.current = null; lastPinchMidpointRef.current = null; return; } if (event.touches.length === 2) { const [a, b] = [event.touches[0], event.touches[1]]; lastPinchDistanceRef.current = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); lastPinchMidpointRef.current = { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2, }; lastTouchPointRef.current = null; } }; const handleImageTouchMove = (event: ReactTouchEvent) => { if (event.target instanceof HTMLElement && event.target.closest("button")) return; if (event.touches.length === 1) { if (lightboxZoom <= MIN_ZOOM) return; event.preventDefault(); const touch = event.touches[0]; if (!lastTouchPointRef.current) { lastTouchPointRef.current = { x: touch.clientX, y: touch.clientY }; return; } const deltaX = touch.clientX - lastTouchPointRef.current.x; const deltaY = touch.clientY - lastTouchPointRef.current.y; lastTouchPointRef.current = { x: touch.clientX, y: touch.clientY }; applyPan(deltaX, deltaY); return; } if (event.touches.length !== 2) { lastPinchDistanceRef.current = null; lastPinchMidpointRef.current = null; return; } event.preventDefault(); const [a, b] = [event.touches[0], event.touches[1]]; const distance = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); const midpoint = { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 }; if (lastPinchDistanceRef.current != null && lastPinchDistanceRef.current > 0) { const ratio = distance / lastPinchDistanceRef.current; updateZoom((current) => current * ratio); } if (lastPinchMidpointRef.current && lightboxZoom > MIN_ZOOM) { const moveX = midpoint.x - lastPinchMidpointRef.current.x; const moveY = midpoint.y - lastPinchMidpointRef.current.y; applyPan(moveX, moveY); } lastPinchDistanceRef.current = distance; lastPinchMidpointRef.current = midpoint; lastTouchPointRef.current = null; }; const handleImageTouchEnd = () => { lastPinchDistanceRef.current = null; lastPinchMidpointRef.current = null; lastTouchPointRef.current = null; }; const toggleZoom = () => { if (lightboxZoom > MIN_ZOOM) { updateZoom(MIN_ZOOM); return; } updateZoom(2); }; const hasAnyParentData = (parent: typeof papaIndices) => parent && (parent.poids || parent.taille || parent.perimCranien || parent.dateNaissance || parent.photos.length > 0); return ( <>
Carnet d'indices

Petites pistes de famille

Retrouvez les infos de maman et papa à leur naissance, puis l'évolution de bébé au fil des trimestres dans le ventre de maman.

Indices de grossesse

{isAdmin && currentProject ? (

Vous êtes administrateur — vous pouvez modifier ces informations.

Modifier les indices
) : null} {message ? (

{message}

) : null} {loading ? (

Chargement des indices...

) : null} {!loading && !currentProject ? (

Aucun projet sélectionné.

) : null} {!loading && currentProject && ( <> {/* ===== PARENTS ===== */}

Les parents

{(["MAMAN", "PAPA"] as ParentType[]).map((type) => ( ))}
{activeParent && hasAnyParentData(activeParent) ? (
{activeParent.poids != null && ( )} {activeParent.taille != null && ( )} {activeParent.perimCranien != null && ( )} {activeParent.dateNaissance && ( )}
{activeParent.photos.length > 0 && ( p.url)} onOpen={openLightbox} /> )}
) : (

{`Aucune information renseignée pour ${PARENT_LABELS[activeParentTab].toLowerCase()} pour l'instant.`}

)}
{/* ===== BÉBÉS ===== */} {(indices?.babyIndices ?? []).map((baby) => { const babyLabel = currentProject.babies?.find((b) => b.babyIndex === baby.babyIndex)?.label ?? `Bébé ${baby.babyIndex}`; const visibleTrimesters = baby.trimesters .filter((tri) => hasTrimesterData(tri)) .sort( (a, b) => TRIMESTER_ORDER.indexOf(a.trimester as Trimester) - TRIMESTER_ORDER.indexOf(b.trimester as Trimester), ); return (

{babyLabel}

{baby.dpa && (
Date prévue d'accouchement {formatDate(baby.dpa)}
)} {visibleTrimesters.length === 0 && !baby.dpa ? (

Aucune information renseignée pour ce bébé.

) : null} {visibleTrimesters.length > 0 ? (() => { const stored = activeTrimesterByBaby[baby.id]; const fallback = visibleTrimesters[0].trimester as Trimester; const activeTri = visibleTrimesters.find((t) => t.trimester === stored)?.trimester as Trimester | undefined; const currentTrimester = activeTri ?? fallback; const tri = visibleTrimesters.find((t) => t.trimester === currentTrimester) ?? visibleTrimesters[0]; return (
{visibleTrimesters.map((t) => { const isActive = t.trimester === currentTrimester; return ( ); })}

{TRIMESTER_LABELS[tri.trimester as Trimester]}

{tri.date && } {tri.poids != null && } {tri.taille != null && } {tri.perimCranien != null && }
{tri.note && (

{tri.note}

)} {tri.photos.length > 0 && ( p.url)} onOpen={openLightbox} /> )}
); })() : null}
); })} {/* Si pas de données bébé du tout */} {(indices?.babyIndices ?? []).length === 0 && (

{`Les informations sur ${currentProject.babyCount > 1 ? "les bébés" : "le bébé"} n'ont pas encore été renseignées.`}

)} )} {currentImage && lightboxIndex != null && (
event.stopPropagation()} >
{lightboxImages.length > 1 && ( )} {/* eslint-disable-next-line @next/next/no-img-element */} {lightboxImages.length > 1 && ( )}
{lightboxIndex + 1} / {lightboxImages.length}
{Math.round(lightboxZoom * 100)}%
)} ); }