295 lines
12 KiB
TypeScript
295 lines
12 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 } from "../../shared/nodedc-ui";
|
||
|
||
export type LauncherAdminMode = "admin" | "platform";
|
||
export type LauncherNotificationKind = "nodedc" | "operational-core" | "engine";
|
||
export type LauncherNotificationStatus = "new" | "approved" | "rejected" | "cancelled";
|
||
|
||
export interface LauncherNotificationItem {
|
||
id: string;
|
||
kind: LauncherNotificationKind;
|
||
title: string;
|
||
description: string;
|
||
meta?: string;
|
||
status: LauncherNotificationStatus;
|
||
createdAt: string;
|
||
}
|
||
|
||
export function TopBar({
|
||
me,
|
||
clients,
|
||
profileOptions,
|
||
activeProfileId,
|
||
activeClientId,
|
||
adminOpen,
|
||
adminMode,
|
||
onProfileChange,
|
||
onClientChange,
|
||
onOpenAdmin,
|
||
onOpenPlatform,
|
||
onOpenShowcase,
|
||
onOpenProfileSettings,
|
||
onLogout,
|
||
brandLinkUrl = "/",
|
||
notifications = [],
|
||
}: {
|
||
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[];
|
||
}) {
|
||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||
const [notificationFilter, setNotificationFilter] = useState<LauncherNotificationKind | "all">("all");
|
||
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 === "new").length;
|
||
const visibleNotifications = useMemo(
|
||
() => notifications.filter((notification) => notificationFilter === "all" || notification.kind === notificationFilter),
|
||
[notificationFilter, notifications]
|
||
);
|
||
|
||
const notificationsModal = notificationsOpen && typeof document !== "undefined"
|
||
? createPortal(
|
||
<div className="nodedc-notifications-overlay" onMouseDown={() => setNotificationsOpen(false)}>
|
||
<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>
|
||
<button className="nodedc-notifications-close" type="button" aria-label="Закрыть" onClick={() => setNotificationsOpen(false)}>
|
||
<X size={18} strokeWidth={1.8} />
|
||
</button>
|
||
</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) => (
|
||
<article className="nodedc-notification-card" data-status={notification.status} key={notification.id}>
|
||
<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>{notificationStatusLabel(notification.status)}</span>
|
||
{notification.meta ? <span>{notification.meta}</span> : null}
|
||
</div>
|
||
</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> = {
|
||
new: "Входящее",
|
||
approved: "Подтверждено",
|
||
rejected: "Отклонено",
|
||
cancelled: "Отменено",
|
||
};
|
||
|
||
return labels[status];
|
||
}
|