Standardize NodeDC UI components
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user