Files
tgex-frontend/components/layout/app-sidebar.tsx
2026-01-21 22:43:54 +03:00

1075 lines
39 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.
"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?: "projects_read" | "placements_read" | "analytics_read" | "admin_full";
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<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 [newWorkspaceAvatar, setNewWorkspaceAvatar] = React.useState<File | null>(null);
const [newWorkspaceAvatarPreview, setNewWorkspaceAvatarPreview] = React.useState<string | null>(null);
const [isCreating, setIsCreating] = React.useState(false);
const newWorkspaceAvatarInputRef = React.useRef<HTMLInputElement>(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")) {
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<NavGuideState | undefined>(() => {
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<SimpleLink>(
() => ({
title: "Наш канал",
href: "https://t.me/+xqHlRelJ2etkMGIy",
icon: Megaphone,
external: true,
}),
[]
);
const helpSubItems = React.useMemo<HelpItem[]>(
() => [
{
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.icon className="mt-1.5 size-3 shrink-0 !text-muted-foreground/90" />
<p className="text-xs font-light mt-1.5 text-muted-foreground/90 flex-1">{item.title}</p>
{item.external && (
<ArrowUpRight className="mt-1.5 size-3 shrink-0 !text-muted-foreground/60" />
)}
</>
);
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(), 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<HTMLInputElement>) => {
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 (
<>
<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.png"
width={expandedLogoWidth*2}
height={expandedLogoHeight*2}
alt="Smartpost"
className="shrink-0 dark:hidden"
style={expandedLogoDimensions}
/>
<Image
src="/logo_full_white.png"
width={expandedLogoWidth*2}
height={expandedLogoHeight*2}
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 pb-0">
<SidebarGroupContent>
<SidebarMenu>
{/* Наш канал */}
<SidebarMenuItem>
<SidebarMenuButton
asChild
size="sm"
className="!p-1.5 !h-7"
tooltip={isSidebarCollapsed ? channelLink.title : undefined}
>
<a
href={channelLink.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<channelLink.icon className="size-3.5 text-muted-foreground/90" />
<span className="text-xs text-muted-foreground/90">{channelLink.title}</span>
<ArrowUpRight className="ml-auto size-3 text-muted-foreground/60" />
</a>
</SidebarMenuButton>
</SidebarMenuItem>
{/* Обучение */}
<SidebarMenuItem>
<SidebarMenuButton
size="sm"
className="!p-1.5 !h-7"
tooltip={isSidebarCollapsed ? "Обучение" : undefined}
onClick={openTraining}
>
<MonitorPlay className="size-3.5 text-muted-foreground/90" />
<span className="text-xs text-muted-foreground/90">Обучение</span>
</SidebarMenuButton>
</SidebarMenuItem>
{/* Помощь с подменю */}
{isSidebarCollapsed ? (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="sm"
className="!p-1.5 !h-7 justify-between"
tooltip="Помощь"
>
<div className="flex items-center gap-2">
<LifeBuoy className="size-3.5 text-muted-foreground/90" />
<span className="text-xs text-muted-foreground/90">Помощь</span>
</div>
<ChevronRight className="size-3 text-muted-foreground/60" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="right"
align="start"
className="min-w-[280px] space-y-1"
>
{helpSubItems.map((item) => (
<DropdownMenuItem key={item.title} asChild>
{renderHelpSubItem(item)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
) : (
<Collapsible defaultOpen asChild>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton
size="sm"
className="!p-1.5 !h-7 justify-between"
>
<div className="flex items-center gap-2">
<LifeBuoy className="size-3.5 text-muted-foreground/90" />
<span className="text-xs text-muted-foreground/90">Помощь</span>
</div>
<ChevronRight className="size-3 text-muted-foreground/60 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{helpSubItems.map((item) => (
<SidebarMenuSubItem key={item.title}>
<SidebarMenuSubButton asChild className="!p-1.5 !h-7">
{renderHelpSubItem(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">
{/* Avatar */}
<div className="flex items-center gap-4">
<Avatar className="h-12 w-12">
<AvatarImage src={newWorkspaceAvatarPreview || undefined} alt="Avatar" />
<AvatarFallback>
{newWorkspaceName.charAt(0).toUpperCase() || "W"}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => newWorkspaceAvatarInputRef.current?.click()}
disabled={isCreating}
>
<Upload className="h-4 w-4 mr-2" />
Загрузить
</Button>
{newWorkspaceAvatarPreview && (
<Button
variant="outline"
size="sm"
onClick={handleNewWorkspaceAvatarRemove}
disabled={isCreating}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<input
ref={newWorkspaceAvatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleNewWorkspaceAvatarChange}
/>
</div>
</div>
{/* Name */}
<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();
}
}}
disabled={isCreating}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowCreateDialog(false);
setNewWorkspaceName("");
setNewWorkspaceAvatar(null);
setNewWorkspaceAvatarPreview(null);
}}
>
Отмена
</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 border-l bg-background/95 p-6 shadow-2xl backdrop-blur">
<div className="flex items-start justify-between gap-4 shrink-0">
<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="flex-1 overflow-y-auto py-4">
<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="mt-6 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="mt-6 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>
<div className="flex gap-2 pt-4 shrink-0">
<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>
);
}