feat: global ui & functional update
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user