import { useEffect, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { ApiError } from "../api/client"; import { ConfirmDialog } from "../components/ConfirmDialog"; import { MatchMedia } from "../components/MatchMedia"; import { PickerSelect } from "../components/PickerSelect"; import { PlaceEditor } from "../components/PlaceEditor"; import { PlayerLink } from "../components/PlayerLink"; import { Spinner } from "../components/Spinner"; import { formatDate, formatDuration, formatTime } from "../domain/format"; import type { MatchFinishDraftData } from "../domain/types"; import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons"; import { useToast } from "../context/ToastContext"; import { useMe } from "../hooks/auth"; import { useDeleteMatch, useDeleteMatchAttachment, useFinishMatch, useMatch, useSaveFinishDraft, useUpdateMatch, useUploadMatchAttachment, } from "../hooks/matches"; import { useGroupFactions } from "../hooks/reference"; const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label })); export function MatchDetailPage() { const { matchId } = useParams(); // Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN. const parsed = matchId ? Number(matchId) : NaN; const id = Number.isInteger(parsed) ? parsed : null; const { data: match, isLoading, refetch } = useMatch(id); const { data: me } = useMe(); const { data: groupFactions } = useGroupFactions(match?.group_id ?? null); const finish = useFinishMatch(); const saveDraft = useSaveFinishDraft(id); const updateMatch = useUpdateMatch(); const del = useDeleteMatch(); const uploadAtt = useUploadMatchAttachment(id ?? 0); const deleteAtt = useDeleteMatchAttachment(id ?? 0); const toast = useToast(); const navigate = useNavigate(); // Раскладка мест: упорядоченные блоки user_id (длина >1 = ничья) + выбывшие. const [blocks, setBlocks] = useState(null); const [elim, setElim] = useState([]); const [comments, setComments] = useState | null>(null); const [winReason, setWinReason] = useState("objectives"); const [overall, setOverall] = useState(""); const [error, setError] = useState(null); const [confirmRemove, setConfirmRemove] = useState(false); // Правка завершённой партии: тот же редактор мест, что и при завершении. const [editing, setEditing] = useState(false); const [editFactions, setEditFactions] = useState>({}); // ─── Совместное заполнение формы ─── // Отложенная запись черновика: он меняется на каждое движение тайла, слать // каждое было бы перебором. Актуальное состояние держим в ref — таймер иначе // отправил бы то, что было на момент его постановки. const draftTimer = useRef | null>(null); const draftPayload = useRef(null); const lastLocalEdit = useRef(0); const appliedDraftAt = useRef(null); const sendDraft = () => { draftTimer.current = null; if (draftPayload.current && id) saveDraft.mutate(draftPayload.current); }; const queueDraft = (payload: MatchFinishDraftData) => { draftPayload.current = payload; lastLocalEdit.current = Date.now(); if (draftTimer.current) clearTimeout(draftTimer.current); draftTimer.current = setTimeout(sendDraft, 600); }; useEffect( () => () => { if (draftTimer.current) clearTimeout(draftTimer.current); }, [], ); // Чужой черновик применяем, только если человек сейчас ничего не двигает: // иначе правка соседа перетёрла бы тайл прямо под рукой. const incoming = match?.finish_draft; useEffect(() => { if (!incoming || match?.status !== "in_progress") return; if (incoming.updated_by === me?.id) return; if (incoming.updated_at === appliedDraftAt.current) return; if (Date.now() - lastLocalEdit.current < 1500) return; setBlocks(incoming.data.blocks); setElim(incoming.data.eliminated); setComments(incoming.data.comments); setWinReason((incoming.data.win_reason ?? "objectives") as WinReason); setOverall(incoming.data.overall_comment ?? ""); appliedDraftAt.current = incoming.updated_at; }, [incoming, match?.status, me?.id]); if (isLoading) return ; if (!match) return
Партия не найдена.
; const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (создатель/owner/admin) const inProgress = match.status === "in_progress"; // Ленивая инициализация раскладки из участников: каждый — отдельным блоком. const finishBlocks: number[][] = blocks ?? match.participants.map((p) => [p.user_id]); const finishComments: Record = comments ?? Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])); // Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем. const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE"; // Снимок формы для общего черновика: то, что увидят остальные заполняющие. const draftOf = (patch: Partial = {}): MatchFinishDraftData => ({ blocks: finishBlocks, eliminated: elim, comments: Object.fromEntries( Object.entries(finishComments).map(([uid, text]) => [String(uid), text]), ), win_reason: winReason, overall_comment: overall.trim() || null, ...patch, }); const submitFinish = async () => { if (!id || !match) return; setError(null); // Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты, // но не в черновик, и второй участник увидел бы не то, что записалось. if (draftTimer.current) { clearTimeout(draftTimer.current); sendDraft(); } const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null; let place = 1; const survivors = finishBlocks.flatMap((ids) => { const rows = ids.map((uid) => ({ user_id: uid, place, eliminated: false, comment: commentOf(uid), })); place += ids.length; // competition ranking: ничья съедает следующие места return rows; }); try { await finish.mutateAsync({ matchId: id, body: { participants: [ ...survivors, ...elim.map((uid) => ({ user_id: uid, place: null, eliminated: true, comment: commentOf(uid), })), ], win_reason: winReason, overall_comment: overall.trim() || null, expected_version: match.version, }, }); toast.show("Партия завершена"); } catch (e) { if (isStale(e)) { toast.show("Партия изменилась на другом устройстве — обновлено"); refetch(); return; } setError(e instanceof ApiError ? e.message : "Не удалось завершить партию"); } }; // Раскладка для правки восстанавливается из сохранённых результатов: одинаковое // место = ничья, выбывшие уходят отдельным списком. const startEditing = () => { const byPlace = new Map(); for (const p of match.participants.filter((x) => !x.eliminated)) { const key = p.place ?? 99; byPlace.set(key, [...(byPlace.get(key) ?? []), p.user_id]); } setBlocks([...byPlace.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids)); setElim(match.participants.filter((p) => p.eliminated).map((p) => p.user_id)); setComments( Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])), ); setWinReason((match.win_reason ?? "objectives") as WinReason); setOverall(match.overall_comment ?? ""); setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id]))); setError(null); setEditing(true); }; const cancelEditing = () => { setEditing(false); setBlocks(null); setComments(null); setError(null); }; const submitEdit = async () => { if (!id || !match) return; setError(null); const wasRandom = Object.fromEntries( match.participants.map((p) => [p.user_id, p.was_random]), ); const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null; const rowOf = (uid: number, place: number | null, eliminated: boolean) => ({ user_id: uid, faction_id: editFactions[uid], place, eliminated, was_random: wasRandom[uid] ?? false, comment: commentOf(uid), }); let place = 1; const survivors = finishBlocks.flatMap((ids) => { const rows = ids.map((uid) => rowOf(uid, place, false)); place += ids.length; // competition ranking: ничья съедает следующие места return rows; }); try { await updateMatch.mutateAsync({ matchId: id, body: { participants: [...survivors, ...elim.map((uid) => rowOf(uid, null, true))], win_reason: winReason, overall_comment: overall.trim() || null, expected_version: match.version, }, }); toast.show("Партия обновлена"); cancelEditing(); } catch (e) { if (isStale(e)) { toast.show("Партия изменилась на другом устройстве — обновлено"); refetch(); cancelEditing(); return; } setError(e instanceof ApiError ? e.message : "Не удалось сохранить"); } }; const remove = async () => { if (!id || !match) return; try { await del.mutateAsync({ matchId: id, expectedVersion: match.version }); toast.show(inProgress ? "Партия отменена" : "Партия удалена"); // Не navigate(-1): партию часто открывают по прямой ссылке, и «назад» // уводит из приложения вместо возврата к группе. navigate("/group", { replace: true }); } catch (e) { if (isStale(e)) { toast.show("Партия изменилась на другом устройстве — обновлено"); refetch(); return; } toast.show(e instanceof ApiError ? e.message : "Не удалось выполнить"); } }; const sorted = [...match.participants].sort( (a, b) => (a.place ?? 99) - (b.place ?? 99), ); // К доступным группе фракциям добавляем те, что уже стоят в партии: дополнение // могли отключить после игры, но менять её фракцию это мешать не должно. const factionOptions = [ ...(groupFactions ?? []), ...match.participants .filter((p) => !(groupFactions ?? []).some((f) => f.id === p.faction_id)) .map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })), ]; return (

{formatDate(match.played_at)}

{inProgress ? ( идёт ) : ( {formatDuration(match.duration_minutes)} )}
Начало: {formatTime(match.started_at)} {match.finished_at && ` · конец: ${formatTime(match.finished_at)}`}
{!inProgress && (
Победа: {winReasonLabel(match.win_reason)}
)} {match.overall_comment &&

{match.overall_comment}

}
{/* Завершённая партия — правка результатов */} {!inProgress && editing && ( <>

Правка результатов

Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого игрока, чтобы разделить место (ничья).

{ setBlocks(b); setElim(e); }} onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })} />

Фракции

{match.participants.map((p) => (
{p.nickname}
f.id === editFactions[p.user_id]) ?? null} options={factionOptions.filter( (f) => f.id === editFactions[p.user_id] || !Object.values(editFactions).includes(f.id), )} placeholder="— фракция —" renderOption={(f) => f.name_ru} onPick={(f) => setEditFactions({ ...editFactions, [p.user_id]: f.id })} />
))}

Причина победы

o.id === winReason) ?? null} options={REASON_OPTIONS} placeholder="— причина —" renderOption={(o) => o.label} onPick={(o) => setWinReason(o.id)} />

О партии