Files
ForbiddenStarsApp/frontend/src/pages/admin/AdminMatchEdit.tsx
T
NotBigGhostandClaude Opus 4.8 1438a17441 Партии: «выбывшие» игроки (красный крестик) = последнее место
Выбор места при завершении партии получает опцию «✕ выбыл»; выбывшему
авто-проставляется общее последнее место (ничья последнего ранга), плюс флаг
eliminated для отрисовки красным крестиком. В результатах и списке партий
выбывшие показываются крестиком и идут в конец. Подсчёт очков не меняется
(используются обычные числовые места).

Бэкенд: модель MatchParticipant.eliminated + миграция 0009 (идемпотентная);
схемы (place стал необязательным, +eliminated); match_service (хелпер
_resolve_finish_places, finish/update); проброс eliminated в роутерах и stats.
Фронт: выбор места с крестиком в MatchDetailPage и AdminMatchEdit, отрисовка в
результатах и MatchList, стиль .place-badge.eliminated; регенерация schema.d.ts.
Тесты: завершение с выбывшим (последнее место, засчитан), все-выбывшие → 422.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:54:37 +03:00

203 lines
7.0 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 { ApiError } from "../../api/client";
import { MatchMedia } from "../../components/MatchMedia";
import { Spinner } from "../../components/Spinner";
import { WIN_REASONS, type WinReason } from "../../domain/winReasons";
import { useToast } from "../../context/ToastContext";
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;
}
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 toast = useToast();
const [rows, setRows] = useState<Row[]>([]);
const [playedAt, setPlayedAt] = useState("");
const [overall, setOverall] = useState("");
const [winReason, setWinReason] = useState<WinReason>("objectives");
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 ?? "",
})),
);
setPlayedAt(match.played_at);
setOverall(match.overall_comment ?? "");
if (match.win_reason) setWinReason(match.win_reason);
}
}, [match]);
const upd = (i: number, patch: Partial<Row>) =>
setRows((rs) => rs.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
const save = async () => {
setError(null);
try {
await update.mutateAsync({
matchId,
body: {
played_at: playedAt,
overall_comment: overall.trim() || null,
win_reason: winReason,
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,
})),
},
});
toast.show("Партия обновлена");
onClose();
} catch (e) {
setError(e instanceof ApiError ? e.message : "Не удалось сохранить");
}
};
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 />
) : (
<>
<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 }}>
<input
type="checkbox"
style={{ width: "auto" }}
checked={r.eliminated}
onChange={(e) => upd(i, { eliminated: e.target.checked })}
/>
выбыл
</label>
</div>
<input
placeholder="Комментарий об игроке"
value={r.comment}
onChange={(e) => upd(i, { comment: e.target.value })}
/>
</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}
</option>
))}
</select>
</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" style={{ width: "100%" }} onClick={save} disabled={update.isPending}>
Сохранить изменения
</button>
</>
)}
</div>
</div>
</div>
);
}