v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { ApiError } from "../../api/client";
|
||||
import { Spinner } from "../../components/Spinner";
|
||||
import { WIN_REASONS, type WinReason } from "../../domain/winReasons";
|
||||
import { useToast } from "../../context/ToastContext";
|
||||
import { useAdminFactions, useAdminMatch, useAdminUpdateMatch } from "../../hooks/admin";
|
||||
|
||||
interface Row {
|
||||
user_id: number;
|
||||
nickname: string;
|
||||
faction_id: number;
|
||||
place: number;
|
||||
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 update = useAdminUpdateMatch();
|
||||
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,
|
||||
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.place,
|
||||
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,
|
||||
background: "rgba(0,0,0,0.6)",
|
||||
zIndex: 200,
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="app-shell"
|
||||
style={{ maxHeight: "92vh", overflowY: "auto", boxShadow: "none" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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}
|
||||
onChange={(e) => upd(i, { place: Math.max(1, Number(e.target.value) || 1) })}
|
||||
/>
|
||||
</div>
|
||||
</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} value={overall} onChange={(e) => setOverall(e.target.value)} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user