390 lines
16 KiB
TypeScript
390 lines
16 KiB
TypeScript
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];
|
||
}
|