Merge pull request 'Историю партий нельзя править после смены состава группы или набора дополнений (#29)' (#39) from issue-29-edit-finished-match into dev
Reviewed-on: #39
This commit was merged in pull request #39.
This commit is contained in:
@@ -168,16 +168,28 @@ def _validate_roster_basics(
|
|||||||
group_id: int,
|
group_id: int,
|
||||||
user_ids: list[int],
|
user_ids: list[int],
|
||||||
faction_ids: list[int],
|
faction_ids: list[int],
|
||||||
|
*,
|
||||||
|
keep_user_ids: set[int] | None = None,
|
||||||
|
keep_faction_ids: set[int] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Состав партии: размер, отсутствие дублей, принадлежность группе.
|
||||||
|
|
||||||
|
keep_* — то, что уже записано в правимой партии: такие игроки и фракции проходят
|
||||||
|
независимо от текущего состава группы. Иначе отключённое дополнение или ушедший из
|
||||||
|
группы игрок делали бы старую партию неисправимой навсегда."""
|
||||||
if len(user_ids) < 2:
|
if len(user_ids) < 2:
|
||||||
raise ValidationError("В партии должно быть не менее 2 участников.")
|
raise ValidationError("В партии должно быть не менее 2 участников.")
|
||||||
if len(user_ids) > MAX_MATCH_PLAYERS:
|
if len(user_ids) > MAX_MATCH_PLAYERS:
|
||||||
raise ValidationError(f"В партии не может быть больше {MAX_MATCH_PLAYERS} игроков.")
|
raise ValidationError(f"В партии не может быть больше {MAX_MATCH_PLAYERS} игроков.")
|
||||||
if len(set(user_ids)) != len(user_ids) or len(set(faction_ids)) != len(faction_ids):
|
if len(set(user_ids)) != len(user_ids) or len(set(faction_ids)) != len(faction_ids):
|
||||||
raise DuplicateParticipantError()
|
raise DuplicateParticipantError()
|
||||||
if not set(user_ids).issubset(_group_member_ids(session, group_id)):
|
allowed_users = _group_member_ids(session, group_id) | (keep_user_ids or set())
|
||||||
|
if not set(user_ids).issubset(allowed_users):
|
||||||
raise ValidationError("Все участники должны состоять в группе.")
|
raise ValidationError("Все участники должны состоять в группе.")
|
||||||
if not set(faction_ids).issubset(group_service.available_faction_ids(session, group_id)):
|
allowed_factions = group_service.available_faction_ids(session, group_id) | (
|
||||||
|
keep_faction_ids or set()
|
||||||
|
)
|
||||||
|
if not set(faction_ids).issubset(allowed_factions):
|
||||||
raise FactionNotAvailableError()
|
raise FactionNotAvailableError()
|
||||||
|
|
||||||
|
|
||||||
@@ -423,18 +435,23 @@ def update_match(
|
|||||||
match.win_reason = win_reason
|
match.win_reason = win_reason
|
||||||
|
|
||||||
if participants is not None:
|
if participants is not None:
|
||||||
|
saved = session.exec(
|
||||||
|
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
||||||
|
).all()
|
||||||
|
# Что уже записано в партии, остаётся допустимым: состав группы и набор
|
||||||
|
# дополнений с тех пор могли поменяться, но историю это чинить не мешает.
|
||||||
_validate_roster_basics(
|
_validate_roster_basics(
|
||||||
session,
|
session,
|
||||||
match.group_id,
|
match.group_id,
|
||||||
[p.user_id for p in participants],
|
[p.user_id for p in participants],
|
||||||
[p.faction_id for p in participants],
|
[p.faction_id for p in participants],
|
||||||
|
keep_user_ids={p.user_id for p in saved},
|
||||||
|
keep_faction_ids={p.faction_id for p in saved},
|
||||||
)
|
)
|
||||||
places = _resolve_finish_places(
|
places = _resolve_finish_places(
|
||||||
[(p.user_id, p.place, p.eliminated) for p in participants]
|
[(p.user_id, p.place, p.eliminated) for p in participants]
|
||||||
)
|
)
|
||||||
for old in session.exec(
|
for old in saved:
|
||||||
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
|
||||||
).all():
|
|
||||||
session.delete(old)
|
session.delete(old)
|
||||||
session.flush()
|
session.flush()
|
||||||
for p in participants:
|
for p in participants:
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Правка завершённой партии игроком: история чинится после изменений в группе."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
||||||
|
|
||||||
|
|
||||||
|
def _exp_id(client: TestClient, code: str) -> int:
|
||||||
|
return next(e["id"] for e in client.get("/api/expansions").json() if e["code"] == code)
|
||||||
|
|
||||||
|
|
||||||
|
def _played(client: TestClient, engine) -> tuple[dict, int, int, int, dict]:
|
||||||
|
"""Партия «Аня против Бори» в группе с обоими дополнениями."""
|
||||||
|
me = login(client, "Аня")
|
||||||
|
fw, fv = _exp_id(client, "forgotten_worlds"), _exp_id(client, "forsaken_voids")
|
||||||
|
gid = client.post(
|
||||||
|
"/api/groups", json={"name": "Группа", "expansion_ids": [fw, fv]},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
).json()["id"]
|
||||||
|
b = add_group_member(engine, gid, "Боря")
|
||||||
|
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
|
||||||
|
mid = create_finished_match(
|
||||||
|
client, gid,
|
||||||
|
[
|
||||||
|
{"user_id": me["id"], "faction_id": factions["orks"], "place": 1},
|
||||||
|
{"user_id": b, "faction_id": factions["tau"], "place": 2},
|
||||||
|
],
|
||||||
|
)["id"]
|
||||||
|
return me, gid, b, mid, factions
|
||||||
|
|
||||||
|
|
||||||
|
def _swap_places(client: TestClient, mid: int, me_id: int, b: int, factions: dict) -> dict:
|
||||||
|
detail = client.get(f"/api/matches/{mid}").json()
|
||||||
|
fid = {p["user_id"]: p["faction_id"] for p in detail["participants"]}
|
||||||
|
return {
|
||||||
|
"participants": [
|
||||||
|
{"user_id": me_id, "faction_id": fid[me_id], "place": 2},
|
||||||
|
{"user_id": b, "faction_id": fid[b], "place": 1},
|
||||||
|
],
|
||||||
|
"expected_version": detail["version"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_after_expansion_disabled(client: TestClient, engine):
|
||||||
|
"""Дополнение выключили — партия на Тау всё равно правится.
|
||||||
|
|
||||||
|
Регрессия: правка проверяла фракции по ТЕКУЩЕМУ набору группы, и партия,
|
||||||
|
сыгранная на фракции из отключённого дополнения, становилась неисправимой."""
|
||||||
|
me, gid, b, mid, factions = _played(client, engine)
|
||||||
|
|
||||||
|
off = client.put(
|
||||||
|
f"/api/groups/{gid}/expansions",
|
||||||
|
json={"expansion_ids": [_exp_id(client, "forsaken_voids")]},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert off.status_code == 200, off.text
|
||||||
|
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/matches/{mid}", json=_swap_places(client, mid, me["id"], b, factions),
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
places = {p["user_id"]: p["place"] for p in client.get(f"/api/matches/{mid}").json()["participants"]}
|
||||||
|
assert places[b] == 1 and places[me["id"]] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_after_player_left_group(client: TestClient, engine):
|
||||||
|
"""Игрока убрали из группы — партия с ним всё равно правится."""
|
||||||
|
me, gid, b, mid, factions = _played(client, engine)
|
||||||
|
|
||||||
|
out = client.delete(f"/api/groups/{gid}/members/{b}", headers=csrf_headers(client))
|
||||||
|
assert out.status_code == 200, out.text
|
||||||
|
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/matches/{mid}", json=_swap_places(client, mid, me["id"], b, factions),
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_rejects_new_outsider_and_unavailable_faction(client: TestClient, engine):
|
||||||
|
"""Послабление — только для того, что уже в партии.
|
||||||
|
|
||||||
|
Вписать нового игрока не из группы или фракцию, которой в партии не было и у
|
||||||
|
группы нет, по-прежнему нельзя: иначе в историю можно занести что угодно."""
|
||||||
|
me, gid, b, mid, factions = _played(client, engine)
|
||||||
|
stranger = client.post(
|
||||||
|
"/api/auth/dev/users", json={"nickname": "Чужак"}, headers=csrf_headers(client)
|
||||||
|
).json()
|
||||||
|
detail = client.get(f"/api/matches/{mid}").json()
|
||||||
|
fid = {p["user_id"]: p["faction_id"] for p in detail["participants"]}
|
||||||
|
|
||||||
|
bad_user = client.patch(
|
||||||
|
f"/api/matches/{mid}",
|
||||||
|
json={
|
||||||
|
"participants": [
|
||||||
|
{"user_id": me["id"], "faction_id": fid[me["id"]], "place": 1},
|
||||||
|
{"user_id": stranger["id"], "faction_id": fid[b], "place": 2},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert bad_user.status_code == 422, bad_user.text
|
||||||
|
|
||||||
|
# Выключаем дополнение и пробуем поставить ЕГО фракцию, которой в партии не было.
|
||||||
|
assert client.put(
|
||||||
|
f"/api/groups/{gid}/expansions",
|
||||||
|
json={"expansion_ids": [_exp_id(client, "forsaken_voids")]},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
).status_code == 200
|
||||||
|
bad_faction = client.patch(
|
||||||
|
f"/api/matches/{mid}",
|
||||||
|
json={
|
||||||
|
"participants": [
|
||||||
|
{"user_id": me["id"], "faction_id": factions["necrons"], "place": 1},
|
||||||
|
{"user_id": b, "faction_id": fid[b], "place": 2},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert bad_faction.status_code == 422, bad_faction.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_match_still_validated(client: TestClient, engine):
|
||||||
|
"""Создание партии не ослабло: посторонний игрок по-прежнему отклоняется."""
|
||||||
|
me, gid, b, mid, factions = _played(client, engine)
|
||||||
|
stranger = client.post(
|
||||||
|
"/api/auth/dev/users", json={"nickname": "Чужак2"}, headers=csrf_headers(client)
|
||||||
|
).json()
|
||||||
|
r = client.post(
|
||||||
|
"/api/matches",
|
||||||
|
json={
|
||||||
|
"group_id": gid,
|
||||||
|
"participants": [
|
||||||
|
{"user_id": me["id"], "faction_id": factions["orks"]},
|
||||||
|
{"user_id": stranger["id"], "faction_id": factions["eldar"]},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
MatchFinish,
|
MatchFinish,
|
||||||
MatchFinishDraftData,
|
MatchFinishDraftData,
|
||||||
MatchRead,
|
MatchRead,
|
||||||
|
MatchUpdate,
|
||||||
} from "../domain/types";
|
} from "../domain/types";
|
||||||
import { uploadFile } from "../lib/upload";
|
import { uploadFile } from "../lib/upload";
|
||||||
|
|
||||||
@@ -88,6 +89,28 @@ export function useFinishMatch() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Правка завершённой партии участником группы (у админки свой эндпойнт). */
|
||||||
|
export function useUpdateMatch() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (args: { matchId: number; body: MatchUpdate }): Promise<MatchRead> =>
|
||||||
|
unwrap(
|
||||||
|
await api.PATCH("/api/matches/{match_id}", {
|
||||||
|
params: { path: { match_id: args.matchId } },
|
||||||
|
body: args.body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: (m) => {
|
||||||
|
qc.setQueryData(qk.match(m.id), m);
|
||||||
|
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
||||||
|
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
|
||||||
|
// Места изменились — значит изменились лидерборд, история и профили.
|
||||||
|
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function useDeleteMatch() {
|
export function useDeleteMatch() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import {
|
|||||||
useFinishMatch,
|
useFinishMatch,
|
||||||
useMatch,
|
useMatch,
|
||||||
useSaveFinishDraft,
|
useSaveFinishDraft,
|
||||||
|
useUpdateMatch,
|
||||||
useUploadMatchAttachment,
|
useUploadMatchAttachment,
|
||||||
} from "../hooks/matches";
|
} from "../hooks/matches";
|
||||||
|
import { useGroupFactions } from "../hooks/reference";
|
||||||
|
|
||||||
const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label }));
|
const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label }));
|
||||||
|
|
||||||
@@ -31,8 +33,10 @@ export function MatchDetailPage() {
|
|||||||
const id = Number.isInteger(parsed) ? parsed : null;
|
const id = Number.isInteger(parsed) ? parsed : null;
|
||||||
const { data: match, isLoading, refetch } = useMatch(id);
|
const { data: match, isLoading, refetch } = useMatch(id);
|
||||||
const { data: me } = useMe();
|
const { data: me } = useMe();
|
||||||
|
const { data: groupFactions } = useGroupFactions(match?.group_id ?? null);
|
||||||
const finish = useFinishMatch();
|
const finish = useFinishMatch();
|
||||||
const saveDraft = useSaveFinishDraft(id);
|
const saveDraft = useSaveFinishDraft(id);
|
||||||
|
const updateMatch = useUpdateMatch();
|
||||||
const del = useDeleteMatch();
|
const del = useDeleteMatch();
|
||||||
const uploadAtt = useUploadMatchAttachment(id ?? 0);
|
const uploadAtt = useUploadMatchAttachment(id ?? 0);
|
||||||
const deleteAtt = useDeleteMatchAttachment(id ?? 0);
|
const deleteAtt = useDeleteMatchAttachment(id ?? 0);
|
||||||
@@ -47,6 +51,9 @@ export function MatchDetailPage() {
|
|||||||
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);
|
||||||
|
// Правка завершённой партии: тот же редактор мест, что и при завершении.
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [editFactions, setEditFactions] = useState<Record<number, number>>({});
|
||||||
|
|
||||||
// ─── Совместное заполнение формы ───
|
// ─── Совместное заполнение формы ───
|
||||||
// Отложенная запись черновика: он меняется на каждое движение тайла, слать
|
// Отложенная запись черновика: он меняется на каждое движение тайла, слать
|
||||||
@@ -181,6 +188,77 @@ export function MatchDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Раскладка для правки восстанавливается из сохранённых результатов: одинаковое
|
||||||
|
// место = ничья, выбывшие уходят отдельным списком.
|
||||||
|
const startEditing = () => {
|
||||||
|
const byPlace = new Map<number, number[]>();
|
||||||
|
for (const p of match.participants.filter((x) => !x.eliminated)) {
|
||||||
|
const key = p.place ?? 99;
|
||||||
|
byPlace.set(key, [...(byPlace.get(key) ?? []), p.user_id]);
|
||||||
|
}
|
||||||
|
setBlocks([...byPlace.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids));
|
||||||
|
setElim(match.participants.filter((p) => p.eliminated).map((p) => p.user_id));
|
||||||
|
setComments(
|
||||||
|
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])),
|
||||||
|
);
|
||||||
|
setWinReason((match.win_reason ?? "objectives") as WinReason);
|
||||||
|
setOverall(match.overall_comment ?? "");
|
||||||
|
setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id])));
|
||||||
|
setError(null);
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEditing = () => {
|
||||||
|
setEditing(false);
|
||||||
|
setBlocks(null);
|
||||||
|
setComments(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitEdit = async () => {
|
||||||
|
if (!id || !match) return;
|
||||||
|
setError(null);
|
||||||
|
const wasRandom = Object.fromEntries(
|
||||||
|
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 {
|
||||||
|
await updateMatch.mutateAsync({
|
||||||
|
matchId: id,
|
||||||
|
body: {
|
||||||
|
participants: [...survivors, ...elim.map((uid) => rowOf(uid, null, true))],
|
||||||
|
win_reason: winReason,
|
||||||
|
overall_comment: overall.trim() || null,
|
||||||
|
expected_version: match.version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.show("Партия обновлена");
|
||||||
|
cancelEditing();
|
||||||
|
} catch (e) {
|
||||||
|
if (isStale(e)) {
|
||||||
|
toast.show("Партия изменилась на другом устройстве — обновлено");
|
||||||
|
refetch();
|
||||||
|
cancelEditing();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(e instanceof ApiError ? e.message : "Не удалось сохранить");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
if (!id || !match) return;
|
if (!id || !match) return;
|
||||||
try {
|
try {
|
||||||
@@ -203,6 +281,15 @@ export function MatchDetailPage() {
|
|||||||
(a, b) => (a.place ?? 99) - (b.place ?? 99),
|
(a, b) => (a.place ?? 99) - (b.place ?? 99),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// К доступным группе фракциям добавляем те, что уже стоят в партии: дополнение
|
||||||
|
// могли отключить после игры, но менять её фракцию это мешать не должно.
|
||||||
|
const factionOptions = [
|
||||||
|
...(groupFactions ?? []),
|
||||||
|
...match.participants
|
||||||
|
.filter((p) => !(groupFactions ?? []).some((f) => f.id === p.faction_id))
|
||||||
|
.map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -226,11 +313,104 @@ export function MatchDetailPage() {
|
|||||||
{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>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Завершённая партия — результаты */}
|
{/* Завершённая партия — правка результатов */}
|
||||||
{!inProgress && (
|
{!inProgress && editing && (
|
||||||
|
<>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Результаты</h3>
|
<h3>Правка результатов</h3>
|
||||||
|
<p className="muted small">
|
||||||
|
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
|
||||||
|
игрока, чтобы разделить место (ничья).
|
||||||
|
</p>
|
||||||
|
<PlaceEditor
|
||||||
|
players={match.participants}
|
||||||
|
blocks={finishBlocks}
|
||||||
|
eliminated={elim}
|
||||||
|
comments={finishComments}
|
||||||
|
onChange={(b, e) => {
|
||||||
|
setBlocks(b);
|
||||||
|
setElim(e);
|
||||||
|
}}
|
||||||
|
onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Фракции</h3>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
|
{match.participants.map((p) => (
|
||||||
|
<div key={p.user_id} className="row" style={{ gap: 8 }}>
|
||||||
|
<b style={{ flex: "0 0 40%" }}>{p.nickname}</b>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<PickerSelect
|
||||||
|
selected={factionOptions.find((f) => f.id === editFactions[p.user_id]) ?? null}
|
||||||
|
options={factionOptions.filter(
|
||||||
|
(f) =>
|
||||||
|
f.id === editFactions[p.user_id] ||
|
||||||
|
!Object.values(editFactions).includes(f.id),
|
||||||
|
)}
|
||||||
|
placeholder="— фракция —"
|
||||||
|
renderOption={(f) => f.name_ru}
|
||||||
|
onPick={(f) => setEditFactions({ ...editFactions, [p.user_id]: f.id })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Причина победы</h3>
|
||||||
|
<PickerSelect
|
||||||
|
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null}
|
||||||
|
options={REASON_OPTIONS}
|
||||||
|
placeholder="— причина —"
|
||||||
|
renderOption={(o) => o.label}
|
||||||
|
onPick={(o) => setWinReason(o.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>О партии</h3>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
style={{ resize: "none" }}
|
||||||
|
placeholder="Комментарий о партии"
|
||||||
|
value={overall}
|
||||||
|
onChange={(e) => setOverall(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
|
||||||
|
<div className="row" style={{ gap: 8 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={submitEdit}
|
||||||
|
disabled={updateMatch.isPending}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={cancelEditing}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Завершённая партия — результаты */}
|
||||||
|
{!inProgress && !editing && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="row-between">
|
||||||
|
<h3 style={{ margin: 0 }}>Результаты</h3>
|
||||||
|
{canModify && (
|
||||||
|
<button className="btn btn-ghost small" onClick={startEditing}>
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="stack" style={{ marginTop: 10 }}>
|
||||||
{sorted.map((p) => (
|
{sorted.map((p) => (
|
||||||
<div key={p.user_id}>
|
<div key={p.user_id}>
|
||||||
<PlayerLink userId={p.user_id} className="row" style={{ gap: 8 }}>
|
<PlayerLink userId={p.user_id} className="row" style={{ gap: 8 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user