Рейтинг: ввод деталей партии и предупреждения в формах

Форма завершения и правка результатов (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:
2026-09-14 22:45:25 +03:00
co-authored by Claude Opus 5
parent 6ea4a67672
commit cc64bb4644
10 changed files with 683 additions and 130 deletions
+70 -15
View File
@@ -377,8 +377,11 @@ export interface paths {
delete?: never; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
/** Rename Group */ /**
patch: operations["rename_group_api_groups__group_id__patch"]; * Update Group
* @description Название и домашние правила группы; меняются только переданные поля.
*/
patch: operations["update_group_api_groups__group_id__patch"];
trace?: never; trace?: never;
}; };
"/api/groups/{group_id}/expansions": { "/api/groups/{group_id}/expansions": {
@@ -1307,7 +1310,7 @@ export interface components {
/** Duration Minutes */ /** Duration Minutes */
duration_minutes?: number | null; duration_minutes?: number | null;
/** Win Reason */ /** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** Player Count */ /** Player Count */
player_count: number; player_count: number;
/** Created By */ /** Created By */
@@ -1576,6 +1579,11 @@ export interface components {
* @default [] * @default []
*/ */
expansion_ids: number[]; expansion_ids: number[];
/**
* Nine Rounds Rule
* @default false
*/
nine_rounds_rule: boolean;
}; };
/** GroupExpansionsUpdate */ /** GroupExpansionsUpdate */
GroupExpansionsUpdate: { GroupExpansionsUpdate: {
@@ -1585,11 +1593,6 @@ export interface components {
*/ */
expansion_ids: number[]; expansion_ids: number[];
}; };
/** GroupRename */
GroupRename: {
/** Name */
name: string;
};
/** GroupStats */ /** GroupStats */
GroupStats: { GroupStats: {
/** Group Id */ /** Group Id */
@@ -1621,6 +1624,13 @@ export interface components {
/** Min Games */ /** Min Games */
min_games: number; min_games: number;
}; };
/** GroupUpdate */
GroupUpdate: {
/** Name */
name?: string | null;
/** Nine Rounds Rule */
nine_rounds_rule?: boolean | null;
};
/** HTTPValidationError */ /** HTTPValidationError */
HTTPValidationError: { HTTPValidationError: {
/** Detail */ /** Detail */
@@ -1730,7 +1740,9 @@ export interface components {
* Win Reason * Win Reason
* @enum {string} * @enum {string}
*/ */
win_reason: "objectives" | "worlds" | "plastic" | "resources"; win_reason: "objectives" | "worlds" | "plastic" | "resources" | "last_standing";
/** End Round */
end_round?: number | null;
/** Overall Comment */ /** Overall Comment */
overall_comment?: string | null; overall_comment?: string | null;
/** Expected Version */ /** Expected Version */
@@ -1761,9 +1773,25 @@ export interface components {
[key: string]: string; [key: string]: string;
}; };
/** Win Reason */ /** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** Overall Comment */ /** Overall Comment */
overall_comment?: string | null; overall_comment?: string | null;
/** End Round */
end_round?: number | null;
/**
* Objectives
* @default {}
*/
objectives: {
[key: string]: number;
};
/**
* Worlds
* @default {}
*/
worlds: {
[key: string]: number;
};
}; };
/** MatchFinishDraftRead */ /** MatchFinishDraftRead */
MatchFinishDraftRead: { MatchFinishDraftRead: {
@@ -1790,6 +1818,10 @@ export interface components {
comment?: string | null; comment?: string | null;
/** Faction Id */ /** Faction Id */
faction_id?: number | null; faction_id?: number | null;
/** Objectives */
objectives?: number | null;
/** Worlds */
worlds?: number | null;
}; };
/** /**
* MatchHistory * MatchHistory
@@ -1844,7 +1876,7 @@ export interface components {
/** Duration Minutes */ /** Duration Minutes */
duration_minutes?: number | null; duration_minutes?: number | null;
/** Win Reason */ /** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** Player Count */ /** Player Count */
player_count: number; player_count: number;
/** Overall Comment */ /** Overall Comment */
@@ -1878,6 +1910,10 @@ export interface components {
was_random: boolean; was_random: boolean;
/** Comment */ /** Comment */
comment?: string | null; comment?: string | null;
/** Objectives */
objectives?: number | null;
/** Worlds */
worlds?: number | null;
}; };
/** MatchParticipantRead */ /** MatchParticipantRead */
MatchParticipantRead: { MatchParticipantRead: {
@@ -1900,6 +1936,10 @@ export interface components {
was_random: boolean; was_random: boolean;
/** Comment */ /** Comment */
comment?: string | null; comment?: string | null;
/** Objectives */
objectives?: number | null;
/** Worlds */
worlds?: number | null;
/** Avatar Url */ /** Avatar Url */
avatar_url?: string | null; avatar_url?: string | null;
}; };
@@ -1923,7 +1963,16 @@ export interface components {
/** Duration Minutes */ /** Duration Minutes */
duration_minutes?: number | null; duration_minutes?: number | null;
/** Win Reason */ /** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** End Round */
end_round?: number | null;
/**
* Nine Rounds Rule
* @default false
*/
nine_rounds_rule: boolean;
/** Max Rounds */
max_rounds: number;
/** Player Count */ /** Player Count */
player_count: number; player_count: number;
/** Overall Comment */ /** Overall Comment */
@@ -1956,7 +2005,9 @@ export interface components {
/** Overall Comment */ /** Overall Comment */
overall_comment?: string | null; overall_comment?: string | null;
/** Win Reason */ /** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** End Round */
end_round?: number | null;
/** Participants */ /** Participants */
participants?: components["schemas"]["ParticipantInput"][] | null; participants?: components["schemas"]["ParticipantInput"][] | null;
/** Expected Version */ /** Expected Version */
@@ -2104,6 +2155,10 @@ export interface components {
was_random: boolean; was_random: boolean;
/** Comment */ /** Comment */
comment?: string | null; comment?: string | null;
/** Objectives */
objectives?: number | null;
/** Worlds */
worlds?: number | null;
}; };
/** PasswordChange */ /** PasswordChange */
PasswordChange: { PasswordChange: {
@@ -2987,7 +3042,7 @@ export interface operations {
}; };
}; };
}; };
rename_group_api_groups__group_id__patch: { update_group_api_groups__group_id__patch: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -2998,7 +3053,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["GroupRename"]; "application/json": components["schemas"]["GroupUpdate"];
}; };
}; };
responses: { responses: {
@@ -0,0 +1,76 @@
import { PickerSelect } from "./PickerSelect";
import { LAST_STANDING, WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label }));
const NO_ROUND = 0;
/**
* Итог партии в форме завершения и правки результатов: причина победы, раунд окончания
* и предупреждения о несогласованном вводе. «Последний выживший» не выбирается —
* пока выживший один, причина зафиксирована (её ставит родитель, см. reasonForSurvivors).
*/
export function MatchOutcomeFields({
winReason,
onReason,
endRound,
onEndRound,
maxRounds,
warnings,
}: {
winReason: WinReason | null;
onReason: (reason: WinReason) => void;
endRound: number | null;
onEndRound: (round: number | null) => void;
maxRounds: number;
warnings: string[];
}) {
const roundOptions = [
{ id: NO_ROUND, label: "не указан" },
...Array.from({ length: maxRounds }, (_, i) => ({ id: i + 1, label: `${i + 1}-й` })),
];
return (
<div className="card">
<h3>Итог партии</h3>
<div className="field">
<label className="label">Причина победы</label>
{winReason === LAST_STANDING ? (
<div className="picker-trigger" aria-disabled>
{winReasonLabel(LAST_STANDING)}
<span className="muted small" style={{ marginLeft: "auto" }}>
все соперники выбыли
</span>
</div>
) : (
<PickerSelect
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null}
options={REASON_OPTIONS}
placeholder="— выберите причину —"
renderOption={(o) => o.label}
onPick={(o) => onReason(o.id)}
/>
)}
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="label">Раунд окончания (из {maxRounds})</label>
<PickerSelect
selected={roundOptions.find((o) => o.id === (endRound ?? NO_ROUND)) ?? null}
options={roundOptions}
placeholder="не указан"
renderOption={(o) => o.label}
onPick={(o) => onEndRound(o.id === NO_ROUND ? null : o.id)}
/>
</div>
<p className="muted small" style={{ marginBottom: 0 }}>
Раунд, цели и миры необязательны, но делают рейтинг точнее: быстрая и крупная победа
весит больше.
</p>
{warnings.length > 0 && (
<ul className="warn-list">
{warnings.map((w) => (
<li key={w}>{w}</li>
))}
</ul>
)}
</div>
);
}
+51 -8
View File
@@ -10,6 +10,10 @@ export interface PlacePlayer {
avatar_url?: string | null; avatar_url?: string | null;
} }
export type CountField = "objectives" | "worlds";
/** Цели и миры на конец партии по user_id; отсутствие ключа или null — не указано. */
export type Counts = Record<number, { objectives: number | null; worlds: number | null }>;
type Target = type Target =
| { type: "merge"; idx: number } | { type: "merge"; idx: number }
| { type: "insert"; idx: number } | { type: "insert"; idx: number }
@@ -28,7 +32,8 @@ interface DragState {
* Редактор мест перетаскиванием (макет A2): вертикальный список блоков игроков, * Редактор мест перетаскиванием (макет A2): вертикальный список блоков игроков,
* верхний — 1-е место. Бросок между блоками — порядок, на середину чужого блока — * верхний — 1-е место. Бросок между блоками — порядок, на середину чужого блока —
* слияние в ничью (общая рамка и место, ✂ выносит обратно), в пунктирную зону — * слияние в ничью (общая рамка и место, ✂ выносит обратно), в пунктирную зону —
* выбывший. В каждом блоке — строка комментария об игроке. * выбывший. В каждом блоке — строка комментария об игроке и (если переданы counts)
* цели и миры на конец партии; у выбывшего миров нет — поле заблокировано нулём.
* *
* Контролируемый: blocks (упорядоченные группы user_id, длина >1 = ничья) и * Контролируемый: blocks (упорядоченные группы user_id, длина >1 = ничья) и
* eliminated живут у родителя. Во время drag DOM двигается напрямую (transform, * eliminated живут у родителя. Во время drag DOM двигается напрямую (transform,
@@ -41,6 +46,8 @@ export function PlaceEditor({
comments, comments,
onChange, onChange,
onComment, onComment,
counts,
onCount,
}: { }: {
players: PlacePlayer[]; players: PlacePlayer[];
blocks: number[][]; blocks: number[][];
@@ -48,6 +55,8 @@ export function PlaceEditor({
comments: Record<number, string>; comments: Record<number, string>;
onChange: (blocks: number[][], eliminated: number[]) => void; onChange: (blocks: number[][], eliminated: number[]) => void;
onComment: (userId: number, text: string) => void; onComment: (userId: number, text: string) => void;
counts?: Counts;
onCount?: (userId: number, field: CountField, value: number | null) => void;
}) { }) {
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const elimRef = useRef<HTMLDivElement>(null); const elimRef = useRef<HTMLDivElement>(null);
@@ -225,15 +234,49 @@ export function PlaceEditor({
); );
}; };
const commentInput = (userId: number) => ( const countInput = (userId: number, field: CountField, label: string, locked = false) => (
<input <label className="rank-count">
className="rank-comment" <span>{label}</span>
placeholder="Комментарий об игроке" <input
value={comments[userId] ?? ""} type="number"
onChange={(e) => onComment(userId, e.target.value)} inputMode="numeric"
/> min={0}
max={99}
placeholder="—"
disabled={locked}
value={locked ? 0 : (counts?.[userId]?.[field] ?? "")}
onChange={(e) => {
const raw = e.target.value.trim();
const num = Number(raw);
onCount?.(
userId,
field,
raw === "" || !Number.isFinite(num) ? null : Math.min(99, Math.max(0, Math.trunc(num))),
);
}}
/>
</label>
); );
const commentInput = (userId: number) => {
const comment = (
<input
className="rank-comment"
placeholder="Комментарий об игроке"
value={comments[userId] ?? ""}
onChange={(e) => onComment(userId, e.target.value)}
/>
);
if (!counts) return comment;
return (
<div className="rank-details">
{comment}
{countInput(userId, "objectives", "цели")}
{countInput(userId, "worlds", "миры", eliminated.includes(userId))}
</div>
);
};
return ( return (
<div> <div>
<div className="rank-list" ref={listRef}> <div className="rank-list" ref={listRef}>
+101
View File
@@ -0,0 +1,101 @@
import { LAST_STANDING, type WinReason, winReasonLabel } from "./winReasons";
export interface OutcomeSeat {
userId: number;
nickname: string;
/** Место невыбывшего; у выбывшего не учитывается. */
place: number;
eliminated: boolean;
objectives: number | null;
worlds: number | null;
}
/**
* Подсказки о несогласованных итогах партии. Это предупреждения, а не отказ: сервер
* проверяет только диапазоны и явные противоречия, а здесь — сочетания, которые по
* правилам почти наверняка опечатка. Проверка идёт только по заполненным полям.
*/
export function finishWarnings({
seats,
winReason,
endRound,
maxRounds,
}: {
seats: OutcomeSeat[];
winReason: WinReason | null;
endRound: number | null;
maxRounds: number;
}): string[] {
const out: string[] = [];
const n = seats.length;
const survivors = seats.filter((s) => !s.eliminated).sort((a, b) => a.place - b.place);
const winners = survivors.filter((s) => s.place === 1);
if (winners.length === 0 || winReason === LAST_STANDING) return out;
// Досрочно партия заканчивается, только когда кто-то набрал столько целей, сколько
// игроков за столом.
const winnerObjectives = winners.map((w) => w.objectives).filter((v): v is number => v != null);
if (
endRound != null &&
endRound < maxRounds &&
winnerObjectives.length > 0 &&
Math.max(...winnerObjectives) < n
) {
out.push(
`Партия закончилась в ${endRound}-м раунде из ${maxRounds}, но у победителя меньше ${n} целей — ` +
`досрочно побеждает тот, кто набрал ${n}.`,
);
}
// Остальное сравнивает победителя с соперниками — при ничьей за 1-е место не с кем.
if (winners.length > 1) return out;
const winner = winners[0];
const secondPlace = survivors.find((s) => s.place > 1)?.place;
const runnersUp = survivors.filter((s) => s.place === secondPlace);
const label = winReasonLabel(winReason).toLowerCase();
for (const r of runnersUp) {
const objKnown = winner.objectives != null && r.objectives != null;
const worldsKnown = winner.worlds != null && r.worlds != null;
if (winReason === "objectives" && objKnown && winner.objectives === r.objectives) {
out.push(
`У ${winner.nickname} и ${r.nickname} поровну целей (${winner.objectives}) — ` +
"при равенстве победа определяется по мирам, пластику или ресурсам.",
);
}
if (winReason && winReason !== "objectives" && objKnown && winner.objectives !== r.objectives) {
out.push(
`Победа ${label} предполагает равные цели у лидеров: у ${winner.nickname} — ` +
`${winner.objectives}, у ${r.nickname} — ${r.objectives}.`,
);
}
if (winReason === "worlds" && worldsKnown && winner.worlds! <= r.worlds!) {
out.push(
`Победа по мирам, но у ${winner.nickname} миров не больше, чем у ${r.nickname} ` +
`(${winner.worlds} и ${r.worlds}).`,
);
}
if (
(winReason === "plastic" || winReason === "resources") &&
worldsKnown &&
winner.worlds !== r.worlds
) {
out.push(
`Победа ${label} предполагает равные миры у лидеров: у ${winner.nickname} — ` +
`${winner.worlds}, у ${r.nickname} — ${r.worlds}.`,
);
}
}
if (winner.objectives != null) {
for (const s of seats) {
if (s.userId !== winner.userId && s.objectives != null && s.objectives > winner.objectives) {
out.push(
`У ${s.nickname} целей больше, чем у победителя ${winner.nickname} ` +
`(${s.objectives} против ${winner.objectives}).`,
);
}
}
}
return out;
}
+18 -4
View File
@@ -2,7 +2,10 @@ import type { components } from "../api/schema";
export type WinReason = components["schemas"]["MatchFinish"]["win_reason"]; export type WinReason = components["schemas"]["MatchFinish"]["win_reason"];
// Фиксированный порядок (сверху вниз). export const LAST_STANDING: WinReason = "last_standing";
// Причины для ручного выбора, фиксированный порядок (сверху вниз). «Последний выживший»
// сюда не входит: она ставится сама, когда невыбывший игрок остался один.
export const WIN_REASONS: { code: WinReason; label: string }[] = [ export const WIN_REASONS: { code: WinReason; label: string }[] = [
{ code: "objectives", label: "По целям" }, { code: "objectives", label: "По целям" },
{ code: "worlds", label: "По мирам" }, { code: "worlds", label: "По мирам" },
@@ -10,10 +13,21 @@ export const WIN_REASONS: { code: WinReason; label: string }[] = [
{ code: "resources", label: "По ресурсам" }, { code: "resources", label: "По ресурсам" },
]; ];
const LABELS: Record<string, string> = Object.fromEntries( const LABELS: Record<string, string> = {
WIN_REASONS.map((w) => [w.code, w.label]), ...Object.fromEntries(WIN_REASONS.map((w) => [w.code, w.label])),
); [LAST_STANDING]: "Последний выживший",
};
export function winReasonLabel(code: string | null | undefined): string { export function winReasonLabel(code: string | null | undefined): string {
return code ? LABELS[code] ?? code : "—"; return code ? LABELS[code] ?? code : "—";
} }
/**
* Причина победы после изменения состава выбывших. Правило сервера: невыбывший ровно
* один ⇔ «последний выживший». Поэтому при одном выжившем причина ставится сама, а когда
* выживших снова двое — сбрасывается (null): её нужно выбрать заново, а не угадывать.
*/
export function reasonForSurvivors(current: WinReason | null, survivors: number): WinReason | null {
if (survivors === 1) return LAST_STANDING;
return current === LAST_STANDING ? null : current;
}
+4 -3
View File
@@ -75,14 +75,15 @@ export function useCreateGroup() {
}); });
} }
export function useRenameGroup(groupId: number) { export function useUpdateGroup(groupId: number) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (name: string) => // Частичная правка: название и/или домашние правила (например, 9 раундов).
mutationFn: async (body: { name?: string; nine_rounds_rule?: boolean }) =>
unwrap( unwrap(
await api.PATCH("/api/groups/{group_id}", { await api.PATCH("/api/groups/{group_id}", {
params: { path: { group_id: groupId } }, params: { path: { group_id: groupId } },
body: { name }, body,
}), }),
), ),
onSuccess: () => { onSuccess: () => {
+29 -4
View File
@@ -13,8 +13,8 @@ import {
useGroup, useGroup,
useGroupMembers, useGroupMembers,
useRemoveMember, useRemoveMember,
useRenameGroup,
useSetGroupExpansions, useSetGroupExpansions,
useUpdateGroup,
} from "../hooks/groups"; } from "../hooks/groups";
import { useExpansions } from "../hooks/reference"; import { useExpansions } from "../hooks/reference";
@@ -25,7 +25,7 @@ export function GroupSettingsPage() {
const { data: expansions } = useExpansions(); const { data: expansions } = useExpansions();
const { data: members } = useGroupMembers(groupId); const { data: members } = useGroupMembers(groupId);
const setExpansions = useSetGroupExpansions(groupId ?? 0); const setExpansions = useSetGroupExpansions(groupId ?? 0);
const renameGroup = useRenameGroup(groupId ?? 0); const updateGroup = useUpdateGroup(groupId ?? 0);
const removeMember = useRemoveMember(groupId ?? 0); const removeMember = useRemoveMember(groupId ?? 0);
const toast = useToast(); const toast = useToast();
@@ -55,7 +55,7 @@ export function GroupSettingsPage() {
const trimmed = nameValue.trim(); const trimmed = nameValue.trim();
if (!trimmed || trimmed === group.name) return; if (!trimmed || trimmed === group.name) return;
try { try {
await renameGroup.mutateAsync(trimmed); await updateGroup.mutateAsync({ name: trimmed });
setName(null); setName(null);
toast.show("Название сохранено"); toast.show("Название сохранено");
} catch (e) { } catch (e) {
@@ -63,6 +63,15 @@ export function GroupSettingsPage() {
} }
}; };
const saveNineRounds = async (enabled: boolean) => {
try {
await updateGroup.mutateAsync({ nine_rounds_rule: enabled });
toast.show(enabled ? "9 раундов при 5–6 игроках включено" : "Правило 9 раундов выключено");
} catch (e) {
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
const saveExpansions = async () => { const saveExpansions = async () => {
try { try {
await setExpansions.mutateAsync([...selected]); await setExpansions.mutateAsync([...selected]);
@@ -95,7 +104,7 @@ export function GroupSettingsPage() {
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={saveName} onClick={saveName}
disabled={renameGroup.isPending || !nameValue.trim() || nameValue.trim() === group.name} disabled={updateGroup.isPending || !nameValue.trim() || nameValue.trim() === group.name}
> >
Сохранить Сохранить
</button> </button>
@@ -127,6 +136,22 @@ export function GroupSettingsPage() {
)} )}
</div> </div>
<div className="card">
<h3>Домашние правила</h3>
<label className="row" style={{ justifyContent: "space-between" }}>
<span>9 раундов при 5–6 игроках</span>
<Switch
checked={group.nine_rounds_rule}
disabled={updateGroup.isPending}
onChange={saveNineRounds}
/>
</label>
<p className="muted small" style={{ marginBottom: 0 }}>
Действует на партии, начатые после смены: лимит раундов уже идущих и сыгранных
партий не меняется.
</p>
</div>
<div className="card"> <div className="card">
{/* Приглашение нового игрока — на странице группы («Список игроков»). */} {/* Приглашение нового игрока — на странице группы («Список игроков»). */}
<h3>Участники ({(members ?? []).length}/{MAX_GROUP_SIZE})</h3> <h3>Участники ({(members ?? []).length}/{MAX_GROUP_SIZE})</h3>
+190 -82
View File
@@ -4,13 +4,15 @@ import { useNavigate, useParams } from "react-router-dom";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { ConfirmDialog } from "../components/ConfirmDialog"; import { ConfirmDialog } from "../components/ConfirmDialog";
import { MatchMedia } from "../components/MatchMedia"; import { MatchMedia } from "../components/MatchMedia";
import { MatchOutcomeFields } from "../components/MatchOutcomeFields";
import { PickerSelect } from "../components/PickerSelect"; import { PickerSelect } from "../components/PickerSelect";
import { PlaceEditor } from "../components/PlaceEditor"; import { type CountField, type Counts, PlaceEditor } from "../components/PlaceEditor";
import { PlayerLink } from "../components/PlayerLink"; import { PlayerLink } from "../components/PlayerLink";
import { Spinner } from "../components/Spinner"; import { Spinner } from "../components/Spinner";
import { finishWarnings, type OutcomeSeat } from "../domain/finishWarnings";
import { formatDate, formatDuration, formatTime } from "../domain/format"; import { formatDate, formatDuration, formatTime } from "../domain/format";
import type { MatchFinishDraftData } from "../domain/types"; import type { MatchFinishDraftData, MatchRead } from "../domain/types";
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons"; import { reasonForSurvivors, type WinReason, winReasonLabel } from "../domain/winReasons";
import { useToast } from "../context/ToastContext"; import { useToast } from "../context/ToastContext";
import { useMe } from "../hooks/auth"; import { useMe } from "../hooks/auth";
import { import {
@@ -24,7 +26,31 @@ import {
} from "../hooks/matches"; } from "../hooks/matches";
import { useGroupFactions } from "../hooks/reference"; import { useGroupFactions } from "../hooks/reference";
const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label })); type Participant = MatchRead["participants"][number];
const countsOf = (participants: Participant[]): Counts =>
Object.fromEntries(
participants.map((p) => [p.user_id, { objectives: p.objectives ?? null, worlds: p.worlds ?? null }]),
);
const countsFromDraft = (participants: Participant[], data: MatchFinishDraftData): Counts =>
Object.fromEntries(
participants.map((p) => [
p.user_id,
{
objectives: data.objectives?.[String(p.user_id)] ?? null,
worlds: data.worlds?.[String(p.user_id)] ?? null,
},
]),
);
// Черновик хранит только заполненные поля: {user_id строкой: число}.
const countDict = (counts: Counts, field: CountField): Record<string, number> =>
Object.fromEntries(
Object.entries(counts).flatMap(([uid, c]) => (c[field] == null ? [] : [[uid, c[field]]])),
);
const survivorsIn = (blocks: number[][]) => blocks.reduce((sum, ids) => sum + ids.length, 0);
export function MatchDetailPage() { export function MatchDetailPage() {
const { matchId } = useParams(); const { matchId } = useParams();
@@ -47,7 +73,10 @@ export function MatchDetailPage() {
const [blocks, setBlocks] = useState<number[][] | null>(null); const [blocks, setBlocks] = useState<number[][] | null>(null);
const [elim, setElim] = useState<number[]>([]); const [elim, setElim] = useState<number[]>([]);
const [comments, setComments] = useState<Record<number, string> | null>(null); const [comments, setComments] = useState<Record<number, string> | null>(null);
const [winReason, setWinReason] = useState<WinReason>("objectives"); const [counts, setCounts] = useState<Counts | null>(null);
// null — причина не выбрана: сброшена после «последнего выжившего» (см. reasonForSurvivors).
const [winReason, setWinReason] = useState<WinReason | null>("objectives");
const [endRound, setEndRound] = useState<number | null>(null);
const [overall, setOverall] = useState(""); const [overall, setOverall] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [confirmRemove, setConfirmRemove] = useState(false); const [confirmRemove, setConfirmRemove] = useState(false);
@@ -86,23 +115,26 @@ export function MatchDetailPage() {
// Чужой черновик применяем, только если человек сейчас ничего не двигает: // Чужой черновик применяем, только если человек сейчас ничего не двигает:
// иначе правка соседа перетёрла бы тайл прямо под рукой. // иначе правка соседа перетёрла бы тайл прямо под рукой.
const incoming = match?.finish_draft; const incoming = match?.finish_draft;
const participants = match?.participants;
useEffect(() => { useEffect(() => {
if (!incoming || match?.status !== "in_progress") return; if (!incoming || !participants || match?.status !== "in_progress") return;
if (incoming.updated_by === me?.id) return; if (incoming.updated_by === me?.id) return;
if (incoming.updated_at === appliedDraftAt.current) return; if (incoming.updated_at === appliedDraftAt.current) return;
if (Date.now() - lastLocalEdit.current < 1500) return; if (Date.now() - lastLocalEdit.current < 1500) return;
setBlocks(incoming.data.blocks); setBlocks(incoming.data.blocks);
setElim(incoming.data.eliminated); setElim(incoming.data.eliminated);
setComments(incoming.data.comments); setComments(incoming.data.comments);
setWinReason((incoming.data.win_reason ?? "objectives") as WinReason); setCounts(countsFromDraft(participants, incoming.data));
setWinReason(incoming.data.win_reason ?? null);
setEndRound(incoming.data.end_round ?? null);
setOverall(incoming.data.overall_comment ?? ""); setOverall(incoming.data.overall_comment ?? "");
appliedDraftAt.current = incoming.updated_at; appliedDraftAt.current = incoming.updated_at;
}, [incoming, match?.status, me?.id]); }, [incoming, participants, match?.status, me?.id]);
if (isLoading) return <Spinner />; if (isLoading) return <Spinner />;
if (!match) return <div className="muted">Партия не найдена.</div>; if (!match) return <div className="muted">Партия не найдена.</div>;
const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (создатель/owner/admin) const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (любой участник группы или админ)
const inProgress = match.status === "in_progress"; const inProgress = match.status === "in_progress";
// Ленивая инициализация раскладки из участников: каждый — отдельным блоком. // Ленивая инициализация раскладки из участников: каждый — отдельным блоком.
@@ -110,6 +142,7 @@ export function MatchDetailPage() {
const finishComments: Record<number, string> = const finishComments: Record<number, string> =
comments ?? comments ??
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])); Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""]));
const finishCounts: Counts = counts ?? countsOf(match.participants);
// Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем. // Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем.
const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE"; const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE";
@@ -123,19 +156,64 @@ export function MatchDetailPage() {
), ),
win_reason: winReason, win_reason: winReason,
overall_comment: overall.trim() || null, overall_comment: overall.trim() || null,
end_round: endRound,
objectives: countDict(finishCounts, "objectives"),
worlds: countDict(finishCounts, "worlds"),
...patch, ...patch,
}); });
const submitFinish = async () => { // Раскладка изменилась: при одном выжившем причина становится «последний выживший»,
if (!id || !match) return; // при возврате второго — сбрасывается.
setError(null); const applyLayout = (b: number[][], e: number[]): WinReason | null => {
// Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты, const reason = reasonForSurvivors(winReason, survivorsIn(b));
// но не в черновик, и второй участник увидел бы не то, что записалось. setBlocks(b);
if (draftTimer.current) { setElim(e);
clearTimeout(draftTimer.current); setWinReason(reason);
sendDraft(); return reason;
} };
const applyCount = (uid: number, field: CountField, value: number | null): Counts => {
const current = finishCounts[uid] ?? { objectives: null, worlds: null };
const next = { ...finishCounts, [uid]: { ...current, [field]: value } };
setCounts(next);
return next;
};
const outcomeSeats = (): OutcomeSeat[] => {
const byId = new Map(match.participants.map((p) => [p.user_id, p]));
const seat = (uid: number, place: number, eliminated: boolean): OutcomeSeat => ({
userId: uid,
nickname: byId.get(uid)?.nickname ?? "",
place,
eliminated,
objectives: finishCounts[uid]?.objectives ?? null,
worlds: eliminated ? 0 : (finishCounts[uid]?.worlds ?? null),
});
let place = 1;
const rows = finishBlocks.flatMap((ids) => {
const out = ids.map((uid) => seat(uid, place, false));
place += ids.length;
return out;
});
return [...rows, ...elim.map((uid) => seat(uid, place, true))];
};
const warnings = () =>
finishWarnings({
seats: outcomeSeats(),
winReason,
endRound,
maxRounds: match.max_rounds,
});
// Строки результатов для API: места по блокам (competition ranking), затем выбывшие.
const resultRows = () => {
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null; const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
const countsFor = (uid: number, eliminated: boolean) => ({
objectives: finishCounts[uid]?.objectives ?? null,
// У выбывшего миров нет — сервер сам запишет 0.
worlds: eliminated ? null : (finishCounts[uid]?.worlds ?? null),
});
let place = 1; let place = 1;
const survivors = finishBlocks.flatMap((ids) => { const survivors = finishBlocks.flatMap((ids) => {
const rows = ids.map((uid) => ({ const rows = ids.map((uid) => ({
@@ -143,24 +221,41 @@ export function MatchDetailPage() {
place, place,
eliminated: false, eliminated: false,
comment: commentOf(uid), comment: commentOf(uid),
...countsFor(uid, false),
})); }));
place += ids.length; // competition ranking: ничья съедает следующие места place += ids.length; // competition ranking: ничья съедает следующие места
return rows; return rows;
}); });
const eliminated = elim.map((uid) => ({
user_id: uid,
place: null,
eliminated: true,
comment: commentOf(uid),
...countsFor(uid, true),
}));
return [...survivors, ...eliminated];
};
const submitFinish = async () => {
if (!id || !match) return;
setError(null);
if (!winReason) {
setError("Выберите причину победы.");
return;
}
// Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты,
// но не в черновик, и второй участник увидел бы не то, что записалось.
if (draftTimer.current) {
clearTimeout(draftTimer.current);
sendDraft();
}
try { try {
await finish.mutateAsync({ await finish.mutateAsync({
matchId: id, matchId: id,
body: { body: {
participants: [ participants: resultRows(),
...survivors,
...elim.map((uid) => ({
user_id: uid,
place: null,
eliminated: true,
comment: commentOf(uid),
})),
],
win_reason: winReason, win_reason: winReason,
end_round: endRound,
overall_comment: overall.trim() || null, overall_comment: overall.trim() || null,
expected_version: match.version, expected_version: match.version,
}, },
@@ -188,7 +283,9 @@ export function MatchDetailPage() {
setComments( setComments(
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])), Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])),
); );
setWinReason((match.win_reason ?? "objectives") as WinReason); setCounts(countsOf(match.participants));
setWinReason((match.win_reason ?? null) as WinReason | null);
setEndRound(match.end_round ?? null);
setOverall(match.overall_comment ?? ""); setOverall(match.overall_comment ?? "");
setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id]))); setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id])));
setError(null); setError(null);
@@ -199,36 +296,31 @@ export function MatchDetailPage() {
setEditing(false); setEditing(false);
setBlocks(null); setBlocks(null);
setComments(null); setComments(null);
setCounts(null);
setError(null); setError(null);
}; };
const submitEdit = async () => { const submitEdit = async () => {
if (!id || !match) return; if (!id || !match) return;
setError(null); setError(null);
if (!winReason) {
setError("Выберите причину победы.");
return;
}
const wasRandom = Object.fromEntries( const wasRandom = Object.fromEntries(
match.participants.map((p) => [p.user_id, p.was_random]), match.participants.map((p) => [p.user_id, p.was_random]),
); );
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
const rowOf = (uid: number, place: number | null, eliminated: boolean) => ({
user_id: uid,
faction_id: editFactions[uid],
place,
eliminated,
was_random: wasRandom[uid] ?? false,
comment: commentOf(uid),
});
let place = 1;
const survivors = finishBlocks.flatMap((ids) => {
const rows = ids.map((uid) => rowOf(uid, place, false));
place += ids.length; // competition ranking: ничья съедает следующие места
return rows;
});
try { try {
await updateMatch.mutateAsync({ await updateMatch.mutateAsync({
matchId: id, matchId: id,
body: { body: {
participants: [...survivors, ...elim.map((uid) => rowOf(uid, null, true))], participants: resultRows().map((r) => ({
...r,
faction_id: editFactions[r.user_id],
was_random: wasRandom[r.user_id] ?? false,
})),
win_reason: winReason, win_reason: winReason,
end_round: endRound,
overall_comment: overall.trim() || null, overall_comment: overall.trim() || null,
expected_version: match.version, expected_version: match.version,
}, },
@@ -276,6 +368,13 @@ export function MatchDetailPage() {
.map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })), .map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })),
]; ];
const placeHint = (
<p className="muted small">
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого игрока, чтобы
разделить место (ничья). Цели и миры — на конец партии.
</p>
);
return ( return (
<div> <div>
<div className="card"> <div className="card">
@@ -294,6 +393,9 @@ export function MatchDetailPage() {
{!inProgress && ( {!inProgress && (
<div className="small" style={{ marginTop: 4 }}> <div className="small" style={{ marginTop: 4 }}>
Победа: <b>{winReasonLabel(match.win_reason)}</b> Победа: <b>{winReasonLabel(match.win_reason)}</b>
{match.end_round != null && (
<span className="muted"> · конец в {match.end_round}-м раунде из {match.max_rounds}</span>
)}
</div> </div>
)} )}
{match.overall_comment && <p className="small" style={{ marginTop: 6 }}>{match.overall_comment}</p>} {match.overall_comment && <p className="small" style={{ marginTop: 6 }}>{match.overall_comment}</p>}
@@ -304,20 +406,16 @@ export function MatchDetailPage() {
<> <>
<div className="card"> <div className="card">
<h3>Правка результатов</h3> <h3>Правка результатов</h3>
<p className="muted small"> {placeHint}
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
игрока, чтобы разделить место (ничья).
</p>
<PlaceEditor <PlaceEditor
players={match.participants} players={match.participants}
blocks={finishBlocks} blocks={finishBlocks}
eliminated={elim} eliminated={elim}
comments={finishComments} comments={finishComments}
onChange={(b, e) => { counts={finishCounts}
setBlocks(b); onChange={applyLayout}
setElim(e);
}}
onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })} onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })}
onCount={applyCount}
/> />
</div> </div>
@@ -345,16 +443,14 @@ export function MatchDetailPage() {
</div> </div>
</div> </div>
<div className="card"> <MatchOutcomeFields
<h3>Причина победы</h3> winReason={winReason}
<PickerSelect onReason={setWinReason}
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null} endRound={endRound}
options={REASON_OPTIONS} onEndRound={setEndRound}
placeholder="— причина —" maxRounds={match.max_rounds}
renderOption={(o) => o.label} warnings={warnings()}
onPick={(o) => setWinReason(o.id)} />
/>
</div>
<div className="card"> <div className="card">
<h3>О партии</h3> <h3>О партии</h3>
@@ -413,6 +509,11 @@ export function MatchDetailPage() {
<span className="muted small">· {p.faction_name}</span> <span className="muted small">· {p.faction_name}</span>
{p.was_random && <span className="badge">🎲</span>} {p.was_random && <span className="badge">🎲</span>}
</PlayerLink> </PlayerLink>
{(p.objectives != null || p.worlds != null) && (
<div className="small muted" style={{ marginLeft: 32 }}>
цели {p.objectives ?? "—"} · миры {p.worlds ?? "—"}
</div>
)}
{p.comment && <div className="small muted" style={{ marginLeft: 32 }}>{p.comment}</div>} {p.comment && <div className="small muted" style={{ marginLeft: 32 }}>{p.comment}</div>}
</div> </div>
))} ))}
@@ -448,10 +549,7 @@ export function MatchDetailPage() {
<> <>
<div className="card"> <div className="card">
<h3>Места</h3> <h3>Места</h3>
<p className="muted small"> {placeHint}
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
игрока, чтобы разделить место (ничья).
</p>
{match.finish_draft && match.finish_draft.updated_by !== me?.id && ( {match.finish_draft && match.finish_draft.updated_by !== me?.id && (
<p className="small" style={{ color: "var(--accent-2)" }}> <p className="small" style={{ color: "var(--accent-2)" }}>
Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"} Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"}
@@ -464,10 +562,10 @@ export function MatchDetailPage() {
blocks={finishBlocks} blocks={finishBlocks}
eliminated={elim} eliminated={elim}
comments={finishComments} comments={finishComments}
counts={finishCounts}
onChange={(b, e) => { onChange={(b, e) => {
setBlocks(b); const reason = applyLayout(b, e);
setElim(e); queueDraft(draftOf({ blocks: b, eliminated: e, win_reason: reason }));
queueDraft(draftOf({ blocks: b, eliminated: e }));
}} }}
onComment={(uid, text) => { onComment={(uid, text) => {
const next = { ...finishComments, [uid]: text }; const next = { ...finishComments, [uid]: text };
@@ -480,23 +578,33 @@ export function MatchDetailPage() {
}), }),
); );
}} }}
/> onCount={(uid, field, value) => {
</div> const next = applyCount(uid, field, value);
queueDraft(
<div className="card"> draftOf({
<h3>Причина победы</h3> objectives: countDict(next, "objectives"),
<PickerSelect worlds: countDict(next, "worlds"),
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null} }),
options={REASON_OPTIONS} );
placeholder="— причина —"
renderOption={(o) => o.label}
onPick={(o) => {
setWinReason(o.id);
queueDraft(draftOf({ win_reason: o.id }));
}} }}
/> />
</div> </div>
<MatchOutcomeFields
winReason={winReason}
onReason={(reason) => {
setWinReason(reason);
queueDraft(draftOf({ win_reason: reason }));
}}
endRound={endRound}
onEndRound={(round) => {
setEndRound(round);
queueDraft(draftOf({ end_round: round }));
}}
maxRounds={match.max_rounds}
warnings={warnings()}
/>
<div className="card"> <div className="card">
<h3>О партии</h3> <h3>О партии</h3>
<textarea <textarea
+120 -14
View File
@@ -5,7 +5,14 @@ import { ApiError } from "../../api/client";
import { MatchMedia } from "../../components/MatchMedia"; import { MatchMedia } from "../../components/MatchMedia";
import { Spinner } from "../../components/Spinner"; import { Spinner } from "../../components/Spinner";
import { Switch } from "../../components/Switch"; 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 { import {
useAdminDeleteAttachment, useAdminDeleteAttachment,
useAdminFactions, useAdminFactions,
@@ -23,8 +30,16 @@ interface Row {
eliminated: boolean; eliminated: boolean;
was_random: boolean; was_random: boolean;
comment: string; 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 }) { export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) {
const { data: match, isLoading } = useAdminMatch(matchId); const { data: match, isLoading } = useAdminMatch(matchId);
const { data: factions } = useAdminFactions(); const { data: factions } = useAdminFactions();
@@ -37,7 +52,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
const [rows, setRows] = useState<Row[]>([]); const [rows, setRows] = useState<Row[]>([]);
const [playedAt, setPlayedAt] = useState(""); const [playedAt, setPlayedAt] = useState("");
const [overall, setOverall] = 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); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -51,19 +67,32 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
eliminated: p.eliminated, eliminated: p.eliminated,
was_random: p.was_random, was_random: p.was_random,
comment: p.comment ?? "", comment: p.comment ?? "",
objectives: p.objectives ?? null,
worlds: p.worlds ?? null,
})), })),
); );
setPlayedAt(match.played_at); setPlayedAt(match.played_at);
setOverall(match.overall_comment ?? ""); setOverall(match.overall_comment ?? "");
if (match.win_reason) setWinReason(match.win_reason); setWinReason(match.win_reason ?? null);
setEndRound(match.end_round ?? null);
} }
}, [match]); }, [match]);
const upd = (i: number, patch: Partial<Row>) => const upd = (i: number, patch: Partial<Row>) => {
setRows((rs) => rs.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); 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 () => { const save = async () => {
setError(null); setError(null);
if (!winReason) {
setError("Выберите причину победы.");
return;
}
try { try {
await update.mutateAsync({ await update.mutateAsync({
matchId, matchId,
@@ -71,6 +100,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
played_at: playedAt, played_at: playedAt,
overall_comment: overall.trim() || null, overall_comment: overall.trim() || null,
win_reason: winReason, win_reason: winReason,
end_round: endRound,
participants: rows.map((r) => ({ participants: rows.map((r) => ({
user_id: r.user_id, user_id: r.user_id,
faction_id: r.faction_id, faction_id: r.faction_id,
@@ -78,6 +108,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
eliminated: r.eliminated, eliminated: r.eliminated,
was_random: r.was_random, was_random: r.was_random,
comment: r.comment.trim() || null, 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 ( return (
// Полноэкранная страница правки (не нижний «лист»): занимает весь экран. // Полноэкранная страница правки (не нижний «лист»): занимает весь экран.
<div <div
@@ -159,23 +207,81 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
выбыл выбыл
</label> </label>
</div> </div>
<input <div className="row">
placeholder="Комментарий об игроке" <input
value={r.comment} style={{ flex: 1 }}
onChange={(e) => upd(i, { comment: e.target.value })} 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>
))} ))}
<div className="field"> <div className="field">
<label className="label">Причина победы</label> <label className="label">Причина победы</label>
<select value={winReason} onChange={(e) => setWinReason(e.target.value as WinReason)}> {winReason === LAST_STANDING ? (
{WIN_REASONS.map((w) => ( <select value={LAST_STANDING} disabled>
<option key={w.code} value={w.code}> <option value={LAST_STANDING}>{winReasonLabel(LAST_STANDING)}</option>
{w.label} </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> </option>
))} ))}
</select> </select>
{warnings.length > 0 && (
<ul className="warn-list">
{warnings.map((w) => (
<li key={w}>{w}</li>
))}
</ul>
)}
</div> </div>
<div className="field"> <div className="field">
+24
View File
@@ -371,6 +371,30 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
padding: 7px 10px; padding: 7px 10px;
border-radius: 8px; border-radius: 8px;
} }
/* Строка под игроком: комментарий + цели и миры на конец партии */
.rank-details { display: flex; gap: 6px; align-items: flex-end; }
.rank-details .rank-comment { flex: 1; min-width: 0; }
.rank-count { flex: 0 0 58px; margin-top: 8px; }
.rank-count span { display: block; font-size: 10px; color: var(--text-muted); text-align: center; }
.rank-count input {
width: 100%;
font-size: 13px;
padding: 7px 4px;
border-radius: 8px;
text-align: center;
}
/* Предупреждения о несогласованном вводе (не блокируют отправку) */
.warn-list {
margin: 10px 0 0;
padding: 8px 10px 8px 26px;
border: 1px solid var(--accent-2);
border-radius: var(--radius-sm);
background: rgba(240, 160, 75, 0.08);
color: var(--accent-2);
font-size: 13px;
}
.warn-list li + li { margin-top: 4px; }
.rank-unlink { .rank-unlink {
flex: none; flex: none;
width: 30px; height: 30px; width: 30px; height: 30px;