1654 lines
64 KiB
TypeScript
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>
|
|
);
|
|
}
|