Files
ForbiddenStarsApp/frontend/src/pages/admin/AdminMatchEdit.tsx
T
NotBigGhostandClaude Opus 5 6c35cb04d4 Итоги партии: общий разбор целей/миров и один расчёт мест
parseCount и граница 99 — в domain/matchCounts.ts вместо двух копий в PlaceEditor
и AdminMatchEdit. В MatchDetailPage места по раскладке считает placeRows, из него
строятся и предупреждения, и строки для API. Поведение не меняется. #89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-18 23:23:02 +03:00

313 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ApiError } from "../../api/client";
import { MatchMedia } from "../../components/MatchMedia";
import { Spinner } from "../../components/Spinner";
import { Switch } from "../../components/Switch";
import { finishWarnings } from "../../domain/finishWarnings";
import { MAX_COUNT, parseCount } from "../../domain/matchCounts";
import {
LAST_STANDING,
reasonForSurvivors,
WIN_REASONS,
type WinReason,
winReasonLabel,
} from "../../domain/winReasons";
import {
useAdminDeleteAttachment,
useAdminFactions,
useAdminMatch,
useAdminMatchAttachments,
useAdminUpdateMatch,
useAdminUploadAttachment,
} from "../../hooks/admin";
interface Row {
user_id: number;
nickname: string;
faction_id: number;
place: number;
eliminated: boolean;
was_random: boolean;
comment: string;
objectives: number | null;
worlds: number | null;
}
export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) {
const { data: match, isLoading } = useAdminMatch(matchId);
const { data: factions } = useAdminFactions();
const { data: attachments } = useAdminMatchAttachments(matchId);
const update = useAdminUpdateMatch();
const uploadAtt = useAdminUploadAttachment(matchId);
const deleteAtt = useAdminDeleteAttachment(matchId);
const navigate = useNavigate();
const [rows, setRows] = useState<Row[]>([]);
const [playedAt, setPlayedAt] = useState("");
const [overall, setOverall] = useState("");
const [winReason, setWinReason] = useState<WinReason | null>("objectives");
const [endRound, setEndRound] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (match) {
setRows(
match.participants.map((p) => ({
user_id: p.user_id,
nickname: p.nickname,
faction_id: p.faction_id,
place: p.place ?? 1,
eliminated: p.eliminated,
was_random: p.was_random,
comment: p.comment ?? "",
objectives: p.objectives ?? null,
worlds: p.worlds ?? null,
})),
);
setPlayedAt(match.played_at);
setOverall(match.overall_comment ?? "");
setWinReason(match.win_reason ?? null);
setEndRound(match.end_round ?? null);
}
}, [match]);
const upd = (i: number, patch: Partial<Row>) => {
const next = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
setRows(next);
// Выбывание меняет число выживших: «последний выживший» ставится и снимается сам.
if ("eliminated" in patch) {
setWinReason(reasonForSurvivors(winReason, next.filter((r) => !r.eliminated).length));
}
};
const save = async () => {
setError(null);
if (!winReason) {
setError("Выберите причину победы.");
return;
}
try {
await update.mutateAsync({
matchId,
body: {
played_at: playedAt,
overall_comment: overall.trim() || null,
win_reason: winReason,
end_round: endRound,
participants: rows.map((r) => ({
user_id: r.user_id,
faction_id: r.faction_id,
place: r.eliminated ? null : r.place,
eliminated: r.eliminated,
was_random: r.was_random,
comment: r.comment.trim() || null,
objectives: r.objectives,
worlds: r.eliminated ? null : r.worlds,
})),
},
});
onClose();
} catch (e) {
setError(e instanceof ApiError ? e.message : "Не удалось сохранить");
}
};
const warnings = match
? finishWarnings({
seats: rows.map((r) => ({
userId: r.user_id,
nickname: r.nickname,
place: r.place,
eliminated: r.eliminated,
objectives: r.objectives,
worlds: r.eliminated ? 0 : r.worlds,
})),
winReason,
endRound,
maxRounds: match.max_rounds,
})
: [];
return (
// Полноэкранная страница правки (не нижний «лист»): занимает весь экран.
<div
style={{
position: "fixed",
inset: 0,
zIndex: 200,
background: "var(--bg)",
overflowY: "auto",
}}
>
<div className="app-shell" style={{ minHeight: "100%", boxShadow: "none" }}>
<div className="app-main" style={{ paddingBottom: 24 }}>
<div className="row-between">
<h2 style={{ margin: 0 }}>Правка партии #{matchId}</h2>
<button className="btn btn-ghost" onClick={onClose}>
✕
</button>
</div>
{isLoading || !match ? (
<Spinner />
) : match.status === "in_progress" ? (
// Результаты идущей партии сервер не примет (409): места без завершения
// оставляли бы партию вне статистики. Объясняем это здесь, а не ошибкой.
<div className="card">
<h3>Партия ещё идёт</h3>
<p className="muted small">
Результаты незавершённой партии правкой не вносятся — их проставляют
при завершении, на странице самой партии. Там же участники заполняют
места совместно.
</p>
<button className="btn btn-block" onClick={() => navigate(`/match/${matchId}`)}>
Открыть страницу партии
</button>
</div>
) : (
<>
<div className="field">
<label className="label">Дата</label>
<input type="date" value={playedAt} onChange={(e) => setPlayedAt(e.target.value)} />
</div>
{rows.map((r, i) => (
<div className="participant-row" key={r.user_id}>
<b>{r.nickname}</b>
<div className="row">
<select
style={{ flex: 1 }}
value={r.faction_id}
onChange={(e) => upd(i, { faction_id: Number(e.target.value) })}
>
{(factions ?? []).map((f) => (
<option key={f.id} value={f.id}>
{f.name_ru}
</option>
))}
</select>
<div style={{ flex: "0 0 90px" }}>
<input
type="number"
min={1}
value={r.place}
disabled={r.eliminated}
onChange={(e) => upd(i, { place: Math.max(1, Number(e.target.value) || 1) })}
/>
</div>
<label className="row small" style={{ flex: "0 0 auto", gap: 4 }}>
<Switch checked={r.eliminated} onChange={(v) => upd(i, { eliminated: v })} />
выбыл
</label>
</div>
<div className="row">
<input
style={{ flex: 1 }}
placeholder="Комментарий об игроке"
value={r.comment}
onChange={(e) => upd(i, { comment: e.target.value })}
/>
<input
style={{ flex: "0 0 70px" }}
type="number"
min={0}
max={MAX_COUNT}
placeholder="цели"
title="Цели на конец партии"
value={r.objectives ?? ""}
onChange={(e) => upd(i, { objectives: parseCount(e.target.value) })}
/>
<input
style={{ flex: "0 0 70px" }}
type="number"
min={0}
max={MAX_COUNT}
placeholder="миры"
title="Миры на конец партии"
disabled={r.eliminated}
value={r.eliminated ? 0 : (r.worlds ?? "")}
onChange={(e) => upd(i, { worlds: parseCount(e.target.value) })}
/>
</div>
</div>
))}
<div className="field">
<label className="label">Причина победы</label>
{winReason === LAST_STANDING ? (
<select value={LAST_STANDING} disabled>
<option value={LAST_STANDING}>{winReasonLabel(LAST_STANDING)}</option>
</select>
) : (
<select
value={winReason ?? ""}
onChange={(e) => setWinReason(e.target.value as WinReason)}
>
<option value="" disabled>
— выберите причину —
</option>
{WIN_REASONS.map((w) => (
<option key={w.code} value={w.code}>
{w.label}
</option>
))}
</select>
)}
</div>
<div className="field">
<label className="label">Раунд окончания (из {match.max_rounds})</label>
<select
value={endRound ?? ""}
onChange={(e) => setEndRound(e.target.value === "" ? null : Number(e.target.value))}
>
<option value="">не указан</option>
{Array.from({ length: match.max_rounds }, (_, k) => k + 1).map((n) => (
<option key={n} value={n}>
{n}-й
</option>
))}
</select>
{warnings.length > 0 && (
<ul className="warn-list">
{warnings.map((w) => (
<li key={w}>{w}</li>
))}
</ul>
)}
</div>
<div className="field">
<label className="label">Комментарий о партии</label>
<textarea
rows={2}
style={{ resize: "none" }}
value={overall}
onChange={(e) => setOverall(e.target.value)}
/>
<div style={{ marginTop: 8 }}>
<MatchMedia
attachments={attachments ?? []}
editable
onUpload={(f) => uploadAtt.mutateAsync(f)}
onDelete={(aid) => deleteAtt.mutateAsync(aid)}
pending={uploadAtt.isPending || deleteAtt.isPending}
/>
</div>
</div>
{error && <p className="error-text">{error}</p>}
<button className="btn btn-primary btn-block" onClick={save} disabled={update.isPending}>
Сохранить изменения
</button>
</>
)}
</div>
</div>
</div>
);
}