"use client"; // ============================================================================ // Application Sidebar // ============================================================================ import * as React from "react"; import { useParams } from "next/navigation"; import Link from "next/link"; import { ArrowUpRight, BarChart3, BookmarkCheck, Building2, CalendarClock, ClipboardCheck, ClipboardList, Filter, FolderKanban, Gauge, KanbanSquare, LayoutDashboard, LineChart, LifeBuoy, ListChecks, Megaphone, MessageCircle, MonitorPlay, NotebookPen, Palette, PlayCircle, Settings, ShoppingCart, Sparkles, Trash2, Tv, TrendingUp, Upload, BookOpen, ChevronRight, X, type LucideIcon, } from "lucide-react"; import { type NavGuideState, NavMain } from "@/components/nav-main"; import { NavUser } from "@/components/nav-user"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 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?: "admin_full" | "projects_read" | "projects_write" | "creatives_read" | "creatives_write" | "placements_read" | "placements_write" | "analytics_read" | "analytics_without_clicks" | "analytics_own_creatives"; tooltip?: string; items?: { title: string; url: string; icon?: LucideIcon; tooltip?: string; }[]; } 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; } 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) { 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 [newWorkspaceAvatar, setNewWorkspaceAvatar] = React.useState(null); const [newWorkspaceAvatarPreview, setNewWorkspaceAvatarPreview] = React.useState(null); const [isCreating, setIsCreating] = React.useState(false); const newWorkspaceAvatarInputRef = React.useRef(null); 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, tooltip: "Обзор ключевых метрик: расходы, охват, подписчики, CPF и CPM", }); // 2. Проекты (всегда доступны) items.push({ title: "Проекты", url: buildRoute.projects(workspaceId), icon: FolderKanban, tooltip: "Управление рекламными кампаниями: статус, бюджет, ответственные", }); // 3. Креативы (только с доступом к размещениям) if (hasPermission("placements_read")) { items.push({ title: "Креативы", url: buildRoute.creatives(workspaceId), icon: Palette, requiredPermission: "placements_read", tooltip: "Шаблоны рекламных сообщений для закупов", }); } // 4. Размещения (ранее "Планы закупов") if (hasPermission("placements_read")) { items.push({ title: "Планы закупов", url: buildRoute.purchasePlans(workspaceId), icon: ClipboardList, requiredPermission: "placements_read", tooltip: "Черновики кампаний: сбор каналов, фиксация ставок, согласование бюджета", }); } // 5. Аналитика — проверяем любой из аналитических доступов if (hasPermission("analytics_read") || hasPermission("analytics_without_clicks") || hasPermission("analytics_own_creatives")) { items.push({ title: "Аналитика", url: buildRoute.analytics(workspaceId), icon: BarChart3, requiredPermission: "analytics_read", tooltip: "Сводные отчеты: эффективность по проектам и креативам", items: [ { title: "Все размещения", url: buildRoute.analyticsPlacements(workspaceId), icon: ShoppingCart, tooltip: "Сводная таблица по всем закупкам", }, { title: "По проектам", url: buildRoute.analyticsProjects(workspaceId), icon: Tv, tooltip: "Метрики эффективности по каждому проекту", }, { title: "По креативам", url: buildRoute.analyticsCreatives(workspaceId), icon: Palette, tooltip: "Анализ результатов по рекламным шаблонам", }, ], }); } // 6. Настройки (без подразделов, админ) if (isAdmin) { items.push({ title: "Настройки", url: buildRoute.settings(workspaceId), icon: Settings, requiredPermission: "admin_full", tooltip: "Управление доступом и параметрами рабочего пространства", }); } 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(() => { if (!isTrainingOpen || !currentTrainingStep) return undefined; return { highlightedItem: currentTrainingStep.navItemTitle, expandedItems: currentTrainingStep.expandedNavItems, }; }, [isTrainingOpen, currentTrainingStep]); const isTrainingVisible = isTrainingOpen && Boolean(currentTrainingStep); type SimpleLink = { title: string; href: string; icon: LucideIcon; external?: boolean }; const channelLink = React.useMemo( () => ({ title: "Наш канал", href: "https://t.me/+xqHlRelJ2etkMGIy", icon: Megaphone, external: true, }), [] ); const helpSubItems = React.useMemo( () => [ { title: "Поддержка", description: "Свяжитесь с нашей службой поддержки.", href: "https://t.me/smartpost_help_bot", icon: MessageCircle, external: true, }, { title: "База знаний", description: "Документация и инструкции по использованию.", href: "https://docs.smartpost.ru", icon: BookOpen, external: true, }, { title: "Демо аккаунт", description: "Посмотрите пример рабочего пространства.", href: buildRoute.dashboard(DEMO_WORKSPACE_ID), icon: Sparkles, }, ], [] ); const renderHelpSubItem = React.useCallback((item: HelpItem) => { const content = ( <>

{item.title}

{item.external && ( )} ); if (item.action) { return ( ); } if (item.href) { if (item.external) { return ( {content} ); } return ( {content} ); } return (
{content}
); }, []); const handleCreateWorkspace = async () => { if (!newWorkspaceName.trim()) return; try { setIsCreating(true); const workspace = await createWorkspace(newWorkspaceName.trim(), newWorkspaceAvatar); setShowCreateDialog(false); setNewWorkspaceName(""); setNewWorkspaceAvatar(null); setNewWorkspaceAvatarPreview(null); setCurrentWorkspace(workspace); } catch (err) { console.error("Failed to create workspace:", err); } finally { setIsCreating(false); } }; const handleNewWorkspaceAvatarChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; if (file.size > 2 * 1024 * 1024) { return; } setNewWorkspaceAvatar(file); const reader = new FileReader(); reader.onload = () => { setNewWorkspaceAvatarPreview(reader.result as string); }; reader.readAsDataURL(file); }; const handleNewWorkspaceAvatarRemove = () => { setNewWorkspaceAvatar(null); setNewWorkspaceAvatarPreview(null); if (newWorkspaceAvatarInputRef.current) { newWorkspaceAvatarInputRef.current.value = ""; } }; return ( <> {isSidebarCollapsed ? (
Smartpost
) : (
Smartpost Smartpost
)} {/*
Smartpost Управление закупками
*/}
{/* Наш канал */} {channelLink.title} {/* Обучение */} Обучение {/* Помощь с подменю */} {isSidebarCollapsed ? (
Помощь
{helpSubItems.map((item) => ( {renderHelpSubItem(item)} ))}
) : (
Помощь
{helpSubItems.map((item) => ( {renderHelpSubItem(item)} ))}
)}
{/* 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 ( handleWorkspaceSelect(workspace.id), onCreate: isDemoMode ? undefined : () => setShowCreateDialog(true), }} /> ); })()}
{isTrainingVisible && (