Standardize modal buttons and delete confirmations
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user