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

1654 lines
64 KiB
TypeScript

"use client";
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ProjectSwitcher } from "@/features/predictions/components/project-switcher";
import { FieldRendererRegistry } from "@/features/predictions/components/fields/field-renderer-registry";
import { WeightDoubleSlider } from "@/features/predictions/components/fields/weight-double-slider";
import predictionStyles from "@/features/predictions/styles/predictions.module.css";
import {
closePredictionGame,
finalizePredictionGame,
getPredictionBoard,
getPredictionCards,
getPredictionScoreboard,
openPredictionGame,
setPredictionOutcomes,
suggestPredictionScores,
validatePredictionScores,
} from "@/lib/predictions-client";
import {
authenticatedFetch,
clearSession,
getCurrentProjectId,
getApiUrl,
loadSession,
logoutSession,
type AuthUser,
type UserRole,
} from "@/lib/auth";
import { getMyProjects } from "@/lib/projects-client";
import { ProjectPhotoModal } from "@/features/predictions/components/project-photo-modal";
import type {
PredictionBoardCard,
PredictionBoardResponse,
PredictionCard,
PredictionDraftValue,
PredictionScoringEntry,
PredictionScoreboardItem,
} from "@/types/predictions";
import type { ProjectSummary } from "@/types/projects";
import styles from "./page.module.css";
type UserListItem = {
id: string;
username: string;
displayName: string | null;
profileImageUrl: string | null;
workspaceId?: string | null;
currentProjectId?: string | null;
role: UserRole;
createdAt: string;
};
type OutcomeDraftValue = { valueText: string; valueNumber: string; valueDate: string };
type OutcomeDraft = Record<string, Record<string, OutcomeDraftValue>>;
type ScoreDraft = Record<string, Record<string, string>>;
type ScoreDraftByCard = Record<string, ScoreDraft>;
type ScoresByCard = Record<string, PredictionScoringEntry[]>;
type ScoresByBabyCard = ScoresByCard;
type ScoreDraftByBabyCard = ScoreDraftByCard;
function babyCardKey(babyIndex: number, cardId: string) {
return `${babyIndex}::${cardId}`;
}
type AdminTab = "projects" | "users" | "final";
function formatDate(value: string) {
return new Date(value).toLocaleDateString("fr-FR");
}
function formatRawValue(
value: { valueText: string | null; valueNumber: number | null; valueDate: string | null },
unit?: string | null,
) {
if (value.valueNumber != null) {
return `${value.valueNumber}${unit ? ` ${unit}` : ""}`;
}
if (value.valueText && value.valueText.trim().length > 0) {
return value.valueText;
}
if (value.valueDate) {
return value.valueDate.slice(0, 10);
}
return "-";
}
function formatLabeledValue(
label: string,
value: { valueText: string | null; valueNumber: number | null; valueDate: string | null },
unit?: string | null,
) {
return `${label}: ${formatRawValue(value, unit)}`;
}
function buildOutcomeDraftKey(cardId: string, babyIndex: number) {
return `${cardId}::${babyIndex}`;
}
function initializeOutcomeDraft(
cards: PredictionCard[],
board: PredictionBoardResponse | null,
babyIndex: number,
) {
const boardByCardId = new Map((board?.cards ?? []).map((card) => [card.id, card]));
const draft: OutcomeDraft = {};
for (const card of cards) {
const key = buildOutcomeDraftKey(card.id, babyIndex);
const boardCard = boardByCardId.get(card.id);
const outcomesByField = new Map((boardCard?.outcomes ?? []).map((outcome) => [outcome.fieldId, outcome]));
draft[key] = {};
for (const field of card.fields) {
const outcome = outcomesByField.get(field.id);
draft[key][field.id] = {
valueText: outcome?.valueText ?? "",
valueNumber: outcome?.valueNumber != null ? `${outcome.valueNumber}` : "",
valueDate: outcome?.valueDate ? outcome.valueDate.slice(0, 10) : "",
};
}
}
return draft;
}
function getOutcomeDraftValues(
card: PredictionCard,
outcomeDraft: OutcomeDraft,
babyIndex: number,
): PredictionDraftValue[] {
const cardDraft = outcomeDraft[buildOutcomeDraftKey(card.id, babyIndex)] ?? {};
return card.fields.map((field) => {
const draftValue = cardDraft[field.id] ?? { valueText: "", valueNumber: "", valueDate: "" };
const parsedNumber = Number(draftValue.valueNumber);
return {
fieldId: field.id,
valueText: draftValue.valueText,
valueNumber:
draftValue.valueNumber.trim().length === 0 || Number.isNaN(parsedNumber)
? null
: parsedNumber,
valueDate: draftValue.valueDate,
};
});
}
function createScoreDraft(entries: PredictionScoringEntry[]) {
return entries.reduce<ScoreDraft>((accumulator, entry) => {
accumulator[entry.id] = entry.scores.reduce<Record<string, string>>((scoreAccumulator, score) => {
scoreAccumulator[score.fieldId] = `${score.awardedPoints ?? score.suggestedPoints}`;
return scoreAccumulator;
}, {});
return accumulator;
}, {});
}
function parsePointsInput(rawValue: string | undefined, fallback: number) {
const parsed = Number(rawValue);
if (Number.isNaN(parsed) || parsed < 0) {
return fallback;
}
return Math.round(parsed);
}
export default function AdminPage() {
const router = useRouter();
const [currentUser, setCurrentUser] = useState<AuthUser | null>(null);
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [activeProjectId, setActiveProjectId] = useState<string | null>(getCurrentProjectId());
const [activeAdminTab, setActiveAdminTab] = useState<AdminTab>("projects");
const [photoModalProjectId, setPhotoModalProjectId] = useState<string | null>(null);
const [users, setUsers] = useState<UserListItem[]>([]);
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [newUsername, setNewUsername] = useState("");
const [newDisplayName, setNewDisplayName] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newRole, setNewRole] = useState<UserRole>("FAMILY");
const [newProjectId, setNewProjectId] = useState("");
const [editingUserId, setEditingUserId] = useState<string | null>(null);
const [editUsername, setEditUsername] = useState("");
const [editDisplayName, setEditDisplayName] = useState("");
const [editPassword, setEditPassword] = useState("");
const [editRole, setEditRole] = useState<UserRole>("FAMILY");
const [predictionCards, setPredictionCards] = useState<PredictionCard[]>([]);
const [boardsByBaby, setBoardsByBaby] = useState<Record<number, PredictionBoardResponse>>({});
const [scoreboard, setScoreboard] = useState<PredictionScoreboardItem[]>([]);
const [outcomeDraft, setOutcomeDraft] = useState<OutcomeDraft>({});
const [scoresByBabyCard, setScoresByBabyCard] = useState<ScoresByBabyCard>({});
const [scoreDraftByBabyCard, setScoreDraftByBabyCard] = useState<ScoreDraftByBabyCard>({});
const [wizardStepIndex, setWizardStepIndex] = useState(0);
const [predictionMessage, setPredictionMessage] = useState("");
const apiUrl = getApiUrl();
const isAdmin = currentUser?.role === "ADMIN";
const primaryBoard = boardsByBaby[1] ?? null;
const isGameOpen = primaryBoard?.game?.status === "OPEN";
const isGameClosed = primaryBoard?.game?.status === "CLOSED";
const isFinalized = Boolean(primaryBoard?.game?.finalized);
const totalWorkflowSteps = predictionCards.length + 1;
const isRecapStep = isGameClosed && !isFinalized && wizardStepIndex >= predictionCards.length;
const currentStepCard = useMemo(
() => (!isRecapStep ? predictionCards[wizardStepIndex] ?? null : null),
[isRecapStep, predictionCards, wizardStepIndex],
);
const currentBoardCard = useMemo(() => {
if (!currentStepCard) {
return null;
}
return primaryBoard?.cards.find((card) => card.id === currentStepCard.id) ?? null;
}, [currentStepCard, primaryBoard?.cards]);
const activeProjectBabyCount = useMemo(() => {
const project = projects.find((item) => item.id === activeProjectId);
return Math.max(1, project?.babyCount ?? 1);
}, [activeProjectId, projects]);
const currentScoringEntriesByBaby = useMemo(() => {
if (!currentStepCard) {
return {};
}
const result: Record<number, PredictionScoringEntry[]> = {};
for (let babyIdx = 1; babyIdx <= activeProjectBabyCount; babyIdx++) {
result[babyIdx] = scoresByBabyCard[babyCardKey(babyIdx, currentStepCard.id)] ?? [];
}
return result;
}, [currentStepCard, scoresByBabyCard, activeProjectBabyCount]);
const currentScoreDraftByBaby = useMemo(() => {
if (!currentStepCard) {
return {};
}
const result: Record<number, ScoreDraft> = {};
for (let babyIdx = 1; babyIdx <= activeProjectBabyCount; babyIdx++) {
result[babyIdx] = scoreDraftByBabyCard[babyCardKey(babyIdx, currentStepCard.id)] ?? {};
}
return result;
}, [currentStepCard, scoreDraftByBabyCard, activeProjectBabyCount]);
const totalsByUserId = useMemo(() => {
return new Map(scoreboard.map((item) => [item.userId, item.totalPoints]));
}, [scoreboard]);
const winnerSummary = useMemo(() => {
if (scoreboard.length === 0) {
return null;
}
const maxPoints = scoreboard[0].totalPoints;
const winners = scoreboard.filter((item) => item.totalPoints === maxPoints);
return {
points: maxPoints,
winners,
};
}, [scoreboard]);
const remainingEntriesToScore = useMemo(() => {
let total = 0;
for (let babyIdx = 1; babyIdx <= activeProjectBabyCount; babyIdx++) {
const board = boardsByBaby[babyIdx];
if (!board) continue;
for (const card of board.cards) {
total += card.entries.filter((entry) => !entry.isScored).length;
}
}
return total;
}, [boardsByBaby, activeProjectBabyCount]);
const canFinalizeContest = isGameClosed && !isFinalized && remainingEntriesToScore === 0 && predictionCards.length > 0;
const activeProjectName = useMemo(() => {
return projects.find((project) => project.id === activeProjectId)?.name ?? null;
}, [activeProjectId, projects]);
const getBabyLabel = useCallback((idx: number): string => {
const project = projects.find((p) => p.id === activeProjectId);
return project?.babies?.find((b) => b.babyIndex === idx)?.label ?? `Bébé ${idx}`;
}, [activeProjectId, projects]);
const handleProjectChanged = useCallback((projectId: string) => {
setActiveProjectId((current) => (current === projectId ? current : projectId));
setPredictionMessage("");
}, []);
useEffect(() => {
const session = loadSession();
if (!session) {
router.replace("/");
return;
}
if (session.user.role !== "ADMIN") {
router.replace("/predictions");
return;
}
setCurrentUser(session.user);
setActiveProjectId(session.user.currentProjectId ?? null);
}, [router]);
useEffect(() => {
setWizardStepIndex((current) => Math.min(current, predictionCards.length));
}, [predictionCards.length]);
const loadUsers = async () => {
const response = await authenticatedFetch(apiUrl, "/users");
const payload = (await response.json()) as UserListItem[] | { message?: string };
if (!response.ok || !Array.isArray(payload)) {
throw new Error((payload as { message?: string }).message ?? "Impossible de charger les utilisateurs");
}
setUsers(payload);
};
const loadProjects = async () => {
const payload = await getMyProjects();
setProjects(payload);
if (payload.length === 0) {
setNewProjectId("");
setActiveProjectId(null);
return null;
}
const currentProjectId = getCurrentProjectId();
const selectedProject = payload.find((project) => project.id === currentProjectId) ?? payload[0];
if (selectedProject) {
setActiveProjectId(selectedProject.id);
setNewProjectId((current) => current || selectedProject.id);
return selectedProject.id;
}
return null;
};
const loadPredictionAdminData = async (projectId?: string | null) => {
const scopedProjectId = projectId ?? getCurrentProjectId();
if (!scopedProjectId) {
setPredictionCards([]);
setBoardsByBaby({});
setScoreboard([]);
setOutcomeDraft({});
setScoresByBabyCard({});
setScoreDraftByBabyCard({});
setWizardStepIndex(0);
return;
}
const project = projects.find((p) => p.id === scopedProjectId);
const babyCount = Math.max(1, project?.babyCount ?? 1);
const boardPromises = Array.from({ length: babyCount }, (_, i) =>
getPredictionBoard(scopedProjectId, i + 1),
);
const [cards, nextScoreboard, ...boards] = await Promise.all([
getPredictionCards(scopedProjectId),
getPredictionScoreboard(scopedProjectId),
...boardPromises,
]);
setPredictionCards(cards);
setScoreboard(nextScoreboard);
const newBoardsByBaby: Record<number, PredictionBoardResponse> = {};
const allOutcomeDrafts: OutcomeDraft = {};
for (let i = 0; i < boards.length; i++) {
const babyIdx = i + 1;
newBoardsByBaby[babyIdx] = boards[i];
const draft = initializeOutcomeDraft(cards, boards[i], babyIdx);
Object.assign(allOutcomeDrafts, draft);
}
setBoardsByBaby(newBoardsByBaby);
setOutcomeDraft((current) => ({ ...current, ...allOutcomeDrafts }));
};
useEffect(() => {
if (!isAdmin) {
return;
}
Promise.all([loadUsers(), loadProjects()])
.then(async ([, selectedProjectId]) => {
await loadPredictionAdminData(selectedProjectId);
})
.catch((error: unknown) => {
if (error instanceof Error && error.message.includes("Session manquante")) {
clearSession();
setCurrentUser(null);
router.replace("/");
}
setMessage("Impossible de charger les donnees admin");
});
}, [isAdmin, activeProjectId, router]);
useEffect(() => {
setScoresByBabyCard({});
setScoreDraftByBabyCard({});
setWizardStepIndex(0);
}, [primaryBoard?.game?.status, primaryBoard?.game?.finalized]);
useEffect(() => {
if (!isGameClosed || isFinalized || isRecapStep || !currentStepCard) {
return;
}
const missingBabies: number[] = [];
for (let babyIdx = 1; babyIdx <= activeProjectBabyCount; babyIdx++) {
const key = babyCardKey(babyIdx, currentStepCard.id);
if (!Object.prototype.hasOwnProperty.call(scoresByBabyCard, key)) {
missingBabies.push(babyIdx);
}
}
if (missingBabies.length === 0) {
return;
}
setLoading(true);
setPredictionMessage("");
Promise.all(
missingBabies.map((babyIdx) =>
suggestPredictionScores(currentStepCard.id, activeProjectId, babyIdx).then((entries) => ({
babyIdx,
entries,
})),
),
)
.then((results) => {
setScoresByBabyCard((current) => {
const next = { ...current };
for (const { babyIdx, entries } of results) {
next[babyCardKey(babyIdx, currentStepCard.id)] = entries;
}
return next;
});
setScoreDraftByBabyCard((current) => {
const next = { ...current };
for (const { babyIdx, entries } of results) {
next[babyCardKey(babyIdx, currentStepCard.id)] = createScoreDraft(entries);
}
return next;
});
})
.catch((error) => {
setPredictionMessage(error instanceof Error ? error.message : "Erreur de chargement de l'etape");
})
.finally(() => {
setLoading(false);
});
}, [
activeProjectBabyCount,
activeProjectId,
currentStepCard,
isFinalized,
isGameClosed,
isRecapStep,
scoresByBabyCard,
]);
const onCreateUser = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setLoading(true);
setMessage("");
try {
const response = await authenticatedFetch(apiUrl, "/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: newUsername,
displayName: newDisplayName || undefined,
password: newPassword,
role: newRole,
projectId: newRole === "FAMILY" ? newProjectId || undefined : undefined,
}),
});
const payload = (await response.json()) as { message?: string };
if (!response.ok) {
setMessage(payload.message ?? "Creation impossible");
return;
}
setNewUsername("");
setNewDisplayName("");
setNewPassword("");
setNewRole("FAMILY");
setNewProjectId((current) => current || projects[0]?.id || "");
setMessage("Compte cree");
await loadUsers();
} catch {
setMessage("Erreur reseau pendant la creation");
} finally {
setLoading(false);
}
};
const startEdit = (user: UserListItem) => {
setEditingUserId(user.id);
setEditUsername(user.username);
setEditDisplayName(user.displayName ?? "");
setEditPassword("");
setEditRole(user.role);
};
const cancelEdit = () => {
setEditingUserId(null);
setEditUsername("");
setEditDisplayName("");
setEditPassword("");
setEditRole("FAMILY");
};
const onSaveEdit = async (userId: string) => {
setLoading(true);
setMessage("");
const body: {
username?: string;
displayName?: string;
password?: string;
role?: UserRole;
} = {};
if (editUsername.trim()) body.username = editUsername.trim();
if (editDisplayName.trim().length >= 2) {
body.displayName = editDisplayName.trim();
}
if (editPassword.trim()) body.password = editPassword;
body.role = editRole;
try {
const response = await authenticatedFetch(apiUrl, `/users/${userId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const payload = (await response.json()) as { message?: string };
if (!response.ok) {
setMessage(payload.message ?? "Mise a jour impossible");
return;
}
setMessage("Compte mis a jour");
cancelEdit();
await loadUsers();
} catch {
setMessage("Erreur reseau pendant la mise a jour");
} finally {
setLoading(false);
}
};
const onDeleteUser = async (userId: string) => {
setLoading(true);
setMessage("");
try {
const response = await authenticatedFetch(apiUrl, `/users/${userId}`, {
method: "DELETE",
});
const payload = (await response.json()) as { message?: string };
if (!response.ok) {
setMessage(payload.message ?? "Suppression impossible");
return;
}
setMessage("Compte supprime");
await loadUsers();
} catch {
setMessage("Erreur reseau pendant la suppression");
} finally {
setLoading(false);
}
};
const onAssignUserProject = async (userId: string, projectId: string) => {
setLoading(true);
setMessage("");
try {
const response = await authenticatedFetch(apiUrl, `/users/${userId}/project`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId: projectId || undefined,
}),
});
const payload = (await response.json()) as { message?: string };
if (!response.ok) {
setMessage(payload.message ?? "Affectation de projet impossible");
return;
}
setMessage("Projet utilisateur mis a jour");
await loadUsers();
} catch {
setMessage("Erreur reseau pendant l'affectation projet");
} finally {
setLoading(false);
}
};
const patchOutcomeDraft = (
cardId: string,
babyIndex: number,
fieldId: string,
patch: Partial<OutcomeDraftValue>,
) => {
const key = buildOutcomeDraftKey(cardId, babyIndex);
setOutcomeDraft((current) => ({
...current,
[key]: {
...(current[key] ?? {}),
[fieldId]: {
...(current[key]?.[fieldId] ?? {
valueText: "",
valueNumber: "",
valueDate: "",
}),
...patch,
},
},
}));
};
const onToggleGame = async () => {
if (!primaryBoard?.game) {
return;
}
if (primaryBoard.game.status === "CLOSED" && isFinalized) {
setPredictionMessage("Concours deja valide: reouverture de La Grande Revelation impossible.");
return;
}
setPredictionMessage("");
setLoading(true);
try {
if (primaryBoard.game.status === "OPEN") {
await closePredictionGame(activeProjectId);
setPredictionMessage("La Grande Revelation est cloturee. Passage en mode notation par etapes.");
} else {
await openPredictionGame(activeProjectId);
setPredictionMessage("La Grande Revelation est reouverte. Tu peux encore corriger les valeurs finales.");
}
await loadPredictionAdminData();
} catch (error) {
setPredictionMessage(error instanceof Error ? error.message : "Erreur de changement d'etat du concours");
} finally {
setLoading(false);
}
};
const onSaveOutcomes = async () => {
if (!isGameOpen) {
setPredictionMessage("La saisie des valeurs finales est fermee.");
return;
}
if (predictionCards.length === 0) {
setPredictionMessage("Aucune categorie disponible.");
return;
}
setLoading(true);
setPredictionMessage("");
let submittedCards = 0;
try {
for (let babyIdx = 1; babyIdx <= activeProjectBabyCount; babyIdx++) {
for (const card of predictionCards) {
const cardDraft = outcomeDraft[buildOutcomeDraftKey(card.id, babyIdx)] ?? {};
const values = card.fields
.map((field) => {
const draft = cardDraft[field.id] ?? { valueText: "", valueNumber: "", valueDate: "" };
if (card.valueType === "NUMBER") {
const parsed = Number(draft.valueNumber);
if (Number.isNaN(parsed)) {
return null;
}
return { fieldId: field.id, valueNumber: parsed };
}
if (card.valueType === "DATE") {
if (!draft.valueDate) {
return null;
}
return { fieldId: field.id, valueDate: draft.valueDate };
}
if (!draft.valueText.trim()) {
return null;
}
return { fieldId: field.id, valueText: draft.valueText.trim() };
})
.filter((value): value is NonNullable<typeof value> => value != null);
if (values.length === 0) {
continue;
}
await setPredictionOutcomes(card.id, { selectedBabyIndex: babyIdx, values }, activeProjectId);
submittedCards += 1;
}
}
if (submittedCards === 0) {
setPredictionMessage("Aucune valeur finale detectee a enregistrer.");
} else {
setPredictionMessage(`${submittedCards} valeur(s) de resultats enregistree(s) pour ${activeProjectBabyCount} bebe(s).`);
}
await loadPredictionAdminData();
} catch (error) {
setPredictionMessage(error instanceof Error ? error.message : "Erreur pendant l'enregistrement des resultats");
} finally {
setLoading(false);
}
};
const onRefreshStepSuggestions = async () => {
if (!currentStepCard) {
return;
}
setLoading(true);
setPredictionMessage("");
try {
const results = await Promise.all(
Array.from({ length: activeProjectBabyCount }, (_, i) => i + 1).map((babyIdx) =>
suggestPredictionScores(currentStepCard.id, activeProjectId, babyIdx).then((entries) => ({
babyIdx,
entries,
})),
),
);
setScoresByBabyCard((current) => {
const next = { ...current };
for (const { babyIdx, entries } of results) {
next[babyCardKey(babyIdx, currentStepCard.id)] = entries;
}
return next;
});
setScoreDraftByBabyCard((current) => {
const next = { ...current };
for (const { babyIdx, entries } of results) {
next[babyCardKey(babyIdx, currentStepCard.id)] = createScoreDraft(entries);
}
return next;
});
setPredictionMessage(`Suggestions de points recalculees pour ${currentStepCard.title}.`);
} catch (error) {
setPredictionMessage(error instanceof Error ? error.message : "Erreur pendant le recalcul des points");
} finally {
setLoading(false);
}
};
const onScoreChange = (babyIdx: number, cardId: string, entryId: string, fieldId: string, value: string) => {
const key = babyCardKey(babyIdx, cardId);
setScoreDraftByBabyCard((current) => ({
...current,
[key]: {
...(current[key] ?? {}),
[entryId]: {
...(current[key]?.[entryId] ?? {}),
[fieldId]: value,
},
},
}));
};
const getStepPointsTotal = (babyIdx: number, entry: PredictionScoringEntry) => {
if (!currentStepCard) {
return 0;
}
const key = babyCardKey(babyIdx, currentStepCard.id);
const draft = scoreDraftByBabyCard[key]?.[entry.id] ?? {};
return entry.scores.reduce((total, score) => {
return total + parsePointsInput(draft[score.fieldId], score.awardedPoints ?? score.suggestedPoints);
}, 0);
};
const onValidateCurrentStep = async () => {
if (!currentStepCard) {
return;
}
let hasEntries = false;
for (let babyIdx = 1; babyIdx <= activeProjectBabyCount; babyIdx++) {
const entries = scoresByBabyCard[babyCardKey(babyIdx, currentStepCard.id)] ?? [];
if (entries.length > 0) {
hasEntries = true;
break;
}
}
if (!hasEntries) {
setPredictionMessage("Aucun participant a noter sur cette categorie.");
setWizardStepIndex((current) => Math.min(current + 1, predictionCards.length));
return;
}
setLoading(true);
setPredictionMessage("");
try {
for (let babyIdx = 1; babyIdx <= activeProjectBabyCount; babyIdx++) {
const key = babyCardKey(babyIdx, currentStepCard.id);
const entries = scoresByBabyCard[key] ?? [];
for (const entry of entries) {
const draftByField = scoreDraftByBabyCard[key]?.[entry.id] ?? {};
const scores = entry.scores.map((score) => ({
fieldId: score.fieldId,
awardedPoints: parsePointsInput(draftByField[score.fieldId], score.awardedPoints ?? score.suggestedPoints),
}));
await validatePredictionScores(entry.id, { scores });
}
}
setScoresByBabyCard((current) => {
const next = { ...current };
for (let babyIdx = 1; babyIdx <= activeProjectBabyCount; babyIdx++) {
delete next[babyCardKey(babyIdx, currentStepCard.id)];
}
return next;
});
setScoreDraftByBabyCard((current) => {
const next = { ...current };
for (let babyIdx = 1; babyIdx <= activeProjectBabyCount; babyIdx++) {
delete next[babyCardKey(babyIdx, currentStepCard.id)];
}
return next;
});
await loadPredictionAdminData();
setPredictionMessage(`Categorie ${currentStepCard.title} validee pour ${activeProjectBabyCount} bebe(s).`);
setWizardStepIndex((current) => Math.min(current + 1, predictionCards.length));
} catch (error) {
setPredictionMessage(error instanceof Error ? error.message : "Erreur pendant la validation des points");
} finally {
setLoading(false);
}
};
const onFinalizeContest = async () => {
setLoading(true);
setPredictionMessage("");
try {
const result = await finalizePredictionGame(activeProjectId);
const names = result.winners
.map((winner) => winner.displayName ?? winner.username)
.join(" / ");
setPredictionMessage(`Concours valide. Gagnant(s): ${names}.`);
await loadPredictionAdminData();
} catch (error) {
setPredictionMessage(error instanceof Error ? error.message : "Erreur pendant la validation finale du concours");
} finally {
setLoading(false);
}
};
const logout = async () => {
await logoutSession(apiUrl);
setCurrentUser(null);
setUsers([]);
setPredictionCards([]);
setBoardsByBaby({});
setScoreboard([]);
setMessage("Deconnecte");
window.location.replace("/");
};
const renderOutcomeCard = (card: PredictionCard, babyIdx: number, boardCard: PredictionBoardCard | null) => {
const draftValues = getOutcomeDraftValues(card, outcomeDraft, babyIdx);
const weightField = card.code === "legacy_weight" ? card.fields[0] : null;
const weightValue =
weightField == null
? null
: draftValues.find((value) => value.fieldId === weightField.id)?.valueNumber ?? null;
return (
<article
key={`${card.id}-${babyIdx}`}
className={`${predictionStyles.tile} ${card.code === "legacy_weight" ? predictionStyles.tileFavorite : ""}`}
>
<header className={predictionStyles.tileHeader}>
<div>
<h3>{card.title}</h3>
{card.description ? <p>{card.description}</p> : null}
</div>
<span className={predictionStyles.badge}>Resultat final</span>
</header>
<div className={predictionStyles.tileBody}>
{weightField ? (
<WeightDoubleSlider
valueKg={weightValue}
minKg={weightField.minNumber ?? 1}
maxKg={weightField.maxNumber ?? 6}
disabled={loading || !isGameOpen}
onChange={(nextValueKg) => {
patchOutcomeDraft(card.id, babyIdx, weightField.id, {
valueNumber: nextValueKg.toFixed(3),
});
}}
/>
) : (
<FieldRendererRegistry
card={card}
draftValues={draftValues}
disabled={loading || !isGameOpen}
setText={(fieldId, value) => {
patchOutcomeDraft(card.id, babyIdx, fieldId, { valueText: value });
}}
setNumber={(fieldId, value) => {
patchOutcomeDraft(card.id, babyIdx, fieldId, { valueNumber: value == null ? "" : `${value}` });
}}
setDate={(fieldId, value) => {
patchOutcomeDraft(card.id, babyIdx, fieldId, { valueDate: value });
}}
/>
)}
<p className={styles.helpText}>
Valeur actuellement enregistree: {boardCard?.outcomes.length
? boardCard.outcomes
.map((outcome) =>
formatLabeledValue(outcome.fieldLabel, {
valueText: outcome.valueText,
valueNumber: outcome.valueNumber,
valueDate: outcome.valueDate,
}, card.unit),
)
.join(" | ")
: "Aucune valeur encore sauvegardee"}
</p>
</div>
</article>
);
};
return (
<main className={`app-shell ${styles.page}`}>
<section className={`panel ${styles.header}`}>
<div>
<p className="mono">Le Juste Poids / Admin</p>
<h1>Gestion des comptes famille</h1>
</div>
<div className={styles.actions}>
<Link className="btn btn-soft" href="/profile">
Mon profil
</Link>
{currentUser ? (
<button type="button" className="btn btn-soft" onClick={logout}>
Se deconnecter
</button>
) : null}
</div>
</section>
{!currentUser || !isAdmin ? (
<section className={`panel ${styles.loginPanel}`}>
<p>Redirection vers la page de connexion...</p>
</section>
) : (
<>
<section className={`panel ${styles.adminTabs}`}>
<button
type="button"
className={`${styles.adminTab} ${activeAdminTab === "projects" ? styles.adminTabActive : ""}`}
onClick={() => setActiveAdminTab("projects")}
>
Gestion des projets
</button>
<button
type="button"
className={`${styles.adminTab} ${activeAdminTab === "users" ? styles.adminTabActive : ""}`}
onClick={() => setActiveAdminTab("users")}
>
Comptes utilisateurs
</button>
<button
type="button"
className={`${styles.adminTab} ${activeAdminTab === "final" ? styles.adminTabActive : ""}`}
onClick={() => setActiveAdminTab("final")}
>
La Grande Revelation
</button>
</section>
{activeAdminTab === "projects" ? (
<section className={`panel ${styles.projectPanel}`}>
<div className={styles.projectPanelHeader}>
<div>
<h2>Gestion des projets</h2>
<p className={styles.helpText}>
Cree, clone et selectionne un projet. Ouvre ensuite La Grande Revelation dans l onglet dedie.
</p>
</div>
<Link className="btn btn-primary" href="/admin/projects/new">
Nouveau projet
</Link>
</div>
<ProjectSwitcher onProjectChanged={handleProjectChanged} />
{projects.length === 0 ? (
<p className={styles.message}>
Aucun projet pour le moment. Commence par creer le premier projet via le wizard.
</p>
) : (
<div className={styles.projectList}>
{projects.map((project) => (
<article key={project.id} className={styles.projectItem}>
<div>
<strong>{project.name}</strong>
<p className={styles.helpText}>
{project.babyCount} bebe{project.babyCount > 1 ? "s" : ""} {project.status}
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<Link
className="btn btn-soft"
href={`/admin/projects/${project.id}/indices`}
>
Configurer les indices
</Link>
<button
className="btn btn-soft"
onClick={() => setPhotoModalProjectId(project.id)}
>
Photo du projet
</button>
<button
className="btn btn-soft"
onClick={() => {
handleProjectChanged(project.id);
setActiveAdminTab("final");
}}
>
Ouvrir La Grande Revelation
</button>
</div>
</article>
))}
</div>
)}
{photoModalProjectId ? (() => {
const modalProject = projects.find((p) => p.id === photoModalProjectId);
return modalProject ? (
<ProjectPhotoModal
project={modalProject}
onSuccess={(updated) => {
setProjects((prev) => prev.map((p) => p.id === updated.id ? updated : p));
}}
onClose={() => setPhotoModalProjectId(null)}
/>
) : null;
})() : null}
</section>
) : null}
{activeAdminTab === "users" ? (
<>
<section className={`panel ${styles.createPanel}`}>
<h2>Creer un compte</h2>
<form onSubmit={onCreateUser} className={styles.createForm}>
<div className="field">
<label htmlFor="newUsername">Username</label>
<input
id="newUsername"
value={newUsername}
onChange={(event) => setNewUsername(event.target.value)}
/>
</div>
<div className="field">
<label htmlFor="newDisplayName">Pseudo</label>
<input
id="newDisplayName"
value={newDisplayName}
onChange={(event) => setNewDisplayName(event.target.value)}
/>
</div>
<div className="field">
<label htmlFor="newPassword">Mot de passe</label>
<input
id="newPassword"
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
/>
</div>
<div className="field">
<label htmlFor="newRole">Role</label>
<select
id="newRole"
value={newRole}
onChange={(event) => setNewRole(event.target.value as UserRole)}
>
<option value="FAMILY">FAMILY</option>
<option value="ADMIN">ADMIN</option>
</select>
</div>
{newRole === "FAMILY" ? (
<div className="field">
<label htmlFor="newProjectId">Projet associe</label>
<select
id="newProjectId"
value={newProjectId}
onChange={(event) => setNewProjectId(event.target.value)}
>
<option value="">Sans projet</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</div>
) : null}
<button className="btn btn-primary" disabled={loading}>
Creer
</button>
</form>
{message ? <p className={styles.message}>{message}</p> : null}
</section>
<section className={`panel ${styles.tablePanel}`}>
<h2>Comptes existants</h2>
<div className={styles.tableWrapper}>
<table>
<thead>
<tr>
<th>Username</th>
<th>Pseudo</th>
<th>Role</th>
<th>Projet</th>
<th>Cree le</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>
{editingUserId === user.id ? (
<input
value={editUsername}
onChange={(event) => setEditUsername(event.target.value)}
/>
) : (
user.username
)}
</td>
<td>
{editingUserId === user.id ? (
<input
value={editDisplayName}
onChange={(event) => setEditDisplayName(event.target.value)}
/>
) : (
user.displayName ?? "-"
)}
</td>
<td>
{editingUserId === user.id ? (
<select
value={editRole}
onChange={(event) => setEditRole(event.target.value as UserRole)}
>
<option value="FAMILY">FAMILY</option>
<option value="ADMIN">ADMIN</option>
</select>
) : (
user.role
)}
</td>
<td>
{user.role === "FAMILY" ? (
<select
value={user.currentProjectId ?? ""}
onChange={(event) => {
void onAssignUserProject(user.id, event.target.value);
}}
disabled={loading}
>
<option value="">Sans projet</option>
{projects.map((project) => (
<option key={`${user.id}-${project.id}`} value={project.id}>
{project.name}
</option>
))}
</select>
) : (
<span className={styles.helpText}>Admin scope espace</span>
)}
</td>
<td className="mono">{formatDate(user.createdAt)}</td>
<td>
<div className={styles.rowActions}>
{editingUserId === user.id ? (
<>
<input
type="password"
placeholder="Nouveau mot de passe (optionnel)"
value={editPassword}
onChange={(event) => setEditPassword(event.target.value)}
/>
<button
className="btn btn-primary"
onClick={() => onSaveEdit(user.id)}
disabled={loading}
>
Sauver
</button>
<button className="btn btn-soft" onClick={cancelEdit}>
Annuler
</button>
</>
) : (
<>
<button className="btn btn-soft" onClick={() => startEdit(user)}>
Editer
</button>
<button
className="btn btn-soft"
onClick={() => onDeleteUser(user.id)}
disabled={loading}
>
Supprimer
</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</>
) : null}
{activeAdminTab === "final" ? (
<section className={`panel ${styles.predictionPanel}`}>
<h2>La Grande Revelation</h2>
<ProjectSwitcher onProjectChanged={handleProjectChanged} />
{!activeProjectId ? (
<p className={styles.helpText}>
Selectionne ou cree d abord un projet pour activer La Grande Revelation.
</p>
) : (
<>
<p className={styles.helpText}>
Projet actif: {activeProjectName ?? "Projet selectionne"}
{activeProjectBabyCount > 1 ? `${activeProjectBabyCount} bebes` : ""}
</p>
<p className={styles.helpText}>
Une fois bebe arrive et ses caracteristiques connues, renseigne ici les resultats finaux.
Ensuite, cloture le concours pour lancer la notation et decouvrir qui a gagne.
</p>
</>
)}
{!activeProjectId ? null : (
<>
<div className={styles.predictionActions}>
<Link className="btn btn-soft" href={`/admin/projects/${activeProjectId}/indices`}>
Ouvrir les indices de ce projet
</Link>
</div>
<div className={styles.predictionActions}>
<span className={styles.statusPill}>
Etat: {isGameOpen ? "OUVERT" : isFinalized ? "CLOTURE ET VALIDE" : "CLOTURE"}
</span>
<button className="btn btn-primary" onClick={onToggleGame} disabled={loading || !primaryBoard?.game}>
{isGameOpen ? "Cloturer La Grande Revelation et lancer la notation" : "Reouvrir La Grande Revelation"}
</button>
</div>
{isGameOpen ? (
<>
<h3>1. Saisie des valeurs finales</h3>
<p className={styles.helpText}>
Renseigne les resultats finaux pour {activeProjectBabyCount > 1 ? "chaque bebe" : "le bebe"}.
</p>
{activeProjectBabyCount > 1 ? (
<div className={styles.babyColumnsGrid} style={{ gridTemplateColumns: `repeat(${activeProjectBabyCount}, 1fr)` }}>
{Array.from({ length: activeProjectBabyCount }, (_, i) => i + 1).map((babyIdx) => (
<div key={babyIdx} className={styles.babyColumn}>
<h4 className={styles.babyColumnHeader}>{getBabyLabel(babyIdx)}</h4>
{predictionCards.map((card) => renderOutcomeCard(
card,
babyIdx,
boardsByBaby[babyIdx]?.cards.find((c) => c.id === card.id) ?? null,
))}
</div>
))}
</div>
) : (
<div className={predictionStyles.tilesGrid}>
{predictionCards.map((card) => renderOutcomeCard(
card,
1,
boardsByBaby[1]?.cards.find((c) => c.id === card.id) ?? null,
))}
</div>
)}
<div className={styles.predictionActions}>
<button className="btn btn-primary" onClick={onSaveOutcomes} disabled={loading}>
Enregistrer les valeurs finales{activeProjectBabyCount > 1 ? " (tous les bebes)" : ""}
</button>
</div>
</>
) : null}
{isGameClosed && !isFinalized ? (
<div className={styles.wizardPanel}>
<div className={styles.wizardHeader}>
<h3>2. Notation par categorie</h3>
<span className={styles.stepPill}>
Etape {Math.min(wizardStepIndex + 1, totalWorkflowSteps)} / {totalWorkflowSteps}
</span>
</div>
{!isRecapStep && currentStepCard ? (
<>
<div className={styles.outcomeSummary}>
<strong>Categorie: {currentStepCard.title}</strong>
{Array.from({ length: activeProjectBabyCount }, (_, i) => i + 1).map((babyIdx) => {
const board = boardsByBaby[babyIdx];
const boardCard = board?.cards.find((c) => c.id === currentStepCard.id);
return (
<p key={babyIdx}>
{activeProjectBabyCount > 1 ? `${getBabyLabel(babyIdx)}: ` : "Resultat final: "}
{boardCard?.outcomes.length
? boardCard.outcomes
.map((outcome) =>
formatLabeledValue(outcome.fieldLabel, {
valueText: outcome.valueText,
valueNumber: outcome.valueNumber,
valueDate: outcome.valueDate,
}, currentStepCard.unit),
)
.join(" | ")
: "Aucun resultat final sauvegarde"}
</p>
);
})}
</div>
<div className={styles.wizardActions}>
<button className="btn btn-soft" onClick={onRefreshStepSuggestions} disabled={loading}>
Recalculer les suggestions auto
</button>
</div>
{activeProjectBabyCount > 1 ? (
<div className={styles.babyColumnsGrid} style={{ gridTemplateColumns: `repeat(${activeProjectBabyCount}, 1fr)` }}>
{Array.from({ length: activeProjectBabyCount }, (_, i) => i + 1).map((babyIdx) => {
const entries = currentScoringEntriesByBaby[babyIdx] ?? [];
const board = boardsByBaby[babyIdx];
const boardCard = board?.cards.find((c) => c.id === currentStepCard.id);
const draft = currentScoreDraftByBaby[babyIdx] ?? {};
return (
<div key={babyIdx} className={styles.babyColumn}>
<h4 className={styles.babyColumnHeader}>{getBabyLabel(babyIdx)}</h4>
{entries.length === 0 ? (
<p className={predictionStyles.empty}>Aucun participant pour ce bebe.</p>
) : (
<div className={styles.candidateList}>
{entries.map((entry) => {
const boardEntry = boardCard?.entries.find((item) => item.entryId === entry.id) ?? null;
const participantName = entry.user.displayName ?? entry.user.username;
const currentTotal = totalsByUserId.get(entry.userId) ?? 0;
return (
<article key={entry.id} className={styles.candidateCard}>
<div className={styles.candidateHeader}>
<strong>{participantName}</strong>
<span className={styles.statusPill}>Total: {currentTotal} pts</span>
</div>
<p className={styles.candidateMeta}>
{boardEntry?.values.length
? boardEntry.values
.map((value) =>
formatLabeledValue(value.fieldLabel, {
valueText: value.valueText,
valueNumber: value.valueNumber,
valueDate: value.valueDate,
}, currentStepCard.unit),
)
.join(" | ")
: "Aucune reponse"}
</p>
<div className={styles.scoreInputs}>
{entry.scores.map((score) => (
<label key={`${entry.id}-${score.fieldId}`} className={styles.scoreInputRow}>
<span>
{score.field.label} {score.field.points} pts max
</span>
<input
type="number"
min={0}
value={draft[entry.id]?.[score.fieldId] ?? `${score.awardedPoints ?? score.suggestedPoints}`}
onChange={(event) => {
onScoreChange(babyIdx, currentStepCard.id, entry.id, score.fieldId, event.target.value);
}}
/>
</label>
))}
</div>
<p className={styles.candidateMeta}>Points: {getStepPointsTotal(babyIdx, entry)}</p>
</article>
);
})}
</div>
)}
</div>
);
})}
</div>
) : (
<>
{(currentScoringEntriesByBaby[1] ?? []).length === 0 ? (
<p className={predictionStyles.empty}>Aucun participant sur cette categorie.</p>
) : (
<div className={styles.candidateList}>
{(currentScoringEntriesByBaby[1] ?? []).map((entry) => {
const boardEntry = currentBoardCard?.entries.find((item) => item.entryId === entry.id) ?? null;
const participantName = entry.user.displayName ?? entry.user.username;
const currentTotal = totalsByUserId.get(entry.userId) ?? 0;
const draft = currentScoreDraftByBaby[1] ?? {};
return (
<article key={entry.id} className={styles.candidateCard}>
<div className={styles.candidateHeader}>
<strong>{participantName}</strong>
<span className={styles.statusPill}>Total actuel: {currentTotal} pts</span>
</div>
<p className={styles.candidateMeta}>
Pronostic: {boardEntry?.values.length
? boardEntry.values
.map((value) =>
formatLabeledValue(value.fieldLabel, {
valueText: value.valueText,
valueNumber: value.valueNumber,
valueDate: value.valueDate,
}, currentStepCard.unit),
)
.join(" | ")
: "Aucune reponse"}
</p>
<div className={styles.scoreInputs}>
{entry.scores.map((score) => (
<label key={`${entry.id}-${score.fieldId}`} className={styles.scoreInputRow}>
<span>
{score.field.label} {score.field.points} pts max
</span>
<input
type="number"
min={0}
value={draft[entry.id]?.[score.fieldId] ?? `${score.awardedPoints ?? score.suggestedPoints}`}
onChange={(event) => {
onScoreChange(1, currentStepCard.id, entry.id, score.fieldId, event.target.value);
}}
/>
</label>
))}
</div>
<p className={styles.candidateMeta}>Points sur cette categorie: {getStepPointsTotal(1, entry)}</p>
</article>
);
})}
</div>
)}
</>
)}
<div className={styles.wizardActions}>
<button
className="btn btn-soft"
onClick={() => setWizardStepIndex((current) => Math.max(current - 1, 0))}
disabled={loading || wizardStepIndex === 0}
>
Etape precedente
</button>
<button
className="btn btn-soft"
onClick={() => setWizardStepIndex((current) => Math.min(current + 1, predictionCards.length))}
disabled={loading}
>
Passer au recap
</button>
<button className="btn btn-primary" onClick={onValidateCurrentStep} disabled={loading}>
Valider cette categorie{activeProjectBabyCount > 1 ? " (tous les bebes)" : ""}
</button>
</div>
</>
) : (
<>
<h4>3. Recap final des points</h4>
<p className={styles.helpText}>
Les points sont cumules entre les bebes. Si un participant est le plus proche pour les {activeProjectBabyCount} bebes, il gagne les points x{activeProjectBabyCount}.
</p>
<p className={styles.helpText}>Pronostics restants a noter: {remainingEntriesToScore}</p>
{winnerSummary ? (
<div className={styles.outcomeSummary}>
<strong>
Gagnant(s) provisoire(s): {winnerSummary.winners
.map((winner) => winner.displayName ?? winner.username)
.join(" / ")}
</strong>
<p>Score total: {winnerSummary.points} points (cumul sur {activeProjectBabyCount} bebe{activeProjectBabyCount > 1 ? "s" : ""})</p>
</div>
) : (
<p className={predictionStyles.empty}>Pas encore de classement disponible.</p>
)}
<table className={predictionStyles.scoreTable}>
<thead>
<tr>
<th>#</th>
<th>Participant</th>
<th>Total cumule</th>
</tr>
</thead>
<tbody>
{scoreboard.map((item, index) => (
<tr key={item.userId}>
<td>{index + 1}</td>
<td>{item.displayName ?? item.username}</td>
<td>{item.totalPoints}</td>
</tr>
))}
</tbody>
</table>
<div className={styles.wizardActions}>
<button
className="btn btn-soft"
onClick={() => setWizardStepIndex((current) => Math.max(current - 1, 0))}
disabled={loading || predictionCards.length === 0}
>
Revenir aux categories
</button>
<button className="btn btn-primary" onClick={onFinalizeContest} disabled={loading || !canFinalizeContest}>
Valider definitivement le concours
</button>
</div>
{!canFinalizeContest ? (
<p className={styles.helpText}>
Valide tous les pronostics de chaque categorie{activeProjectBabyCount > 1 ? " pour chaque bebe" : ""} avant la validation finale.
</p>
) : null}
</>
)}
</div>
) : null}
{isFinalized ? (
<div className={styles.wizardPanel}>
<h3>Concours valide</h3>
{winnerSummary ? (
<div className={styles.outcomeSummary}>
<strong>
Gagnant(s): {winnerSummary.winners
.map((winner) => winner.displayName ?? winner.username)
.join(" / ")}
</strong>
<p>{winnerSummary.points} points (cumul {activeProjectBabyCount} bebe{activeProjectBabyCount > 1 ? "s" : ""})</p>
</div>
) : null}
<table className={predictionStyles.scoreTable}>
<thead>
<tr>
<th>#</th>
<th>Participant</th>
<th>Total cumule</th>
</tr>
</thead>
<tbody>
{scoreboard.map((item, index) => (
<tr key={item.userId}>
<td>{index + 1}</td>
<td>{item.displayName ?? item.username}</td>
<td>{item.totalPoints}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{predictionMessage ? <p className={styles.message}>{predictionMessage}</p> : null}
</>
)}
</section>
) : null}
</>
)}
</main>
);
}