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; 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: }, { id: "clients", label: "Клиенты", icon: }, { id: "users", label: "Участники", icon: }, { id: "groups", label: "Группы", icon: }, { id: "services", label: "Каталог сервисов", icon: }, { id: "access", label: "Доступы", icon: }, { id: "invites", label: "Инвайты", icon: }, { id: "sync", label: "Синхронизация", icon: }, { id: "audit", label: "Аудит", icon: }, { id: "misc", label: "Разное", icon: }, ]; const clientSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [ { id: "overview", label: "Обзор", icon: }, { id: "users", label: "Участники", icon: }, { id: "groups", label: "Группы", icon: }, { id: "access", label: "Доступы", icon: }, { id: "invites", label: "Инвайты", icon: }, { id: "company", label: "Профиль компании", icon: }, ]; 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) => void; onUpdateInvite: (inviteId: string, patch: Partial) => void; onDeleteInvite: (inviteId: string) => void; onRetrySync: (syncId: string) => void; onCreateClient: () => void; onUpdateClient: (clientId: string, patch: Partial) => void; onDeleteClient: (clientId: string) => void; onCreateUser: (command: CreateUserCommand) => void; onUpdateUser: (userId: string, patch: Partial) => void; onUpdateMembership: (membershipId: string, patch: Partial) => void; onDeleteMembership: (membershipId: string) => void; pendingAccessAssignments: Record; onCreateGroup: (clientId: string) => void; onUpdateGroup: (groupId: string, patch: Partial) => void; onDeleteGroup: (groupId: string) => void; onUpdateService: (serviceId: string, patch: Partial) => void; onReorderServices: (orderedServiceIds: string[]) => void; onCreateService: () => void; onDeleteService: (serviceId: string) => void; onUpdateSettings: (patch: Partial) => void; taskManagerWorkspaces: TaskManagerWorkspaceSummary[]; taskManagerWorkspacesLoading: boolean; taskManagerWorkspacesError: string | null; pendingTaskManagerMemberships: Record; pendingTaskManagerProjectMemberships: Record; 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(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 (
{activeSection ? (
setIsContentFullscreen((current) => !current)} onCloseContent={() => setActiveSection(null)} />
{activeSection === "overview" ? : null} {activeSection === "clients" && isRoot ? ( ) : null} {activeSection === "users" ? ( ) : null} {activeSection === "groups" ? ( ) : null} {activeSection === "services" && isRoot ? ( ) : null} {activeSection === "access" ? ( 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" ? ( ) : null} {activeSection === "sync" ? : null} {activeSection === "audit" && isRoot ? : null} {activeSection === "misc" && isRoot ? : null} {activeSection === "company" ? : null}
) : null}
); } function AdminHeader({ isFullscreen, onToggleFullscreen, onCloseContent, }: { isFullscreen: boolean; onToggleFullscreen: () => void; onCloseContent: () => void; }) { return (
{isFullscreen ? : }
); } 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 (
service.status === "active").length} hint="В каталоге" /> 0} />

Последние действия

{data.auditEvents.slice(0, 5).map((event) => (
{formatDateTime(event.at)} {event.action} {event.objectName}
))}
); } 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) => void; onDeleteClient: (clientId: string) => void; }) { const [editingClientId, setEditingClientId] = useState(null); const editingClient = data.clients.find((client) => client.id === editingClientId) ?? null; return ( <>

Клиенты

{data.clients.map((client) => ( ))}
Название Тип Статус Участников Demo Контакт
onUpdateClient(client.id, { name: event.target.value })} aria-label={`Название клиента ${client.name}`} /> onUpdateClient(client.id, { legalName: event.target.value || null })} aria-label={`Юридическое название ${client.name}`} /> onUpdateClient(client.id, { type })} /> onUpdateClient(client.id, { status })} /> {data.memberships.filter((membership) => membership.clientId === client.id).length} onUpdateClient(client.id, { demoEndsAt: value })} /> onUpdateClient(client.id, { contactEmail: event.target.value || null })} aria-label={`Контакт клиента ${client.name}`} /> setEditingClientId(client.id)} >
{editingClient ? ( 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) => void; }) { const [newUserEmail, setNewUserEmail] = useState(""); const [newUserName, setNewUserName] = useState(""); const [newUserRole, setNewUserRole] = useState("member"); const [newUserGroupId, setNewUserGroupId] = useState("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> = [ { 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 ( <>

Launcher → Authentik

Создать участника

setNewUserEmail(event.target.value)} placeholder="email@company.ru" /> setNewUserName(event.target.value)} placeholder="Имя пользователя" /> setNewUserRole(role)} /> setNewUserGroupId(groupId)} />

Участники

{rows.map(({ membership, user, client }) => { const protectedUser = user.id === "user_root"; return ( ); })}
Пользователь Клиент Телефон Должность Заметки Статус аккаунта
onUpdateUser(user.id, { name: event.target.value })} aria-label={`Имя пользователя ${user.name}`} /> onUpdateUser(user.id, { email: event.target.value })} aria-label={`Email пользователя ${user.name}`} />
{client.name} onUpdateUser(user.id, { phone: event.target.value || null })} placeholder="—" aria-label={`Телефон пользователя ${user.name}`} /> onUpdateUser(user.id, { position: event.target.value || null })} placeholder="—" aria-label={`Должность пользователя ${user.name}`} /> onUpdateUser(user.id, { notes: event.target.value || null })} placeholder="—" aria-label={`Заметки пользователя ${user.name}`} /> {protectedUser ? ( {statusOptionLabel(userStatusOptions, user.globalStatus)} ) : ( onUpdateUser(user.id, { globalStatus: status })} /> )}
); } function GroupsSection({ data, clientId, onCreateGroup, onUpdateGroup, onDeleteGroup, }: { data: LauncherData; clientId: string; onCreateGroup: (clientId: string) => void; onUpdateGroup: (groupId: string, patch: Partial) => void; onDeleteGroup: (groupId: string) => void; }) { const [editingGroupId, setEditingGroupId] = useState(null); const groups = data.groups.filter((group) => group.clientId === clientId); const editingGroup = groups.find((group) => group.id === editingGroupId) ?? null; return ( <>

Группы

onCreateGroup(clientId)}>
{groups.map((group) => ( ))}
Название Описание Участников Подключённые сервисы
onUpdateGroup(group.id, { name: event.target.value })} aria-label={`Название группы ${group.name}`} /> onUpdateGroup(group.id, { description: event.target.value || null })} aria-label={`Описание группы ${group.name}`} /> {group.memberIds.length} {data.grants.filter((grant) => grant.targetType === "group" && grant.targetId === group.id).length} setEditingGroupId(group.id)} >
{editingGroup ? ( 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 = { value: T; label: string; tone: AdminStatusTone }; const serviceStatusOptions: Array> = [ { value: "active", label: "Активен", tone: "green" }, { value: "maintenance", label: "Техработы", tone: "yellow" }, { value: "hidden", label: "Скрыт", tone: "violet" }, { value: "disabled", label: "Отключён", tone: "red" }, ]; const clientTypeOptions: Array> = [ { value: "company", label: "Компания" }, { value: "person", label: "Частное лицо" }, ]; const membershipRoleOptions: Array> = [ { value: "client_owner", label: "Owner", description: "Владелец клиента" }, { value: "client_admin", label: "Admin", description: "Администратор клиента" }, { value: "member", label: "Member", description: "Пользователь" }, ]; const inviteRoleOptions: Array> = [ { value: "member", label: "Member" }, { value: "client_admin", label: "Client Admin" }, ]; const clientStatusOptions: Array> = [ { value: "active", label: "Активен", tone: "green" }, { value: "demo", label: "Demo", tone: "yellow" }, { value: "suspended", label: "Приостановлен", tone: "red" }, { value: "expired", label: "Истёк", tone: "red" }, ]; const userStatusOptions: Array> = [ { value: "active", label: "Активен", tone: "green" }, { value: "invited", label: "Приглашён", tone: "yellow" }, { value: "blocked", label: "Заблокирован", tone: "red" }, ]; const mainStatusOptions: Array> = [ { value: "active", label: "Активен", tone: "green" }, { value: "blocked", label: "Заблокирован", tone: "red" }, { value: "invited", label: "Приглашён", tone: "yellow", hidden: true }, ]; const membershipStatusOptions: Array> = [ { value: "active", label: "Включён", tone: "green" }, { value: "disabled", label: "Отключён", tone: "red" }, ]; const inviteStatusOptions: Array> = [ { 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> = [ { value: "synced", label: "Синхронизировано", tone: "green" }, { value: "pending", label: "В очереди", tone: "yellow" }, { value: "error", label: "Ошибка", tone: "red" }, { value: "disabled", label: "Отключено", tone: "muted" }, ]; const auditResultOptions: Array> = [ { value: "success", label: "Успех", tone: "green" }, { value: "warning", label: "Внимание", tone: "yellow" }, { value: "error", label: "Ошибка", tone: "red" }, ]; const accessAssignmentOptions: Array> = [ { 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> = [ ...accessAssignmentOptions, { value: "pending", label: "Сохраняем...", disabled: true, hidden: true }, ]; const taskManagerProjectRoleOptions: Array> = [ { 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(options: Array>, 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(); 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(); 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 {children}; } const taskManagerWorkspacePolicyOptions: Array> = [ { 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) => void; onReorderServices: (orderedServiceIds: string[]) => void; onCreateService: () => void; onDeleteService: (serviceId: string) => void; }) { const [contentServiceId, setContentServiceId] = useState(null); const [activeServiceId, setActiveServiceId] = useState(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(() => 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 ( <>

Каталог сервисов

service.id)} strategy={verticalListSortingStrategy}> {displayedServices.map((service) => ( setContentServiceId(service.id)} /> ))}
Сервис Slug Статус Ссылка запуска Authentik
{contentService ? ( setContentServiceId(null)} onSave={(patch) => { onUpdateService(contentService.id, patch); setContentServiceId(null); }} onDelete={() => { onDeleteService(contentService.id); setContentServiceId(null); }} /> ) : null} ); } function ServiceTableColGroup() { return ( ); } function SortableServiceRow({ service, onUpdateService, onOpenContent, }: { service: Service; onUpdateService: (serviceId: string, patch: Partial) => void; onOpenContent: () => void; }) { const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({ id: service.id }); const style = { transform: CSS.Transform.toString(transform), transition, }; return ( ); } function ServiceTableCells({ service, onUpdateService, onOpenContent, dragAttributes, dragListeners, setDragHandleRef, }: { service: Service; onUpdateService: (serviceId: string, patch: Partial) => void; onOpenContent: () => void; dragAttributes?: DraggableAttributes; dragListeners?: DraggableSyntheticListeners; setDragHandleRef?: (node: HTMLButtonElement | null) => void; }) { return ( <> onUpdateService(service.id, { title: event.target.value })} aria-label={`Название сервиса ${service.title}`} /> onUpdateService(service.id, { subtitle: event.target.value || null })} aria-label={`Подзаголовок сервиса ${service.title}`} /> onUpdateService(service.id, { slug: event.target.value })} aria-label={`Slug сервиса ${service.title}`} /> onUpdateService(service.id, { status })} /> onUpdateService(service.id, createServiceLaunchLinkPatch(event.target.value))} aria-label={`Ссылка запуска сервиса ${service.title}`} /> onUpdateService(service.id, { authentikApplicationSlug: event.target.value || null })} aria-label={`Authentik slug сервиса ${service.title}`} /> ); } function ServiceStatusDropdown({ value, label, onChange, }: { value: ServiceStatus; label: string; onChange: (status: ServiceStatus) => void; }) { const selectedOption = serviceStatusOptions.find((option) => option.value === value) ?? serviceStatusOptions[0]; return ( ( )} > {({ close }) => (
{serviceStatusOptions.map((option) => ( ))}
)}
); } function AdminStatusDropdown({ value, options, label, onChange, }: { value: T; options: Array>; label: string; onChange: (value: T) => void; }) { const selectedOption = options.find((option) => option.value === value) ?? options[0]; return ( ( )} > {({ close }) => (
{options.map((option) => ( ))}
)}
); } function AdminStatusPill({ value, options }: { value: T; options: Array> }) { const option = options.find((item) => item.value === value) ?? options[0]; return ( {option.label} ); } function ServiceContentModal({ service, onClose, onSave, onDelete, }: { service: Service; onClose: () => void; onSave: (patch: Partial) => void; onDelete: () => void; }) { const [draft, setDraft] = useState(service); const [uploadingSlot, setUploadingSlot] = useState<"cover" | "ambient" | null>(null); const [storageError, setStorageError] = useState(null); const [deleteOpen, setDeleteOpen] = useState(false); useEffect(() => { setDraft(service); setStorageError(null); setUploadingSlot(null); }, [service]); function update(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 (

Витрина сервиса

{service.title}