init import projet

This commit is contained in:
2026-05-03 21:53:59 +02:00
parent f3756fdf8d
commit f4795e538c
179 changed files with 37694 additions and 132 deletions
@@ -0,0 +1,5 @@
.photoStepGrid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
@@ -0,0 +1,594 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { clearSession, getApiUrl, loadSession } from "@/lib/auth";
import { getMyProjects } from "@/lib/projects-client";
import {
deleteParentPhoto,
deleteTrimesterPhoto,
getProjectIndices,
uploadParentPhoto,
uploadTrimesterPhoto,
upsertBabyIndices,
upsertBabyTrimester,
upsertParentIndices,
} from "@/lib/indices-client";
import type {
BabyIndices,
ParentIndices,
ParentType,
ProjectIndicesResponse,
Trimester,
} from "@/types/indices";
import type { ProjectSummary } from "@/types/projects";
import { PhotoField } from "@/features/indices/components/PhotoField";
import styles from "./page.module.css";
import wizardStyles from "../../new/page.module.css";
const TRIMESTERS: Trimester[] = ["DATATION", "T1", "T2", "T3"];
const TRIMESTER_LABELS: Record<Trimester, string> = {
DATATION: "Datation (1ère échographie)",
T1: "1er trimestre",
T2: "2ème trimestre",
T3: "3ème trimestre",
};
type ParentDraft = {
poids: string;
taille: string;
perimCranien: string;
dateNaissance: string;
};
type TrimesterDraft = {
date: string;
note: string;
poids: string;
taille: string;
perimCranien: string;
};
function emptyParentDraft(): ParentDraft {
return { poids: "", taille: "", perimCranien: "", dateNaissance: "" };
}
function emptyTrimesterDraft(): TrimesterDraft {
return { date: "", note: "", poids: "", taille: "", perimCranien: "" };
}
function parentToForm(p: ParentIndices | undefined): ParentDraft {
if (!p) return emptyParentDraft();
return {
poids: p.poids != null ? String(p.poids) : "",
taille: p.taille != null ? String(p.taille) : "",
perimCranien: p.perimCranien != null ? String(p.perimCranien) : "",
dateNaissance: p.dateNaissance ? p.dateNaissance.slice(0, 10) : "",
};
}
function babyTrimesterToForm(baby: BabyIndices | undefined, tri: Trimester): TrimesterDraft {
const entry = baby?.trimesters.find((t) => t.trimester === tri);
if (!entry) return emptyTrimesterDraft();
return {
date: entry.date ? entry.date.slice(0, 10) : "",
note: entry.note ?? "",
poids: entry.poids != null ? String(entry.poids) : "",
taille: entry.taille != null ? String(entry.taille) : "",
perimCranien: entry.perimCranien != null ? String(entry.perimCranien) : "",
};
}
function parseOptionalFloat(val: string): number | undefined {
const n = parseFloat(val);
return Number.isNaN(n) ? undefined : n;
}
/* ─────────────────────────── Page ─────────────────────────── */
export default function AdminIndicesPage() {
const params = useParams<{ projectId: string }>();
const projectId = params.projectId;
const router = useRouter();
const [ready, setReady] = useState(false);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const [stepIndex, setStepIndex] = useState(0);
const [project, setProject] = useState<ProjectSummary | null>(null);
const [indices, setIndices] = useState<ProjectIndicesResponse | null>(null);
// Papa
const [papaDraft, setPapaDraft] = useState<ParentDraft>(emptyParentDraft());
const [papaPhotos, setPapaPhotos] = useState<Array<{ id: string; url: string }>>([]);
// Maman
const [mamanDraft, setManamDraft] = useState<ParentDraft>(emptyParentDraft());
const [mamanPhotos, setManamPhotos] = useState<Array<{ id: string; url: string }>>([]);
// Bébés — datation + trimestres + DPA
const [babyDpas, setBabyDpas] = useState<Record<number, string>>({});
const [trimesterDrafts, setTrimesterDrafts] = useState<Record<string, TrimesterDraft>>({});
const [trimesterPhotos, setTrimesterPhotos] = useState<Record<string, Array<{ id: string; url: string }>>>({});
const apiBaseUrl = getApiUrl();
const babyCount = project?.babyCount ?? 1;
// Steps: 0=papa mesures, 1=papa photos, 2=maman mesures, 3=maman photos,
// puis par bébé: 4+4n=datation(+DPA), 4+4n+1=T1, 4+4n+2=T2, 4+4n+3=T3
// Dernier step = recap
const totalBabySteps = babyCount * 4;
const totalSteps = 4 + totalBabySteps + 1; // +1 recap
const recapStepIndex = totalSteps - 1;
const getBabyLabel = useCallback(
(idx: number) => project?.babies?.find((b) => b.babyIndex === idx)?.label ?? `Bébé ${idx}`,
[project],
);
const getBabyStepInfo = (step: number): { babyIndex: number; trimester: Trimester } | null => {
if (step < 4 || step >= 4 + totalBabySteps) return null;
const offset = step - 4;
const babyIndex = Math.floor(offset / 4) + 1;
const triIndex = offset % 4;
return { babyIndex, trimester: TRIMESTERS[triIndex] };
};
const triKey = (babyIndex: number, tri: Trimester) => `${babyIndex}-${tri}`;
/* ─── Init ─── */
useEffect(() => {
const session = loadSession();
if (!session) { router.replace("/"); return; }
if (session.user.role !== "ADMIN") { router.replace("/predictions"); return; }
Promise.all([getMyProjects(), getProjectIndices(projectId)])
.then(([projects, idx]) => {
const proj = projects.find((p) => p.id === projectId) ?? null;
setProject(proj);
setIndices(idx);
// Init papa
const papa = idx.parentIndices.find((p) => p.parentType === "PAPA");
setPapaDraft(parentToForm(papa));
setPapaPhotos(papa?.photos.map((ph) => ({ id: ph.id, url: ph.url })) ?? []);
// Init maman
const maman = idx.parentIndices.find((p) => p.parentType === "MAMAN");
setManamDraft(parentToForm(maman));
setManamPhotos(maman?.photos.map((ph) => ({ id: ph.id, url: ph.url })) ?? []);
// Init bébés
const dpas: Record<number, string> = {};
const triDrafts: Record<string, TrimesterDraft> = {};
const triPh: Record<string, Array<{ id: string; url: string }>> = {};
const bc = proj?.babyCount ?? 1;
for (let i = 1; i <= bc; i++) {
const baby = idx.babyIndices.find((b) => b.babyIndex === i);
dpas[i] = baby?.dpa ? baby.dpa.slice(0, 10) : "";
for (const tri of TRIMESTERS) {
triDrafts[triKey(i, tri)] = babyTrimesterToForm(baby, tri);
const existingTri = baby?.trimesters.find((t) => t.trimester === tri);
triPh[triKey(i, tri)] = existingTri?.photos.map((ph) => ({ id: ph.id, url: ph.url })) ?? [];
}
}
setBabyDpas(dpas);
setTrimesterDrafts(triDrafts);
setTrimesterPhotos(triPh);
})
.catch((err) => {
setMessage(err instanceof Error ? err.message : "Erreur de chargement");
})
.finally(() => setReady(true));
}, [projectId, router]);
/* ─── Save current step ─── */
const saveCurrentStep = useCallback(async () => {
setLoading(true);
setMessage("");
try {
if (stepIndex === 0) {
await upsertParentIndices(projectId, "PAPA", {
poids: parseOptionalFloat(papaDraft.poids),
taille: parseOptionalFloat(papaDraft.taille),
perimCranien: parseOptionalFloat(papaDraft.perimCranien),
dateNaissance: papaDraft.dateNaissance || null,
});
} else if (stepIndex === 2) {
await upsertParentIndices(projectId, "MAMAN", {
poids: parseOptionalFloat(mamanDraft.poids),
taille: parseOptionalFloat(mamanDraft.taille),
perimCranien: parseOptionalFloat(mamanDraft.perimCranien),
dateNaissance: mamanDraft.dateNaissance || null,
});
} else {
const babyStep = getBabyStepInfo(stepIndex);
if (babyStep) {
const { babyIndex, trimester } = babyStep;
const triOffset = stepIndex - 4 - (babyIndex - 1) * 4;
if (triOffset === 0) {
// Datation step: DPA + données de la première échographie
await upsertBabyIndices(projectId, babyIndex, { dpa: babyDpas[babyIndex] || null });
}
const draft = trimesterDrafts[triKey(babyIndex, trimester)] ?? emptyTrimesterDraft();
await upsertBabyTrimester(projectId, babyIndex, trimester, {
date: draft.date || null,
note: draft.note || null,
poids: parseOptionalFloat(draft.poids),
taille: parseOptionalFloat(draft.taille),
perimCranien: parseOptionalFloat(draft.perimCranien),
});
}
}
} catch (err) {
if (err instanceof Error && err.message.includes("Session")) {
clearSession();
router.replace("/");
return false;
}
setMessage(err instanceof Error ? err.message : "Erreur de sauvegarde");
return false;
} finally {
setLoading(false);
}
return true;
}, [stepIndex, projectId, papaDraft, mamanDraft, babyDpas, trimesterDrafts, getBabyStepInfo, router]);
const goNext = async () => {
if (stepIndex === recapStepIndex) return;
const photoStep = stepIndex === 1 || stepIndex === 3;
let ok = true;
if (!photoStep) {
ok = (await saveCurrentStep()) !== false;
}
if (ok) setStepIndex((s) => s + 1);
};
const goBack = () => setStepIndex((s) => Math.max(0, s - 1));
/* ─── Photo upload ─── */
const handleParentPhotosAdd = async (parentType: ParentType, files: File[]) => {
setLoading(true);
try {
for (const file of files) {
const photo = await uploadParentPhoto(projectId, parentType, file);
if (parentType === "PAPA") {
setPapaPhotos((prev) => [...prev, { id: photo.id, url: photo.url }]);
} else {
setManamPhotos((prev) => [...prev, { id: photo.id, url: photo.url }]);
}
}
} catch (err) {
setMessage(err instanceof Error ? err.message : "Erreur upload");
} finally {
setLoading(false);
}
};
const handleTrimesterPhotosAdd = async (babyIndex: number, trimester: Trimester, files: File[]) => {
setLoading(true);
try {
for (const file of files) {
const photo = await uploadTrimesterPhoto(projectId, babyIndex, trimester, file);
const k = triKey(babyIndex, trimester);
setTrimesterPhotos((prev) => ({ ...prev, [k]: [...(prev[k] ?? []), { id: photo.id, url: photo.url }] }));
}
} catch (err) {
setMessage(err instanceof Error ? err.message : "Erreur upload");
} finally {
setLoading(false);
}
};
const removeParentPhoto = async (parentType: ParentType, photoId: string) => {
setLoading(true);
try {
await deleteParentPhoto(projectId, parentType, photoId);
if (parentType === "PAPA") {
setPapaPhotos((prev) => prev.filter((p) => p.id !== photoId));
} else {
setManamPhotos((prev) => prev.filter((p) => p.id !== photoId));
}
} catch (err) {
setMessage(err instanceof Error ? err.message : "Erreur suppression");
} finally {
setLoading(false);
}
};
const removeTrimesterPhoto = async (babyIndex: number, trimester: Trimester, photoId: string) => {
setLoading(true);
try {
await deleteTrimesterPhoto(projectId, babyIndex, trimester, photoId);
const k = triKey(babyIndex, trimester);
setTrimesterPhotos((prev) => ({ ...prev, [k]: (prev[k] ?? []).filter((p) => p.id !== photoId) }));
} catch (err) {
setMessage(err instanceof Error ? err.message : "Erreur suppression");
} finally {
setLoading(false);
}
};
/* ─── Helpers render ─── */
const renderParentForm = (parentType: ParentType, draft: ParentDraft, setDraft: (d: ParentDraft) => void) => {
const label = parentType === "PAPA" ? "Papa" : "Maman";
return (
<div className={wizardStyles.formGrid}>
<p className={wizardStyles.helpText}>
Renseigne les informations physiques de naissance de {label}. Tous les champs sont optionnels.
</p>
<div className="field">
<label htmlFor={`${parentType}-poids`}>Poids de naissance (kg)</label>
<input
id={`${parentType}-poids`}
type="number"
step="0.1"
placeholder="ex: 3.4"
value={draft.poids}
onChange={(e) => setDraft({ ...draft, poids: e.target.value })}
/>
</div>
<div className="field">
<label htmlFor={`${parentType}-taille`}>Taille de naissance (cm)</label>
<input
id={`${parentType}-taille`}
type="number"
step="1"
placeholder="ex: 51"
value={draft.taille}
onChange={(e) => setDraft({ ...draft, taille: e.target.value })}
/>
</div>
<div className="field">
<label htmlFor={`${parentType}-perim`}>Périmètre crânien de naissance (cm)</label>
<input
id={`${parentType}-perim`}
type="number"
step="0.1"
placeholder="ex: 35"
value={draft.perimCranien}
onChange={(e) => setDraft({ ...draft, perimCranien: e.target.value })}
/>
</div>
<div className="field">
<label htmlFor={`${parentType}-dob`}>Date de naissance</label>
<input
id={`${parentType}-dob`}
type="date"
value={draft.dateNaissance}
onChange={(e) => setDraft({ ...draft, dateNaissance: e.target.value })}
/>
</div>
</div>
);
};
const renderPhotoStep = (
parentType: ParentType,
photos: Array<{ id: string; url: string }>,
label: string,
) => (
<div className={styles.photoStepGrid}>
<p className={wizardStyles.helpText}>
Ajoute jusqu&apos;à 5 photos pour {label} (échographies, portraits...).
</p>
<PhotoField
photos={photos}
maxPhotos={5}
loading={loading}
apiBaseUrl={apiBaseUrl}
onAdd={(files) => void handleParentPhotosAdd(parentType, files)}
onRemove={(id) => void removeParentPhoto(parentType, id)}
/>
</div>
);
const renderTrimesterStep = (babyIndex: number, trimester: Trimester) => {
const k = triKey(babyIndex, trimester);
const draft = trimesterDrafts[k] ?? emptyTrimesterDraft();
const photos = trimesterPhotos[k] ?? [];
const triOffset = (stepIndex - 4) % 4;
const isDpaStep = triOffset === 0;
return (
<div className={wizardStyles.formGrid}>
{isDpaStep && (
<div className="field">
<label htmlFor={`dpa-${babyIndex}`}>
Date prévue d&apos;accouchement {getBabyLabel(babyIndex)}
</label>
<input
id={`dpa-${babyIndex}`}
type="date"
value={babyDpas[babyIndex] ?? ""}
onChange={(e) => setBabyDpas((prev) => ({ ...prev, [babyIndex]: e.target.value }))}
/>
<p className={wizardStyles.helpText}>Optionnel peut être renseignée plus tard.</p>
</div>
)}
<p className={wizardStyles.helpText}>
{TRIMESTER_LABELS[trimester]} données d&apos;échographie pour {getBabyLabel(babyIndex)}. Tous les champs sont optionnels.
</p>
<div className="field">
<label htmlFor={`${k}-date`}>Date de l&apos;échographie</label>
<input
id={`${k}-date`}
type="date"
value={draft.date}
onChange={(e) => setTrimesterDrafts((prev) => ({ ...prev, [k]: { ...draft, date: e.target.value } }))}
/>
</div>
<div className="field">
<label htmlFor={`${k}-poids`}>Poids estimé (kg)</label>
<input
id={`${k}-poids`}
type="number"
step="0.001"
placeholder="ex: 0.185"
value={draft.poids}
onChange={(e) => setTrimesterDrafts((prev) => ({ ...prev, [k]: { ...draft, poids: e.target.value } }))}
/>
</div>
<div className="field">
<label htmlFor={`${k}-taille`}>Taille estimée (cm)</label>
<input
id={`${k}-taille`}
type="number"
step="0.1"
placeholder="ex: 16"
value={draft.taille}
onChange={(e) => setTrimesterDrafts((prev) => ({ ...prev, [k]: { ...draft, taille: e.target.value } }))}
/>
</div>
<div className="field">
<label htmlFor={`${k}-perim`}>Périmètre crânien (cm)</label>
<input
id={`${k}-perim`}
type="number"
step="0.1"
placeholder="ex: 12"
value={draft.perimCranien}
onChange={(e) => setTrimesterDrafts((prev) => ({ ...prev, [k]: { ...draft, perimCranien: e.target.value } }))}
/>
</div>
<div className="field">
<label htmlFor={`${k}-note`}>Note / observations</label>
<textarea
id={`${k}-note`}
rows={3}
placeholder="Tout va bien, bébé est en bonne santé..."
value={draft.note}
onChange={(e) => setTrimesterDrafts((prev) => ({ ...prev, [k]: { ...draft, note: e.target.value } }))}
/>
</div>
{/* Photos trimestre */}
<div>
<p className={wizardStyles.helpText}>Photos / échographies (max 5)</p>
<PhotoField
photos={photos}
maxPhotos={5}
loading={loading}
apiBaseUrl={apiBaseUrl}
onAdd={(files) => void handleTrimesterPhotosAdd(babyIndex, trimester, files)}
onRemove={(id) => void removeTrimesterPhoto(babyIndex, trimester, id)}
/>
</div>
</div>
);
};
const getStepTitle = (): string => {
if (stepIndex === 0) return "Papa — informations de naissance";
if (stepIndex === 1) return "Papa — photos";
if (stepIndex === 2) return "Maman — informations de naissance";
if (stepIndex === 3) return "Maman — photos";
if (stepIndex === recapStepIndex) return "Récapitulatif";
const babyStep = getBabyStepInfo(stepIndex);
if (babyStep) {
return `${getBabyLabel(babyStep.babyIndex)}${TRIMESTER_LABELS[babyStep.trimester]}`;
}
return "";
};
if (!ready) {
return (
<main className={`app-shell ${wizardStyles.page}`}>
<section className={`panel ${wizardStyles.panel}`}>
<p>Chargement...</p>
</section>
</main>
);
}
return (
<main className={`app-shell ${wizardStyles.page}`}>
<section className={`panel ${wizardStyles.header}`}>
<div>
<p className="mono">Admin / Projets / {project?.name ?? projectId} / Indices</p>
<h1>Indices de grossesse</h1>
<p>Renseigne les informations physiques de naissance des parents et les données du bébé, trimestre par trimestre.</p>
</div>
<div className={wizardStyles.headerActions}>
<Link className="btn btn-soft" href="/admin">
Retour admin
</Link>
</div>
</section>
<section className={`panel ${wizardStyles.panel}`}>
<div className={wizardStyles.stepHeader}>
<span className={wizardStyles.stepPill}>
Étape {stepIndex + 1} / {totalSteps}
</span>
<strong style={{ fontSize: "0.95rem" }}>{getStepTitle()}</strong>
</div>
{/* ── Étapes ── */}
{stepIndex === 0 && renderParentForm("PAPA", papaDraft, setPapaDraft)}
{stepIndex === 1 && renderPhotoStep("PAPA", papaPhotos, "papa")}
{stepIndex === 2 && renderParentForm("MAMAN", mamanDraft, setManamDraft)}
{stepIndex === 3 && renderPhotoStep("MAMAN", mamanPhotos, "maman")}
{stepIndex >= 4 && stepIndex < 4 + totalBabySteps && (() => {
const babyStep = getBabyStepInfo(stepIndex);
if (!babyStep) return null;
return renderTrimesterStep(babyStep.babyIndex, babyStep.trimester);
})()}
{stepIndex === recapStepIndex && (
<div className={wizardStyles.reviewPanel}>
<h3>Récapitulatif</h3>
<p><strong>Projet :</strong> {project?.name ?? projectId}</p>
<p><strong>Papa (naissance) :</strong> {papaDraft.poids ? `${papaDraft.poids} kg` : "—"}, {papaDraft.taille ? `${papaDraft.taille} cm` : "—"} {papaPhotos.length} photo(s)</p>
<p><strong>Maman (naissance) :</strong> {mamanDraft.poids ? `${mamanDraft.poids} kg` : "—"}, {mamanDraft.taille ? `${mamanDraft.taille} cm` : "—"} {mamanPhotos.length} photo(s)</p>
{Array.from({ length: babyCount }, (_, i) => i + 1).map((bi) => (
<p key={bi}>
<strong>{getBabyLabel(bi)} :</strong>{" "}
DPA {babyDpas[bi] || "—"} {" "}
{TRIMESTERS.map((t) => {
const ph = trimesterPhotos[triKey(bi, t)]?.length ?? 0;
return `${TRIMESTER_LABELS[t]}: ${ph} photo(s)`;
}).join(", ")}
</p>
))}
<p>Les informations ont é sauvegardées au fil des étapes.</p>
</div>
)}
{message ? <p className={wizardStyles.message}>{message}</p> : null}
<div className={wizardStyles.wizardActions}>
<button
type="button"
className="btn btn-soft"
onClick={goBack}
disabled={loading || stepIndex === 0}
>
Retour
</button>
{stepIndex === recapStepIndex ? (
<Link className="btn btn-primary" href="/admin">
Terminer
</Link>
) : (
<button
type="button"
className="btn btn-primary"
disabled={loading}
onClick={() => void goNext()}
>
{loading ? "Sauvegarde..." : "Continuer"}
</button>
)}
</div>
</section>
</main>
);
}