Standardize modal buttons and delete confirmations

This commit is contained in:
DCCONSTRUCTIONS
2026-05-02 12:57:54 +03:00
parent adad0bd344
commit 17e007f49d
21 changed files with 869 additions and 73 deletions
+197 -19
View File
@@ -63,7 +63,7 @@ import {
import { uploadStorageFile } from "../../shared/api/storageApi";
import { cn } from "../../shared/lib/cn";
import { formatDate, formatDateTime } from "../../shared/lib/format";
import { NodeDcDateField, NodeDcDropdown, NodeDcSelect, type NodeDcSelectOption } from "../../shared/nodedc-ui";
import { NodeDcDateField, NodeDcDeleteModal, NodeDcDropdown, NodeDcSelect, type NodeDcSelectOption } from "../../shared/nodedc-ui";
import { Button, IconButton } from "../../shared/ui/Button";
import { GlassSurface } from "../../shared/ui/Glass";
@@ -118,13 +118,17 @@ export function AdminOverlay({
onSetUserServiceAccess,
onCreateInvite,
onUpdateInvite,
onDeleteInvite,
onRetrySync,
onCreateClient,
onUpdateClient,
onDeleteClient,
onUpdateUser,
onUpdateMembership,
onDeleteMembership,
onCreateGroup,
onUpdateGroup,
onDeleteGroup,
onUpdateService,
onReorderServices,
onCreateService,
@@ -137,13 +141,17 @@ export function AdminOverlay({
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
onCreateInvite: (invite: Pick<Invite, "clientId" | "email" | "role">) => void;
onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void;
onDeleteInvite: (inviteId: string) => void;
onRetrySync: (syncId: string) => void;
onCreateClient: () => void;
onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
onDeleteClient: (clientId: string) => void;
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
onDeleteMembership: (membershipId: string) => void;
onCreateGroup: (clientId: string) => void;
onUpdateGroup: (groupId: string, patch: Partial<ClientGroup>) => void;
onDeleteGroup: (groupId: string) => void;
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
onReorderServices: (orderedServiceIds: string[]) => void;
onCreateService: () => void;
@@ -155,7 +163,9 @@ export function AdminOverlay({
const [selectedClientId, setSelectedClientId] = useState(activeClientId);
const [selectedCell, setSelectedCell] = useState<{ userId: string; serviceId: string } | null>(null);
const scopedClientId = isRoot ? selectedClientId : activeClientId;
const fallbackClientId = data.clients[0]?.id ?? activeClientId;
const selectedClientExists = data.clients.some((client) => client.id === selectedClientId);
const scopedClientId = isRoot ? (selectedClientExists ? selectedClientId : fallbackClientId) : activeClientId;
const currentClient = getClient(data, scopedClientId);
const accessMatrix = useMemo(() => buildAccessMatrix(data, scopedClientId, isRoot), [data, scopedClientId, isRoot]);
const selectedAccessCell =
@@ -163,6 +173,13 @@ export function AdminOverlay({
accessMatrix.cells[0] ??
null;
useEffect(() => {
if (isRoot && !selectedClientExists && data.clients.length) {
setSelectedClientId(data.clients[0].id);
setSelectedCell(null);
}
}, [data.clients, isRoot, selectedClientExists]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
@@ -250,7 +267,7 @@ export function AdminOverlay({
<div className="admin-panel-content__body">
{activeSection === "overview" ? <OverviewSection data={data} clientId={scopedClientId} isRoot={isRoot} /> : null}
{activeSection === "clients" && isRoot ? (
<ClientsSection data={data} onCreateClient={onCreateClient} onUpdateClient={onUpdateClient} />
<ClientsSection data={data} onCreateClient={onCreateClient} onUpdateClient={onUpdateClient} onDeleteClient={onDeleteClient} />
) : null}
{activeSection === "users" ? (
<UsersSection
@@ -259,10 +276,17 @@ export function AdminOverlay({
isRoot={isRoot}
onUpdateUser={onUpdateUser}
onUpdateMembership={onUpdateMembership}
onDeleteMembership={onDeleteMembership}
/>
) : null}
{activeSection === "groups" ? (
<GroupsSection data={data} clientId={scopedClientId} onCreateGroup={onCreateGroup} onUpdateGroup={onUpdateGroup} />
<GroupsSection
data={data}
clientId={scopedClientId}
onCreateGroup={onCreateGroup}
onUpdateGroup={onUpdateGroup}
onDeleteGroup={onDeleteGroup}
/>
) : null}
{activeSection === "services" && isRoot ? (
<ServicesSection
@@ -289,6 +313,7 @@ export function AdminOverlay({
actorUserId={me.user.id}
onCreateInvite={onCreateInvite}
onUpdateInvite={onUpdateInvite}
onDeleteInvite={onDeleteInvite}
/>
) : null}
{activeSection === "sync" ? <SyncSection data={data} clientId={scopedClientId} isRoot={isRoot} onRetrySync={onRetrySync} /> : null}
@@ -348,10 +373,12 @@ function ClientsSection({
data,
onCreateClient,
onUpdateClient,
onDeleteClient,
}: {
data: LauncherData;
onCreateClient: () => void;
onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
onDeleteClient: (clientId: string) => void;
}) {
const [editingClientId, setEditingClientId] = useState<string | null>(null);
const editingClient = data.clients.find((client) => client.id === editingClientId) ?? null;
@@ -453,6 +480,11 @@ function ClientsSection({
onUpdateClient(editingClient.id, patch);
setEditingClientId(null);
}}
onDelete={() => {
onDeleteClient(editingClient.id);
setEditingClientId(null);
}}
canDelete={data.clients.length > 1}
/>
) : null}
</>
@@ -465,12 +497,14 @@ function UsersSection({
isRoot,
onUpdateUser,
onUpdateMembership,
onDeleteMembership,
}: {
data: LauncherData;
clientId: string;
isRoot: boolean;
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
onDeleteMembership: (membershipId: string) => void;
}) {
const [editingMembershipId, setEditingMembershipId] = useState<string | null>(null);
const rows = isRoot
@@ -579,6 +613,10 @@ function UsersSection({
onUpdateMembership(editingRow.membership.id, membershipPatch);
setEditingMembershipId(null);
}}
onDelete={() => {
onDeleteMembership(editingRow.membership.id);
setEditingMembershipId(null);
}}
/>
) : null}
</>
@@ -590,11 +628,13 @@ function GroupsSection({
clientId,
onCreateGroup,
onUpdateGroup,
onDeleteGroup,
}: {
data: LauncherData;
clientId: string;
onCreateGroup: (clientId: string) => void;
onUpdateGroup: (groupId: string, patch: Partial<ClientGroup>) => void;
onDeleteGroup: (groupId: string) => void;
}) {
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const groups = data.groups.filter((group) => group.clientId === clientId);
@@ -667,6 +707,10 @@ function GroupsSection({
onUpdateGroup(editingGroup.id, patch);
setEditingGroupId(null);
}}
onDelete={() => {
onDeleteGroup(editingGroup.id);
setEditingGroupId(null);
}}
/>
) : null}
</>
@@ -747,6 +791,7 @@ const accessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>>
];
const mediaAccept = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv";
const modalActionAccentRgb = [247, 248, 244] as const;
function ServicesSection({
data,
@@ -1131,6 +1176,7 @@ function ServiceContentModal({
const [draft, setDraft] = useState<Service>(service);
const [uploadingSlot, setUploadingSlot] = useState<"cover" | "ambient" | null>(null);
const [storageError, setStorageError] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
useEffect(() => {
setDraft(service);
@@ -1265,15 +1311,17 @@ function ServiceContentModal({
</div>
<div className="service-content-modal__foot">
<Button variant="secondary" type="button" onClick={onClose}>
<Button variant="secondary" surface="modal" type="button" onClick={onClose}>
Отмена
</Button>
<div className="service-content-modal__foot-actions">
<Button variant="danger" type="button" icon={<Trash2 size={16} />} onClick={onDelete}>
<Button variant="danger" surface="modal" type="button" icon={<Trash2 size={16} />} onClick={() => setDeleteOpen(true)}>
Удалить
</Button>
<Button
variant="primary"
variant="accent"
surface="modal"
accentRgb={modalActionAccentRgb}
type="button"
disabled={uploadingSlot !== null}
icon={<Save size={16} />}
@@ -1300,6 +1348,20 @@ function ServiceContentModal({
</div>
</div>
</article>
<NodeDcDeleteModal
isOpen={deleteOpen}
title="Удалить витрину"
description={
<>
Витрина <strong>{service.title}</strong> будет удалена из каталога, нижней панели и матрицы доступов.
</>
}
onClose={() => setDeleteOpen(false)}
onConfirm={() => {
setDeleteOpen(false);
onDelete();
}}
/>
</div>
);
}
@@ -1308,10 +1370,14 @@ function ClientEditorModal({
client,
onClose,
onSave,
onDelete,
canDelete,
}: {
client: Client;
onClose: () => void;
onSave: (patch: Partial<Client>) => void;
onDelete: () => void;
canDelete: boolean;
}) {
const [draft, setDraft] = useState<Client>(client);
@@ -1394,7 +1460,24 @@ function ClientEditorModal({
<textarea value={draft.notes ?? ""} onChange={(event) => update("notes", event.target.value || null)} rows={4} />
</label>
</div>
<EntityModalFoot onClose={onClose} onSave={() => onSave(draft)} />
<EntityModalFoot
onClose={onClose}
onSave={() => onSave(draft)}
deleteConfig={
canDelete
? {
label: "Удалить",
title: "Удалить компанию",
description: (
<>
Компания <strong>{client.name}</strong>, ее участники, группы, инвайты и клиентские гранты будут удалены из демо-данных.
</>
),
onConfirm: onDelete,
}
: undefined
}
/>
</article>
</div>
);
@@ -1406,12 +1489,14 @@ function UserEditorModal({
client,
onClose,
onSave,
onDelete,
}: {
user: LauncherUser;
membership: ClientMembership;
client: Client;
onClose: () => void;
onSave: (userPatch: Partial<LauncherUser>, membershipPatch: Partial<ClientMembership>) => void;
onDelete: () => void;
}) {
const [userDraft, setUserDraft] = useState<LauncherUser>(user);
const [membershipDraft, setMembershipDraft] = useState<ClientMembership>(membership);
@@ -1479,7 +1564,20 @@ function UserEditorModal({
<textarea value={userDraft.notes ?? ""} onChange={(event) => updateUser("notes", event.target.value || null)} rows={4} />
</label>
</div>
<EntityModalFoot onClose={onClose} onSave={() => onSave(userDraft, membershipDraft)} />
<EntityModalFoot
onClose={onClose}
onSave={() => onSave(userDraft, membershipDraft)}
deleteConfig={{
label: "Удалить",
title: "Удалить участника",
description: (
<>
Участник <strong>{user.name}</strong> будет удален из клиента <strong>{client.name}</strong>. Глобальный профиль пользователя останется в демо-справочнике.
</>
),
onConfirm: onDelete,
}}
/>
</article>
</div>
);
@@ -1490,11 +1588,13 @@ function GroupEditorModal({
users,
onClose,
onSave,
onDelete,
}: {
group: ClientGroup;
users: LauncherUser[];
onClose: () => void;
onSave: (patch: Partial<ClientGroup>) => void;
onDelete: () => void;
}) {
const [draft, setDraft] = useState<ClientGroup>(group);
@@ -1543,7 +1643,20 @@ function GroupEditorModal({
</div>
</div>
</div>
<EntityModalFoot onClose={onClose} onSave={() => onSave(draft)} />
<EntityModalFoot
onClose={onClose}
onSave={() => onSave(draft)}
deleteConfig={{
label: "Удалить",
title: "Удалить группу",
description: (
<>
Группа <strong>{group.name}</strong> и привязанные к ней гранты сервисов будут удалены.
</>
),
onConfirm: onDelete,
}}
/>
</article>
</div>
);
@@ -1563,16 +1676,52 @@ function EntityModalHead({ eyebrow, title, onClose }: { eyebrow: string; title:
);
}
function EntityModalFoot({ onClose, onSave }: { onClose: () => void; onSave: () => void }) {
function EntityModalFoot({
onClose,
onSave,
deleteConfig,
}: {
onClose: () => void;
onSave: () => void;
deleteConfig?: {
label: string;
title: string;
description: ReactNode;
onConfirm: () => void;
};
}) {
const [deleteOpen, setDeleteOpen] = useState(false);
return (
<div className="service-content-modal__foot">
<Button variant="secondary" type="button" onClick={onClose}>
Отмена
</Button>
<Button variant="primary" type="button" icon={<Save size={16} />} onClick={onSave}>
Сохранить
</Button>
</div>
<>
<div className="service-content-modal__foot">
<Button variant="secondary" surface="modal" type="button" onClick={onClose}>
Отмена
</Button>
<div className="service-content-modal__foot-actions">
{deleteConfig ? (
<Button variant="danger" surface="modal" type="button" icon={<Trash2 size={16} />} onClick={() => setDeleteOpen(true)}>
{deleteConfig.label}
</Button>
) : null}
<Button variant="accent" surface="modal" accentRgb={modalActionAccentRgb} type="button" icon={<Save size={16} />} onClick={onSave}>
Сохранить
</Button>
</div>
</div>
{deleteConfig ? (
<NodeDcDeleteModal
isOpen={deleteOpen}
title={deleteConfig.title}
description={deleteConfig.description}
onClose={() => setDeleteOpen(false)}
onConfirm={() => {
setDeleteOpen(false);
deleteConfig.onConfirm();
}}
/>
) : null}
</>
);
}
@@ -1821,16 +1970,20 @@ function InvitesSection({
actorUserId,
onCreateInvite,
onUpdateInvite,
onDeleteInvite,
}: {
data: LauncherData;
clientId: string;
actorUserId: string;
onCreateInvite: (invite: Pick<Invite, "clientId" | "email" | "role">) => void;
onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void;
onDeleteInvite: (inviteId: string) => void;
}) {
const [email, setEmail] = useState("");
const [role, setRole] = useState<ClientMembershipRole>("member");
const [deleteInviteId, setDeleteInviteId] = useState<string | null>(null);
const invites = data.invites.filter((invite) => invite.clientId === clientId);
const deletingInvite = invites.find((invite) => invite.id === deleteInviteId) ?? null;
const actor = getUser(data, actorUserId);
function handleCreateInvite() {
@@ -1884,6 +2037,7 @@ function InvitesSection({
<th>Статус</th>
<th>Ссылка</th>
<th>Истекает</th>
<th aria-label="Удаление" />
</tr>
</thead>
<tbody>
@@ -1938,11 +2092,35 @@ function InvitesSection({
}}
/>
</td>
<td className="services-admin-table__actions">
<IconButton
label={`Удалить инвайт ${invite.email}`}
className="admin-circle-action services-admin-table__edit"
type="button"
onClick={() => setDeleteInviteId(invite.id)}
>
<Trash2 size={15} />
</IconButton>
</td>
</tr>
))}
</tbody>
</table>
</GlassSurface>
<NodeDcDeleteModal
isOpen={Boolean(deletingInvite)}
title="Удалить инвайт"
description={
<>
Инвайт для <strong>{deletingInvite?.email}</strong> будет удален вместе с токеном приглашения.
</>
}
onClose={() => setDeleteInviteId(null)}
onConfirm={() => {
if (deletingInvite) onDeleteInvite(deletingInvite.id);
setDeleteInviteId(null);
}}
/>
</div>
);
}