Рейтинг: ввод деталей партии и предупреждения в формах
Форма завершения и правка результатов (MatchDetailPage): у каждого игрока
в PlaceEditor поля «цели» и «миры» на конец партии (у выбывшего миры
заблокированы нулём), общий блок «Итог партии» — причина победы и раунд
окончания из max_rounds партии. Всё новое уходит в общий черновик.
Причина «последний выживший» ставится сама при одном невыбывшем, выбор
заблокирован; вернули второго — причина сбрасывается и её нужно выбрать.
То же в админской правке партии.
Предупреждения (domain/finishWarnings.ts, отправку не блокируют): тип победы
и цели/миры лидеров не согласуются, досрочный конец без N целей у победителя,
у соперника целей больше, чем у победителя. Настройки группы: переключатель
«9 раундов при 5–6 игроках» через PATCH /groups/{id}. schema.d.ts
перегенерирован. #23
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
This commit is contained in:
@@ -5,7 +5,14 @@ import { ApiError } from "../../api/client";
|
||||
import { MatchMedia } from "../../components/MatchMedia";
|
||||
import { Spinner } from "../../components/Spinner";
|
||||
import { Switch } from "../../components/Switch";
|
||||
import { WIN_REASONS, type WinReason } from "../../domain/winReasons";
|
||||
import { finishWarnings } from "../../domain/finishWarnings";
|
||||
import {
|
||||
LAST_STANDING,
|
||||
reasonForSurvivors,
|
||||
WIN_REASONS,
|
||||
type WinReason,
|
||||
winReasonLabel,
|
||||
} from "../../domain/winReasons";
|
||||
import {
|
||||
useAdminDeleteAttachment,
|
||||
useAdminFactions,
|
||||
@@ -23,8 +30,16 @@ interface Row {
|
||||
eliminated: boolean;
|
||||
was_random: boolean;
|
||||
comment: string;
|
||||
objectives: number | null;
|
||||
worlds: number | null;
|
||||
}
|
||||
|
||||
// Пустое поле — «не указано» (null); иначе целое 0..99.
|
||||
const parseCount = (raw: string): number | null => {
|
||||
const num = Number(raw.trim());
|
||||
return raw.trim() === "" || !Number.isFinite(num) ? null : Math.min(99, Math.max(0, Math.trunc(num)));
|
||||
};
|
||||
|
||||
export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) {
|
||||
const { data: match, isLoading } = useAdminMatch(matchId);
|
||||
const { data: factions } = useAdminFactions();
|
||||
@@ -37,7 +52,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [playedAt, setPlayedAt] = useState("");
|
||||
const [overall, setOverall] = useState("");
|
||||
const [winReason, setWinReason] = useState<WinReason>("objectives");
|
||||
const [winReason, setWinReason] = useState<WinReason | null>("objectives");
|
||||
const [endRound, setEndRound] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,19 +67,32 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
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 ?? "");
|
||||
if (match.win_reason) setWinReason(match.win_reason);
|
||||
setWinReason(match.win_reason ?? null);
|
||||
setEndRound(match.end_round ?? null);
|
||||
}
|
||||
}, [match]);
|
||||
|
||||
const upd = (i: number, patch: Partial<Row>) =>
|
||||
setRows((rs) => rs.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||
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,
|
||||
@@ -71,6 +100,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
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,
|
||||
@@ -78,6 +108,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
eliminated: r.eliminated,
|
||||
was_random: r.was_random,
|
||||
comment: r.comment.trim() || null,
|
||||
objectives: r.objectives,
|
||||
worlds: r.eliminated ? null : r.worlds,
|
||||
})),
|
||||
},
|
||||
});
|
||||
@@ -87,6 +119,22 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
@@ -159,23 +207,81 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
выбыл
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
placeholder="Комментарий об игроке"
|
||||
value={r.comment}
|
||||
onChange={(e) => upd(i, { comment: e.target.value })}
|
||||
/>
|
||||
<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={99}
|
||||
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={99}
|
||||
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>
|
||||
<select value={winReason} onChange={(e) => setWinReason(e.target.value as WinReason)}>
|
||||
{WIN_REASONS.map((w) => (
|
||||
<option key={w.code} value={w.code}>
|
||||
{w.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">
|
||||
|
||||
Reference in New Issue
Block a user