NODEDC_LAUNCHER/src/widgets/top-bar/TopBar.tsx

390 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Inbox, X } from "lucide-react";
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import type { Client } from "../../entities/client/types";
import { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants";
import type { MeResponse, ProfileOption } from "../../shared/api/mockApi";
import { initials } from "../../shared/lib/format";
import { NodeDcProfileMenu, NodeDcSelect, type NodeDcSelectOption } from "../../shared/nodedc-ui";
export type LauncherAdminMode = "admin" | "platform";
export type LauncherNotificationKind = "nodedc" | "operational-core" | "engine";
export type LauncherNotificationStatus = "unread" | "read" | "archived";
export interface LauncherNotificationItem {
id: string;
kind: LauncherNotificationKind;
title: string;
description: string;
meta?: string;
displayStatus?: string;
status: LauncherNotificationStatus;
createdAt: string;
updatedAt?: string | null;
actionKind?: "engine-role-request";
requestId?: string;
requestedServiceRole?: "viewer" | "member" | null;
}
type EngineRoleActionValue = "choose" | "viewer" | "member";
const engineRoleActionOptions: Array<NodeDcSelectOption<EngineRoleActionValue>> = [
{ value: "choose", label: "Изменить роль" },
{ value: "viewer", label: "Гость" },
{ value: "member", label: "Участник" },
];
export function TopBar({
me,
clients,
profileOptions,
activeProfileId,
activeClientId,
adminOpen,
adminMode,
onProfileChange,
onClientChange,
onOpenAdmin,
onOpenPlatform,
onOpenShowcase,
onOpenProfileSettings,
onLogout,
brandLinkUrl = "/",
notifications = [],
onMarkNotificationsRead,
onResolveEngineRoleRequest,
}: {
me: MeResponse;
clients: Client[];
profileOptions: ProfileOption[];
activeProfileId: string;
activeClientId: string;
adminOpen: boolean;
adminMode: LauncherAdminMode;
onProfileChange: (userId: string) => void;
onClientChange: (clientId: string) => void;
onOpenAdmin: () => void;
onOpenPlatform: () => void;
onOpenShowcase: () => void;
onOpenProfileSettings: () => void;
onLogout?: () => void;
brandLinkUrl?: string;
notifications?: LauncherNotificationItem[];
onMarkNotificationsRead?: () => void | Promise<unknown>;
onResolveEngineRoleRequest?: (requestId: string, action: "approve" | "reject", serviceRole?: "viewer" | "member", deliveryId?: string) => void | Promise<unknown>;
}) {
const [notificationsOpen, setNotificationsOpen] = useState(false);
const [notificationFilter, setNotificationFilter] = useState<LauncherNotificationKind | "all">("all");
const [notificationsOnlyNew, setNotificationsOnlyNew] = useState(false);
const [resolvingNotificationIds, setResolvingNotificationIds] = useState<Record<string, boolean>>({});
const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId));
const clientsWithPublicPool = [
...clients,
availableClientIds.has(PUBLIC_POOL_CLIENT.id) && !clients.some((client) => isPublicPoolClientId(client.id)) ? PUBLIC_POOL_CLIENT : null,
].filter((client): client is Client => Boolean(client));
const availableClients = clientsWithPublicPool.filter((client) => availableClientIds.has(client.id));
const activeClient = availableClients.find((client) => client.id === activeClientId);
const clientOptions = availableClients.map((client) => ({
value: client.id,
label: client.name,
description: client.legalName ?? undefined,
}));
const canOpenPlatform = me.launcherRole === "root_admin";
const showLauncherNavigation = me.permissions.canOpenAdmin || canOpenPlatform;
const unreadCount = notifications.filter((notification) => notification.status === "unread").length;
const visibleNotifications = useMemo(
() => notifications.filter((notification) => {
if (notificationsOnlyNew && notification.status !== "unread") return false;
return notificationFilter === "all" || notification.kind === notificationFilter;
}),
[notificationFilter, notifications, notificationsOnlyNew]
);
function markNotificationsSeen() {
if (unreadCount <= 0) return;
void Promise.resolve(onMarkNotificationsRead?.());
}
function closeNotifications() {
markNotificationsSeen();
setNotificationsOpen(false);
}
function resolveEngineRoleNotification(requestId: string, action: "approve" | "reject", serviceRole?: "viewer" | "member", deliveryId?: string) {
if (!onResolveEngineRoleRequest || resolvingNotificationIds[requestId]) return;
setResolvingNotificationIds((current) => ({ ...current, [requestId]: true }));
Promise.resolve(onResolveEngineRoleRequest(requestId, action, serviceRole, deliveryId))
.finally(() => {
setResolvingNotificationIds((current) => {
const { [requestId]: _done, ...rest } = current;
return rest;
});
});
}
const notificationsModal = notificationsOpen && typeof document !== "undefined"
? createPortal(
<div className="nodedc-notifications-overlay" onMouseDown={closeNotifications}>
<section className="nodedc-notifications-modal" aria-modal="true" role="dialog" onMouseDown={(event) => event.stopPropagation()}>
<div className="nodedc-notifications-head">
<div>
<h2>Уведомления</h2>
<span>NODE DC</span>
</div>
<div className="nodedc-notifications-head__actions">
<button
className="nodedc-notifications-only-new"
type="button"
data-active={notificationsOnlyNew ? "true" : "false"}
onClick={() => setNotificationsOnlyNew((current) => !current)}
>
Только новые
</button>
<button className="nodedc-notifications-close" type="button" aria-label="Закрыть" onClick={closeNotifications}>
<X size={18} strokeWidth={1.8} />
</button>
</div>
</div>
<div className="nodedc-notifications-tabs" role="tablist" aria-label="Фильтр уведомлений">
<NotificationFilterButton active={notificationFilter === "all"} onClick={() => setNotificationFilter("all")}>
Все
</NotificationFilterButton>
<NotificationFilterButton active={notificationFilter === "nodedc"} onClick={() => setNotificationFilter("nodedc")}>
NODE.DC
</NotificationFilterButton>
<NotificationFilterButton active={notificationFilter === "operational-core"} onClick={() => setNotificationFilter("operational-core")}>
Operational Core
</NotificationFilterButton>
<NotificationFilterButton active={notificationFilter === "engine"} onClick={() => setNotificationFilter("engine")}>
Engine
</NotificationFilterButton>
</div>
<div className="nodedc-notifications-list">
{visibleNotifications.length === 0 ? (
<div className="nodedc-notifications-empty">
<strong>Уведомлений нет</strong>
<span>История заявок NODE.DC, Operational Core и Engine появится здесь.</span>
</div>
) : (
visibleNotifications.map((notification) => {
const unread = notification.status === "unread";
return (
<article className="nodedc-notification-card" data-status={unread ? "new" : notification.status} key={notification.id}>
<div className="nodedc-notification-card__content">
<div className="nodedc-notification-card__kicker">{notificationKindLabel(notification.kind)}</div>
<div className="nodedc-notification-card__title">{notification.title}</div>
<div className="nodedc-notification-card__description">{notification.description}</div>
<div className="nodedc-notification-card__foot">
<span>{notification.displayStatus || notificationStatusLabel(notification.status)}</span>
{notification.meta ? <span>{notification.meta}</span> : null}
</div>
</div>
{notification.actionKind === "engine-role-request" &&
notification.requestId &&
notification.status === "unread" &&
onResolveEngineRoleRequest ? (
<div className="nodedc-notification-card__actions">
{(() => {
const requestBusy = Boolean(resolvingNotificationIds[notification.requestId]);
return (
<>
<button
className="nodedc-notification-card__action-button"
type="button"
disabled={requestBusy}
onClick={() => resolveEngineRoleNotification(notification.requestId!, "reject", undefined, notification.id)}
>
Отклонить
</button>
<NodeDcSelect<EngineRoleActionValue>
className="nodedc-notification-card__role-select-wrap"
triggerClassName="nodedc-notification-card__role-select"
menuClassName="nodedc-notification-card__role-menu"
value="choose"
options={engineRoleActionOptions}
label="Изменить роль Engine"
minMenuWidth={166}
disabled={requestBusy}
onChange={(value) => {
if (value === "viewer" || value === "member") {
resolveEngineRoleNotification(notification.requestId!, "approve", value, notification.id);
}
}}
/>
</>
);
})()}
</div>
) : null}
</article>
);
})
)}
</div>
</section>
</div>,
document.body
)
: null;
return (
<header className="nodedc-expanded-toolbar-shell">
<div className="nodedc-expanded-toolbar">
<div className="nodedc-expanded-toolbar-top">
<div className="nodedc-expanded-toolbar-left">
<a href={brandLinkUrl} className="nodedc-expanded-brand-link" aria-label="NODE.DC">
<img src="/nodedc-logo.svg" alt="NODE DC" className="nodedc-expanded-brand-logo" />
</a>
</div>
<div className="nodedc-expanded-toolbar-center">
{showLauncherNavigation ? (
<>
<NodeDcSelect
value={activeClientId}
options={clientOptions}
label="Выбрать клиента"
searchable
minMenuWidth={248}
onChange={(clientId) => onClientChange(clientId)}
trigger={({ open, toggle, setTriggerRef }) => (
<button
ref={setTriggerRef}
className="nodedc-expanded-workspace-button"
title={activeClient?.name ?? "Клиент"}
type="button"
aria-label="Выбрать клиента"
aria-expanded={open}
onClick={toggle}
>
{activeClient?.avatarUrl ? <img src={activeClient.avatarUrl} alt="" className="nodedc-expanded-workspace-avatar" /> : null}
</button>
)}
/>
<nav className="nodedc-expanded-nav-group" aria-label="Навигация лаунчера">
<button className="nodedc-expanded-nav-button" type="button" data-active={!adminOpen} onClick={onOpenShowcase}>
<span>Витрина</span>
</button>
{me.permissions.canOpenAdmin ? (
<button
className="nodedc-expanded-nav-button"
type="button"
data-active={adminOpen && adminMode === "admin"}
onClick={onOpenAdmin}
>
<span>Администрирование</span>
</button>
) : null}
{canOpenPlatform ? (
<button
className="nodedc-expanded-nav-button"
type="button"
data-active={adminOpen && adminMode === "platform"}
onClick={onOpenPlatform}
>
<span>Платформа</span>
</button>
) : null}
</nav>
</>
) : null}
</div>
<div className="nodedc-expanded-toolbar-right">
<div className="nodedc-expanded-user-group">
<button
className="nodedc-toolbar-icon-button nodedc-expanded-notification-button"
data-active={unreadCount > 0 ? "true" : "false"}
type="button"
aria-label="Уведомления"
onClick={() => setNotificationsOpen(true)}
>
<span className="nodedc-toolbar-icon-active-dot">
<Inbox size={20} strokeWidth={1.7} />
</span>
{unreadCount > 0 ? <span className="nodedc-notification-badge">{unreadCount > 9 ? "9+" : unreadCount}</span> : null}
</button>
<NodeDcProfileMenu
user={me.user}
onSettings={onOpenProfileSettings}
onLogout={onLogout}
trigger={({ open, toggle, setTriggerRef }) => (
<div
ref={setTriggerRef}
className="nodedc-expanded-profile-trigger"
title={`${me.user.name} · ${me.user.email}`}
role="button"
tabIndex={0}
aria-label="Профиль пользователя"
aria-expanded={open}
onClick={toggle}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggle();
}
}}
>
<span className="nodedc-expanded-nav-button" data-active="false">
<span>Профиль</span>
</span>
<span className="nodedc-expanded-user-avatar-button" aria-hidden="true">
{me.user.avatarUrl ? (
<img className="nodedc-expanded-user-avatar" src={me.user.avatarUrl} alt="" style={{ objectFit: "cover" }} />
) : (
<span className="nodedc-expanded-user-avatar">{initials(me.user.name)}</span>
)}
</span>
</div>
)}
/>
</div>
{notificationsModal}
</div>
</div>
</div>
</header>
);
}
function NotificationFilterButton({
active,
children,
onClick,
}: {
active: boolean;
children: string;
onClick: () => void;
}) {
return (
<button className="nodedc-notifications-tab" data-active={active ? "true" : "false"} type="button" onClick={onClick}>
{children}
</button>
);
}
function notificationKindLabel(kind: LauncherNotificationKind): string {
const labels: Record<LauncherNotificationKind, string> = {
nodedc: "NODE.DC",
"operational-core": "Operational Core",
engine: "NODE.DC Engine",
};
return labels[kind];
}
function notificationStatusLabel(status: LauncherNotificationStatus): string {
const labels: Record<LauncherNotificationStatus, string> = {
unread: "Входящее",
read: "Прочитано",
archived: "В архиве",
};
return labels[status];
}