Standardize NodeDC UI components

This commit is contained in:
DCCONSTRUCTIONS
2026-05-02 10:56:43 +03:00
parent c99c91c826
commit 69eb5260b0
22 changed files with 1955 additions and 550 deletions
+287 -361
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
closestCenter,
DndContext,
@@ -14,8 +14,6 @@ import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy }
import { CSS } from "@dnd-kit/utilities";
import {
Building2,
CalendarDays,
ChevronDown,
ClipboardList,
Copy,
DatabaseZap,
@@ -39,7 +37,7 @@ import {
Video,
X,
} from "lucide-react";
import type { ServiceAccessException, ServiceGrant } from "../../entities/access/types";
import type { ServiceAppRole } from "../../entities/access/types";
import type { Client, ClientStatus, ClientType } from "../../entities/client/types";
import type { Invite, InviteStatus } from "../../entities/invite/types";
import type { MediaKind, Service, ServiceMediaSource, ServiceStatus } from "../../entities/service/types";
@@ -65,9 +63,9 @@ 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 { Button, IconButton } from "../../shared/ui/Button";
import { GlassSurface } from "../../shared/ui/Glass";
import { PortalDropdown } from "../../shared/ui/PortalDropdown";
type AdminSection =
| "overview"
@@ -81,6 +79,15 @@ type AdminSection =
| "audit"
| "company";
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
export interface SetUserServiceAccessCommand {
userId: string;
serviceId: string;
value: AccessAssignmentValue;
}
const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
{ id: "clients", label: "Клиенты", icon: <Building2 size={16} /> },
@@ -108,9 +115,7 @@ export function AdminOverlay({
me,
activeClientId,
onClose,
onCreateGrant,
onCreateDenyException,
onRemoveException,
onSetUserServiceAccess,
onCreateInvite,
onUpdateInvite,
onRetrySync,
@@ -129,9 +134,7 @@ export function AdminOverlay({
me: MeResponse;
activeClientId: string;
onClose: () => void;
onCreateGrant: (grant: Omit<ServiceGrant, "id" | "status" | "createdAt" | "updatedAt">) => void;
onCreateDenyException: (exception: Omit<ServiceAccessException, "id" | "type" | "createdAt" | "updatedAt">) => void;
onRemoveException: (exceptionId: string) => void;
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
onCreateInvite: (invite: Pick<Invite, "clientId" | "email" | "role">) => void;
onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void;
onRetrySync: (syncId: string) => void;
@@ -157,7 +160,8 @@ export function AdminOverlay({
const accessMatrix = useMemo(() => buildAccessMatrix(data, scopedClientId, isRoot), [data, scopedClientId, isRoot]);
const selectedAccessCell =
accessMatrix.cells.find((cell) => cell.userId === selectedCell?.userId && cell.serviceId === selectedCell?.serviceId) ??
accessMatrix.cells[0];
accessMatrix.cells[0] ??
null;
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -182,22 +186,33 @@ export function AdminOverlay({
</div>
{isRoot ? (
<label className="admin-panel-client-select">
<span className="admin-panel-client-select__icon">
<Building2 size={16} />
</span>
<span className="admin-panel-client-select__name">{currentClient.name}</span>
<span className="admin-panel-client-select__chevron">
<ChevronDown size={14} strokeWidth={1.75} />
</span>
<select value={selectedClientId} onChange={(event) => setSelectedClientId(event.target.value)}>
{data.clients.map((client) => (
<option key={client.id} value={client.id}>
{client.name}
</option>
))}
</select>
</label>
<NodeDcSelect
value={selectedClientId}
options={data.clients.map((client) => ({ value: client.id, label: client.name, description: client.legalName ?? undefined }))}
label="Выбрать клиента"
searchable
minMenuWidth={292}
onChange={(clientId) => {
setSelectedClientId(clientId);
setSelectedCell(null);
}}
trigger={({ open, selectedOption, toggle, setTriggerRef }) => (
<button
ref={setTriggerRef}
className="admin-panel-client-select"
type="button"
aria-label="Выбрать клиента"
aria-expanded={open}
onClick={toggle}
>
<span className="admin-panel-client-select__icon">
<Building2 size={16} />
</span>
<span className="admin-panel-client-select__name">{selectedOption?.label ?? currentClient.name}</span>
<span className="admin-panel-client-select__chevron" aria-hidden="true" />
</button>
)}
/>
) : (
<div className="admin-panel-client-select">
<span className="admin-panel-client-select__icon">
@@ -264,9 +279,7 @@ export function AdminOverlay({
matrix={accessMatrix}
selectedCell={selectedAccessCell}
onSelectCell={(cell) => setSelectedCell({ userId: cell.userId, serviceId: cell.serviceId })}
onCreateGrant={onCreateGrant}
onCreateDenyException={onCreateDenyException}
onRemoveException={onRemoveException}
onSetUserServiceAccess={onSetUserServiceAccess}
/>
) : null}
{activeSection === "invites" ? (
@@ -382,15 +395,15 @@ function ClientsSection({
/>
</td>
<td>
<select
className="admin-table-input admin-table-input--select"
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-table-select-trigger"
value={client.type}
onChange={(event) => onUpdateClient(client.id, { type: event.target.value as ClientType })}
aria-label={`Тип клиента ${client.name}`}
>
<option value="company">Компания</option>
<option value="person">Частное лицо</option>
</select>
options={clientTypeOptions}
label={`Тип клиента ${client.name}`}
minMenuWidth={156}
onChange={(type) => onUpdateClient(client.id, { type })}
/>
</td>
<td>
<AdminStatusDropdown
@@ -402,7 +415,11 @@ function ClientsSection({
</td>
<td>{data.memberships.filter((membership) => membership.clientId === client.id).length}</td>
<td>
<DateField value={client.demoEndsAt ?? null} label={`Demo до ${client.name}`} onChange={(value) => onUpdateClient(client.id, { demoEndsAt: value })} />
<NodeDcDateField
value={client.demoEndsAt ?? null}
label={`Demo до ${client.name}`}
onChange={(value) => onUpdateClient(client.id, { demoEndsAt: value })}
/>
</td>
<td>
<input
@@ -503,16 +520,15 @@ function UsersSection({
</td>
{isRoot ? <td>{client.name}</td> : null}
<td>
<select
className="admin-table-input admin-table-input--select"
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-table-select-trigger"
value={membership.role}
onChange={(event) => onUpdateMembership(membership.id, { role: event.target.value as ClientMembershipRole })}
aria-label={`Роль ${user.name}`}
>
<option value="client_owner">Owner</option>
<option value="client_admin">Admin</option>
<option value="member">Member</option>
</select>
options={membershipRoleOptions}
label={`Роль ${user.name}`}
minMenuWidth={198}
onChange={(role) => onUpdateMembership(membership.id, { role })}
/>
</td>
<td>
{data.groups
@@ -657,16 +673,32 @@ function GroupsSection({
);
}
const serviceStatusOptions: Array<{ value: ServiceStatus; label: string }> = [
{ value: "active", label: "Активен" },
{ value: "maintenance", label: "Техработы" },
{ value: "hidden", label: "Скрыт" },
{ value: "disabled", label: "Отключён" },
];
type AdminStatusTone = "green" | "yellow" | "red" | "violet" | "muted";
type AdminStatusOption<T extends string> = { value: T; label: string; tone: AdminStatusTone };
const serviceStatusOptions: Array<AdminStatusOption<ServiceStatus>> = [
{ value: "active", label: "Активен", tone: "green" },
{ value: "maintenance", label: "Техработы", tone: "yellow" },
{ value: "hidden", label: "Скрыт", tone: "violet" },
{ value: "disabled", label: "Отключён", tone: "red" },
];
const clientTypeOptions: Array<NodeDcSelectOption<ClientType>> = [
{ value: "company", label: "Компания" },
{ value: "person", label: "Частное лицо" },
];
const membershipRoleOptions: Array<NodeDcSelectOption<ClientMembershipRole>> = [
{ value: "client_owner", label: "Owner", description: "Владелец клиента" },
{ value: "client_admin", label: "Admin", description: "Администратор клиента" },
{ value: "member", label: "Member", description: "Пользователь" },
];
const inviteRoleOptions: Array<NodeDcSelectOption<ClientMembershipRole>> = [
{ value: "member", label: "Member" },
{ value: "client_admin", label: "Client Admin" },
];
const clientStatusOptions: Array<AdminStatusOption<ClientStatus>> = [
{ value: "active", label: "Активен", tone: "green" },
{ value: "demo", label: "Demo", tone: "yellow" },
@@ -706,6 +738,14 @@ const auditResultOptions: Array<AdminStatusOption<"success" | "warning" | "error
{ value: "error", label: "Ошибка", tone: "red" },
];
const accessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>> = [
{ value: "unset", label: "—", description: "Не назначен" },
{ value: "viewer", label: "viewer", description: "Просмотр", tone: "green" },
{ value: "member", label: "member", description: "Участник", tone: "green" },
{ value: "admin", label: "admin", description: "Администратор", tone: "green" },
{ value: "deny", label: "Deny", description: "Исключение", tone: "red" },
];
const mediaAccept = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv";
function ServicesSection({
@@ -965,76 +1005,41 @@ function ServiceStatusDropdown({
label: string;
onChange: (status: ServiceStatus) => void;
}) {
const triggerRef = useRef<HTMLButtonElement | null>(null);
const [open, setOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>();
const selectedOption = serviceStatusOptions.find((option) => option.value === value) ?? serviceStatusOptions[0];
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as HTMLElement | null;
if (target && (triggerRef.current?.contains(target) || target.closest("[data-service-status-menu='true']"))) {
return;
}
setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
function toggleOpen() {
const rect = triggerRef.current?.getBoundingClientRect();
if (rect) {
setMenuStyle({
top: rect.bottom + 8,
left: rect.left,
width: Math.max(rect.width, 156),
});
}
setOpen((current) => !current);
}
return (
<div className="service-status-dropdown">
<button
ref={triggerRef}
className="service-status-trigger"
data-status={value}
type="button"
aria-label={label}
aria-expanded={open}
onClick={toggleOpen}
>
<span>{selectedOption.label}</span>
</button>
<PortalDropdown open={open} style={menuStyle}>
<div className="service-status-menu" data-service-status-menu="true">
<NodeDcDropdown
className="service-status-dropdown"
minWidth={156}
surfaceClassName="service-status-menu"
trigger={({ open, toggle, setTriggerRef }) => (
<button
ref={setTriggerRef}
className="service-status-trigger"
data-status={value}
data-tone={selectedOption.tone}
type="button"
aria-label={label}
aria-expanded={open}
onClick={toggle}
>
<span>{selectedOption.label}</span>
</button>
)}
>
{({ close }) => (
<div className="admin-status-menu__list">
{serviceStatusOptions.map((option) => (
<button
key={option.value}
className="service-status-menu__option"
className="service-status-menu__option nodedc-ui-option"
data-selected={option.value === value}
data-status={option.value}
data-tone={option.tone}
type="button"
onClick={() => {
onChange(option.value);
setOpen(false);
close();
}}
>
<span className="service-status-menu__mark" aria-hidden="true" />
@@ -1042,8 +1047,8 @@ function ServiceStatusDropdown({
</button>
))}
</div>
</PortalDropdown>
</div>
)}
</NodeDcDropdown>
);
}
@@ -1058,76 +1063,39 @@ function AdminStatusDropdown<T extends string>({
label: string;
onChange: (value: T) => void;
}) {
const triggerRef = useRef<HTMLButtonElement | null>(null);
const [open, setOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>();
const selectedOption = options.find((option) => option.value === value) ?? options[0];
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as HTMLElement | null;
if (target && (triggerRef.current?.contains(target) || target.closest("[data-admin-status-menu='true']"))) {
return;
}
setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
function toggleOpen() {
const rect = triggerRef.current?.getBoundingClientRect();
if (rect) {
setMenuStyle({
top: rect.bottom + 8,
left: rect.left,
width: Math.max(rect.width, 164),
});
}
setOpen((current) => !current);
}
return (
<div className="admin-status-dropdown">
<button
ref={triggerRef}
className="admin-status-trigger"
data-tone={selectedOption.tone}
type="button"
aria-label={label}
aria-expanded={open}
onClick={toggleOpen}
>
<span>{selectedOption.label}</span>
</button>
<PortalDropdown open={open} style={menuStyle}>
<div className="admin-status-menu" data-admin-status-menu="true">
<NodeDcDropdown
className="admin-status-dropdown"
minWidth={164}
surfaceClassName="admin-status-menu"
trigger={({ open, toggle, setTriggerRef }) => (
<button
ref={setTriggerRef}
className="admin-status-trigger"
data-tone={selectedOption.tone}
type="button"
aria-label={label}
aria-expanded={open}
onClick={toggle}
>
<span>{selectedOption.label}</span>
</button>
)}
>
{({ close }) => (
<div className="admin-status-menu__list">
{options.map((option) => (
<button
key={option.value}
className="admin-status-menu__option"
className="admin-status-menu__option nodedc-ui-option"
data-selected={option.value === value}
data-tone={option.tone}
type="button"
onClick={() => {
onChange(option.value);
setOpen(false);
close();
}}
>
<span className="admin-status-menu__mark" aria-hidden="true" />
@@ -1135,8 +1103,8 @@ function AdminStatusDropdown<T extends string>({
</button>
))}
</div>
</PortalDropdown>
</div>
)}
</NodeDcDropdown>
);
}
@@ -1149,86 +1117,6 @@ function AdminStatusPill<T extends string>({ value, options }: { value: T; optio
);
}
function DateField({
value,
label,
onChange,
}: {
value: string | null;
label: string;
onChange: (value: string | null) => void;
}) {
const triggerRef = useRef<HTMLButtonElement | null>(null);
const [open, setOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>();
const inputValue = toDateInputValue(value);
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as HTMLElement | null;
if (target && (triggerRef.current?.contains(target) || target.closest("[data-admin-date-popover='true']"))) {
return;
}
setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
function toggleOpen() {
const rect = triggerRef.current?.getBoundingClientRect();
if (rect) {
setMenuStyle({
top: rect.bottom + 8,
left: rect.left,
width: 248,
});
}
setOpen((current) => !current);
}
return (
<div className="admin-date-field">
<button ref={triggerRef} className="admin-date-trigger" type="button" aria-label={label} onClick={toggleOpen}>
<CalendarDays size={14} />
<span>{value ? formatDate(value) : "—"}</span>
</button>
<PortalDropdown open={open} style={menuStyle}>
<div className="admin-date-popover nodedc-calendar-shell" data-admin-date-popover="true">
<input
type="date"
value={inputValue}
onChange={(event) => onChange(fromDateInputValue(event.target.value))}
/>
<div className="admin-date-popover__actions">
<button type="button" onClick={() => onChange(null)}>
Сбросить
</button>
<button type="button" onClick={() => setOpen(false)}>
Готово
</button>
</div>
</div>
</PortalDropdown>
</div>
);
}
function ServiceContentModal({
service,
onClose,
@@ -1452,10 +1340,14 @@ function ClientEditorModal({
</label>
<label className="service-content-field">
<span>Тип</span>
<select value={draft.type} onChange={(event) => update("type", event.target.value as ClientType)}>
<option value="company">Компания</option>
<option value="person">Частное лицо</option>
</select>
<NodeDcSelect
className="admin-modal-select-wrap"
triggerClassName="admin-modal-select-trigger"
value={draft.type}
options={clientTypeOptions}
label="Тип клиента"
onChange={(type) => update("type", type)}
/>
</label>
<div className="service-content-field">
<span>Статус</span>
@@ -1471,19 +1363,31 @@ function ClientEditorModal({
</label>
<div className="service-content-field">
<span>Демо до</span>
<DateField value={draft.demoEndsAt ?? null} label="Демо до" onChange={(value) => update("demoEndsAt", value)} />
<NodeDcDateField value={draft.demoEndsAt ?? null} label="Демо до" onChange={(value) => update("demoEndsAt", value)} />
</div>
<div className="service-content-field">
<span>Договор с</span>
<DateField value={draft.contractStartsAt ?? null} label="Договор с" onChange={(value) => update("contractStartsAt", value)} />
<NodeDcDateField
value={draft.contractStartsAt ?? null}
rangeStart={draft.contractStartsAt ?? null}
rangeEnd={draft.contractEndsAt ?? null}
label="Договор с"
onChange={(value) => update("contractStartsAt", value)}
/>
</div>
<div className="service-content-field">
<span>Договор до</span>
<DateField value={draft.contractEndsAt ?? null} label="Договор до" onChange={(value) => update("contractEndsAt", value)} />
<NodeDcDateField
value={draft.contractEndsAt ?? null}
rangeStart={draft.contractStartsAt ?? null}
rangeEnd={draft.contractEndsAt ?? null}
label="Договор до"
onChange={(value) => update("contractEndsAt", value)}
/>
</div>
<div className="service-content-field">
<span>Оплачено до</span>
<DateField value={draft.paidUntil ?? null} label="Оплачено до" onChange={(value) => update("paidUntil", value)} />
<NodeDcDateField value={draft.paidUntil ?? null} label="Оплачено до" onChange={(value) => update("paidUntil", value)} />
</div>
<label className="service-content-field service-content-field--wide">
<span>Заметки</span>
@@ -1552,11 +1456,14 @@ function UserEditorModal({
</div>
<label className="service-content-field">
<span>Роль в клиенте</span>
<select value={membershipDraft.role} onChange={(event) => updateMembership("role", event.target.value as ClientMembershipRole)}>
<option value="client_owner">Client Owner</option>
<option value="client_admin">Client Admin</option>
<option value="member">Member</option>
</select>
<NodeDcSelect
className="admin-modal-select-wrap"
triggerClassName="admin-modal-select-trigger"
value={membershipDraft.role}
options={membershipRoleOptions}
label="Роль в клиенте"
onChange={(role) => updateMembership("role", role)}
/>
</label>
<div className="service-content-field">
<span>Доступ</span>
@@ -1761,30 +1668,51 @@ function AccessSection({
matrix,
selectedCell,
onSelectCell,
onCreateGrant,
onCreateDenyException,
onRemoveException,
onSetUserServiceAccess,
}: {
data: LauncherData;
matrix: ReturnType<typeof buildAccessMatrix>;
selectedCell: AccessMatrixCell;
selectedCell: AccessMatrixCell | null;
onSelectCell: (cell: AccessMatrixCell) => void;
onCreateGrant: (grant: Omit<ServiceGrant, "id" | "status" | "createdAt" | "updatedAt">) => void;
onCreateDenyException: (exception: Omit<ServiceAccessException, "id" | "type" | "createdAt" | "updatedAt">) => void;
onRemoveException: (exceptionId: string) => void;
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
}) {
const hasMatrixData = matrix.users.length > 0 && matrix.services.length > 0 && selectedCell !== null;
if (!hasMatrixData) {
return (
<div className="access-layout">
<GlassSurface className="access-matrix">
<div className="table-toolbar">
<h3>Матрица доступа · {matrix.client.name}</h3>
<span className="muted-text">Нет данных для матрицы</span>
</div>
<div className="access-empty-state">
<strong>У клиента пока нет участников</strong>
<span>Добавьте участника или инвайт, после этого здесь появятся ячейки доступа.</span>
</div>
</GlassSurface>
<GlassSurface className="access-explanation access-explanation--empty">
<p className="eyebrow">Explanation panel</p>
<h3>Ячейка не выбрана</h3>
<div className="explanation-stack">
<InfoLine label="Итог" value="Нет данных" />
<InfoLine label="Причина" value="У выбранного клиента нет участников в текущем наборе данных" />
</div>
</GlassSurface>
</div>
);
}
const selectedUser = getUser(data, selectedCell.userId);
const selectedService = getService(data, selectedCell.serviceId);
const denyException = data.exceptions.find(
(exception) => exception.serviceId === selectedCell.serviceId && exception.userId === selectedCell.userId && exception.type === "deny"
);
return (
<div className="access-layout">
<GlassSurface className="access-matrix">
<div className="table-toolbar">
<h3>Матрица доступа · {matrix.client.name}</h3>
<span className="muted-text">Клик по ячейке открывает объяснение</span>
<span className="muted-text">Клик по ячейке открывает назначение</span>
</div>
<div className="matrix-scroll">
<table>
@@ -1808,20 +1736,12 @@ function AccessSection({
const active = selectedCell.userId === user.id && selectedCell.serviceId === service.id;
return (
<td key={service.id}>
<button
className={cn(
"access-cell",
cell.effectiveAccess.allowed && "access-cell--allowed",
!cell.effectiveAccess.allowed && "access-cell--denied",
cell.effectiveAccess.source === "exception" && "access-cell--exception",
active && "access-cell--active"
)}
type="button"
onClick={() => onSelectCell(cell)}
>
<strong>{accessCellTitle(cell)}</strong>
<span>{sourceLabel(cell.effectiveAccess.source)}</span>
</button>
<AccessCellControl
cell={cell}
active={active}
onSelectCell={onSelectCell}
onSetAccess={(value) => onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value })}
/>
</td>
);
})}
@@ -1844,48 +1764,57 @@ function AccessSection({
<InfoLine label="Источник" value={sourceLabel(selectedCell.effectiveAccess.source)} />
<InfoLine label="Роль" value={selectedCell.effectiveAccess.appRole ?? "—"} />
</div>
<div className="access-actions">
{!selectedCell.effectiveAccess.allowed ? (
<Button
variant="primary"
icon={<KeyRound size={16} />}
onClick={() =>
onCreateGrant({
serviceId: selectedService.id,
targetType: "user",
targetId: selectedUser.id,
appRole: "member",
})
}
>
Выдать пользователю
</Button>
) : null}
{denyException ? (
<Button variant="secondary" onClick={() => onRemoveException(denyException.id)}>
Убрать deny
</Button>
) : (
<Button
variant="danger"
onClick={() =>
onCreateDenyException({
serviceId: selectedService.id,
userId: selectedUser.id,
reason: "Создано из mock-матрицы доступа.",
})
}
>
Создать deny
</Button>
)}
</div>
</GlassSurface>
</div>
);
}
function AccessCellControl({
cell,
active,
onSelectCell,
onSetAccess,
}: {
cell: AccessMatrixCell;
active: boolean;
onSelectCell: (cell: AccessMatrixCell) => void;
onSetAccess: (value: AccessAssignmentValue) => void;
}) {
const assignmentValue = accessAssignmentValue(cell);
return (
<NodeDcSelect
value={assignmentValue}
options={accessAssignmentOptions}
label={`Назначить доступ ${cell.userId} / ${cell.serviceId}`}
minMenuWidth={172}
menuClassName="access-cell-menu"
onChange={(value) => onSetAccess(value)}
trigger={({ open, toggle, setTriggerRef }) => (
<button
ref={setTriggerRef}
className={cn(
"access-cell",
cell.effectiveAccess.allowed && "access-cell--allowed",
!cell.effectiveAccess.allowed && "access-cell--denied",
cell.effectiveAccess.source === "exception" && "access-cell--exception",
active && "access-cell--active"
)}
type="button"
aria-expanded={open}
onClick={() => {
onSelectCell(cell);
toggle();
}}
>
<strong>{accessCellTitle(cell)}</strong>
<span>{sourceLabel(cell.effectiveAccess.source)}</span>
</button>
)}
/>
);
}
function InvitesSection({
data,
clientId,
@@ -1932,10 +1861,14 @@ function InvitesSection({
</div>
<div className="invite-form__fields">
<input value={email} onChange={(event) => setEmail(event.target.value)} placeholder="email@company.ru" />
<select value={role} onChange={(event) => setRole(event.target.value as ClientMembershipRole)}>
<option value="member">Member</option>
<option value="client_admin">Client Admin</option>
</select>
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-modal-select-trigger"
value={role}
options={inviteRoleOptions}
label="Роль инвайта"
onChange={(nextRole) => setRole(nextRole)}
/>
</div>
</GlassSurface>
@@ -1965,15 +1898,15 @@ function InvitesSection({
/>
</td>
<td>
<select
className="admin-table-input admin-table-input--select"
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-table-select-trigger"
value={invite.role}
onChange={(event) => onUpdateInvite(invite.id, { role: event.target.value as ClientMembershipRole })}
aria-label={`Роль инвайта ${invite.email}`}
>
<option value="member">Member</option>
<option value="client_admin">Client Admin</option>
</select>
options={inviteRoleOptions}
label={`Роль инвайта ${invite.email}`}
minMenuWidth={172}
onChange={(nextRole) => onUpdateInvite(invite.id, { role: nextRole })}
/>
</td>
<td>
<AdminStatusDropdown
@@ -1997,7 +1930,7 @@ function InvitesSection({
</div>
</td>
<td>
<DateField
<NodeDcDateField
value={invite.expiresAt}
label={`Инвайт истекает ${invite.email}`}
onChange={(value) => {
@@ -2152,21 +2085,6 @@ function InfoLine({ label, value }: { label: string; value: string }) {
);
}
function toDateInputValue(value: string | null): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value.slice(0, 10);
return date.toISOString().slice(0, 10);
}
function fromDateInputValue(value: string): string | null {
if (!value) return null;
return new Date(`${value}T00:00:00`).toISOString();
}
function roleLabel(role: string): string {
const labels: Record<string, string> = {
root_admin: "Root Admin",
@@ -2199,6 +2117,14 @@ function accessCellTitle(cell: AccessMatrixCell): string {
return cell.effectiveAccess.appRole ?? "allow";
}
function accessAssignmentValue(cell: AccessMatrixCell): AccessAssignmentValue {
if (cell.effectiveAccess.source === "exception" && !cell.effectiveAccess.allowed) return "deny";
if (cell.effectiveAccess.source === "user" && cell.effectiveAccess.appRole) {
return cell.effectiveAccess.appRole === "owner" ? "admin" : cell.effectiveAccess.appRole;
}
return "unset";
}
function sourceLabel(source?: AccessMatrixCell["effectiveAccess"]["source"]): string {
if (!source) return "—";
const labels = {
+85 -33
View File
@@ -2,6 +2,7 @@ import { Inbox } from "lucide-react";
import type { Client } from "../../entities/client/types";
import type { MeResponse, ProfileOption } from "../../shared/api/mockApi";
import { initials } from "../../shared/lib/format";
import { NodeDcProfileMenu, NodeDcSelect } from "../../shared/nodedc-ui";
export function TopBar({
me,
@@ -30,6 +31,16 @@ export function TopBar({
const availableClients = clients.filter((client) => availableClientIds.has(client.id));
const activeClient = availableClients.find((client) => client.id === activeClientId);
const activeProfile = profileOptions.find((profile) => profile.userId === activeProfileId);
const clientOptions = availableClients.map((client) => ({
value: client.id,
label: client.name,
description: client.legalName ?? undefined,
}));
const profileSelectOptions = profileOptions.map((profile) => ({
value: profile.userId,
label: profile.label,
description: profile.description,
}));
return (
<header className="nodedc-expanded-toolbar-shell">
@@ -42,32 +53,53 @@ export function TopBar({
</div>
<div className="nodedc-expanded-toolbar-center">
<label className="nodedc-expanded-workspace-button" title={activeClient?.name ?? "Клиент"}>
<img src="/nodedc-mark.svg" alt="" className="nodedc-expanded-workspace-mark" />
<select value={activeClientId} onChange={(event) => onClientChange(event.target.value)} aria-label="Выбрать клиента">
{availableClients.map((client) => (
<option key={client.id} value={client.id}>
{client.name}
</option>
))}
</select>
</label>
<NodeDcSelect
value={activeClientId}
options={clientOptions}
label="Выбрать клиента"
searchable
minMenuWidth={248}
onChange={(clientId) => onClientChange(clientId)}
trigger={({ open, toggle, setTriggerRef }) => (
<button
ref={setTriggerRef}
className="nodedc-expanded-workspace-button"
title={activeClient?.name ?? "Клиент"}
type="button"
aria-label="Выбрать клиента"
aria-expanded={open}
onClick={toggle}
>
<img src="/nodedc-mark.svg" alt="" className="nodedc-expanded-workspace-mark" />
</button>
)}
/>
<nav className="nodedc-expanded-nav-group" aria-label="Навигация лаунчера">
<button className="nodedc-expanded-nav-button" type="button" data-active={!adminOpen} onClick={onOpenShowcase}>
<span>Витрина</span>
</button>
<label className="nodedc-expanded-nav-button nodedc-expanded-select-button" data-active="false">
<span>{activeProfile?.label ?? me.user.name}</span>
<select value={activeProfileId} onChange={(event) => onProfileChange(event.target.value)} aria-label="Выбрать профиль">
{profileOptions.map((profile) => (
<option key={profile.userId} value={profile.userId}>
{profile.label}
</option>
))}
</select>
</label>
<NodeDcSelect
value={activeProfileId}
options={profileSelectOptions}
label="Выбрать профиль доступа"
minMenuWidth={236}
onChange={(userId) => onProfileChange(userId)}
trigger={({ open, selectedOption, toggle, setTriggerRef }) => (
<button
ref={setTriggerRef}
className="nodedc-expanded-nav-button nodedc-expanded-select-button"
type="button"
data-active="false"
aria-label="Выбрать профиль доступа"
aria-expanded={open}
onClick={toggle}
>
<span>{selectedOption?.label ?? activeProfile?.label ?? me.user.name}</span>
</button>
)}
/>
{me.permissions.canOpenAdmin ? (
<button className="nodedc-expanded-nav-button" type="button" data-active={adminOpen} onClick={onOpenAdmin}>
@@ -78,19 +110,39 @@ export function TopBar({
</div>
<div className="nodedc-expanded-toolbar-right">
<div className="nodedc-expanded-user-group" title={`${me.user.name} · ${me.user.email}`}>
<button className="nodedc-expanded-nav-button" type="button" data-active="false">
<span>Профиль</span>
</button>
<button className="nodedc-toolbar-icon-button nodedc-expanded-notification-button" type="button" data-active="false" aria-label="Уведомления">
<span className="nodedc-toolbar-icon-active-dot">
<Inbox size={20} strokeWidth={1.7} />
</span>
</button>
<button className="nodedc-expanded-user-avatar-button" type="button" aria-label="Профиль пользователя">
<span className="nodedc-expanded-user-avatar">{initials(me.user.name)}</span>
</button>
</div>
<NodeDcProfileMenu
user={me.user}
trigger={({ open, toggle, setTriggerRef }) => (
<div
ref={setTriggerRef}
className="nodedc-expanded-user-group"
title={`${me.user.name} · ${me.user.email}`}
role="button"
tabIndex={0}
aria-label="Профиль пользователя"
aria-expanded={open}
onClick={toggle}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggle();
}
}}
>
<span className="nodedc-expanded-nav-button" data-active="false">
<span>Профиль</span>
</span>
<span className="nodedc-toolbar-icon-button nodedc-expanded-notification-button" data-active="false" aria-hidden="true">
<span className="nodedc-toolbar-icon-active-dot">
<Inbox size={20} strokeWidth={1.7} />
</span>
</span>
<span className="nodedc-expanded-user-avatar-button" aria-hidden="true">
<span className="nodedc-expanded-user-avatar">{initials(me.user.name)}</span>
</span>
</div>
)}
/>
</div>
</div>
</div>