feat: global ui & functional update

This commit is contained in:
ivannoskov
2026-01-21 22:43:54 +03:00
parent 8958d89d12
commit 619fd5c1ef
32 changed files with 3803 additions and 1325 deletions

View File

@@ -16,7 +16,7 @@ import {
ClipboardCheck,
ClipboardList,
Filter,
Folder,
FolderKanban,
Gauge,
KanbanSquare,
LayoutDashboard,
@@ -27,12 +27,15 @@ import {
MessageCircle,
MonitorPlay,
NotebookPen,
Palette,
PlayCircle,
Settings,
ShoppingCart,
Sparkles,
Trash2,
Tv,
TrendingUp,
Upload,
BookOpen,
ChevronRight,
X,
@@ -41,6 +44,7 @@ import {
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,
@@ -97,10 +101,12 @@ interface NavItem {
icon?: LucideIcon;
isActive?: boolean;
requiredPermission?: "projects_read" | "placements_read" | "analytics_read" | "admin_full";
tooltip?: string;
items?: {
title: string;
url: string;
icon?: LucideIcon;
tooltip?: string;
}[];
}
@@ -338,7 +344,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
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;
@@ -362,13 +371,15 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
url: buildRoute.dashboard(workspaceId),
icon: LayoutDashboard,
isActive: true,
tooltip: "Обзор ключевых метрик: расходы, охват, подписчики, CPF и CPM",
});
// 2. Проекты (всегда доступны)
items.push({
title: "Проекты",
url: buildRoute.projects(workspaceId),
icon: Tv,
icon: FolderKanban,
tooltip: "Управление рекламными кампаниями: статус, бюджет, ответственные",
});
// 3. Креативы (только с доступом к размещениям)
@@ -376,67 +387,62 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
items.push({
title: "Креативы",
url: buildRoute.creatives(workspaceId),
icon: Folder,
icon: Palette,
requiredPermission: "placements_read",
tooltip: "Шаблоны рекламных сообщений для закупов",
});
}
// 4. Каталог каналов (всегда доступен)
items.push({
title: "Каталог каналов",
url: buildRoute.channels(workspaceId),
icon: Building2,
});
// 5. Планы закупов (правка названия)
// 4. Размещения (ранее "Планы закупов")
if (hasPermission("placements_read")) {
items.push({
title: "Планы закупов",
url: buildRoute.purchasePlans(workspaceId),
icon: ClipboardList,
requiredPermission: "placements_read",
tooltip: "Черновики кампаний: сбор каналов, фиксация ставок, согласование бюджета",
});
}
// 6. Размещения
if (hasPermission("placements_read")) {
items.push({
title: "Размещения",
url: buildRoute.placements(workspaceId),
icon: ShoppingCart,
requiredPermission: "placements_read",
});
}
// 7. Аналитика — только два подраздела
// 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: Folder,
icon: Palette,
tooltip: "Анализ результатов по рекламным шаблонам",
},
],
});
}
// 8. Настройки (без подразделов, админ)
// 6. Настройки (без подразделов, админ)
if (isAdmin) {
items.push({
title: "Настройки",
url: buildRoute.settings(workspaceId),
icon: Settings,
requiredPermission: "admin_full",
tooltip: "Управление доступом и параметрами рабочего пространства",
});
}
@@ -503,7 +509,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}, [isTrainingOpen, currentTrainingStep]);
const isTrainingVisible = isTrainingOpen && Boolean(currentTrainingStep);
const channelLink = React.useMemo<SupportLink>(
type SimpleLink = { title: string; href: string; icon: LucideIcon; external?: boolean };
const channelLink = React.useMemo<SimpleLink>(
() => ({
title: "Наш канал",
href: "https://t.me/+xqHlRelJ2etkMGIy",
@@ -593,9 +601,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
try {
setIsCreating(true);
const workspace = await createWorkspace(newWorkspaceName.trim());
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);
@@ -604,6 +614,30 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}
};
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}>
@@ -663,7 +697,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarContent>
<SidebarFooter>
<SidebarGroup className="px-0">
<SidebarGroup className="px-0 pb-0">
<SidebarGroupContent>
<SidebarMenu>
{/* Наш канал */}
@@ -671,6 +705,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenuButton
asChild
size="sm"
className="!p-1.5 !h-7"
tooltip={isSidebarCollapsed ? channelLink.title : undefined}
>
<a
@@ -679,9 +714,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<channelLink.icon className="size-4 text-muted-foreground/90" />
<span className="text-muted-foreground/90">{channelLink.title}</span>
<ArrowUpRight className="ml-auto size-3.5 text-muted-foreground/60" />
<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>
@@ -690,12 +725,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenuItem>
<SidebarMenuButton
size="sm"
className="!p-1.5 !h-7"
tooltip={isSidebarCollapsed ? "Обучение" : undefined}
onClick={openTraining}
className="cursor-pointer"
>
<MonitorPlay className="size-4 text-muted-foreground/90" />
<span className="text-muted-foreground/90">Обучение</span>
<MonitorPlay className="size-3.5 text-muted-foreground/90" />
<span className="text-xs text-muted-foreground/90">Обучение</span>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -706,14 +741,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="sm"
className="!p-1.5 !h-7 justify-between"
tooltip="Помощь"
className="justify-between"
>
<div className="flex items-center gap-2">
<LifeBuoy className="size-4 text-muted-foreground/90" />
<span className="text-muted-foreground/90">Помощь</span>
<LifeBuoy className="size-3.5 text-muted-foreground/90" />
<span className="text-xs text-muted-foreground/90">Помощь</span>
</div>
<ChevronRight className="size-4 text-muted-foreground/60" />
<ChevronRight className="size-3 text-muted-foreground/60" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -735,20 +770,20 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<CollapsibleTrigger asChild>
<SidebarMenuButton
size="sm"
className="justify-between"
className="!p-1.5 !h-7 justify-between"
>
<div className="flex items-center gap-2">
<LifeBuoy className="size-4 text-muted-foreground/90" />
<span className="text-muted-foreground/90">Помощь</span>
<LifeBuoy className="size-3.5 text-muted-foreground/90" />
<span className="text-xs text-muted-foreground/90">Помощь</span>
</div>
<ChevronRight className="size-4 text-muted-foreground/60 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
<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="">
<SidebarMenuSubButton asChild className="!p-1.5 !h-7">
{renderHelpSubItem(item)}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
@@ -826,6 +861,47 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</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
@@ -838,13 +914,19 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
handleCreateWorkspace();
}
}}
disabled={isCreating}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateDialog(false)}
onClick={() => {
setShowCreateDialog(false);
setNewWorkspaceName("");
setNewWorkspaceAvatar(null);
setNewWorkspaceAvatarPreview(null);
}}
>
Отмена
</Button>