993 lines
34 KiB
TypeScript
993 lines
34 KiB
TypeScript
"use client";
|
||
|
||
// ============================================================================
|
||
// Application Sidebar
|
||
// ============================================================================
|
||
|
||
import * as React from "react";
|
||
import { useParams } from "next/navigation";
|
||
import Link from "next/link";
|
||
import {
|
||
BarChart3,
|
||
BookmarkCheck,
|
||
Building2,
|
||
CalendarClock,
|
||
ClipboardCheck,
|
||
ClipboardList,
|
||
Filter,
|
||
Folder,
|
||
Gauge,
|
||
KanbanSquare,
|
||
LayoutDashboard,
|
||
LineChart,
|
||
LifeBuoy,
|
||
ListChecks,
|
||
Megaphone,
|
||
MessageCircle,
|
||
MonitorPlay,
|
||
NotebookPen,
|
||
PlayCircle,
|
||
Settings,
|
||
ShoppingCart,
|
||
Sparkles,
|
||
Tv,
|
||
TrendingUp,
|
||
BookOpen,
|
||
ChevronRight,
|
||
X,
|
||
type LucideIcon,
|
||
} from "lucide-react";
|
||
|
||
import { type NavGuideState, NavMain } from "@/components/nav-main";
|
||
import { NavUser } from "@/components/nav-user";
|
||
import {
|
||
Sidebar,
|
||
SidebarContent,
|
||
SidebarFooter,
|
||
SidebarHeader,
|
||
SidebarMenu,
|
||
SidebarMenuButton,
|
||
SidebarMenuItem,
|
||
SidebarMenuSub,
|
||
SidebarMenuSubButton,
|
||
SidebarMenuSubItem,
|
||
SidebarRail,
|
||
SidebarSeparator,
|
||
SidebarGroup,
|
||
SidebarGroupContent,
|
||
SidebarGroupLabel,
|
||
useSidebar,
|
||
} from "@/components/ui/sidebar";
|
||
import {
|
||
Collapsible,
|
||
CollapsibleContent,
|
||
CollapsibleTrigger,
|
||
} from "@/components/ui/collapsible";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { useAuth } from "@/components/auth/auth-provider";
|
||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||
import { buildRoute } from "@/lib/utils/constants";
|
||
import Image from "next/image";
|
||
import { DEMO_WORKSPACE_ID } from "@/lib/demo";
|
||
|
||
// ============================================================================
|
||
// Types
|
||
// ============================================================================
|
||
|
||
interface NavItem {
|
||
title: string;
|
||
url: string;
|
||
icon?: LucideIcon;
|
||
isActive?: boolean;
|
||
requiredPermission?: "projects_read" | "placements_read" | "analytics_read" | "admin_full";
|
||
items?: {
|
||
title: string;
|
||
url: string;
|
||
icon?: LucideIcon;
|
||
}[];
|
||
}
|
||
|
||
interface TrainingStep {
|
||
id: string;
|
||
title: string;
|
||
description: string;
|
||
navItemTitle: string;
|
||
expandedNavItems?: string[];
|
||
icon?: LucideIcon;
|
||
sections?: {
|
||
title: string;
|
||
description: string;
|
||
icon?: LucideIcon;
|
||
points?: string[];
|
||
}[];
|
||
points?: string[];
|
||
}
|
||
|
||
interface HelpItem {
|
||
title: string;
|
||
description: string;
|
||
icon: LucideIcon;
|
||
href?: string;
|
||
external?: boolean;
|
||
action?: () => void;
|
||
}
|
||
|
||
interface SupportLink {
|
||
title: string;
|
||
icon: LucideIcon;
|
||
href: string;
|
||
external?: boolean;
|
||
}
|
||
|
||
const TRAINING_STEPS: TrainingStep[] = [
|
||
{
|
||
id: "dashboard",
|
||
title: "Главная панель",
|
||
description:
|
||
"Это стартовая точка: отсюда видно общее состояние закупок, статус проектов и свежие уведомления. Сюда возвращаемся, чтобы понять, что делать дальше и какие задачи горят.",
|
||
navItemTitle: "Главная",
|
||
icon: LayoutDashboard,
|
||
sections: [
|
||
{
|
||
title: "Сводка показателей",
|
||
description:
|
||
"Карточки расхода, охвата и эффективности обновляются в режиме близи real-time. Это быстрый способ понять, всё ли идёт по плану.",
|
||
icon: Gauge,
|
||
points: [
|
||
"Подсветка указывает, где есть отклонения от плана или резкие изменения.",
|
||
"Нажмите на карточку, чтобы перейти к связанному отчёту или проекту.",
|
||
],
|
||
},
|
||
{
|
||
title: "Обзор показателей",
|
||
description:
|
||
"На главной панели отображаются ключевые метрики эффективности рекламных кампаний: общие расходы, охват, количество размещений и подписчиков, а также средние показатели CPM и CPF.",
|
||
icon: CalendarClock,
|
||
points: [
|
||
"Метрики показывают динамику с процентным изменением относительно предыдущего периода.",
|
||
"Выбор периода позволяет анализировать данные за любой временной интервал.",
|
||
"Фильтр по проектам позволяет просматривать статистику по выбранным каналам или всем сразу.",
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "projects",
|
||
title: "Проекты",
|
||
description:
|
||
"Управляйте рекламными активностями по каждому продукту или клиенту и контролируйте статусы задач.",
|
||
navItemTitle: "Проекты",
|
||
icon: KanbanSquare,
|
||
sections: [
|
||
{
|
||
title: "Карточки проектов",
|
||
description:
|
||
"Каждый проект знает свой бюджет, статус и ответственных. Это рабочее пространство конкретной кампании.",
|
||
icon: NotebookPen,
|
||
points: [
|
||
"Новые проекты создаются через кнопку «Добавить» — укажите клиента, цель и дедлайн.",
|
||
"Меняйте статусы (подготовка, в работе, завершён) по мере продвижения кампании.",
|
||
],
|
||
},
|
||
{
|
||
title: "Фильтры и поиск",
|
||
description:
|
||
"Фильтруйте проекты по менеджерам или тегам, чтобы быстро найти нужное направление.",
|
||
icon: Filter,
|
||
points: [
|
||
"Сохраняйте выбранный фильтр как «Избранный», чтобы открывать его в один клик.",
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "channels",
|
||
title: "Каталог каналов",
|
||
description:
|
||
"Ищите площадки по темам и метрикам, собирайте избранные каналы и сравнивайте их перед закупкой.",
|
||
navItemTitle: "Каталог каналов",
|
||
icon: BookmarkCheck,
|
||
sections: [
|
||
{
|
||
title: "Поиск и фильтры",
|
||
description:
|
||
"Фильтруйте каналы по тематике, охвату и цене. Результаты можно сортировать и группировать.",
|
||
icon: Filter,
|
||
points: [
|
||
"Подборка фильтров сохраняется автоматически, чтобы вернуться к ней позже.",
|
||
],
|
||
},
|
||
{
|
||
title: "Работа с подборками",
|
||
description:
|
||
"Отмечайте интересные площадки, добавляйте их в планы закупов и сравнивайте показатели.",
|
||
icon: ListChecks,
|
||
points: [
|
||
"Одним кликом отправляйте канал в план или проект, чтобы не собирать всё заново.",
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "purchase-plans",
|
||
title: "Планы закупов",
|
||
description:
|
||
"Это черновики кампаний. Собирайте каналы, фиксируйте ставки и согласовывайте бюджет до запуска.",
|
||
navItemTitle: "Планы закупов",
|
||
icon: ListChecks,
|
||
sections: [
|
||
{
|
||
title: "Конструктор кампаний",
|
||
description:
|
||
"Добавляйте каналы, задавайте даты и ставки. Система сама подсчитает бюджет и прогноз охвата.",
|
||
icon: NotebookPen,
|
||
points: [
|
||
"Используйте заметки, чтобы фиксировать договорённости с площадками.",
|
||
"Приглашайте коллег к плану — они увидят прогресс в реальном времени.",
|
||
],
|
||
},
|
||
{
|
||
title: "Согласование",
|
||
description:
|
||
"Когда план готов, его можно перевести в статус «Утверждён» и автоматически развернуть в размещения.",
|
||
icon: ClipboardCheck,
|
||
points: [
|
||
"История изменений сохранится и поможет восстановить контекст договорённостей.",
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "placements",
|
||
title: "Размещения",
|
||
description:
|
||
"Здесь живут все закупки: от черновиков до опубликованных постов. Следите за статусом и результатами.",
|
||
navItemTitle: "Размещения",
|
||
icon: ClipboardCheck,
|
||
sections: [
|
||
{
|
||
title: "Линия жизни закупки",
|
||
description:
|
||
"Каждое размещение проходит статусы: черновик, согласовано, оплачено, опубликовано. Становится понятно, где застряли процессы.",
|
||
icon: CalendarClock,
|
||
points: [
|
||
"Обновляйте статусы и дедлайны — таблица подсветит просроченные задачи.",
|
||
],
|
||
},
|
||
{
|
||
title: "Фактические результаты",
|
||
description:
|
||
"После выхода поста вносите реальные просмотры, CTR и стоимость — аналитика подхватит данные автоматически.",
|
||
icon: Gauge,
|
||
points: [
|
||
"Добавляйте скриншоты и ссылки для архива — к ним всегда можно вернуться.",
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "analytics",
|
||
title: "Аналитика",
|
||
description:
|
||
"Сводная аналитика показывает, что работает и куда уходят бюджеты. Не нужно собирать отчёты вручную.",
|
||
navItemTitle: "Аналитика",
|
||
expandedNavItems: ["Аналитика"],
|
||
icon: TrendingUp,
|
||
sections: [
|
||
{
|
||
title: "Срезы данных",
|
||
description:
|
||
"Выбирайте отчёт по проектам, каналам или креативам, чтобы увидеть вклад каждой части.",
|
||
icon: LineChart,
|
||
points: [
|
||
"Сравнивайте периоды и экспортируйте графики для презентаций.",
|
||
],
|
||
},
|
||
{
|
||
title: "Фильтры и сегменты",
|
||
description:
|
||
"Фильтр по менеджеру, периоду или типу кампании помогает отвечать на конкретные вопросы бизнеса.",
|
||
icon: Filter,
|
||
points: [
|
||
"Сохранённые сегменты экономят время: достаточно выбрать нужный набор критериев.",
|
||
],
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
// ============================================================================
|
||
// Component
|
||
// ============================================================================
|
||
|
||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||
const params = useParams();
|
||
const workspaceId = params?.workspaceId as string;
|
||
const { user } = useAuth();
|
||
const {
|
||
workspaces,
|
||
currentWorkspace,
|
||
setCurrentWorkspace,
|
||
hasPermission,
|
||
isAdmin,
|
||
createWorkspace,
|
||
isDemoMode,
|
||
} = useWorkspace();
|
||
const { state: sidebarState } = useSidebar();
|
||
const isSidebarCollapsed = sidebarState === "collapsed";
|
||
const expandedLogoWidth = 182;
|
||
const expandedLogoHeight = 31;
|
||
const expandedLogoDimensions = React.useMemo(
|
||
() => ({
|
||
height: expandedLogoHeight,
|
||
width: expandedLogoWidth,
|
||
minWidth: expandedLogoWidth,
|
||
}),
|
||
[expandedLogoHeight, expandedLogoWidth]
|
||
);
|
||
|
||
const [showCreateDialog, setShowCreateDialog] = React.useState(false);
|
||
const [newWorkspaceName, setNewWorkspaceName] = React.useState("");
|
||
const [isCreating, setIsCreating] = React.useState(false);
|
||
const handleWorkspaceSelect = React.useCallback(
|
||
(workspaceId: string) => {
|
||
if (currentWorkspace?.id === workspaceId) return;
|
||
const workspace = workspaces.find((w) => w.id === workspaceId);
|
||
if (workspace) {
|
||
setCurrentWorkspace(workspace);
|
||
}
|
||
},
|
||
[currentWorkspace?.id, workspaces, setCurrentWorkspace]
|
||
);
|
||
|
||
// Build navigation items based on permissions
|
||
const navItems: NavItem[] = React.useMemo(() => {
|
||
if (!workspaceId) return [];
|
||
|
||
const items: NavItem[] = [];
|
||
|
||
// 1. Главная
|
||
items.push({
|
||
title: "Главная",
|
||
url: buildRoute.dashboard(workspaceId),
|
||
icon: LayoutDashboard,
|
||
isActive: true,
|
||
});
|
||
|
||
// 2. Проекты (всегда доступны)
|
||
items.push({
|
||
title: "Проекты",
|
||
url: buildRoute.projects(workspaceId),
|
||
icon: Tv,
|
||
});
|
||
|
||
// 3. Креативы (только с доступом к размещениям)
|
||
if (hasPermission("placements_read")) {
|
||
items.push({
|
||
title: "Креативы",
|
||
url: buildRoute.creatives(workspaceId),
|
||
icon: Folder,
|
||
requiredPermission: "placements_read",
|
||
});
|
||
}
|
||
|
||
// 4. Каталог каналов (всегда доступен)
|
||
items.push({
|
||
title: "Каталог каналов",
|
||
url: buildRoute.channels(workspaceId),
|
||
icon: Building2,
|
||
});
|
||
|
||
// 5. Планы закупов (правка названия)
|
||
if (hasPermission("placements_read")) {
|
||
items.push({
|
||
title: "Планы закупов",
|
||
url: buildRoute.purchasePlans(workspaceId),
|
||
icon: ClipboardList,
|
||
requiredPermission: "placements_read",
|
||
});
|
||
}
|
||
|
||
// 6. Размещения
|
||
if (hasPermission("placements_read")) {
|
||
items.push({
|
||
title: "Размещения",
|
||
url: buildRoute.placements(workspaceId),
|
||
icon: ShoppingCart,
|
||
requiredPermission: "placements_read",
|
||
});
|
||
}
|
||
|
||
// 7. Аналитика — только два подраздела
|
||
if (hasPermission("analytics_read")) {
|
||
items.push({
|
||
title: "Аналитика",
|
||
url: buildRoute.analytics(workspaceId),
|
||
icon: BarChart3,
|
||
requiredPermission: "analytics_read",
|
||
items: [
|
||
{
|
||
title: "По проектам",
|
||
url: buildRoute.analytics(workspaceId),
|
||
icon: Tv,
|
||
},
|
||
{
|
||
title: "По креативам",
|
||
url: buildRoute.analyticsCreatives(workspaceId),
|
||
icon: Folder,
|
||
},
|
||
],
|
||
});
|
||
}
|
||
|
||
// 8. Настройки (без подразделов, админ)
|
||
if (isAdmin) {
|
||
items.push({
|
||
title: "Настройки",
|
||
url: buildRoute.settings(workspaceId),
|
||
icon: Settings,
|
||
requiredPermission: "admin_full",
|
||
});
|
||
}
|
||
|
||
return items;
|
||
}, [workspaceId, hasPermission, isAdmin]);
|
||
|
||
const availableTrainingSteps = React.useMemo(
|
||
() =>
|
||
TRAINING_STEPS.filter((step) =>
|
||
navItems.some((item) => item.title === step.navItemTitle)
|
||
),
|
||
[navItems]
|
||
);
|
||
const trainingStepsCount = availableTrainingSteps.length;
|
||
const [isTrainingOpen, setIsTrainingOpen] = React.useState(false);
|
||
const [trainingStepIndex, setTrainingStepIndex] = React.useState(0);
|
||
const safeTrainingStepIndex =
|
||
trainingStepsCount > 0
|
||
? Math.min(trainingStepIndex, trainingStepsCount - 1)
|
||
: 0;
|
||
const currentTrainingStep =
|
||
trainingStepsCount > 0
|
||
? availableTrainingSteps[safeTrainingStepIndex]
|
||
: undefined;
|
||
|
||
React.useEffect(() => {
|
||
if (trainingStepsCount === 0) {
|
||
setIsTrainingOpen(false);
|
||
setTrainingStepIndex(0);
|
||
return;
|
||
}
|
||
|
||
if (trainingStepIndex > trainingStepsCount - 1) {
|
||
setTrainingStepIndex(0);
|
||
}
|
||
}, [trainingStepIndex, trainingStepsCount]);
|
||
|
||
const openTraining = React.useCallback(() => {
|
||
if (!trainingStepsCount) return;
|
||
setTrainingStepIndex(0);
|
||
setIsTrainingOpen(true);
|
||
}, [trainingStepsCount]);
|
||
|
||
const closeTraining = React.useCallback(() => {
|
||
setIsTrainingOpen(false);
|
||
}, []);
|
||
|
||
const goToNextTrainingStep = React.useCallback(() => {
|
||
setTrainingStepIndex((prev) =>
|
||
Math.min(prev + 1, Math.max(trainingStepsCount - 1, 0))
|
||
);
|
||
}, [trainingStepsCount]);
|
||
|
||
const goToPrevTrainingStep = React.useCallback(() => {
|
||
setTrainingStepIndex((prev) => Math.max(prev - 1, 0));
|
||
}, []);
|
||
|
||
const navGuideState = React.useMemo<NavGuideState | undefined>(() => {
|
||
if (!isTrainingOpen || !currentTrainingStep) return undefined;
|
||
return {
|
||
highlightedItem: currentTrainingStep.navItemTitle,
|
||
expandedItems: currentTrainingStep.expandedNavItems,
|
||
};
|
||
}, [isTrainingOpen, currentTrainingStep]);
|
||
const isTrainingVisible = isTrainingOpen && Boolean(currentTrainingStep);
|
||
|
||
const supportLinks = React.useMemo<SupportLink[]>(
|
||
() => [
|
||
{
|
||
title: "Наш канал",
|
||
href: "https://t.me/+xqHlRelJ2etkMGIy",
|
||
icon: Megaphone,
|
||
external: true,
|
||
},
|
||
{
|
||
title: "Поддержка",
|
||
href: "https://t.me/smartpost_help_bot",
|
||
icon: MessageCircle,
|
||
external: true,
|
||
},
|
||
{
|
||
title: "База знаний",
|
||
href: "https://docs.smartpost.ru",
|
||
icon: BookOpen,
|
||
external: true,
|
||
},
|
||
],
|
||
[]
|
||
);
|
||
|
||
const extraHelpItems = React.useMemo<HelpItem[]>(
|
||
() => [
|
||
{
|
||
title: "Демо дашборд",
|
||
description: "Посмотрите пример рабочего пространства со всеми разделами.",
|
||
href: buildRoute.dashboard(DEMO_WORKSPACE_ID),
|
||
icon: Sparkles,
|
||
},
|
||
{
|
||
title: "Открыть обучение",
|
||
description: "Запустите интерактивный тур по основным разделам.",
|
||
action: openTraining,
|
||
icon: MonitorPlay,
|
||
},
|
||
],
|
||
[openTraining]
|
||
);
|
||
|
||
const renderExtraHelpItem = React.useCallback((item: HelpItem) => {
|
||
const content = (
|
||
<>
|
||
<item.icon className="mt-1.5 size-3 shrink-0 text-muted-foreground" />
|
||
<p className="text-xs font-light mt-1.5">{item.title}</p>
|
||
</>
|
||
);
|
||
|
||
if (item.action) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => item.action?.()}
|
||
className="flex w-full items-start gap-3 text-left"
|
||
>
|
||
{content}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
if (item.href) {
|
||
if (item.external) {
|
||
return (
|
||
<a
|
||
href={item.href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="flex w-full items-start gap-3 text-left"
|
||
>
|
||
{content}
|
||
</a>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Link href={item.href} className="flex w-full items-start gap-3 text-left">
|
||
{content}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex w-full items-start gap-3 text-left">{content}</div>
|
||
);
|
||
}, []);
|
||
|
||
const handleCreateWorkspace = async () => {
|
||
if (!newWorkspaceName.trim()) return;
|
||
|
||
try {
|
||
setIsCreating(true);
|
||
const workspace = await createWorkspace(newWorkspaceName.trim());
|
||
setShowCreateDialog(false);
|
||
setNewWorkspaceName("");
|
||
setCurrentWorkspace(workspace);
|
||
} catch (err) {
|
||
console.error("Failed to create workspace:", err);
|
||
} finally {
|
||
setIsCreating(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Sidebar collapsible="icon" {...props}>
|
||
<SidebarHeader>
|
||
<SidebarMenu>
|
||
<SidebarMenuItem>
|
||
<SidebarMenuButton
|
||
size="lg"
|
||
className="cursor-default border border-transparent transition-none bg-transparent hover:bg-transparent active:bg-transparent"
|
||
>
|
||
{isSidebarCollapsed ? (
|
||
<div className="flex aspect-square size-8 items-center justify-center">
|
||
<Image
|
||
src="/logo.webp"
|
||
width={64}
|
||
height={64}
|
||
alt="Smartpost"
|
||
className="rounded-lg"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className="flex items-center overflow-hidden"
|
||
style={expandedLogoDimensions}
|
||
>
|
||
<Image
|
||
src="/logo_full_dark.webp"
|
||
width={expandedLogoWidth}
|
||
height={expandedLogoHeight}
|
||
alt="Smartpost"
|
||
className="shrink-0 dark:hidden"
|
||
style={expandedLogoDimensions}
|
||
/>
|
||
<Image
|
||
src="/logo_full_white_2.png"
|
||
width={expandedLogoWidth}
|
||
height={expandedLogoHeight}
|
||
alt="Smartpost"
|
||
className="hidden shrink-0 dark:block"
|
||
style={expandedLogoDimensions}
|
||
/>
|
||
</div>
|
||
)}
|
||
{/* <div className="grid flex-1 text-left text-sm leading-tight">
|
||
<span className="truncate font-semibold">Smartpost</span>
|
||
<span className="truncate text-xs text-muted-foreground">
|
||
Управление закупками
|
||
</span>
|
||
</div> */}
|
||
</SidebarMenuButton>
|
||
</SidebarMenuItem>
|
||
</SidebarMenu>
|
||
</SidebarHeader>
|
||
|
||
<SidebarContent>
|
||
<NavMain items={navItems} guideState={navGuideState} />
|
||
</SidebarContent>
|
||
|
||
<SidebarFooter>
|
||
<SidebarGroup className="px-0">
|
||
<SidebarGroupContent>
|
||
<SidebarMenu>
|
||
{supportLinks.map((link) => (
|
||
<SidebarMenuItem key={link.title}>
|
||
<SidebarMenuButton
|
||
asChild
|
||
size="sm"
|
||
tooltip={isSidebarCollapsed ? link.title : undefined}
|
||
>
|
||
{link.external ? (
|
||
<a
|
||
href={link.href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="flex items-center gap-2"
|
||
>
|
||
<link.icon className="size-4" />
|
||
<span>{link.title}</span>
|
||
</a>
|
||
) : (
|
||
<Link href={link.href} className="flex items-center gap-2">
|
||
<link.icon className="size-4" />
|
||
<span>{link.title}</span>
|
||
</Link>
|
||
)}
|
||
</SidebarMenuButton>
|
||
</SidebarMenuItem>
|
||
))}
|
||
|
||
{isSidebarCollapsed ? (
|
||
<SidebarMenuItem>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<SidebarMenuButton
|
||
size="sm"
|
||
tooltip="Помощь"
|
||
className="justify-between"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<LifeBuoy className="size-4" />
|
||
<span>Помощь</span>
|
||
</div>
|
||
<ChevronRight className="size-4 opacity-60" />
|
||
</SidebarMenuButton>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
side="right"
|
||
align="start"
|
||
className="min-w-[280px] space-y-1"
|
||
>
|
||
{extraHelpItems.map((item) => (
|
||
<DropdownMenuItem key={item.title} asChild>
|
||
{renderExtraHelpItem(item)}
|
||
</DropdownMenuItem>
|
||
))}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</SidebarMenuItem>
|
||
) : (
|
||
<Collapsible defaultOpen asChild>
|
||
<SidebarMenuItem>
|
||
<CollapsibleTrigger asChild>
|
||
<SidebarMenuButton
|
||
size="sm"
|
||
className="justify-between"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<LifeBuoy className="size-4" />
|
||
<span>Помощь</span>
|
||
</div>
|
||
<ChevronRight className="size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||
</SidebarMenuButton>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent>
|
||
<SidebarMenuSub>
|
||
{extraHelpItems.map((item) => (
|
||
<SidebarMenuSubItem key={item.title}>
|
||
<SidebarMenuSubButton asChild className="">
|
||
{renderExtraHelpItem(item)}
|
||
</SidebarMenuSubButton>
|
||
</SidebarMenuSubItem>
|
||
))}
|
||
</SidebarMenuSub>
|
||
</CollapsibleContent>
|
||
</SidebarMenuItem>
|
||
</Collapsible>
|
||
)}
|
||
</SidebarMenu>
|
||
</SidebarGroupContent>
|
||
</SidebarGroup>
|
||
|
||
<SidebarSeparator />
|
||
|
||
{/* User */}
|
||
{user && (() => {
|
||
// Формируем полное имя из first_name и last_name
|
||
const fullName = [user.first_name, user.last_name]
|
||
.filter(Boolean)
|
||
.join(" ") || user.username || `User ${user.telegram_id}`;
|
||
|
||
// Для аватара используем полное имя или username
|
||
const avatarName = [user.first_name, user.last_name]
|
||
.filter(Boolean)
|
||
.join(" ") || user.username || "User";
|
||
|
||
// Telegram avatar URL
|
||
const avatarUrl = user.username
|
||
? `https://t.me/i/userpic/320/${user.username}.jpg`
|
||
: `https://ui-avatars.com/api/?name=${encodeURIComponent(avatarName)}`;
|
||
|
||
return (
|
||
<NavUser
|
||
user={{
|
||
name: fullName,
|
||
email: user.username
|
||
? `@${user.username}`
|
||
: `ID: ${user.telegram_id}`,
|
||
avatar: avatarUrl,
|
||
}}
|
||
workspaceSwitcher={{
|
||
workspaces,
|
||
currentWorkspaceId: currentWorkspace?.id,
|
||
onSelect: (workspace) => handleWorkspaceSelect(workspace.id),
|
||
onCreate: isDemoMode ? undefined : () => setShowCreateDialog(true),
|
||
}}
|
||
/>
|
||
);
|
||
})()}
|
||
</SidebarFooter>
|
||
<SidebarRail />
|
||
</Sidebar>
|
||
|
||
{isTrainingVisible && (
|
||
<div className="fixed inset-0 z-30 bg-transparent" aria-hidden="true" />
|
||
)}
|
||
<TrainingGuidePanel
|
||
open={isTrainingVisible}
|
||
step={currentTrainingStep}
|
||
stepIndex={safeTrainingStepIndex}
|
||
totalSteps={trainingStepsCount}
|
||
onClose={closeTraining}
|
||
onNext={goToNextTrainingStep}
|
||
onPrev={goToPrevTrainingStep}
|
||
/>
|
||
|
||
{/* Create Workspace Dialog */}
|
||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||
<DialogContent className="sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>Создать воркспейс</DialogTitle>
|
||
<DialogDescription>
|
||
Воркспейс — это изолированное пространство для вашей команды
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="grid gap-4 py-4">
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="workspace-name">Название</Label>
|
||
<Input
|
||
id="workspace-name"
|
||
placeholder="Например: Моя команда"
|
||
value={newWorkspaceName}
|
||
onChange={(e) => setNewWorkspaceName(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
handleCreateWorkspace();
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setShowCreateDialog(false)}
|
||
>
|
||
Отмена
|
||
</Button>
|
||
<Button
|
||
onClick={handleCreateWorkspace}
|
||
disabled={!newWorkspaceName.trim() || isCreating}
|
||
>
|
||
{isCreating ? "Создание..." : "Создать"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|
||
|
||
interface TrainingGuidePanelProps {
|
||
open: boolean;
|
||
step?: TrainingStep;
|
||
stepIndex: number;
|
||
totalSteps: number;
|
||
onPrev: () => void;
|
||
onNext: () => void;
|
||
onClose: () => void;
|
||
}
|
||
|
||
function TrainingGuidePanel({
|
||
open,
|
||
step,
|
||
stepIndex,
|
||
totalSteps,
|
||
onPrev,
|
||
onNext,
|
||
onClose,
|
||
}: TrainingGuidePanelProps) {
|
||
if (!open || !step) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-y-0 right-0 z-40 flex w-full justify-end"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="Обучение платформе"
|
||
>
|
||
<div className="flex h-full w-full max-w-md flex-col gap-6 border-l bg-background/95 p-6 shadow-2xl backdrop-blur">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<div className="flex items-center gap-2 text-xs font-semibold uppercase text-muted-foreground">
|
||
<PlayCircle className="size-4 text-primary" />
|
||
Обучение
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
Шаг {stepIndex + 1} из {totalSteps}
|
||
</p>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={onClose}
|
||
aria-label="Закрыть обучение"
|
||
>
|
||
<X className="size-4" />
|
||
</Button>
|
||
</div>
|
||
<div className="space-y-3">
|
||
<div className="flex items-center gap-3">
|
||
{step.icon ? (
|
||
<div className="rounded-full bg-primary/10 p-2 text-primary">
|
||
<step.icon className="size-5" />
|
||
</div>
|
||
) : null}
|
||
<h3 className="text-2xl font-semibold">{step.title}</h3>
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">{step.description}</p>
|
||
</div>
|
||
{step.sections?.length ? (
|
||
<div className="space-y-4">
|
||
{step.sections.map((section) => (
|
||
<div
|
||
key={section.title}
|
||
className="rounded-lg border border-border/70 bg-muted/20 p-4"
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
{section.icon ? (
|
||
<div className="rounded-md bg-primary/10 p-2 text-primary">
|
||
<section.icon className="size-4" />
|
||
</div>
|
||
) : null}
|
||
<div>
|
||
<p className="text-sm font-semibold">{section.title}</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{section.description}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{section.points?.length ? (
|
||
<ul className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||
{section.points.map((point) => (
|
||
<li
|
||
key={point}
|
||
className="flex items-start gap-2 rounded-md bg-background/80 p-2"
|
||
>
|
||
<span className="mt-1 h-1.5 w-1.5 rounded-full bg-primary" />
|
||
<span>{point}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : step.points?.length ? (
|
||
<ul className="space-y-3 text-sm text-muted-foreground">
|
||
{step.points.map((point) => (
|
||
<li
|
||
key={point}
|
||
className="rounded-md border border-border/60 bg-muted/40 p-3"
|
||
>
|
||
{point}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
<div className="mt-auto flex gap-2 pt-2">
|
||
<Button variant="outline" onClick={onPrev} disabled={stepIndex === 0}>
|
||
Назад
|
||
</Button>
|
||
{stepIndex >= totalSteps - 1 ? (
|
||
<Button className="flex-1" onClick={onClose}>
|
||
Завершить
|
||
</Button>
|
||
) : (
|
||
<Button className="flex-1" onClick={onNext}>
|
||
Дальше
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|