UI: единое подтверждение удаления через ConfirmDialog

- админка, записи: удаление партии раньше срабатывало сразу по клику на
  иконку и показывало «Удалено» даже при ошибке — теперь модалка
  подтверждения и честная обработка ошибок;
- ачивки и dev-удаление аккаунта: window.confirm заменён на ConfirmDialog
  (в приложении принят именно он);
- настройки группы: удаление участника — с подтверждением.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 17:40:53 +03:00
co-authored by Claude Fable 5
parent d2c0feafcc
commit 7b61443fb9
4 changed files with 96 additions and 34 deletions
+21 -1
View File
@@ -2,6 +2,7 @@ import { Trash2, UserPlus } from "lucide-react";
import { useEffect, useState } from "react";
import { ApiError } from "../api/client";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { PlayerLink } from "../components/PlayerLink";
import { Spinner } from "../components/Spinner";
import { Switch } from "../components/Switch";
@@ -24,6 +25,7 @@ export function GroupSettingsPage() {
const [selected, setSelected] = useState<Set<number>>(new Set());
const [newNick, setNewNick] = useState("");
const [removeTarget, setRemoveTarget] = useState<{ userId: number; nickname: string } | null>(null);
useEffect(() => {
if (group) setSelected(new Set(group.expansion_ids));
@@ -121,7 +123,10 @@ export function GroupSettingsPage() {
{m.nickname} <span className="muted small">· {m.role}</span>
</PlayerLink>
{isOwner && m.role !== "owner" && (
<button className="btn btn-ghost btn-danger small" onClick={() => remove(m.user_id)}>
<button
className="btn btn-ghost btn-danger small"
onClick={() => setRemoveTarget({ userId: m.user_id, nickname: m.nickname })}
>
<Trash2 size={16} />
</button>
)}
@@ -129,6 +134,21 @@ export function GroupSettingsPage() {
))}
</div>
</div>
<ConfirmDialog
open={removeTarget != null}
danger
title={`Удалить участника «${removeTarget?.nickname}»?`}
message="Игрок будет удалён из группы. Его сыгранные партии сохранятся."
confirmLabel="Удалить"
onCancel={() => setRemoveTarget(null)}
onConfirm={() => {
if (!removeTarget) return;
const userId = removeTarget.userId;
setRemoveTarget(null);
remove(userId);
}}
/>
</div>
);
}
@@ -2,6 +2,7 @@ import { useRef, useState } from "react";
import { ApiError } from "../../api/client";
import { Avatar } from "../../components/Avatar";
import { ConfirmDialog } from "../../components/ConfirmDialog";
import { Spinner } from "../../components/Spinner";
import { useToast } from "../../context/ToastContext";
import {
@@ -87,6 +88,7 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
const toast = useToast();
const fileRef = useRef<HTMLInputElement>(null);
const [editing, setEditing] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
const [name, setName] = useState(ach.name);
const [description, setDescription] = useState(ach.description);
const [condition, setCondition] = useState(ach.condition);
@@ -117,7 +119,6 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
};
const remove = async () => {
if (!window.confirm(`Удалить ачивку «${ach.name}»?`)) return;
try {
await del.mutateAsync(ach.slug);
toast.show("Удалено");
@@ -175,7 +176,11 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
<button className="btn small" onClick={() => setEditing(true)}>
Изменить
</button>
<button className="btn btn-ghost btn-danger small" onClick={remove} disabled={del.isPending}>
<button
className="btn btn-ghost btn-danger small"
onClick={() => setConfirmRemove(true)}
disabled={del.isPending}
>
Удалить
</button>
<input
@@ -187,6 +192,19 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
/>
</div>
)}
<ConfirmDialog
open={confirmRemove}
danger
title={`Удалить ачивку «${ach.name}»?`}
message="Ачивка и её иконка будут удалены. Действие необратимо."
confirmLabel="Удалить"
onCancel={() => setConfirmRemove(false)}
onConfirm={() => {
setConfirmRemove(false);
remove();
}}
/>
</div>
);
}
+23 -7
View File
@@ -1,6 +1,8 @@
import { Pencil, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { ApiError } from "../../api/client";
import { ConfirmDialog } from "../../components/ConfirmDialog";
import { Spinner } from "../../components/Spinner";
import { Switch } from "../../components/Switch";
import { useToast } from "../../context/ToastContext";
@@ -20,6 +22,7 @@ export function AdminRecordsPage() {
const [factionFilter, setFactionFilter] = useState<number | null>(null);
const [grouped, setGrouped] = useState(true);
const [editId, setEditId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AdminMatchRead | null>(null);
const { data: groups } = useAdminGroups();
const { data: users } = useAdminUsers();
@@ -61,13 +64,7 @@ export function AdminRecordsPage() {
<button className="btn btn-ghost small" onClick={() => setEditId(m.id)}>
<Pencil size={16} />
</button>
<button
className="btn btn-ghost btn-danger small"
onClick={async () => {
await del.mutateAsync(m.id).catch(() => {});
toast.show("Удалено");
}}
>
<button className="btn btn-ghost btn-danger small" onClick={() => setDeleteTarget(m)}>
<Trash2 size={16} />
</button>
</div>
@@ -137,6 +134,25 @@ export function AdminRecordsPage() {
)}
{editId != null && <AdminMatchEdit matchId={editId} onClose={() => setEditId(null)} />}
<ConfirmDialog
open={deleteTarget != null}
danger
title={`Удалить партию #${deleteTarget?.id}?`}
message="Партия и её результаты будут удалены. Действие необратимо."
confirmLabel="Удалить"
onCancel={() => setDeleteTarget(null)}
onConfirm={async () => {
if (!deleteTarget) return;
setDeleteTarget(null);
try {
await del.mutateAsync(deleteTarget.id);
toast.show("Удалено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
}
}}
/>
</div>
);
}
@@ -1,7 +1,9 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { ApiError, api, unwrap } from "../../api/client";
import { qk } from "../../api/queryKeys";
import { ConfirmDialog } from "../../components/ConfirmDialog";
import { useToast } from "../../context/ToastContext";
/**
@@ -21,6 +23,7 @@ export function DevDeleteAccountButton({
}) {
const qc = useQueryClient();
const toast = useToast();
const [confirmOpen, setConfirmOpen] = useState(false);
const del = useMutation({
mutationFn: async () =>
unwrap(
@@ -32,29 +35,34 @@ export function DevDeleteAccountButton({
});
return (
<button
className="btn small"
// Яркая (сплошная) красная кнопка. Стиль инлайном, а не классом в общем CSS,
// чтобы в прод/тест-стили не попало ничего, связанного с удалением.
style={{ background: "var(--danger)", borderColor: "var(--danger)", color: "#fff" }}
disabled={del.isPending}
onClick={async () => {
if (
!window.confirm(
`Удалить аккаунт «${nickname}»? Партии сохранятся, но игрок будет ` +
`вычеркнут из них. Действие необратимо.`,
)
)
return;
try {
await del.mutateAsync();
toast.show("Аккаунт удалён");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
}
}}
>
Удалить
</button>
<>
<button
className="btn small"
// Яркая (сплошная) красная кнопка. Стиль инлайном, а не классом в общем CSS,
// чтобы в прод/тест-стили не попало ничего, связанного с удалением.
style={{ background: "var(--danger)", borderColor: "var(--danger)", color: "#fff" }}
disabled={del.isPending}
onClick={() => setConfirmOpen(true)}
>
Удалить
</button>
<ConfirmDialog
open={confirmOpen}
danger
title={`Удалить аккаунт «${nickname}»?`}
message="Партии сохранятся, но игрок будет вычеркнут из них. Действие необратимо."
confirmLabel="Удалить"
onCancel={() => setConfirmOpen(false)}
onConfirm={async () => {
setConfirmOpen(false);
try {
await del.mutateAsync();
toast.show("Аккаунт удалён");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
}
}}
/>
</>
);
}