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
313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|