init import projet

This commit is contained in:
2026-05-03 21:58:59 +02:00
parent f3756fdf8d
commit 8d3df9bbbb
179 changed files with 37694 additions and 132 deletions
@@ -0,0 +1,708 @@
"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>
)}
</>
);
}