Выбор места при завершении партии получает опцию «✕ выбыл»; выбывшему авто-проставляется общее последнее место (ничья последнего ранга), плюс флаг 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>
203 lines
7.0 KiB
TypeScript
203 lines
7.0 KiB
TypeScript
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>
|
||
);
|
||
}
|