Files
mybabyguess/apps/web/src/app/predictions/indices/page.tsx
T
2026-05-03 21:58:59 +02:00

709 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<ParentType, string> = {
PAPA: "Papa",
MAMAN: "Maman",
};
const TRIMESTER_LABELS: Record<Trimester, string> = {
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 (
<div className={indicesStyles.statRow}>
<span className={indicesStyles.statLabel}>{label}</span>
<span className={indicesStyles.statValue}>{value}</span>
</div>
);
}
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 (
<div className={indicesStyles.photoGrid}>
{images.map((image, index) => (
<button
key={`${image}-${index}`}
type="button"
className={indicesStyles.photoThumb}
onClick={() => onOpen(images, index)}
aria-label={`Ouvrir la photo ${index + 1}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image} alt="" />
</button>
))}
</div>
);
}
export default function IndicesPage() {
const MIN_ZOOM = 1;
const MAX_ZOOM = 4;
const [indices, setIndices] = useState<ProjectIndicesResponse | null>(null);
const [currentProject, setCurrentProject] = useState<ProjectSummary | null>(null);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState("");
const [activeParentTab, setActiveParentTab] = useState<ParentType>("MAMAN");
const [activeTrimesterByBaby, setActiveTrimesterByBaby] = useState<Record<string, Trimester>>({});
const [projectRefreshKey, setProjectRefreshKey] = useState(0);
const [isAdmin, setIsAdmin] = useState(false);
const [lightboxImages, setLightboxImages] = useState<string[]>([]);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [lightboxZoom, setLightboxZoom] = useState(1);
const [lightboxOffset, setLightboxOffset] = useState({ x: 0, y: 0 });
const imageWrapRef = useRef<HTMLDivElement | null>(null);
const activePointerIdRef = useRef<number | null>(null);
const lastPointerRef = useRef<{ x: number; y: number } | null>(null);
const lastPinchDistanceRef = useRef<number | null>(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<HTMLDivElement>) => {
event.preventDefault();
const step = event.deltaY < 0 ? 0.15 : -0.15;
updateZoom((current) => current + step);
};
const handleImagePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (activePointerIdRef.current === event.pointerId) {
activePointerIdRef.current = null;
lastPointerRef.current = null;
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
const handleImageTouchStart = (event: ReactTouchEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<>
<section className={`${styles.recapHero} ${styles.indicesHero}`}>
<div className={styles.recapHeroContent}>
<span className={styles.recapHeroEyebrow}>Carnet d&apos;indices</span>
<h1>Petites pistes de famille</h1>
<p>
Retrouvez les infos de maman et papa à leur naissance, puis l&apos;évolution de bébé au fil des trimestres dans le ventre de maman.
</p>
<div className={styles.recapHeroStats} aria-hidden="true">
<div className={styles.recapHeroStatChip}>
<span className={styles.recapHeroStatValue}>Maman</span>
<span className={styles.recapHeroStatLabel}>Naissance</span>
</div>
<div className={styles.recapHeroStatChip}>
<span className={styles.recapHeroStatValue}>Papa</span>
<span className={styles.recapHeroStatLabel}>Naissance</span>
</div>
<div className={styles.recapHeroStatChip}>
<span className={styles.recapHeroStatValue}>T1-T3</span>
<span className={styles.recapHeroStatLabel}>Bébé</span>
</div>
</div>
</div>
<div className={styles.indicesHeroIllustration} aria-hidden="true">
<span className={styles.indicesHeroCardBack} />
<span className={styles.indicesHeroCardMiddle} />
<span className={styles.indicesHeroCardFront}>
<span className={styles.indicesHeroCardAccent} />
<span className={styles.indicesHeroCardLineStrong} />
<span className={styles.indicesHeroCardLineSoft} />
</span>
<span className={styles.indicesHeroCardBadge} />
</div>
</section>
<MainTabBar />
<section className={styles.sectionTitle}>
<h2>Indices de grossesse</h2>
</section>
{isAdmin && currentProject ? (
<section className={`panel ${indicesStyles.editBanner}`}>
<p>Vous êtes administrateur vous pouvez modifier ces informations.</p>
<Link className="btn btn-soft" href={`/admin/projects/${currentProject.id}/indices`}>
Modifier les indices
</Link>
</section>
) : null}
{message ? (
<section className={`panel ${indicesStyles.messagePanel}`}>
<p className={styles.error}>{message}</p>
</section>
) : null}
{loading ? (
<section className={`panel ${indicesStyles.messagePanel}`}>
<p>Chargement des indices...</p>
</section>
) : null}
{!loading && !currentProject ? (
<section className={`panel ${indicesStyles.messagePanel}`}>
<p className={styles.empty}>Aucun projet sélectionné.</p>
</section>
) : null}
{!loading && currentProject && (
<>
{/* ===== PARENTS ===== */}
<section className={`panel ${indicesStyles.section}`}>
<h3 className={indicesStyles.sectionHeading}>Les parents</h3>
<div className={indicesStyles.parentTabs}>
{(["MAMAN", "PAPA"] as ParentType[]).map((type) => (
<button
key={type}
type="button"
className={`${indicesStyles.parentTab} ${activeParentTab === type ? indicesStyles.parentTabActive : ""}`}
onClick={() => setActiveParentTab(type)}
>
{PARENT_LABELS[type]}
</button>
))}
</div>
{activeParent && hasAnyParentData(activeParent) ? (
<div className={indicesStyles.cardBlock}>
<div className={indicesStyles.statsGrid}>
{activeParent.poids != null && (
<StatRow label="Poids" value={`${activeParent.poids} kg`} />
)}
{activeParent.taille != null && (
<StatRow label="Taille" value={`${activeParent.taille} cm`} />
)}
{activeParent.perimCranien != null && (
<StatRow label="Périm. crânien" value={`${activeParent.perimCranien} cm`} />
)}
{activeParent.dateNaissance && (
<StatRow label="Date de naissance" value={formatDate(activeParent.dateNaissance)} />
)}
</div>
{activeParent.photos.length > 0 && (
<PhotoGallery
urls={activeParent.photos.map((p) => p.url)}
onOpen={openLightbox}
/>
)}
</div>
) : (
<p className={indicesStyles.emptyHint}>
{`Aucune information renseignée pour ${PARENT_LABELS[activeParentTab].toLowerCase()} pour l'instant.`}
</p>
)}
</section>
{/* ===== 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 (
<section key={baby.id} className={`panel ${indicesStyles.section}`}>
<h3 className={indicesStyles.sectionHeading}>{babyLabel}</h3>
{baby.dpa && (
<div className={indicesStyles.dpaBadge}>
<span className={indicesStyles.dpaLabel}>Date prévue d&apos;accouchement</span>
<span className={indicesStyles.dpaValue}>{formatDate(baby.dpa)}</span>
</div>
)}
{visibleTrimesters.length === 0 && !baby.dpa ? (
<p className={indicesStyles.emptyHint}>Aucune information renseignée pour ce bébé.</p>
) : 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 (
<div className={indicesStyles.trimesterTabsWrapper}>
<div
className={indicesStyles.trimesterTabs}
role="tablist"
aria-label="Choisir un trimestre"
>
{visibleTrimesters.map((t) => {
const isActive = t.trimester === currentTrimester;
return (
<button
key={t.id}
type="button"
role="tab"
aria-selected={isActive}
className={`${indicesStyles.trimesterTab} ${isActive ? indicesStyles.trimesterTabActive : ""}`}
onClick={() => setActiveTrimester(baby.id, t.trimester as Trimester)}
>
{t.trimester === "DATATION" ? "Datation" : t.trimester}
</button>
);
})}
</div>
<div className={indicesStyles.trimesterPanel} role="tabpanel">
<h4 className={indicesStyles.trimesterPanelTitle}>{TRIMESTER_LABELS[tri.trimester as Trimester]}</h4>
<div className={indicesStyles.statsGrid}>
{tri.date && <StatRow label="Date écho" value={formatDate(tri.date)} />}
{tri.poids != null && <StatRow label="Poids estimé" value={`${tri.poids} kg`} />}
{tri.taille != null && <StatRow label="Taille estimée" value={`${tri.taille} cm`} />}
{tri.perimCranien != null && <StatRow label="Périm. crânien" value={`${tri.perimCranien} cm`} />}
</div>
{tri.note && (
<p className={indicesStyles.triNote}>{tri.note}</p>
)}
{tri.photos.length > 0 && (
<PhotoGallery
urls={tri.photos.map((p) => p.url)}
onOpen={openLightbox}
/>
)}
</div>
</div>
);
})() : null}
</section>
);
})}
{/* Si pas de données bébé du tout */}
{(indices?.babyIndices ?? []).length === 0 && (
<section className={`panel ${indicesStyles.messagePanel}`}>
<p className={indicesStyles.emptyHint}>
{`Les informations sur ${currentProject.babyCount > 1 ? "les bébés" : "le bébé"} n'ont pas encore été renseignées.`}
</p>
</section>
)}
</>
)}
{currentImage && lightboxIndex != null && (
<div className={indicesStyles.lightboxOverlay} onClick={closeLightbox}>
<div
className={indicesStyles.lightboxDialog}
role="dialog"
aria-modal="true"
aria-label="Aperçu photo"
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
className={indicesStyles.lightboxClose}
onClick={closeLightbox}
aria-label="Fermer"
>
×
</button>
<div
ref={imageWrapRef}
className={indicesStyles.lightboxImageWrap}
onWheel={handleImageWheel}
onPointerDown={handleImagePointerDown}
onPointerMove={handleImagePointerMove}
onPointerUp={handleImagePointerUp}
onPointerCancel={handleImagePointerUp}
onTouchStart={handleImageTouchStart}
onTouchMove={handleImageTouchMove}
onTouchEnd={handleImageTouchEnd}
onTouchCancel={handleImageTouchEnd}
onDoubleClick={toggleZoom}
>
{lightboxImages.length > 1 && (
<button
type="button"
className={`${indicesStyles.lightboxNav} ${indicesStyles.lightboxNavPrev}`}
onClick={showPreviousImage}
aria-label="Photo précédente"
>
</button>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className={indicesStyles.lightboxImage}
style={{ transform: `translate(${lightboxOffset.x}px, ${lightboxOffset.y}px) scale(${lightboxZoom})` }}
src={currentImage}
alt=""
draggable={false}
/>
{lightboxImages.length > 1 && (
<button
type="button"
className={`${indicesStyles.lightboxNav} ${indicesStyles.lightboxNavNext}`}
onClick={showNextImage}
aria-label="Photo suivante"
>
</button>
)}
</div>
<div className={indicesStyles.lightboxFooter}>
<span>{lightboxIndex + 1} / {lightboxImages.length}</span>
<div className={indicesStyles.lightboxActions}>
<button
type="button"
className={indicesStyles.lightboxZoomBtn}
onClick={() => updateZoom((current) => current - 0.25)}
aria-label="Dézoomer"
>
</button>
<span className={indicesStyles.lightboxZoomValue}>{Math.round(lightboxZoom * 100)}%</span>
<button
type="button"
className={indicesStyles.lightboxZoomBtn}
onClick={() => updateZoom((current) => current + 0.25)}
aria-label="Zoomer"
>
+
</button>
<button type="button" className="btn btn-soft" onClick={closeLightbox}>
Retour
</button>
</div>
</div>
</div>
</div>
)}
</>
);
}