3351 lines
126 KiB
TypeScript
3351 lines
126 KiB
TypeScript
import { Fragment, useEffect, useMemo, useState, type ReactNode } from "react";
|
||
import {
|
||
closestCenter,
|
||
DndContext,
|
||
PointerSensor,
|
||
useSensor,
|
||
useSensors,
|
||
type DraggableAttributes,
|
||
type DraggableSyntheticListeners,
|
||
type DragEndEvent,
|
||
type DragStartEvent,
|
||
} from "@dnd-kit/core";
|
||
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||
import { CSS } from "@dnd-kit/utilities";
|
||
import {
|
||
Building2,
|
||
ClipboardList,
|
||
Copy,
|
||
DatabaseZap,
|
||
Edit3,
|
||
Globe2,
|
||
GripVertical,
|
||
HardDrive,
|
||
Image as ImageIcon,
|
||
KeyRound,
|
||
LayoutDashboard,
|
||
Link2,
|
||
ListChecks,
|
||
MailPlus,
|
||
Maximize2,
|
||
Minimize2,
|
||
Plus,
|
||
RefreshCw,
|
||
Save,
|
||
ShieldCheck,
|
||
SlidersHorizontal,
|
||
Trash2,
|
||
UsersRound,
|
||
Video,
|
||
X,
|
||
} from "lucide-react";
|
||
import type { ServiceAppRole } from "../../entities/access/types";
|
||
import type { Client, ClientStatus, ClientTaskManagerWorkspaceBinding, ClientType } from "../../entities/client/types";
|
||
import type { Invite, InviteStatus } from "../../entities/invite/types";
|
||
import { createServiceLaunchLinkPatch, getServiceLaunchLink } from "../../entities/service/links";
|
||
import type { MediaKind, Service, ServiceMediaSource, ServiceStatus } from "../../entities/service/types";
|
||
import type { SyncState, SyncStatus } from "../../entities/sync/types";
|
||
import type {
|
||
ClientGroup,
|
||
ClientMembership,
|
||
ClientMembershipRole,
|
||
ClientMembershipStatus,
|
||
LauncherUser,
|
||
LauncherUserStatus,
|
||
} from "../../entities/user/types";
|
||
import {
|
||
buildAccessMatrix,
|
||
getClient,
|
||
getClientUsers,
|
||
getService,
|
||
getUser,
|
||
type AccessMatrixCell,
|
||
type LauncherData,
|
||
type LauncherSettings,
|
||
type MeResponse,
|
||
type TaskManagerWorkspaceCreationPolicy,
|
||
} from "../../shared/api/mockApi";
|
||
import type { TaskManagerProjectSummary, TaskManagerWorkspaceMemberRole, TaskManagerWorkspaceSummary } from "../../shared/api/adminApi";
|
||
import { uploadStorageFile } from "../../shared/api/storageApi";
|
||
import { cn } from "../../shared/lib/cn";
|
||
import { formatDate, formatDateTime } from "../../shared/lib/format";
|
||
import { NodeDcDateField, NodeDcDeleteModal, NodeDcDropdown, NodeDcSelect, type NodeDcSelectOption } from "../../shared/nodedc-ui";
|
||
import { Button, IconButton } from "../../shared/ui/Button";
|
||
import { GlassSurface } from "../../shared/ui/Glass";
|
||
|
||
type AdminSection =
|
||
| "overview"
|
||
| "clients"
|
||
| "users"
|
||
| "groups"
|
||
| "services"
|
||
| "access"
|
||
| "invites"
|
||
| "sync"
|
||
| "audit"
|
||
| "misc"
|
||
| "company";
|
||
|
||
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
|
||
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
|
||
type OperationalCoreRoleSelectValue = AccessAssignmentValue | "pending";
|
||
|
||
export interface SetUserServiceAccessCommand {
|
||
userId: string;
|
||
serviceId: string;
|
||
value: AccessAssignmentValue;
|
||
}
|
||
|
||
export interface CreateUserCommand {
|
||
clientId: string;
|
||
email: string;
|
||
name?: string;
|
||
role: ClientMembershipRole;
|
||
groupIds: string[];
|
||
provisionAuth: boolean;
|
||
generatePassword: boolean;
|
||
}
|
||
|
||
export interface EnsureTaskManagerWorkspaceMemberCommand {
|
||
clientId: string;
|
||
userId: string;
|
||
workspaceSlug?: string;
|
||
role: TaskManagerWorkspaceMemberRole;
|
||
}
|
||
|
||
export interface EnsureTaskManagerProjectMemberCommand {
|
||
clientId: string;
|
||
userId: string;
|
||
workspaceSlug: string;
|
||
projectId: string;
|
||
role: TaskManagerWorkspaceMemberRole;
|
||
}
|
||
|
||
const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
|
||
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
|
||
{ id: "clients", label: "Клиенты", icon: <Building2 size={16} /> },
|
||
{ id: "users", label: "Участники", icon: <UsersRound size={16} /> },
|
||
{ id: "groups", label: "Группы", icon: <ListChecks size={16} /> },
|
||
{ id: "services", label: "Каталог сервисов", icon: <DatabaseZap size={16} /> },
|
||
{ id: "access", label: "Доступы", icon: <KeyRound size={16} /> },
|
||
{ id: "invites", label: "Инвайты", icon: <MailPlus size={16} /> },
|
||
{ id: "sync", label: "Синхронизация", icon: <RefreshCw size={16} /> },
|
||
{ id: "audit", label: "Аудит", icon: <ClipboardList size={16} /> },
|
||
{ id: "misc", label: "Разное", icon: <SlidersHorizontal size={16} /> },
|
||
];
|
||
|
||
const clientSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
|
||
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
|
||
{ id: "users", label: "Участники", icon: <UsersRound size={16} /> },
|
||
{ id: "groups", label: "Группы", icon: <ListChecks size={16} /> },
|
||
{ id: "access", label: "Доступы", icon: <KeyRound size={16} /> },
|
||
{ id: "invites", label: "Инвайты", icon: <MailPlus size={16} /> },
|
||
{ id: "company", label: "Профиль компании", icon: <Building2 size={16} /> },
|
||
];
|
||
|
||
export function AdminOverlay({
|
||
data,
|
||
me,
|
||
activeClientId,
|
||
onClose,
|
||
onSetUserServiceAccess,
|
||
onCreateInvite,
|
||
onUpdateInvite,
|
||
onDeleteInvite,
|
||
onRetrySync,
|
||
onCreateClient,
|
||
onUpdateClient,
|
||
onDeleteClient,
|
||
onCreateUser,
|
||
onUpdateUser,
|
||
onUpdateMembership,
|
||
onDeleteMembership,
|
||
pendingAccessAssignments,
|
||
onCreateGroup,
|
||
onUpdateGroup,
|
||
onDeleteGroup,
|
||
onUpdateService,
|
||
onReorderServices,
|
||
onCreateService,
|
||
onDeleteService,
|
||
onUpdateSettings,
|
||
taskManagerWorkspaces,
|
||
taskManagerWorkspacesLoading,
|
||
taskManagerWorkspacesError,
|
||
pendingTaskManagerMemberships,
|
||
pendingTaskManagerProjectMemberships,
|
||
onRefreshTaskManagerWorkspaces,
|
||
onSetTaskManagerWorkspaceMemberRole,
|
||
onSetTaskManagerProjectMemberRole,
|
||
}: {
|
||
data: LauncherData;
|
||
me: MeResponse;
|
||
activeClientId: string;
|
||
onClose: () => void;
|
||
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;
|
||
onCreateUser: (command: CreateUserCommand) => void;
|
||
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
|
||
onDeleteMembership: (membershipId: string) => void;
|
||
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
|
||
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;
|
||
onDeleteService: (serviceId: string) => void;
|
||
onUpdateSettings: (patch: Partial<LauncherSettings>) => void;
|
||
taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
|
||
taskManagerWorkspacesLoading: boolean;
|
||
taskManagerWorkspacesError: string | null;
|
||
pendingTaskManagerMemberships: Record<string, boolean>;
|
||
pendingTaskManagerProjectMemberships: Record<string, boolean>;
|
||
onRefreshTaskManagerWorkspaces: () => void;
|
||
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
|
||
onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void;
|
||
}) {
|
||
const isRoot = me.launcherRole === "root_admin";
|
||
const sections = isRoot ? rootSections : clientSections;
|
||
const [activeSection, setActiveSection] = useState<AdminSection | null>(null);
|
||
const [isContentFullscreen, setIsContentFullscreen] = useState(false);
|
||
const [selectedClientId, setSelectedClientId] = useState(activeClientId);
|
||
const [selectedCell, setSelectedCell] = useState<{ userId: string; serviceId: string } | null>(null);
|
||
|
||
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 =
|
||
accessMatrix.cells.find((cell) => cell.userId === selectedCell?.userId && cell.serviceId === selectedCell?.serviceId) ??
|
||
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();
|
||
};
|
||
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||
}, [onClose]);
|
||
|
||
useEffect(() => {
|
||
if (!activeSection) setIsContentFullscreen(false);
|
||
}, [activeSection]);
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"admin-panel-layer",
|
||
activeSection && "admin-panel-layer--content-open",
|
||
isContentFullscreen && "admin-panel-layer--fullscreen"
|
||
)}
|
||
>
|
||
<aside className="admin-panel-nav">
|
||
<div className="admin-panel-nav__head">
|
||
<div>
|
||
<p className="eyebrow">NODE.DC</p>
|
||
<h2>Администрирование</h2>
|
||
</div>
|
||
<IconButton label="Закрыть администрирование" className="admin-panel-close" onClick={onClose}>
|
||
<X size={15} strokeWidth={1.45} />
|
||
</IconButton>
|
||
</div>
|
||
|
||
{isRoot ? (
|
||
<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">
|
||
<Building2 size={16} />
|
||
</span>
|
||
<span className="admin-panel-client-select__name">{currentClient.name}</span>
|
||
</div>
|
||
)}
|
||
|
||
<nav className="admin-panel-nav-list">
|
||
{sections.map((section) => (
|
||
<button
|
||
key={section.id}
|
||
className={cn("admin-panel-nav-item", activeSection === section.id && "admin-panel-nav-item--active")}
|
||
type="button"
|
||
onClick={() => setActiveSection((current) => (current === section.id ? null : section.id))}
|
||
>
|
||
<span className="admin-panel-nav-item__icon">{section.icon}</span>
|
||
<span>{section.label}</span>
|
||
</button>
|
||
))}
|
||
</nav>
|
||
|
||
<div className="admin-panel-role">
|
||
<span className="admin-panel-nav-item__icon">
|
||
<ShieldCheck size={16} />
|
||
</span>
|
||
<span>{roleLabel(me.launcherRole)}</span>
|
||
</div>
|
||
</aside>
|
||
|
||
{activeSection ? (
|
||
<section className="admin-panel-content">
|
||
<AdminHeader
|
||
isFullscreen={isContentFullscreen}
|
||
onToggleFullscreen={() => setIsContentFullscreen((current) => !current)}
|
||
onCloseContent={() => setActiveSection(null)}
|
||
/>
|
||
<div className="admin-panel-content__body">
|
||
{activeSection === "overview" ? <OverviewSection data={data} clientId={scopedClientId} isRoot={isRoot} /> : null}
|
||
{activeSection === "clients" && isRoot ? (
|
||
<ClientsSection
|
||
data={data}
|
||
taskManagerWorkspaces={taskManagerWorkspaces}
|
||
taskManagerWorkspacesLoading={taskManagerWorkspacesLoading}
|
||
taskManagerWorkspacesError={taskManagerWorkspacesError}
|
||
onRefreshTaskManagerWorkspaces={onRefreshTaskManagerWorkspaces}
|
||
onCreateClient={onCreateClient}
|
||
onUpdateClient={onUpdateClient}
|
||
onDeleteClient={onDeleteClient}
|
||
/>
|
||
) : null}
|
||
{activeSection === "users" ? (
|
||
<UsersSection
|
||
data={data}
|
||
clientId={scopedClientId}
|
||
isRoot={isRoot}
|
||
onCreateUser={onCreateUser}
|
||
onUpdateUser={onUpdateUser}
|
||
/>
|
||
) : null}
|
||
{activeSection === "groups" ? (
|
||
<GroupsSection
|
||
data={data}
|
||
clientId={scopedClientId}
|
||
onCreateGroup={onCreateGroup}
|
||
onUpdateGroup={onUpdateGroup}
|
||
onDeleteGroup={onDeleteGroup}
|
||
/>
|
||
) : null}
|
||
{activeSection === "services" && isRoot ? (
|
||
<ServicesSection
|
||
data={data}
|
||
onUpdateService={onUpdateService}
|
||
onReorderServices={onReorderServices}
|
||
onCreateService={onCreateService}
|
||
onDeleteService={onDeleteService}
|
||
/>
|
||
) : null}
|
||
{activeSection === "access" ? (
|
||
<AccessSection
|
||
data={data}
|
||
matrix={accessMatrix}
|
||
selectedCell={selectedAccessCell}
|
||
onSelectCell={(cell) => setSelectedCell({ userId: cell.userId, serviceId: cell.serviceId })}
|
||
onSetUserServiceAccess={onSetUserServiceAccess}
|
||
pendingAccessAssignments={pendingAccessAssignments}
|
||
onUpdateUser={onUpdateUser}
|
||
onUpdateMembership={onUpdateMembership}
|
||
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
|
||
pendingTaskManagerProjectMemberships={pendingTaskManagerProjectMemberships}
|
||
taskManagerWorkspaceCatalog={taskManagerWorkspaces}
|
||
onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole}
|
||
onSetTaskManagerProjectMemberRole={onSetTaskManagerProjectMemberRole}
|
||
/>
|
||
) : null}
|
||
{activeSection === "invites" ? (
|
||
<InvitesSection
|
||
data={data}
|
||
clientId={scopedClientId}
|
||
actorUserId={me.user.id}
|
||
onCreateInvite={onCreateInvite}
|
||
onUpdateInvite={onUpdateInvite}
|
||
onDeleteInvite={onDeleteInvite}
|
||
/>
|
||
) : null}
|
||
{activeSection === "sync" ? <SyncSection data={data} clientId={scopedClientId} isRoot={isRoot} onRetrySync={onRetrySync} /> : null}
|
||
{activeSection === "audit" && isRoot ? <AuditSection data={data} /> : null}
|
||
{activeSection === "misc" && isRoot ? <MiscSection data={data} onUpdateSettings={onUpdateSettings} /> : null}
|
||
{activeSection === "company" ? <CompanySection data={data} clientId={scopedClientId} /> : null}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AdminHeader({
|
||
isFullscreen,
|
||
onToggleFullscreen,
|
||
onCloseContent,
|
||
}: {
|
||
isFullscreen: boolean;
|
||
onToggleFullscreen: () => void;
|
||
onCloseContent: () => void;
|
||
}) {
|
||
return (
|
||
<div className="admin-header">
|
||
<div className="admin-header__actions">
|
||
<IconButton
|
||
label={isFullscreen ? "Свернуть панель" : "Открыть панель на весь экран"}
|
||
className={cn("admin-circle-action", isFullscreen && "admin-circle-action--active")}
|
||
type="button"
|
||
onClick={onToggleFullscreen}
|
||
aria-pressed={isFullscreen}
|
||
>
|
||
{isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||
</IconButton>
|
||
<IconButton label="Закрыть панель раздела" className="admin-circle-action admin-content-close" type="button" onClick={onCloseContent}>
|
||
<X size={15} strokeWidth={1.45} />
|
||
</IconButton>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function OverviewSection({ data, clientId, isRoot }: { data: LauncherData; clientId: string; isRoot: boolean }) {
|
||
const clientUsers = getClientUsers(data, clientId);
|
||
const clientServiceCount = data.grants.filter((grant) => grant.targetType === "client" && grant.targetId === clientId).length;
|
||
const syncErrors = data.syncStatuses.filter((sync) => sync.state === "error" && (isRoot || sync.objectId === clientId)).length;
|
||
|
||
return (
|
||
<section className="admin-section-grid">
|
||
<MetricCard label={isRoot ? "Клиентов" : "Участников"} value={isRoot ? data.clients.length : clientUsers.length} hint="Текущая область" />
|
||
<MetricCard label="Активных сервисов" value={data.services.filter((service) => service.status === "active").length} hint="В каталоге" />
|
||
<MetricCard label="Подключений клиента" value={clientServiceCount} hint="Гранты клиента" />
|
||
<MetricCard label="Ошибок sync" value={syncErrors} hint="Требуют внимания" danger={syncErrors > 0} />
|
||
|
||
<GlassSurface className="admin-wide-card">
|
||
<h3>Последние действия</h3>
|
||
<div className="activity-list">
|
||
{data.auditEvents.slice(0, 5).map((event) => (
|
||
<div key={event.id} className="activity-row">
|
||
<span>{formatDateTime(event.at)}</span>
|
||
<strong>{event.action}</strong>
|
||
<em>{event.objectName}</em>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</GlassSurface>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ClientsSection({
|
||
data,
|
||
taskManagerWorkspaces,
|
||
taskManagerWorkspacesLoading,
|
||
taskManagerWorkspacesError,
|
||
onRefreshTaskManagerWorkspaces,
|
||
onCreateClient,
|
||
onUpdateClient,
|
||
onDeleteClient,
|
||
}: {
|
||
data: LauncherData;
|
||
taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
|
||
taskManagerWorkspacesLoading: boolean;
|
||
taskManagerWorkspacesError: string | null;
|
||
onRefreshTaskManagerWorkspaces: () => void;
|
||
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;
|
||
|
||
return (
|
||
<>
|
||
<GlassSurface className="table-shell">
|
||
<div className="table-toolbar">
|
||
<h3>Клиенты</h3>
|
||
<IconButton label="Создать клиента" className="admin-circle-action admin-circle-action--solid" type="button" onClick={onCreateClient}>
|
||
<Plus size={17} />
|
||
</IconButton>
|
||
</div>
|
||
<table className="admin-data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Название</th>
|
||
<th>Тип</th>
|
||
<th>Статус</th>
|
||
<th>Участников</th>
|
||
<th>Demo</th>
|
||
<th>Контакт</th>
|
||
<th aria-label="Редактирование" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.clients.map((client) => (
|
||
<tr key={client.id}>
|
||
<td className="services-admin-table__service">
|
||
<input
|
||
className="admin-table-input admin-table-input--strong"
|
||
value={client.name}
|
||
onChange={(event) => onUpdateClient(client.id, { name: event.target.value })}
|
||
aria-label={`Название клиента ${client.name}`}
|
||
/>
|
||
<input
|
||
className="admin-table-input admin-table-input--muted"
|
||
value={client.legalName ?? ""}
|
||
onChange={(event) => onUpdateClient(client.id, { legalName: event.target.value || null })}
|
||
aria-label={`Юридическое название ${client.name}`}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<NodeDcSelect
|
||
className="admin-table-select-wrap"
|
||
triggerClassName="admin-table-select-trigger"
|
||
value={client.type}
|
||
options={clientTypeOptions}
|
||
label={`Тип клиента ${client.name}`}
|
||
minMenuWidth={156}
|
||
onChange={(type) => onUpdateClient(client.id, { type })}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<AdminStatusDropdown
|
||
value={client.status}
|
||
options={clientStatusOptions}
|
||
label={`Статус клиента ${client.name}`}
|
||
onChange={(status) => onUpdateClient(client.id, { status })}
|
||
/>
|
||
</td>
|
||
<td>{data.memberships.filter((membership) => membership.clientId === client.id).length}</td>
|
||
<td>
|
||
<NodeDcDateField
|
||
value={client.demoEndsAt ?? null}
|
||
label={`Demo до ${client.name}`}
|
||
onChange={(value) => onUpdateClient(client.id, { demoEndsAt: value })}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<input
|
||
className="admin-table-input"
|
||
value={client.contactEmail ?? ""}
|
||
onChange={(event) => onUpdateClient(client.id, { contactEmail: event.target.value || null })}
|
||
aria-label={`Контакт клиента ${client.name}`}
|
||
/>
|
||
</td>
|
||
<td className="services-admin-table__actions">
|
||
<IconButton
|
||
label={`Редактировать клиента ${client.name}`}
|
||
className="admin-icon-action services-admin-table__edit"
|
||
type="button"
|
||
onClick={() => setEditingClientId(client.id)}
|
||
>
|
||
<Edit3 size={12} />
|
||
</IconButton>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</GlassSurface>
|
||
|
||
{editingClient ? (
|
||
<ClientEditorModal
|
||
client={editingClient}
|
||
taskManagerWorkspaces={taskManagerWorkspaces}
|
||
taskManagerWorkspacesLoading={taskManagerWorkspacesLoading}
|
||
taskManagerWorkspacesError={taskManagerWorkspacesError}
|
||
onRefreshTaskManagerWorkspaces={onRefreshTaskManagerWorkspaces}
|
||
onClose={() => setEditingClientId(null)}
|
||
onSave={(patch) => {
|
||
onUpdateClient(editingClient.id, patch);
|
||
setEditingClientId(null);
|
||
}}
|
||
onDelete={() => {
|
||
onDeleteClient(editingClient.id);
|
||
setEditingClientId(null);
|
||
}}
|
||
canDelete={data.clients.length > 1}
|
||
/>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function UsersSection({
|
||
data,
|
||
clientId,
|
||
isRoot,
|
||
onCreateUser,
|
||
onUpdateUser,
|
||
}: {
|
||
data: LauncherData;
|
||
clientId: string;
|
||
isRoot: boolean;
|
||
onCreateUser: (command: CreateUserCommand) => void;
|
||
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||
}) {
|
||
const [newUserEmail, setNewUserEmail] = useState("");
|
||
const [newUserName, setNewUserName] = useState("");
|
||
const [newUserRole, setNewUserRole] = useState<ClientMembershipRole>("member");
|
||
const [newUserGroupId, setNewUserGroupId] = useState<string>("none");
|
||
const rows = isRoot
|
||
? data.memberships.map((membership) => ({ membership, user: getUser(data, membership.userId), client: getClient(data, membership.clientId) }))
|
||
: data.memberships
|
||
.filter((membership) => membership.clientId === clientId)
|
||
.map((membership) => ({ membership, user: getUser(data, membership.userId), client: getClient(data, membership.clientId) }));
|
||
const clientGroups = data.groups.filter((group) => group.clientId === clientId);
|
||
const groupOptions: Array<NodeDcSelectOption<string>> = [
|
||
{ value: "none", label: "Без группы" },
|
||
...clientGroups.map((group) => ({ value: group.id, label: group.name })),
|
||
];
|
||
|
||
function handleCreateUser() {
|
||
const email = newUserEmail.trim();
|
||
|
||
if (!email) {
|
||
return;
|
||
}
|
||
|
||
onCreateUser({
|
||
clientId,
|
||
email,
|
||
name: newUserName.trim() || undefined,
|
||
role: newUserRole,
|
||
groupIds: newUserGroupId === "none" ? [] : [newUserGroupId],
|
||
provisionAuth: true,
|
||
generatePassword: true,
|
||
});
|
||
setNewUserEmail("");
|
||
setNewUserName("");
|
||
setNewUserRole("member");
|
||
setNewUserGroupId("none");
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<GlassSurface className="invite-form invite-form--compact">
|
||
<div className="table-toolbar">
|
||
<div>
|
||
<p className="eyebrow">Launcher → Authentik</p>
|
||
<h3>Создать участника</h3>
|
||
</div>
|
||
<IconButton
|
||
label="Создать участника"
|
||
className="admin-circle-action admin-circle-action--solid"
|
||
type="button"
|
||
disabled={!newUserEmail.trim()}
|
||
onClick={handleCreateUser}
|
||
>
|
||
<Plus size={17} />
|
||
</IconButton>
|
||
</div>
|
||
<div className="invite-form__fields">
|
||
<input value={newUserEmail} onChange={(event) => setNewUserEmail(event.target.value)} placeholder="email@company.ru" />
|
||
<input value={newUserName} onChange={(event) => setNewUserName(event.target.value)} placeholder="Имя пользователя" />
|
||
<NodeDcSelect
|
||
className="admin-table-select-wrap"
|
||
triggerClassName="admin-modal-select-trigger"
|
||
value={newUserRole}
|
||
options={membershipRoleOptions}
|
||
label="Роль"
|
||
onChange={(role) => setNewUserRole(role)}
|
||
/>
|
||
<NodeDcSelect
|
||
className="admin-table-select-wrap"
|
||
triggerClassName="admin-modal-select-trigger"
|
||
value={newUserGroupId}
|
||
options={groupOptions}
|
||
label="Группа"
|
||
onChange={(groupId) => setNewUserGroupId(groupId)}
|
||
/>
|
||
</div>
|
||
</GlassSurface>
|
||
|
||
<GlassSurface className="table-shell table-shell--users">
|
||
<div className="table-toolbar">
|
||
<h3>Участники</h3>
|
||
</div>
|
||
<table className="admin-data-table admin-data-table--users">
|
||
<thead>
|
||
<tr>
|
||
<th>Пользователь</th>
|
||
<th>Клиент</th>
|
||
<th>Телефон</th>
|
||
<th>Должность</th>
|
||
<th>Заметки</th>
|
||
<th>Статус аккаунта</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map(({ membership, user, client }) => {
|
||
const protectedUser = user.id === "user_root";
|
||
|
||
return (
|
||
<tr key={membership.id}>
|
||
<td className="admin-user-cell">
|
||
<div className="admin-user-cell__fields">
|
||
<input
|
||
className="admin-table-input admin-table-input--strong"
|
||
value={user.name}
|
||
onChange={(event) => onUpdateUser(user.id, { name: event.target.value })}
|
||
aria-label={`Имя пользователя ${user.name}`}
|
||
/>
|
||
<input
|
||
className="admin-table-input admin-table-input--muted"
|
||
value={user.email}
|
||
onChange={(event) => onUpdateUser(user.id, { email: event.target.value })}
|
||
aria-label={`Email пользователя ${user.name}`}
|
||
/>
|
||
</div>
|
||
</td>
|
||
<td>{client.name}</td>
|
||
<td>
|
||
<input
|
||
className="admin-table-input"
|
||
value={user.phone ?? ""}
|
||
onChange={(event) => onUpdateUser(user.id, { phone: event.target.value || null })}
|
||
placeholder="—"
|
||
aria-label={`Телефон пользователя ${user.name}`}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<input
|
||
className="admin-table-input"
|
||
value={user.position ?? ""}
|
||
onChange={(event) => onUpdateUser(user.id, { position: event.target.value || null })}
|
||
placeholder="—"
|
||
aria-label={`Должность пользователя ${user.name}`}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<input
|
||
className="admin-table-input"
|
||
value={user.notes ?? ""}
|
||
onChange={(event) => onUpdateUser(user.id, { notes: event.target.value || null })}
|
||
placeholder="—"
|
||
aria-label={`Заметки пользователя ${user.name}`}
|
||
/>
|
||
</td>
|
||
<td>
|
||
{protectedUser ? (
|
||
<AdminStaticPill>{statusOptionLabel(userStatusOptions, user.globalStatus)}</AdminStaticPill>
|
||
) : (
|
||
<AdminStatusDropdown
|
||
value={user.globalStatus}
|
||
options={userStatusOptions}
|
||
label={`Статус аккаунта ${user.name}`}
|
||
onChange={(status) => onUpdateUser(user.id, { globalStatus: status })}
|
||
/>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</GlassSurface>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function GroupsSection({
|
||
data,
|
||
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);
|
||
const editingGroup = groups.find((group) => group.id === editingGroupId) ?? null;
|
||
|
||
return (
|
||
<>
|
||
<GlassSurface className="table-shell">
|
||
<div className="table-toolbar">
|
||
<h3>Группы</h3>
|
||
<IconButton label="Создать группу" className="admin-circle-action admin-circle-action--solid" type="button" onClick={() => onCreateGroup(clientId)}>
|
||
<Plus size={17} />
|
||
</IconButton>
|
||
</div>
|
||
<table className="admin-data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Название</th>
|
||
<th>Описание</th>
|
||
<th>Участников</th>
|
||
<th>Подключённые сервисы</th>
|
||
<th aria-label="Редактирование" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{groups.map((group) => (
|
||
<tr key={group.id}>
|
||
<td>
|
||
<input
|
||
className="admin-table-input admin-table-input--strong"
|
||
value={group.name}
|
||
onChange={(event) => onUpdateGroup(group.id, { name: event.target.value })}
|
||
aria-label={`Название группы ${group.name}`}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<input
|
||
className="admin-table-input"
|
||
value={group.description ?? ""}
|
||
onChange={(event) => onUpdateGroup(group.id, { description: event.target.value || null })}
|
||
aria-label={`Описание группы ${group.name}`}
|
||
/>
|
||
</td>
|
||
<td>{group.memberIds.length}</td>
|
||
<td>{data.grants.filter((grant) => grant.targetType === "group" && grant.targetId === group.id).length}</td>
|
||
<td className="services-admin-table__actions">
|
||
<IconButton
|
||
label={`Редактировать группу ${group.name}`}
|
||
className="admin-icon-action services-admin-table__edit"
|
||
type="button"
|
||
onClick={() => setEditingGroupId(group.id)}
|
||
>
|
||
<Edit3 size={12} />
|
||
</IconButton>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</GlassSurface>
|
||
|
||
{editingGroup ? (
|
||
<GroupEditorModal
|
||
group={editingGroup}
|
||
users={data.memberships
|
||
.filter((membership) => membership.clientId === clientId)
|
||
.map((membership) => getUser(data, membership.userId))}
|
||
onClose={() => setEditingGroupId(null)}
|
||
onSave={(patch) => {
|
||
onUpdateGroup(editingGroup.id, patch);
|
||
setEditingGroupId(null);
|
||
}}
|
||
onDelete={() => {
|
||
onDeleteGroup(editingGroup.id);
|
||
setEditingGroupId(null);
|
||
}}
|
||
/>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
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" },
|
||
{ value: "suspended", label: "Приостановлен", tone: "red" },
|
||
{ value: "expired", label: "Истёк", tone: "red" },
|
||
];
|
||
|
||
const userStatusOptions: Array<AdminStatusOption<LauncherUserStatus>> = [
|
||
{ value: "active", label: "Активен", tone: "green" },
|
||
{ value: "invited", label: "Приглашён", tone: "yellow" },
|
||
{ value: "blocked", label: "Заблокирован", tone: "red" },
|
||
];
|
||
|
||
const mainStatusOptions: Array<NodeDcSelectOption<LauncherUserStatus>> = [
|
||
{ value: "active", label: "Активен", tone: "green" },
|
||
{ value: "blocked", label: "Заблокирован", tone: "red" },
|
||
{ value: "invited", label: "Приглашён", tone: "yellow", hidden: true },
|
||
];
|
||
|
||
const membershipStatusOptions: Array<AdminStatusOption<ClientMembershipStatus>> = [
|
||
{ value: "active", label: "Включён", tone: "green" },
|
||
{ value: "disabled", label: "Отключён", tone: "red" },
|
||
];
|
||
|
||
const inviteStatusOptions: Array<AdminStatusOption<InviteStatus>> = [
|
||
{ value: "created", label: "Создан", tone: "muted" },
|
||
{ value: "sent", label: "Отправлен", tone: "green" },
|
||
{ value: "accepted", label: "Принят", tone: "green" },
|
||
{ value: "expired", label: "Истёк", tone: "red" },
|
||
{ value: "revoked", label: "Отозван", tone: "red" },
|
||
];
|
||
|
||
const syncStatusOptions: Array<AdminStatusOption<SyncState>> = [
|
||
{ value: "synced", label: "Синхронизировано", tone: "green" },
|
||
{ value: "pending", label: "В очереди", tone: "yellow" },
|
||
{ value: "error", label: "Ошибка", tone: "red" },
|
||
{ value: "disabled", label: "Отключено", tone: "muted" },
|
||
];
|
||
|
||
const auditResultOptions: Array<AdminStatusOption<"success" | "warning" | "error">> = [
|
||
{ value: "success", label: "Успех", tone: "green" },
|
||
{ value: "warning", label: "Внимание", tone: "yellow" },
|
||
{ value: "error", label: "Ошибка", tone: "red" },
|
||
];
|
||
|
||
const accessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>> = [
|
||
{ value: "unset", label: "—", description: "Не назначен" },
|
||
{ value: "viewer", label: "Гость", description: "Просмотр", tone: "green" },
|
||
{ value: "member", label: "Участник", description: "Рабочий доступ", tone: "green" },
|
||
{ value: "admin", label: "Админ", description: "Администрирование", tone: "green" },
|
||
{ value: "deny", label: "Заблокирован", description: "Запрет доступа", tone: "red" },
|
||
];
|
||
|
||
const operationalCoreRoleOptions: Array<NodeDcSelectOption<OperationalCoreRoleSelectValue>> = [
|
||
...accessAssignmentOptions,
|
||
{ value: "pending", label: "Сохраняем...", disabled: true, hidden: true },
|
||
];
|
||
|
||
const taskManagerProjectRoleOptions: Array<NodeDcSelectOption<OperationalCoreRoleSelectValue>> = [
|
||
{ value: "unset", label: "—", description: "Не назначен" },
|
||
{ value: "viewer", label: "Гость", description: "Просмотр", tone: "green" },
|
||
{ value: "member", label: "Участник", description: "Рабочий доступ", tone: "green" },
|
||
{ value: "admin", label: "Админ", description: "Администрирование", tone: "green" },
|
||
{ value: "pending", label: "Сохраняем...", disabled: true, hidden: true },
|
||
];
|
||
|
||
function membershipRoleLabel(role: ClientMembershipRole): string {
|
||
return membershipRoleOptions.find((option) => option.value === role)?.label ?? role;
|
||
}
|
||
|
||
function statusOptionLabel<T extends string>(options: Array<AdminStatusOption<T>>, value: T): string {
|
||
return options.find((option) => option.value === value)?.label ?? value;
|
||
}
|
||
|
||
function mainStatusLabel(value: LauncherUserStatus): string {
|
||
return mainStatusOptions.find((option) => option.value === value)?.label ?? value;
|
||
}
|
||
|
||
function getClientTaskManagerWorkspaces(client: Client): ClientTaskManagerWorkspaceBinding[] {
|
||
const taskManager = client.integrations?.taskManager;
|
||
const bySlug = new Map<string, ClientTaskManagerWorkspaceBinding>();
|
||
|
||
for (const workspace of taskManager?.workspaces ?? []) {
|
||
if (!workspace.slug) continue;
|
||
bySlug.set(workspace.slug, {
|
||
slug: workspace.slug,
|
||
name: workspace.name ?? null,
|
||
isPrimary: workspace.isPrimary === true,
|
||
});
|
||
}
|
||
|
||
if (taskManager?.workspaceSlug && !bySlug.has(taskManager.workspaceSlug)) {
|
||
bySlug.set(taskManager.workspaceSlug, {
|
||
slug: taskManager.workspaceSlug,
|
||
name: taskManager.workspaceName ?? null,
|
||
isPrimary: true,
|
||
});
|
||
}
|
||
|
||
const workspaces = [...bySlug.values()];
|
||
if (!workspaces.length) return [];
|
||
|
||
if (!workspaces.some((workspace) => workspace.isPrimary)) {
|
||
workspaces[0].isPrimary = true;
|
||
}
|
||
|
||
let primarySeen = false;
|
||
return workspaces.map((workspace) => {
|
||
const isPrimary = workspace.isPrimary === true && !primarySeen;
|
||
if (isPrimary) primarySeen = true;
|
||
return { ...workspace, isPrimary };
|
||
});
|
||
}
|
||
|
||
function getPrimaryTaskManagerWorkspace(client: Client): ClientTaskManagerWorkspaceBinding | null {
|
||
const workspaces = getClientTaskManagerWorkspaces(client);
|
||
return workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null;
|
||
}
|
||
|
||
function getTaskManagerMembershipRole(data: LauncherData, clientId: string, userId: string, workspaceSlug: string): TaskManagerWorkspaceMemberRole {
|
||
return data.taskManagerMemberships.find((membership) => membership.clientId === clientId && membership.userId === userId && membership.workspaceSlug === workspaceSlug)?.role ?? "unset";
|
||
}
|
||
|
||
function getTaskManagerProjectMembershipRole(
|
||
data: LauncherData,
|
||
clientId: string,
|
||
userId: string,
|
||
workspaceSlug: string,
|
||
projectId: string
|
||
): TaskManagerWorkspaceMemberRole {
|
||
return (
|
||
data.taskManagerProjectMemberships.find(
|
||
(membership) =>
|
||
membership.clientId === clientId &&
|
||
membership.userId === userId &&
|
||
membership.workspaceSlug === workspaceSlug &&
|
||
membership.projectId === projectId
|
||
)?.role ?? "unset"
|
||
);
|
||
}
|
||
|
||
function getWorkspaceCatalogProjects(workspace: ClientTaskManagerWorkspaceBinding, catalog: TaskManagerWorkspaceSummary[]): TaskManagerProjectSummary[] {
|
||
return catalog.find((item) => item.slug === workspace.slug)?.projects ?? [];
|
||
}
|
||
|
||
function normalizeClientTaskManagerWorkspaceDraft(workspaces: ClientTaskManagerWorkspaceBinding[]): ClientTaskManagerWorkspaceBinding[] {
|
||
const bySlug = new Map<string, ClientTaskManagerWorkspaceBinding>();
|
||
|
||
for (const workspace of workspaces) {
|
||
if (!workspace.slug) continue;
|
||
bySlug.set(workspace.slug, {
|
||
slug: workspace.slug,
|
||
name: workspace.name ?? null,
|
||
isPrimary: workspace.isPrimary === true,
|
||
});
|
||
}
|
||
|
||
const normalized = [...bySlug.values()];
|
||
if (!normalized.length) return [];
|
||
|
||
if (!normalized.some((workspace) => workspace.isPrimary)) {
|
||
normalized[0].isPrimary = true;
|
||
}
|
||
|
||
let primarySeen = false;
|
||
return normalized.map((workspace) => {
|
||
const isPrimary = workspace.isPrimary === true && !primarySeen;
|
||
if (isPrimary) primarySeen = true;
|
||
return { ...workspace, isPrimary };
|
||
});
|
||
}
|
||
|
||
function AdminStaticPill({ children }: { children: ReactNode }) {
|
||
return <span className="admin-static-pill">{children}</span>;
|
||
}
|
||
|
||
const taskManagerWorkspacePolicyOptions: Array<NodeDcSelectOption<TaskManagerWorkspaceCreationPolicy>> = [
|
||
{
|
||
value: "any_authorized_user",
|
||
label: "Все с доступом",
|
||
description: "Пользователь с доступом к Operational Core может создать собственный workspace.",
|
||
tone: "green",
|
||
},
|
||
{
|
||
value: "task_admins_only",
|
||
label: "Только админы",
|
||
description: "Workspace создают только суперпользователь и админы Operational Core.",
|
||
tone: "yellow",
|
||
},
|
||
{
|
||
value: "disabled",
|
||
label: "Отключено",
|
||
description: "Создание workspace закрыто для всех через платформенную policy.",
|
||
tone: "red",
|
||
},
|
||
];
|
||
|
||
const mediaAccept = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv";
|
||
const modalActionAccentRgb = [247, 248, 244] as const;
|
||
|
||
function ServicesSection({
|
||
data,
|
||
onUpdateService,
|
||
onReorderServices,
|
||
onCreateService,
|
||
onDeleteService,
|
||
}: {
|
||
data: LauncherData;
|
||
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
|
||
onReorderServices: (orderedServiceIds: string[]) => void;
|
||
onCreateService: () => void;
|
||
onDeleteService: (serviceId: string) => void;
|
||
}) {
|
||
const [contentServiceId, setContentServiceId] = useState<string | null>(null);
|
||
const [activeServiceId, setActiveServiceId] = useState<string | null>(null);
|
||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
||
const sortedServices = useMemo(() => data.services.slice().sort((a, b) => a.order - b.order), [data.services]);
|
||
const [orderedServiceIds, setOrderedServiceIds] = useState<string[]>(() => sortedServices.map((service) => service.id));
|
||
const displayedServices = useMemo(() => {
|
||
const servicesById = new Map(data.services.map((service) => [service.id, service]));
|
||
const orderedServices = orderedServiceIds.map((serviceId) => servicesById.get(serviceId)).filter(Boolean) as Service[];
|
||
const missingServices = sortedServices.filter((service) => !orderedServiceIds.includes(service.id));
|
||
|
||
return [...orderedServices, ...missingServices];
|
||
}, [data.services, orderedServiceIds, sortedServices]);
|
||
const contentService = data.services.find((service) => service.id === contentServiceId) ?? null;
|
||
|
||
useEffect(() => {
|
||
if (!activeServiceId) {
|
||
setOrderedServiceIds(sortedServices.map((service) => service.id));
|
||
}
|
||
}, [activeServiceId, sortedServices]);
|
||
|
||
function handleDragStart(event: DragStartEvent) {
|
||
setActiveServiceId(String(event.active.id));
|
||
}
|
||
|
||
function handleDragEnd(event: DragEndEvent) {
|
||
const activeId = String(event.active.id);
|
||
const overId = event.over ? String(event.over.id) : null;
|
||
|
||
setActiveServiceId(null);
|
||
|
||
if (!overId || activeId === overId) return;
|
||
|
||
const oldIndex = orderedServiceIds.indexOf(activeId);
|
||
const newIndex = orderedServiceIds.indexOf(overId);
|
||
|
||
if (oldIndex === -1 || newIndex === -1) return;
|
||
|
||
const nextIds = arrayMove(orderedServiceIds, oldIndex, newIndex);
|
||
setOrderedServiceIds(nextIds);
|
||
onReorderServices(nextIds);
|
||
}
|
||
|
||
function handleDragCancel() {
|
||
setActiveServiceId(null);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<GlassSurface className="table-shell services-table-shell">
|
||
<div className="table-toolbar">
|
||
<h3>Каталог сервисов</h3>
|
||
<IconButton label="Создать сервис" className="admin-circle-action admin-circle-action--solid" type="button" onClick={onCreateService}>
|
||
<Plus size={17} />
|
||
</IconButton>
|
||
</div>
|
||
<DndContext
|
||
sensors={sensors}
|
||
collisionDetection={closestCenter}
|
||
onDragStart={handleDragStart}
|
||
onDragEnd={handleDragEnd}
|
||
onDragCancel={handleDragCancel}
|
||
>
|
||
<table className="services-admin-table">
|
||
<ServiceTableColGroup />
|
||
<thead>
|
||
<tr>
|
||
<th>Сервис</th>
|
||
<th>Slug</th>
|
||
<th>Статус</th>
|
||
<th>Ссылка запуска</th>
|
||
<th>Authentik</th>
|
||
<th aria-label="Редактирование" />
|
||
<th aria-label="Порядок" />
|
||
</tr>
|
||
</thead>
|
||
<SortableContext items={displayedServices.map((service) => service.id)} strategy={verticalListSortingStrategy}>
|
||
<tbody>
|
||
{displayedServices.map((service) => (
|
||
<SortableServiceRow
|
||
key={service.id}
|
||
service={service}
|
||
onUpdateService={onUpdateService}
|
||
onOpenContent={() => setContentServiceId(service.id)}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
</SortableContext>
|
||
</table>
|
||
</DndContext>
|
||
</GlassSurface>
|
||
|
||
{contentService ? (
|
||
<ServiceContentModal
|
||
service={contentService}
|
||
onClose={() => setContentServiceId(null)}
|
||
onSave={(patch) => {
|
||
onUpdateService(contentService.id, patch);
|
||
setContentServiceId(null);
|
||
}}
|
||
onDelete={() => {
|
||
onDeleteService(contentService.id);
|
||
setContentServiceId(null);
|
||
}}
|
||
/>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ServiceTableColGroup() {
|
||
return (
|
||
<colgroup>
|
||
<col style={{ width: "23%" }} />
|
||
<col style={{ width: "12%" }} />
|
||
<col style={{ width: "12%" }} />
|
||
<col style={{ width: "27%" }} />
|
||
<col style={{ width: "15%" }} />
|
||
<col style={{ width: "3.4rem" }} />
|
||
<col style={{ width: "3.1rem" }} />
|
||
</colgroup>
|
||
);
|
||
}
|
||
|
||
function SortableServiceRow({
|
||
service,
|
||
onUpdateService,
|
||
onOpenContent,
|
||
}: {
|
||
service: Service;
|
||
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
|
||
onOpenContent: () => void;
|
||
}) {
|
||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({ id: service.id });
|
||
const style = {
|
||
transform: CSS.Transform.toString(transform),
|
||
transition,
|
||
};
|
||
|
||
return (
|
||
<tr ref={setNodeRef} style={style} className={cn("services-admin-table__row", isDragging && "services-admin-table__row--dragging")}>
|
||
<ServiceTableCells
|
||
service={service}
|
||
onUpdateService={onUpdateService}
|
||
onOpenContent={onOpenContent}
|
||
dragAttributes={attributes}
|
||
dragListeners={listeners}
|
||
setDragHandleRef={setActivatorNodeRef}
|
||
/>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
function ServiceTableCells({
|
||
service,
|
||
onUpdateService,
|
||
onOpenContent,
|
||
dragAttributes,
|
||
dragListeners,
|
||
setDragHandleRef,
|
||
}: {
|
||
service: Service;
|
||
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
|
||
onOpenContent: () => void;
|
||
dragAttributes?: DraggableAttributes;
|
||
dragListeners?: DraggableSyntheticListeners;
|
||
setDragHandleRef?: (node: HTMLButtonElement | null) => void;
|
||
}) {
|
||
return (
|
||
<>
|
||
<td className="services-admin-table__service">
|
||
<input
|
||
className="admin-table-input admin-table-input--strong"
|
||
value={service.title}
|
||
onChange={(event) => onUpdateService(service.id, { title: event.target.value })}
|
||
aria-label={`Название сервиса ${service.title}`}
|
||
/>
|
||
<input
|
||
className="admin-table-input admin-table-input--muted"
|
||
value={service.subtitle ?? ""}
|
||
onChange={(event) => onUpdateService(service.id, { subtitle: event.target.value || null })}
|
||
aria-label={`Подзаголовок сервиса ${service.title}`}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<input
|
||
className="admin-table-input"
|
||
value={service.slug}
|
||
onChange={(event) => onUpdateService(service.id, { slug: event.target.value })}
|
||
aria-label={`Slug сервиса ${service.title}`}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<ServiceStatusDropdown
|
||
value={service.status}
|
||
label={`Статус сервиса ${service.title}`}
|
||
onChange={(status) => onUpdateService(service.id, { status })}
|
||
/>
|
||
</td>
|
||
<td className="services-admin-table__launch">
|
||
<input
|
||
className="admin-table-input"
|
||
value={getServiceLaunchLink(service)}
|
||
onChange={(event) => onUpdateService(service.id, createServiceLaunchLinkPatch(event.target.value))}
|
||
aria-label={`Ссылка запуска сервиса ${service.title}`}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<input
|
||
className="admin-table-input"
|
||
value={service.authentikApplicationSlug ?? ""}
|
||
onChange={(event) => onUpdateService(service.id, { authentikApplicationSlug: event.target.value || null })}
|
||
aria-label={`Authentik slug сервиса ${service.title}`}
|
||
/>
|
||
</td>
|
||
<td className="services-admin-table__actions">
|
||
<IconButton
|
||
label={`Контент витрины ${service.title}`}
|
||
className="admin-icon-action services-admin-table__edit"
|
||
type="button"
|
||
onClick={onOpenContent}
|
||
>
|
||
<Edit3 size={12} />
|
||
</IconButton>
|
||
</td>
|
||
<td className="services-admin-table__drag-cell">
|
||
<button
|
||
ref={setDragHandleRef}
|
||
className="services-admin-table__drag-handle"
|
||
type="button"
|
||
aria-label={`Перетащить сервис ${service.title}`}
|
||
{...dragAttributes}
|
||
{...dragListeners}
|
||
>
|
||
<GripVertical size={16} strokeWidth={1.9} />
|
||
</button>
|
||
</td>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ServiceStatusDropdown({
|
||
value,
|
||
label,
|
||
onChange,
|
||
}: {
|
||
value: ServiceStatus;
|
||
label: string;
|
||
onChange: (status: ServiceStatus) => void;
|
||
}) {
|
||
const selectedOption = serviceStatusOptions.find((option) => option.value === value) ?? serviceStatusOptions[0];
|
||
|
||
return (
|
||
<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 nodedc-ui-option"
|
||
data-selected={option.value === value}
|
||
data-status={option.value}
|
||
data-tone={option.tone}
|
||
type="button"
|
||
onClick={() => {
|
||
onChange(option.value);
|
||
close();
|
||
}}
|
||
>
|
||
<span className="service-status-menu__mark" aria-hidden="true" />
|
||
<span>{option.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</NodeDcDropdown>
|
||
);
|
||
}
|
||
|
||
function AdminStatusDropdown<T extends string>({
|
||
value,
|
||
options,
|
||
label,
|
||
onChange,
|
||
}: {
|
||
value: T;
|
||
options: Array<AdminStatusOption<T>>;
|
||
label: string;
|
||
onChange: (value: T) => void;
|
||
}) {
|
||
const selectedOption = options.find((option) => option.value === value) ?? options[0];
|
||
|
||
return (
|
||
<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 nodedc-ui-option"
|
||
data-selected={option.value === value}
|
||
data-tone={option.tone}
|
||
type="button"
|
||
onClick={() => {
|
||
onChange(option.value);
|
||
close();
|
||
}}
|
||
>
|
||
<span className="admin-status-menu__mark" aria-hidden="true" />
|
||
<span>{option.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</NodeDcDropdown>
|
||
);
|
||
}
|
||
|
||
function AdminStatusPill<T extends string>({ value, options }: { value: T; options: Array<AdminStatusOption<T>> }) {
|
||
const option = options.find((item) => item.value === value) ?? options[0];
|
||
return (
|
||
<span className="admin-status-trigger admin-status-trigger--static" data-tone={option.tone}>
|
||
<span>{option.label}</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function ServiceContentModal({
|
||
service,
|
||
onClose,
|
||
onSave,
|
||
onDelete,
|
||
}: {
|
||
service: Service;
|
||
onClose: () => void;
|
||
onSave: (patch: Partial<Service>) => void;
|
||
onDelete: () => void;
|
||
}) {
|
||
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);
|
||
setStorageError(null);
|
||
setUploadingSlot(null);
|
||
}, [service]);
|
||
|
||
function update<K extends keyof Service>(key: K, value: Service[K]) {
|
||
setDraft((current) => ({ ...current, [key]: value }));
|
||
}
|
||
|
||
async function handleCoverUpload(file?: File) {
|
||
if (!file) return;
|
||
await uploadServiceMedia(file, "cover");
|
||
}
|
||
|
||
async function handleAmbientUpload(file?: File) {
|
||
if (!file) return;
|
||
await uploadServiceMedia(file, "ambient");
|
||
}
|
||
|
||
async function uploadServiceMedia(file: File, slot: "cover" | "ambient") {
|
||
setStorageError(null);
|
||
setUploadingSlot(slot);
|
||
|
||
try {
|
||
const storedFile = await uploadStorageFile(file);
|
||
const mediaKind = mediaKindFromFile(file);
|
||
|
||
if (slot === "cover") {
|
||
update("coverImageUrl", storedFile.url);
|
||
update("coverMediaKind", mediaKind);
|
||
update("coverMediaSource", "file");
|
||
update("coverMediaFileName", storedFile.fileName);
|
||
} else {
|
||
update("ambientVideoUrl", storedFile.url);
|
||
update("ambientMediaKind", mediaKind);
|
||
update("ambientMediaSource", "file");
|
||
update("ambientMediaFileName", storedFile.fileName);
|
||
}
|
||
} catch (error) {
|
||
setStorageError(error instanceof Error ? error.message : "Не удалось сохранить файл в storage");
|
||
} finally {
|
||
setUploadingSlot(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Контент витрины ${service.title}`}>
|
||
<article className="service-content-modal">
|
||
<div className="service-content-modal__head">
|
||
<div>
|
||
<p className="eyebrow">Витрина сервиса</p>
|
||
<h3>{service.title}</h3>
|
||
</div>
|
||
<IconButton label="Закрыть редактор" className="admin-circle-action" type="button" onClick={onClose}>
|
||
<X size={15} strokeWidth={1.45} />
|
||
</IconButton>
|
||
</div>
|
||
|
||
<div className="service-content-modal__grid">
|
||
<label className="service-content-field">
|
||
<span>Название в витрине</span>
|
||
<input value={draft.title} onChange={(event) => update("title", event.target.value)} />
|
||
</label>
|
||
|
||
<label className="service-content-field">
|
||
<span>Подзаголовок</span>
|
||
<input value={draft.subtitle ?? ""} onChange={(event) => update("subtitle", event.target.value || null)} />
|
||
</label>
|
||
|
||
<label className="service-content-field service-content-field--wide">
|
||
<span>Короткое описание</span>
|
||
<textarea value={draft.description} onChange={(event) => update("description", event.target.value)} rows={3} />
|
||
</label>
|
||
|
||
<label className="service-content-field service-content-field--wide">
|
||
<span>Description</span>
|
||
<textarea value={draft.fullDescription ?? ""} onChange={(event) => update("fullDescription", event.target.value || null)} rows={5} />
|
||
</label>
|
||
|
||
<label className="service-content-field service-content-field--wide">
|
||
<span>
|
||
<Link2 size={14} /> Ссылка запуска
|
||
</span>
|
||
<input
|
||
value={getServiceLaunchLink(draft)}
|
||
onChange={(event) => setDraft((current) => ({ ...current, ...createServiceLaunchLinkPatch(event.target.value) }))}
|
||
/>
|
||
</label>
|
||
|
||
<MediaSourceField
|
||
label="Карточка"
|
||
icon={<ImageIcon size={14} />}
|
||
source={draft.coverMediaSource ?? "url"}
|
||
value={draft.coverImageUrl ?? ""}
|
||
fileName={draft.coverMediaFileName ?? null}
|
||
isUploading={uploadingSlot === "cover"}
|
||
onSourceChange={(source) => update("coverMediaSource", source)}
|
||
onUrlChange={(value) => {
|
||
update("coverImageUrl", value || null);
|
||
update("coverMediaSource", "url");
|
||
update("coverMediaKind", mediaKindFromUrl(value));
|
||
update("coverMediaFileName", null);
|
||
}}
|
||
onFileChange={handleCoverUpload}
|
||
/>
|
||
|
||
<MediaSourceField
|
||
label="Фоновый контент"
|
||
icon={<Video size={14} />}
|
||
source={draft.ambientMediaSource ?? "url"}
|
||
value={draft.ambientVideoUrl ?? ""}
|
||
fileName={draft.ambientMediaFileName ?? null}
|
||
isUploading={uploadingSlot === "ambient"}
|
||
onSourceChange={(source) => update("ambientMediaSource", source)}
|
||
onUrlChange={(value) => {
|
||
update("ambientVideoUrl", value || null);
|
||
update("ambientMediaSource", "url");
|
||
update("ambientMediaKind", mediaKindFromUrl(value));
|
||
update("ambientMediaFileName", null);
|
||
}}
|
||
onFileChange={handleAmbientUpload}
|
||
/>
|
||
|
||
<div className="service-content-preview service-content-preview--image">
|
||
{draft.coverImageUrl ? <MediaPreview src={draft.coverImageUrl} kind={draft.coverMediaKind} /> : <ImageIcon size={30} />}
|
||
</div>
|
||
|
||
<div className="service-content-preview service-content-preview--video">
|
||
{draft.ambientVideoUrl ? <MediaPreview src={draft.ambientVideoUrl} kind={draft.ambientMediaKind} /> : <Video size={30} />}
|
||
</div>
|
||
|
||
{storageError ? <p className="service-content-storage-error">{storageError}</p> : null}
|
||
</div>
|
||
|
||
<div className="service-content-modal__foot">
|
||
<Button variant="secondary" surface="modal" type="button" onClick={onClose}>
|
||
Отмена
|
||
</Button>
|
||
<div className="service-content-modal__foot-actions">
|
||
<Button variant="danger" surface="modal" type="button" icon={<Trash2 size={16} />} onClick={() => setDeleteOpen(true)}>
|
||
Удалить
|
||
</Button>
|
||
<Button
|
||
variant="accent"
|
||
surface="modal"
|
||
accentRgb={modalActionAccentRgb}
|
||
type="button"
|
||
disabled={uploadingSlot !== null}
|
||
icon={<Save size={16} />}
|
||
onClick={() =>
|
||
onSave({
|
||
title: draft.title,
|
||
subtitle: draft.subtitle,
|
||
description: draft.description,
|
||
fullDescription: draft.fullDescription,
|
||
url: draft.url,
|
||
launchUrl: draft.launchUrl,
|
||
coverImageUrl: draft.coverImageUrl,
|
||
coverMediaKind: draft.coverMediaKind,
|
||
coverMediaSource: draft.coverMediaSource,
|
||
coverMediaFileName: draft.coverMediaFileName,
|
||
ambientVideoUrl: draft.ambientVideoUrl,
|
||
ambientMediaKind: draft.ambientMediaKind,
|
||
ambientMediaSource: draft.ambientMediaSource,
|
||
ambientMediaFileName: draft.ambientMediaFileName,
|
||
})
|
||
}
|
||
>
|
||
{uploadingSlot ? "Сохраняем файл" : "Сохранить"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
<NodeDcDeleteModal
|
||
isOpen={deleteOpen}
|
||
title="Удалить витрину"
|
||
description={
|
||
<>
|
||
Витрина <strong>{service.title}</strong> будет удалена из каталога, нижней панели и матрицы доступов.
|
||
</>
|
||
}
|
||
onClose={() => setDeleteOpen(false)}
|
||
onConfirm={() => {
|
||
setDeleteOpen(false);
|
||
onDelete();
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ClientEditorModal({
|
||
client,
|
||
taskManagerWorkspaces,
|
||
taskManagerWorkspacesLoading,
|
||
taskManagerWorkspacesError,
|
||
onRefreshTaskManagerWorkspaces,
|
||
onClose,
|
||
onSave,
|
||
onDelete,
|
||
canDelete,
|
||
}: {
|
||
client: Client;
|
||
taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
|
||
taskManagerWorkspacesLoading: boolean;
|
||
taskManagerWorkspacesError: string | null;
|
||
onRefreshTaskManagerWorkspaces: () => void;
|
||
onClose: () => void;
|
||
onSave: (patch: Partial<Client>) => void;
|
||
onDelete: () => void;
|
||
canDelete: boolean;
|
||
}) {
|
||
const [draft, setDraft] = useState<Client>(client);
|
||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||
const [storageError, setStorageError] = useState<string | null>(null);
|
||
const taskManagerWorkspaceBindings = getClientTaskManagerWorkspaces(draft);
|
||
const selectedTaskManagerWorkspaceSlugs = new Set(taskManagerWorkspaceBindings.map((workspace) => workspace.slug));
|
||
const primaryTaskManagerWorkspace = getPrimaryTaskManagerWorkspace(draft);
|
||
|
||
useEffect(() => {
|
||
setDraft(client);
|
||
setUploadingAvatar(false);
|
||
setStorageError(null);
|
||
}, [client]);
|
||
|
||
function update<K extends keyof Client>(key: K, value: Client[K]) {
|
||
setDraft((current) => ({ ...current, [key]: value }));
|
||
}
|
||
|
||
function updateTaskManagerWorkspaces(workspaces: ClientTaskManagerWorkspaceBinding[]) {
|
||
const primaryWorkspace = workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null;
|
||
setDraft((current) => ({
|
||
...current,
|
||
integrations: {
|
||
...current.integrations,
|
||
taskManager: {
|
||
...current.integrations?.taskManager,
|
||
workspaceSlug: primaryWorkspace?.slug ?? null,
|
||
workspaceName: primaryWorkspace?.name ?? null,
|
||
workspaces,
|
||
},
|
||
},
|
||
}));
|
||
}
|
||
|
||
function toggleTaskManagerWorkspace(workspace: TaskManagerWorkspaceSummary) {
|
||
const currentWorkspaces = getClientTaskManagerWorkspaces(draft);
|
||
const exists = currentWorkspaces.some((item) => item.slug === workspace.slug);
|
||
const nextWorkspaces = exists
|
||
? currentWorkspaces.filter((item) => item.slug !== workspace.slug)
|
||
: [
|
||
...currentWorkspaces,
|
||
{
|
||
slug: workspace.slug,
|
||
name: workspace.name,
|
||
isPrimary: currentWorkspaces.length === 0,
|
||
},
|
||
];
|
||
|
||
updateTaskManagerWorkspaces(normalizeClientTaskManagerWorkspaceDraft(nextWorkspaces));
|
||
}
|
||
|
||
function setPrimaryTaskManagerWorkspace(workspaceSlug: string) {
|
||
const nextWorkspaces = getClientTaskManagerWorkspaces(draft).map((workspace) => ({
|
||
...workspace,
|
||
isPrimary: workspace.slug === workspaceSlug,
|
||
}));
|
||
updateTaskManagerWorkspaces(normalizeClientTaskManagerWorkspaceDraft(nextWorkspaces));
|
||
}
|
||
|
||
async function handleAvatarUpload(file?: File) {
|
||
if (!file) return;
|
||
|
||
setUploadingAvatar(true);
|
||
setStorageError(null);
|
||
|
||
try {
|
||
const storedFile = await uploadStorageFile(file);
|
||
update("avatarUrl", storedFile.url);
|
||
} catch (error) {
|
||
setStorageError(error instanceof Error ? error.message : "Не удалось сохранить аватар компании");
|
||
} finally {
|
||
setUploadingAvatar(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Редактор клиента ${client.name}`}>
|
||
<article className="service-content-modal admin-entity-modal client-editor-modal">
|
||
<EntityModalHead
|
||
eyebrow="Клиент"
|
||
title={client.name}
|
||
onClose={onClose}
|
||
actions={
|
||
<IconButton
|
||
label={taskManagerWorkspacesLoading ? "Обновляем workspace Operational Core" : "Обновить workspace Operational Core"}
|
||
className="admin-circle-action admin-circle-action--solid"
|
||
type="button"
|
||
disabled={taskManagerWorkspacesLoading}
|
||
onClick={onRefreshTaskManagerWorkspaces}
|
||
>
|
||
<RefreshCw size={16} />
|
||
</IconButton>
|
||
}
|
||
/>
|
||
<div className="service-content-modal__grid">
|
||
<label className="service-content-field">
|
||
<span>Название</span>
|
||
<input value={draft.name} onChange={(event) => update("name", event.target.value)} />
|
||
</label>
|
||
<label className="service-content-field">
|
||
<span>Юридическое название</span>
|
||
<input value={draft.legalName ?? ""} onChange={(event) => update("legalName", event.target.value || null)} />
|
||
</label>
|
||
<label className="service-content-field">
|
||
<span>ИНН</span>
|
||
<input value={draft.inn ?? ""} onChange={(event) => update("inn", event.target.value || null)} />
|
||
</label>
|
||
<label className="service-content-field">
|
||
<span>Тип</span>
|
||
<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>
|
||
<AdminStatusDropdown value={draft.status} options={clientStatusOptions} label="Статус клиента" onChange={(status) => update("status", status)} />
|
||
</div>
|
||
<div className="service-content-field service-content-field--wide client-avatar-field">
|
||
<span>Аватар компании</span>
|
||
<div className="client-avatar-control">
|
||
<div className="client-avatar-preview" aria-hidden="true">
|
||
{draft.avatarUrl ? <img src={draft.avatarUrl} alt="" /> : null}
|
||
</div>
|
||
<div className="client-avatar-control__copy">
|
||
<strong>{draft.avatarUrl ? "Аватар подключён" : "Аватар не задан"}</strong>
|
||
<small>Показывается в верхнем переключателе компании.</small>
|
||
</div>
|
||
<label className="service-media-file-button client-avatar-upload-button">
|
||
{uploadingAvatar ? "Загрузка..." : "Выберите файл"}
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
disabled={uploadingAvatar}
|
||
onChange={(event) => {
|
||
void handleAvatarUpload(event.target.files?.[0]);
|
||
event.target.value = "";
|
||
}}
|
||
/>
|
||
</label>
|
||
{draft.avatarUrl ? (
|
||
<button className="admin-icon-action client-avatar-clear-action" type="button" onClick={() => update("avatarUrl", null)} aria-label="Убрать аватар">
|
||
<X size={11} />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
{storageError ? <small className="client-avatar-error">{storageError}</small> : null}
|
||
</div>
|
||
<div className="service-content-field service-content-field--wide">
|
||
<span>Operational Core workspaces</span>
|
||
<div className="task-workspace-picker-card">
|
||
<div className="task-workspace-picker" role="list" aria-label="Operational Core workspaces клиента">
|
||
{taskManagerWorkspaces.length ? (
|
||
taskManagerWorkspaces.map((workspace) => {
|
||
const selected = selectedTaskManagerWorkspaceSlugs.has(workspace.slug);
|
||
const primary = primaryTaskManagerWorkspace?.slug === workspace.slug;
|
||
|
||
return (
|
||
<button
|
||
key={workspace.slug}
|
||
className={cn("task-workspace-chip", selected && "task-workspace-chip--selected")}
|
||
type="button"
|
||
aria-pressed={selected}
|
||
onClick={() => toggleTaskManagerWorkspace(workspace)}
|
||
>
|
||
<span>
|
||
<strong>{workspace.name}</strong>
|
||
<small>
|
||
{workspace.slug} · {workspace.memberCount} участников
|
||
</small>
|
||
</span>
|
||
{selected ? (
|
||
<em
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setPrimaryTaskManagerWorkspace(workspace.slug);
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setPrimaryTaskManagerWorkspace(workspace.slug);
|
||
}}
|
||
>
|
||
{primary ? "Основной" : "Сделать основным"}
|
||
</em>
|
||
) : null}
|
||
</button>
|
||
);
|
||
})
|
||
) : (
|
||
<span className="task-workspace-picker__empty">Workspace Operational Core не загружены</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<small>
|
||
{taskManagerWorkspacesError
|
||
? taskManagerWorkspacesError
|
||
: "Эти workspace доступны для детальных назначений пользователей в Operational Core."}
|
||
</small>
|
||
</div>
|
||
<label className="service-content-field">
|
||
<span>Контактное лицо</span>
|
||
<input value={draft.contactName ?? ""} onChange={(event) => update("contactName", event.target.value || null)} />
|
||
</label>
|
||
<label className="service-content-field">
|
||
<span>Email</span>
|
||
<input value={draft.contactEmail ?? ""} onChange={(event) => update("contactEmail", event.target.value || null)} />
|
||
</label>
|
||
<div className="service-content-field">
|
||
<span>Договор с</span>
|
||
<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>
|
||
<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>
|
||
<NodeDcDateField value={draft.paidUntil ?? null} label="Оплачено до" onChange={(value) => update("paidUntil", value)} />
|
||
</div>
|
||
<div className="service-content-field">
|
||
<span>Демо до</span>
|
||
<NodeDcDateField value={draft.demoEndsAt ?? null} label="Демо до" onChange={(value) => update("demoEndsAt", value)} />
|
||
</div>
|
||
<label className="service-content-field service-content-field--wide">
|
||
<span>Заметки</span>
|
||
<textarea value={draft.notes ?? ""} onChange={(event) => update("notes", event.target.value || null)} rows={4} />
|
||
</label>
|
||
</div>
|
||
<EntityModalFoot
|
||
onClose={onClose}
|
||
onSave={() => onSave(draft)}
|
||
deleteConfig={
|
||
canDelete
|
||
? {
|
||
label: "Удалить",
|
||
title: "Удалить компанию",
|
||
description: (
|
||
<>
|
||
Компания <strong>{client.name}</strong>, ее участники, группы, инвайты и клиентские гранты будут удалены из демо-данных.
|
||
</>
|
||
),
|
||
onConfirm: onDelete,
|
||
}
|
||
: undefined
|
||
}
|
||
/>
|
||
</article>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function UserEditorModal({
|
||
user,
|
||
membership,
|
||
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);
|
||
|
||
useEffect(() => {
|
||
setUserDraft(user);
|
||
setMembershipDraft(membership);
|
||
}, [membership, user]);
|
||
|
||
function updateUser<K extends keyof LauncherUser>(key: K, value: LauncherUser[K]) {
|
||
setUserDraft((current) => ({ ...current, [key]: value }));
|
||
}
|
||
|
||
function updateMembership<K extends keyof ClientMembership>(key: K, value: ClientMembership[K]) {
|
||
setMembershipDraft((current) => ({ ...current, [key]: value }));
|
||
}
|
||
|
||
return (
|
||
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Редактор пользователя ${user.name}`}>
|
||
<article className="service-content-modal admin-entity-modal">
|
||
<EntityModalHead eyebrow={client.name} title={user.name} onClose={onClose} />
|
||
<div className="service-content-modal__grid">
|
||
<label className="service-content-field">
|
||
<span>Имя</span>
|
||
<input value={userDraft.name} onChange={(event) => updateUser("name", event.target.value)} />
|
||
</label>
|
||
<label className="service-content-field">
|
||
<span>Email</span>
|
||
<input value={userDraft.email} onChange={(event) => updateUser("email", event.target.value)} />
|
||
</label>
|
||
<label className="service-content-field">
|
||
<span>Телефон</span>
|
||
<input value={userDraft.phone ?? ""} onChange={(event) => updateUser("phone", event.target.value || null)} />
|
||
</label>
|
||
<label className="service-content-field">
|
||
<span>Должность</span>
|
||
<input value={userDraft.position ?? ""} onChange={(event) => updateUser("position", event.target.value || null)} />
|
||
</label>
|
||
<div className="service-content-field">
|
||
<span>Статус</span>
|
||
<AdminStatusDropdown value={userDraft.globalStatus} options={userStatusOptions} label="Статус пользователя" onChange={(status) => updateUser("globalStatus", status)} />
|
||
</div>
|
||
<label className="service-content-field">
|
||
<span>Роль в клиенте</span>
|
||
<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>
|
||
<AdminStatusDropdown
|
||
value={membershipDraft.status}
|
||
options={membershipStatusOptions}
|
||
label="Доступ пользователя"
|
||
onChange={(status) => updateMembership("status", status)}
|
||
/>
|
||
</div>
|
||
<label className="service-content-field service-content-field--wide">
|
||
<span>Заметки</span>
|
||
<textarea value={userDraft.notes ?? ""} onChange={(event) => updateUser("notes", event.target.value || null)} rows={4} />
|
||
</label>
|
||
</div>
|
||
<EntityModalFoot
|
||
onClose={onClose}
|
||
onSave={() => onSave(userDraft, membershipDraft)}
|
||
deleteConfig={{
|
||
label: "Удалить",
|
||
title: "Удалить участника",
|
||
description: (
|
||
<>
|
||
Участник <strong>{user.name}</strong> будет удален из клиента <strong>{client.name}</strong>. Глобальный профиль пользователя останется в демо-справочнике.
|
||
</>
|
||
),
|
||
onConfirm: onDelete,
|
||
}}
|
||
/>
|
||
</article>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function GroupEditorModal({
|
||
group,
|
||
users,
|
||
onClose,
|
||
onSave,
|
||
onDelete,
|
||
}: {
|
||
group: ClientGroup;
|
||
users: LauncherUser[];
|
||
onClose: () => void;
|
||
onSave: (patch: Partial<ClientGroup>) => void;
|
||
onDelete: () => void;
|
||
}) {
|
||
const [draft, setDraft] = useState<ClientGroup>(group);
|
||
|
||
useEffect(() => setDraft(group), [group]);
|
||
|
||
function toggleUser(userId: string) {
|
||
setDraft((current) => ({
|
||
...current,
|
||
memberIds: current.memberIds.includes(userId)
|
||
? current.memberIds.filter((item) => item !== userId)
|
||
: [...current.memberIds, userId],
|
||
}));
|
||
}
|
||
|
||
return (
|
||
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Редактор группы ${group.name}`}>
|
||
<article className="service-content-modal admin-entity-modal">
|
||
<EntityModalHead eyebrow="Группа" title={group.name} onClose={onClose} />
|
||
<div className="service-content-modal__grid">
|
||
<label className="service-content-field">
|
||
<span>Название</span>
|
||
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} />
|
||
</label>
|
||
<label className="service-content-field service-content-field--wide">
|
||
<span>Описание</span>
|
||
<textarea
|
||
value={draft.description ?? ""}
|
||
onChange={(event) => setDraft((current) => ({ ...current, description: event.target.value || null }))}
|
||
rows={4}
|
||
/>
|
||
</label>
|
||
<div className="service-content-field service-content-field--wide">
|
||
<span>Участники</span>
|
||
<div className="admin-token-grid">
|
||
{users.map((user) => (
|
||
<button
|
||
key={user.id}
|
||
className="admin-token"
|
||
data-active={draft.memberIds.includes(user.id)}
|
||
type="button"
|
||
onClick={() => toggleUser(user.id)}
|
||
>
|
||
{user.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<EntityModalFoot
|
||
onClose={onClose}
|
||
onSave={() => onSave(draft)}
|
||
deleteConfig={{
|
||
label: "Удалить",
|
||
title: "Удалить группу",
|
||
description: (
|
||
<>
|
||
Группа <strong>{group.name}</strong> и привязанные к ней гранты сервисов будут удалены.
|
||
</>
|
||
),
|
||
onConfirm: onDelete,
|
||
}}
|
||
/>
|
||
</article>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EntityModalHead({
|
||
eyebrow,
|
||
title,
|
||
onClose,
|
||
actions,
|
||
}: {
|
||
eyebrow: string;
|
||
title: string;
|
||
onClose: () => void;
|
||
actions?: ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="service-content-modal__head">
|
||
<div>
|
||
<p className="eyebrow">{eyebrow}</p>
|
||
<h3>{title}</h3>
|
||
</div>
|
||
<div className="service-content-modal__head-actions">
|
||
{actions}
|
||
<IconButton label="Закрыть редактор" className="admin-circle-action" type="button" onClick={onClose}>
|
||
<X size={15} strokeWidth={1.45} />
|
||
</IconButton>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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" 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}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function MediaSourceField({
|
||
label,
|
||
icon,
|
||
source,
|
||
value,
|
||
fileName,
|
||
isUploading = false,
|
||
onSourceChange,
|
||
onUrlChange,
|
||
onFileChange,
|
||
}: {
|
||
label: string;
|
||
icon: ReactNode;
|
||
source: ServiceMediaSource;
|
||
value: string;
|
||
fileName?: string | null;
|
||
isUploading?: boolean;
|
||
onSourceChange: (source: ServiceMediaSource) => void;
|
||
onUrlChange: (value: string) => void;
|
||
onFileChange: (file?: File) => void | Promise<void>;
|
||
}) {
|
||
const inputId = `${label.replace(/\s+/g, "-").toLowerCase()}-${source}`;
|
||
const displayFileName = isUploading ? "Сохраняем в storage..." : truncateText(fileName ?? "Файл не выбран", 15);
|
||
const fileTitle = isUploading ? undefined : (fileName ?? "Файл не выбран");
|
||
|
||
return (
|
||
<div className="service-content-field service-media-field">
|
||
<span>
|
||
{icon} {label}
|
||
</span>
|
||
<div className="service-media-control">
|
||
{source === "file" ? (
|
||
<div className="service-media-file-control">
|
||
<label className="service-media-file-button" htmlFor={inputId}>
|
||
Выберите файл
|
||
</label>
|
||
<span className="service-media-file-name" title={fileTitle}>
|
||
{displayFileName}
|
||
</span>
|
||
<input id={inputId} type="file" accept={mediaAccept} onChange={(event) => onFileChange(event.currentTarget.files?.[0])} />
|
||
</div>
|
||
) : (
|
||
<input className="service-media-url-input" value={value} onChange={(event) => onUrlChange(event.target.value)} placeholder="https://..." />
|
||
)}
|
||
|
||
<div className="service-media-source-switch" aria-label={`${label}: источник`}>
|
||
<button
|
||
type="button"
|
||
className="service-media-source-button"
|
||
data-active={source === "file"}
|
||
aria-label="Файл с диска"
|
||
onClick={() => onSourceChange("file")}
|
||
>
|
||
<HardDrive size={15} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="service-media-source-button"
|
||
data-active={source === "url"}
|
||
aria-label="Внешняя ссылка"
|
||
onClick={() => onSourceChange("url")}
|
||
>
|
||
<Globe2 size={15} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function truncateText(value: string, maxLength: number) {
|
||
return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
|
||
}
|
||
|
||
function MediaPreview({ src, kind }: { src: string; kind?: MediaKind | null }) {
|
||
if (kind === "video" || mediaKindFromUrl(src) === "video") {
|
||
return <video src={src} autoPlay loop muted playsInline />;
|
||
}
|
||
|
||
return <img src={src} alt="" />;
|
||
}
|
||
|
||
function mediaKindFromFile(file: File): MediaKind {
|
||
if (file.type.startsWith("video/")) return "video";
|
||
if (file.type === "image/gif" || /\.gif$/i.test(file.name)) return "gif";
|
||
return "image";
|
||
}
|
||
|
||
function mediaKindFromUrl(value: string): MediaKind | null {
|
||
if (!value.trim()) return null;
|
||
if (/\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i.test(value)) return "video";
|
||
if (/\.gif(\?.*)?$/i.test(value)) return "gif";
|
||
return "image";
|
||
}
|
||
|
||
function AccessSection({
|
||
data,
|
||
matrix,
|
||
selectedCell,
|
||
onSelectCell,
|
||
onSetUserServiceAccess,
|
||
pendingAccessAssignments,
|
||
onUpdateUser,
|
||
onUpdateMembership,
|
||
pendingTaskManagerMemberships,
|
||
pendingTaskManagerProjectMemberships,
|
||
taskManagerWorkspaceCatalog,
|
||
onSetTaskManagerWorkspaceMemberRole,
|
||
onSetTaskManagerProjectMemberRole,
|
||
}: {
|
||
data: LauncherData;
|
||
matrix: ReturnType<typeof buildAccessMatrix>;
|
||
selectedCell: AccessMatrixCell | null;
|
||
onSelectCell: (cell: AccessMatrixCell) => void;
|
||
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
|
||
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
|
||
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
|
||
pendingTaskManagerMemberships: Record<string, boolean>;
|
||
pendingTaskManagerProjectMemberships: Record<string, boolean>;
|
||
taskManagerWorkspaceCatalog: TaskManagerWorkspaceSummary[];
|
||
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
|
||
onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void;
|
||
}) {
|
||
const hasUsers = matrix.users.length > 0;
|
||
const clientTaskManagerWorkspaces = getClientTaskManagerWorkspaces(matrix.client);
|
||
const primaryTaskManagerWorkspace = getPrimaryTaskManagerWorkspace(matrix.client);
|
||
const [detailsCell, setDetailsCell] = useState<AccessMatrixCell | null>(null);
|
||
|
||
if (!hasUsers) {
|
||
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>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const accessGridTemplateColumns = `15rem repeat(2, 9.7rem) repeat(${matrix.services.length}, 11.25rem)`;
|
||
|
||
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="matrix-scroll">
|
||
<div className="access-matrix-grid" style={{ gridTemplateColumns: accessGridTemplateColumns }} role="table">
|
||
<div className="access-grid-head access-grid-sticky" role="columnheader">
|
||
Участник
|
||
</div>
|
||
<div className="access-grid-head" role="columnheader">
|
||
MAIN
|
||
</div>
|
||
<div className="access-grid-head" role="columnheader">
|
||
MAIN ROLE
|
||
</div>
|
||
{matrix.services.map((service) => (
|
||
<div key={service.id} className="access-grid-head" role="columnheader">
|
||
{service.title}
|
||
</div>
|
||
))}
|
||
|
||
{matrix.users.map((user) => {
|
||
const membership = data.memberships.find((item) => item.clientId === matrix.client.id && item.userId === user.id);
|
||
if (!membership) return null;
|
||
|
||
const protectedUser = user.id === "user_root";
|
||
const pendingKey = `${matrix.client.id}:${user.id}:${primaryTaskManagerWorkspace?.slug ?? "primary"}`;
|
||
const pendingTaskerAssignment = Boolean(pendingTaskManagerMemberships[pendingKey]);
|
||
const forcedTaskManagerAdmin = membership.role === "client_owner";
|
||
|
||
return (
|
||
<Fragment key={user.id}>
|
||
<div className="access-grid-cell access-grid-sticky access-user-cell" role="rowheader">
|
||
<strong>{user.name}</strong>
|
||
<small>{user.email}</small>
|
||
</div>
|
||
<div className="access-grid-cell" role="cell">
|
||
<MainStatusControl
|
||
value={user.globalStatus}
|
||
protectedUser={protectedUser}
|
||
onChange={(status) => onUpdateUser(user.id, { globalStatus: status })}
|
||
/>
|
||
</div>
|
||
<div className="access-grid-cell" role="cell">
|
||
<MainRoleControl
|
||
value={membership.role}
|
||
protectedUser={protectedUser}
|
||
onChange={(role) => onUpdateMembership(membership.id, { role })}
|
||
/>
|
||
</div>
|
||
{matrix.services.map((service) => {
|
||
const cell = matrix.cells.find((item) => item.userId === user.id && item.serviceId === service.id)!;
|
||
const active = selectedCell?.userId === user.id && selectedCell.serviceId === service.id;
|
||
const isTaskManagerService = isOperationalCoreService(service);
|
||
|
||
return (
|
||
<div key={service.id} className="access-grid-cell" role="cell">
|
||
<AccessCellControl
|
||
cell={cell}
|
||
active={active}
|
||
pendingValue={pendingAccessAssignments[accessCellKey(user.id, service.id)]}
|
||
busy={isTaskManagerService && pendingTaskerAssignment}
|
||
onSelectCell={onSelectCell}
|
||
onSetAccess={(value) => {
|
||
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value });
|
||
|
||
if (!isTaskManagerService || !primaryTaskManagerWorkspace || protectedUser || forcedTaskManagerAdmin) return;
|
||
|
||
onSetTaskManagerWorkspaceMemberRole({
|
||
clientId: matrix.client.id,
|
||
userId: user.id,
|
||
workspaceSlug: primaryTaskManagerWorkspace.slug,
|
||
role: accessAssignmentToTaskManagerRole(value),
|
||
});
|
||
}}
|
||
onOpenDetails={isTaskManagerService ? () => setDetailsCell(cell) : undefined}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</Fragment>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</GlassSurface>
|
||
|
||
{detailsCell ? (
|
||
<OperationalCoreAccessModal
|
||
data={data}
|
||
client={matrix.client}
|
||
user={getUser(data, detailsCell.userId)}
|
||
service={getService(data, detailsCell.serviceId)}
|
||
cell={detailsCell}
|
||
workspaces={clientTaskManagerWorkspaces}
|
||
workspaceCatalog={taskManagerWorkspaceCatalog}
|
||
pendingAccessAssignments={pendingAccessAssignments}
|
||
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
|
||
pendingTaskManagerProjectMemberships={pendingTaskManagerProjectMemberships}
|
||
onClose={() => setDetailsCell(null)}
|
||
onSetUserServiceAccess={onSetUserServiceAccess}
|
||
onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole}
|
||
onSetTaskManagerProjectMemberRole={onSetTaskManagerProjectMemberRole}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MainStatusControl({
|
||
value,
|
||
protectedUser,
|
||
onChange,
|
||
}: {
|
||
value: LauncherUserStatus;
|
||
protectedUser: boolean;
|
||
onChange: (value: LauncherUserStatus) => void;
|
||
}) {
|
||
const label = mainStatusLabel(value);
|
||
const allowed = value === "active";
|
||
|
||
if (protectedUser) {
|
||
return (
|
||
<span className={cn("access-cell access-cell--main access-cell--readonly", allowed ? "access-cell--allowed" : "access-cell--exception")}>
|
||
<strong>{label}</strong>
|
||
<span>MAIN</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<NodeDcSelect
|
||
value={value}
|
||
options={mainStatusOptions}
|
||
label="MAIN статус"
|
||
minMenuWidth={172}
|
||
menuClassName="access-cell-menu"
|
||
onChange={onChange}
|
||
trigger={({ open, toggle, setTriggerRef, selectedOption }) => (
|
||
<button
|
||
ref={setTriggerRef}
|
||
className={cn("access-cell access-cell--main", value === "active" ? "access-cell--allowed" : "access-cell--exception")}
|
||
type="button"
|
||
aria-expanded={open}
|
||
onClick={toggle}
|
||
>
|
||
<strong>{selectedOption.label}</strong>
|
||
<span>MAIN</span>
|
||
</button>
|
||
)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function MainRoleControl({
|
||
value,
|
||
protectedUser,
|
||
onChange,
|
||
}: {
|
||
value: ClientMembershipRole;
|
||
protectedUser: boolean;
|
||
onChange: (value: ClientMembershipRole) => void;
|
||
}) {
|
||
const label = membershipRoleLabel(value);
|
||
|
||
if (protectedUser) {
|
||
return (
|
||
<span className="access-cell access-cell--main access-cell--allowed access-cell--readonly">
|
||
<strong>{label}</strong>
|
||
<span>MAIN роль</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<NodeDcSelect
|
||
value={value}
|
||
options={membershipRoleOptions}
|
||
label="MAIN роль"
|
||
minMenuWidth={198}
|
||
menuClassName="access-cell-menu"
|
||
onChange={onChange}
|
||
trigger={({ open, toggle, setTriggerRef, selectedOption }) => (
|
||
<button ref={setTriggerRef} className="access-cell access-cell--main access-cell--allowed" type="button" aria-expanded={open} onClick={toggle}>
|
||
<strong>{selectedOption.label}</strong>
|
||
<span>MAIN роль</span>
|
||
</button>
|
||
)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function OperationalCoreAccessModal({
|
||
data,
|
||
client,
|
||
user,
|
||
service,
|
||
cell,
|
||
workspaces,
|
||
workspaceCatalog,
|
||
pendingAccessAssignments,
|
||
pendingTaskManagerMemberships,
|
||
pendingTaskManagerProjectMemberships,
|
||
onClose,
|
||
onSetUserServiceAccess,
|
||
onSetTaskManagerWorkspaceMemberRole,
|
||
onSetTaskManagerProjectMemberRole,
|
||
}: {
|
||
data: LauncherData;
|
||
client: Client;
|
||
user: LauncherUser;
|
||
service: Service;
|
||
cell: AccessMatrixCell;
|
||
workspaces: ClientTaskManagerWorkspaceBinding[];
|
||
workspaceCatalog: TaskManagerWorkspaceSummary[];
|
||
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
|
||
pendingTaskManagerMemberships: Record<string, boolean>;
|
||
pendingTaskManagerProjectMemberships: Record<string, boolean>;
|
||
onClose: () => void;
|
||
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
|
||
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
|
||
onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void;
|
||
}) {
|
||
const membership = data.memberships.find((item) => item.clientId === client.id && item.userId === user.id);
|
||
const protectedUser = user.id === "user_root" || membership?.role === "client_owner";
|
||
const basePendingValue = pendingAccessAssignments[accessCellKey(user.id, service.id)];
|
||
const baseAssignmentValue = basePendingValue ?? accessAssignmentValue(cell);
|
||
|
||
return (
|
||
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Operational Core доступы ${user.name}`}>
|
||
<article className="service-content-modal admin-entity-modal task-access-modal">
|
||
<EntityModalHead eyebrow="Доступы → Operational Core" title={user.name} onClose={onClose} />
|
||
<div className="task-access-summary">
|
||
<InfoLine label="Клиент" value={client.name} />
|
||
<InfoLine label="Сервис" value={service.title} />
|
||
<InfoLine label="Пользователь" value={user.email} />
|
||
</div>
|
||
|
||
<div className="task-workspace-access-list">
|
||
{workspaces.length ? (
|
||
workspaces.map((workspace) => {
|
||
const role = getTaskManagerMembershipRole(data, client.id, user.id, workspace.slug);
|
||
const projects = getWorkspaceCatalogProjects(workspace, workspaceCatalog);
|
||
const pendingKey = `${client.id}:${user.id}:${workspace.slug}`;
|
||
const pending = Boolean(pendingTaskManagerMemberships[pendingKey]) || basePendingValue !== undefined;
|
||
const value: OperationalCoreRoleSelectValue = pending
|
||
? "pending"
|
||
: baseAssignmentValue === "deny"
|
||
? "deny"
|
||
: taskManagerRoleToAccessAssignment(role);
|
||
|
||
return (
|
||
<section key={workspace.slug} className="task-workspace-access-card">
|
||
<div className="task-workspace-access-card__head">
|
||
<div>
|
||
<strong>{workspace.name ?? workspace.slug}</strong>
|
||
<small>
|
||
{workspace.slug}
|
||
{workspace.isPrimary ? " · основной workspace" : ""}
|
||
</small>
|
||
</div>
|
||
{protectedUser ? (
|
||
<AdminStaticPill>{accessAssignmentLabel("admin")}</AdminStaticPill>
|
||
) : (
|
||
<NodeDcSelect
|
||
className="admin-table-select-wrap"
|
||
triggerClassName="admin-table-select-trigger"
|
||
value={value}
|
||
options={operationalCoreRoleOptions}
|
||
label={`Роль ${user.name} в ${workspace.name ?? workspace.slug}`}
|
||
minMenuWidth={180}
|
||
disabled={pending}
|
||
onChange={(nextValue) => {
|
||
if (nextValue === "pending") return;
|
||
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value: nextValue });
|
||
onSetTaskManagerWorkspaceMemberRole({
|
||
clientId: client.id,
|
||
userId: user.id,
|
||
workspaceSlug: workspace.slug,
|
||
role: accessAssignmentToTaskManagerRole(nextValue),
|
||
});
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
<div className="task-project-access-list">
|
||
<div className="task-project-access-list__head">
|
||
<strong>Проекты</strong>
|
||
<span>{projects.length ? "Точечные роли внутри выбранного workspace" : "В этом workspace пока нет проектов"}</span>
|
||
</div>
|
||
{projects.map((project) => {
|
||
const projectRole = getTaskManagerProjectMembershipRole(data, client.id, user.id, workspace.slug, project.id);
|
||
const projectPendingKey = `${client.id}:${user.id}:${workspace.slug}:${project.id}`;
|
||
const projectPending = Boolean(pendingTaskManagerProjectMemberships[projectPendingKey]);
|
||
const projectValue: OperationalCoreRoleSelectValue = projectPending ? "pending" : taskManagerRoleToAccessAssignment(projectRole);
|
||
|
||
return (
|
||
<div key={project.id} className="task-project-access-row">
|
||
<div className="task-project-access-row__meta">
|
||
<strong>{project.name}</strong>
|
||
<small>
|
||
{project.identifier}
|
||
{project.memberCount ? ` · ${project.memberCount} участников` : ""}
|
||
</small>
|
||
</div>
|
||
{protectedUser ? (
|
||
<AdminStaticPill>{accessAssignmentLabel("admin")}</AdminStaticPill>
|
||
) : (
|
||
<NodeDcSelect
|
||
className="admin-table-select-wrap"
|
||
triggerClassName="admin-table-select-trigger"
|
||
value={projectValue}
|
||
options={taskManagerProjectRoleOptions}
|
||
label={`Роль ${user.name} в проекте ${project.name}`}
|
||
minMenuWidth={180}
|
||
disabled={projectPending}
|
||
onChange={(nextValue) => {
|
||
if (nextValue === "pending") return;
|
||
|
||
const nextTaskManagerRole = accessAssignmentToTaskManagerRole(nextValue);
|
||
if (nextTaskManagerRole !== "unset") {
|
||
if (baseAssignmentValue === "unset" || baseAssignmentValue === "deny") {
|
||
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value: nextValue });
|
||
}
|
||
if (role === "unset") {
|
||
onSetTaskManagerWorkspaceMemberRole({
|
||
clientId: client.id,
|
||
userId: user.id,
|
||
workspaceSlug: workspace.slug,
|
||
role: nextTaskManagerRole,
|
||
});
|
||
}
|
||
}
|
||
|
||
onSetTaskManagerProjectMemberRole({
|
||
clientId: client.id,
|
||
userId: user.id,
|
||
workspaceSlug: workspace.slug,
|
||
projectId: project.id,
|
||
role: nextTaskManagerRole,
|
||
});
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
);
|
||
})
|
||
) : (
|
||
<div className="access-empty-state">
|
||
<strong>Workspace не привязаны к клиенту</strong>
|
||
<span>Откройте карточку клиента и выберите Operational Core workspaces, после этого здесь появятся назначения.</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="service-content-modal__foot">
|
||
<Button variant="secondary" surface="modal" type="button" onClick={onClose}>
|
||
Закрыть
|
||
</Button>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AccessCellControl({
|
||
cell,
|
||
active,
|
||
pendingValue,
|
||
busy = false,
|
||
onSelectCell,
|
||
onSetAccess,
|
||
onOpenDetails,
|
||
}: {
|
||
cell: AccessMatrixCell;
|
||
active: boolean;
|
||
pendingValue?: AccessAssignmentValue;
|
||
busy?: boolean;
|
||
onSelectCell: (cell: AccessMatrixCell) => void;
|
||
onSetAccess: (value: AccessAssignmentValue) => void;
|
||
onOpenDetails?: () => void;
|
||
}) {
|
||
const isPending = pendingValue !== undefined || busy;
|
||
const assignmentValue = pendingValue ?? accessAssignmentValue(cell);
|
||
const cellClassName = cn(
|
||
"access-cell",
|
||
onOpenDetails && "access-cell--modal",
|
||
cell.effectiveAccess.allowed && "access-cell--allowed",
|
||
!cell.effectiveAccess.allowed && "access-cell--denied",
|
||
cell.effectiveAccess.source === "exception" && "access-cell--exception",
|
||
isPending && "access-cell--pending",
|
||
active && "access-cell--active"
|
||
);
|
||
|
||
if (onOpenDetails) {
|
||
return (
|
||
<button
|
||
className={cellClassName}
|
||
type="button"
|
||
aria-busy={isPending}
|
||
disabled={isPending}
|
||
onClick={() => {
|
||
onSelectCell(cell);
|
||
onOpenDetails();
|
||
}}
|
||
>
|
||
<strong>{isPending ? accessAssignmentLabel(assignmentValue) : accessCellTitle(cell)}</strong>
|
||
<span>{isPending ? "Сохраняем..." : sourceLabel(cell.effectiveAccess.source)}</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<NodeDcSelect
|
||
value={assignmentValue}
|
||
options={accessAssignmentOptions}
|
||
label={`Назначить доступ ${cell.userId} / ${cell.serviceId}`}
|
||
minMenuWidth={172}
|
||
menuClassName="access-cell-menu"
|
||
disabled={isPending}
|
||
onChange={(value) => onSetAccess(value)}
|
||
trigger={({ open, toggle, setTriggerRef }) => (
|
||
<button
|
||
ref={setTriggerRef}
|
||
className={cellClassName}
|
||
type="button"
|
||
aria-expanded={open}
|
||
aria-busy={isPending}
|
||
onClick={() => {
|
||
onSelectCell(cell);
|
||
toggle();
|
||
}}
|
||
>
|
||
<strong>{isPending ? accessAssignmentLabel(assignmentValue) : accessCellTitle(cell)}</strong>
|
||
<span>{isPending ? "Сохраняем..." : sourceLabel(cell.effectiveAccess.source)}</span>
|
||
</button>
|
||
)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function InvitesSection({
|
||
data,
|
||
clientId,
|
||
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 [copiedInviteId, setCopiedInviteId] = 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() {
|
||
if (!email.trim()) return;
|
||
|
||
onCreateInvite({ clientId, email: email.trim(), role });
|
||
setEmail("");
|
||
setRole("member");
|
||
}
|
||
|
||
async function handleCopyInvite(invite: Invite) {
|
||
const inviteUrl = buildInviteUrl(invite.token);
|
||
|
||
try {
|
||
await copyToClipboard(inviteUrl);
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
setCopiedInviteId(invite.id);
|
||
|
||
if (invite.status === "created") {
|
||
onUpdateInvite(invite.id, { status: "sent" });
|
||
}
|
||
|
||
window.setTimeout(() => {
|
||
setCopiedInviteId((currentInviteId) => (currentInviteId === invite.id ? null : currentInviteId));
|
||
}, 1800);
|
||
}
|
||
|
||
return (
|
||
<div className="invites-layout invites-layout--catalog">
|
||
<GlassSurface className="invite-form invite-form--compact">
|
||
<div className="table-toolbar">
|
||
<div>
|
||
<p className="eyebrow">Инвайт от {actor.name}</p>
|
||
<h3>Создать приглашение</h3>
|
||
</div>
|
||
<IconButton
|
||
label="Сгенерировать ссылку"
|
||
className="admin-circle-action admin-circle-action--solid"
|
||
type="button"
|
||
disabled={!email.trim()}
|
||
onClick={handleCreateInvite}
|
||
>
|
||
<MailPlus size={16} />
|
||
</IconButton>
|
||
</div>
|
||
<div className="invite-form__fields">
|
||
<input value={email} onChange={(event) => setEmail(event.target.value)} placeholder="email@company.ru" />
|
||
<NodeDcSelect
|
||
className="admin-table-select-wrap"
|
||
triggerClassName="admin-modal-select-trigger"
|
||
value={role}
|
||
options={inviteRoleOptions}
|
||
label="Роль инвайта"
|
||
onChange={(nextRole) => setRole(nextRole)}
|
||
/>
|
||
</div>
|
||
</GlassSurface>
|
||
|
||
<GlassSurface className="table-shell">
|
||
<div className="table-toolbar">
|
||
<h3>Инвайты</h3>
|
||
</div>
|
||
<table className="admin-data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Email</th>
|
||
<th>Роль</th>
|
||
<th>Статус</th>
|
||
<th>Ссылка</th>
|
||
<th>Истекает</th>
|
||
<th aria-label="Удаление" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{invites.map((invite) => {
|
||
const inviteUrl = buildInviteUrl(invite.token);
|
||
const isCopied = copiedInviteId === invite.id;
|
||
|
||
return (
|
||
<tr key={invite.id}>
|
||
<td>
|
||
<input
|
||
className="admin-table-input admin-table-input--strong"
|
||
value={invite.email}
|
||
onChange={(event) => onUpdateInvite(invite.id, { email: event.target.value })}
|
||
aria-label={`Email инвайта ${invite.email}`}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<NodeDcSelect
|
||
className="admin-table-select-wrap"
|
||
triggerClassName="admin-table-select-trigger"
|
||
value={invite.role}
|
||
options={inviteRoleOptions}
|
||
label={`Роль инвайта ${invite.email}`}
|
||
minMenuWidth={172}
|
||
onChange={(nextRole) => onUpdateInvite(invite.id, { role: nextRole })}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<AdminStatusDropdown
|
||
value={invite.status}
|
||
options={inviteStatusOptions}
|
||
label={`Статус инвайта ${invite.email}`}
|
||
onChange={(status) => onUpdateInvite(invite.id, { status })}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<div className="invite-link-cell">
|
||
<code title={inviteUrl}>{inviteUrl}</code>
|
||
{isCopied ? <span className="invite-link-cell__state">Скопировано</span> : null}
|
||
<IconButton
|
||
label={isCopied ? `Инвайт ${invite.email} скопирован` : `Скопировать инвайт ${invite.email}`}
|
||
className="admin-icon-action invite-icon-action"
|
||
type="button"
|
||
onClick={() => void handleCopyInvite(invite)}
|
||
>
|
||
<Copy size={11} />
|
||
</IconButton>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<NodeDcDateField
|
||
value={invite.expiresAt}
|
||
label={`Инвайт истекает ${invite.email}`}
|
||
onChange={(value) => {
|
||
if (value) onUpdateInvite(invite.id, { expiresAt: value });
|
||
}}
|
||
/>
|
||
</td>
|
||
<td className="services-admin-table__actions">
|
||
<IconButton
|
||
label={`Удалить инвайт ${invite.email}`}
|
||
className="admin-icon-action invite-icon-action"
|
||
type="button"
|
||
onClick={() => setDeleteInviteId(invite.id)}
|
||
>
|
||
<Trash2 size={12} />
|
||
</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>
|
||
);
|
||
}
|
||
|
||
function buildInviteUrl(token: string) {
|
||
if (typeof window === "undefined") return `/invite/${token}`;
|
||
|
||
return new URL(`/invite/${token}`, window.location.origin).toString();
|
||
}
|
||
|
||
async function copyToClipboard(value: string) {
|
||
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(value);
|
||
return;
|
||
}
|
||
|
||
if (typeof document === "undefined") {
|
||
throw new Error("Clipboard API is not available");
|
||
}
|
||
|
||
const input = document.createElement("textarea");
|
||
input.value = value;
|
||
input.setAttribute("readonly", "");
|
||
input.style.position = "fixed";
|
||
input.style.opacity = "0";
|
||
document.body.append(input);
|
||
input.select();
|
||
|
||
const copied = document.execCommand("copy");
|
||
input.remove();
|
||
|
||
if (!copied) {
|
||
throw new Error("Fallback copy failed");
|
||
}
|
||
}
|
||
|
||
function SyncSection({
|
||
data,
|
||
clientId,
|
||
isRoot,
|
||
onRetrySync,
|
||
}: {
|
||
data: LauncherData;
|
||
clientId: string;
|
||
isRoot: boolean;
|
||
onRetrySync: (syncId: string) => void;
|
||
}) {
|
||
const syncRows = data.syncStatuses.filter((sync) => isRoot || sync.objectId === clientId || sync.objectName.includes(getClient(data, clientId).name));
|
||
|
||
return (
|
||
<GlassSurface className="table-shell">
|
||
<div className="table-toolbar">
|
||
<div>
|
||
<h3>Синхронизация</h3>
|
||
<p className="admin-helper-note">Контроль доставки изменений в Authentik, сервисы и внешние контуры. Ошибочные строки можно отправить повторно.</p>
|
||
</div>
|
||
</div>
|
||
<table className="admin-data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Объект</th>
|
||
<th>Тип</th>
|
||
<th>Цель</th>
|
||
<th>Статус</th>
|
||
<th>Последняя sync</th>
|
||
<th>Ошибка</th>
|
||
<th>Действие</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{syncRows.map((sync) => (
|
||
<tr key={sync.id}>
|
||
<td>{sync.objectName}</td>
|
||
<td>{sync.objectType}</td>
|
||
<td>{targetLabel(sync.target)}</td>
|
||
<td>
|
||
<AdminStatusPill value={sync.state} options={syncStatusOptions} />
|
||
</td>
|
||
<td>{formatDateTime(sync.lastSyncAt ?? sync.updatedAt)}</td>
|
||
<td>{sync.error ?? "—"}</td>
|
||
<td>
|
||
<IconButton
|
||
label={`Повторить синхронизацию ${sync.objectName}`}
|
||
className="admin-icon-action services-admin-table__edit"
|
||
type="button"
|
||
onClick={() => onRetrySync(sync.id)}
|
||
>
|
||
<RefreshCw size={12} />
|
||
</IconButton>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</GlassSurface>
|
||
);
|
||
}
|
||
|
||
function AuditSection({ data }: { data: LauncherData }) {
|
||
return (
|
||
<GlassSurface className="table-shell">
|
||
<div className="table-toolbar">
|
||
<div>
|
||
<h3>Аудит</h3>
|
||
<p className="admin-helper-note">Журнал действий: кто менял доступы, каталоги, инвайты и синхронизацию. Нужен для разбора спорных изменений.</p>
|
||
</div>
|
||
</div>
|
||
<table className="admin-data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Дата</th>
|
||
<th>Пользователь</th>
|
||
<th>Действие</th>
|
||
<th>Объект</th>
|
||
<th>Результат</th>
|
||
<th>Детали</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.auditEvents.map((event) => (
|
||
<tr key={event.id}>
|
||
<td>{formatDateTime(event.at)}</td>
|
||
<td>{event.actorName}</td>
|
||
<td>{event.action}</td>
|
||
<td>{event.objectName}</td>
|
||
<td>
|
||
<AdminStatusPill value={event.result} options={auditResultOptions} />
|
||
</td>
|
||
<td>{event.details ?? "—"}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</GlassSurface>
|
||
);
|
||
}
|
||
|
||
function MiscSection({
|
||
data,
|
||
onUpdateSettings,
|
||
}: {
|
||
data: LauncherData;
|
||
onUpdateSettings: (patch: Partial<LauncherSettings>) => void;
|
||
}) {
|
||
const [logoLinkUrl, setLogoLinkUrl] = useState(data.settings.brand.logoLinkUrl);
|
||
const [workspaceCreationPolicy, setWorkspaceCreationPolicy] = useState(
|
||
data.settings.taskManager.workspaceCreationPolicy
|
||
);
|
||
|
||
useEffect(() => {
|
||
setLogoLinkUrl(data.settings.brand.logoLinkUrl);
|
||
setWorkspaceCreationPolicy(data.settings.taskManager.workspaceCreationPolicy);
|
||
}, [data.settings.brand.logoLinkUrl, data.settings.taskManager.workspaceCreationPolicy]);
|
||
|
||
const normalizedLogoLinkUrl = logoLinkUrl.trim() || "/";
|
||
const hasChanges =
|
||
normalizedLogoLinkUrl !== data.settings.brand.logoLinkUrl ||
|
||
workspaceCreationPolicy !== data.settings.taskManager.workspaceCreationPolicy;
|
||
|
||
return (
|
||
<GlassSurface className="table-shell admin-settings-panel">
|
||
<div className="table-toolbar">
|
||
<div>
|
||
<h3>Разное</h3>
|
||
<p className="admin-helper-note">
|
||
Общие настройки платформы, которые должны применяться в лаунчере, Task Manager и системных auth-экранах.
|
||
</p>
|
||
</div>
|
||
<Button
|
||
variant="accent"
|
||
type="button"
|
||
icon={<Save size={16} />}
|
||
disabled={!hasChanges}
|
||
onClick={() =>
|
||
onUpdateSettings({
|
||
brand: { logoLinkUrl: normalizedLogoLinkUrl },
|
||
taskManager: { workspaceCreationPolicy },
|
||
})
|
||
}
|
||
>
|
||
Сохранить
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="admin-settings-grid">
|
||
<label className="admin-settings-field">
|
||
<span>Ссылка логотипа</span>
|
||
<input
|
||
className="admin-table-input admin-settings-field__input"
|
||
value={logoLinkUrl}
|
||
placeholder="/"
|
||
onChange={(event) => setLogoLinkUrl(event.target.value)}
|
||
/>
|
||
<small>Куда ведёт клик по логотипу NODE.DC. Можно указать относительный путь или полный URL.</small>
|
||
</label>
|
||
<label className="admin-settings-field">
|
||
<span>Operational Core: создание workspace</span>
|
||
<NodeDcSelect
|
||
className="admin-modal-select-wrap"
|
||
triggerClassName="admin-modal-select-trigger"
|
||
value={workspaceCreationPolicy}
|
||
options={taskManagerWorkspacePolicyOptions}
|
||
label="Политика создания workspace в Operational Core"
|
||
onChange={(value) => setWorkspaceCreationPolicy(value)}
|
||
/>
|
||
<small>Платформенная policy для новых пользователей без назначенных рабочих пространств в Task Manager.</small>
|
||
</label>
|
||
</div>
|
||
</GlassSurface>
|
||
);
|
||
}
|
||
|
||
function CompanySection({ data, clientId }: { data: LauncherData; clientId: string }) {
|
||
const client = getClient(data, clientId);
|
||
|
||
return (
|
||
<GlassSurface className="company-panel">
|
||
<p className="eyebrow">Профиль компании</p>
|
||
<h3>{client.name}</h3>
|
||
<div className="explanation-stack">
|
||
<InfoLine label="Юридическое название" value={client.legalName ?? "—"} />
|
||
<InfoLine label="Тип" value={client.type === "company" ? "Компания" : "Частное лицо"} />
|
||
<InfoLine label="Контакт" value={`${client.contactName ?? "—"} · ${client.contactEmail ?? "—"}`} />
|
||
<InfoLine label="Demo до" value={formatDate(client.demoEndsAt)} />
|
||
<InfoLine label="Заметки" value={client.notes ?? "—"} />
|
||
</div>
|
||
</GlassSurface>
|
||
);
|
||
}
|
||
|
||
function MetricCard({ label, value, hint, danger = false }: { label: string; value: number; hint: string; danger?: boolean }) {
|
||
return (
|
||
<GlassSurface className={cn("metric-card", danger && "metric-card--danger")}>
|
||
<span>{label}</span>
|
||
<strong>{value}</strong>
|
||
<small>{hint}</small>
|
||
</GlassSurface>
|
||
);
|
||
}
|
||
|
||
function InfoLine({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div className="info-line">
|
||
<span>{label}</span>
|
||
<strong>{value}</strong>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function roleLabel(role: string): string {
|
||
const labels: Record<string, string> = {
|
||
root_admin: "Root Admin",
|
||
support_admin: "Support Admin",
|
||
client_owner: "Client Owner",
|
||
client_admin: "Client Admin",
|
||
member: "Member",
|
||
};
|
||
return labels[role] ?? role;
|
||
}
|
||
|
||
function sectionTitle(section: AdminSection): string {
|
||
const labels: Record<AdminSection, string> = {
|
||
overview: "Обзор",
|
||
clients: "Клиенты",
|
||
users: "Участники",
|
||
groups: "Группы",
|
||
services: "Каталог сервисов",
|
||
access: "Доступы",
|
||
invites: "Инвайты",
|
||
sync: "Синхронизация",
|
||
audit: "Аудит",
|
||
misc: "Разное",
|
||
company: "Профиль компании",
|
||
};
|
||
return labels[section];
|
||
}
|
||
|
||
function accessCellTitle(cell: AccessMatrixCell): string {
|
||
if (!cell.effectiveAccess.allowed) return cell.effectiveAccess.source === "exception" ? accessAssignmentLabel("deny") : "—";
|
||
return accessAssignmentRoleLabel(cell.effectiveAccess.appRole);
|
||
}
|
||
|
||
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 accessAssignmentLabel(value: AccessAssignmentValue): string {
|
||
return accessAssignmentOptions.find((option) => option.value === value)?.label ?? value;
|
||
}
|
||
|
||
function accessAssignmentRoleLabel(role?: ServiceAppRole | null): string {
|
||
if (!role) return "—";
|
||
return accessAssignmentLabel(role === "owner" ? "admin" : role);
|
||
}
|
||
|
||
function accessCellKey(userId: string, serviceId: string): string {
|
||
return `${userId}:${serviceId}`;
|
||
}
|
||
|
||
function isOperationalCoreService(service: Service): boolean {
|
||
return service.slug === "task-manager" || service.authentikApplicationSlug === "task-manager";
|
||
}
|
||
|
||
function accessAssignmentToTaskManagerRole(value: AccessAssignmentValue): TaskManagerWorkspaceMemberRole {
|
||
if (value === "admin" || value === "member") return value;
|
||
if (value === "viewer") return "guest";
|
||
return "unset";
|
||
}
|
||
|
||
function taskManagerRoleToAccessAssignment(role: TaskManagerWorkspaceMemberRole): AccessAssignmentValue {
|
||
if (role === "admin" || role === "member") return role;
|
||
if (role === "guest") return "viewer";
|
||
return "unset";
|
||
}
|
||
|
||
function sourceLabel(source?: AccessMatrixCell["effectiveAccess"]["source"]): string {
|
||
if (!source) return "—";
|
||
const labels = {
|
||
client: "Клиент",
|
||
group: "Группа",
|
||
user: "Пользователь",
|
||
exception: "Исключение",
|
||
};
|
||
return labels[source];
|
||
}
|
||
|
||
function targetLabel(target: SyncStatus["target"]): string {
|
||
const labels: Record<SyncStatus["target"], string> = {
|
||
authentik: "Authentik",
|
||
task_manager: "Task Manager",
|
||
nodedc: "NodeDC",
|
||
service: "Сервис",
|
||
};
|
||
return labels[target];
|
||
}
|