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

295 lines
12 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 } 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];
}