feat: global platform v2 update

This commit is contained in:
ivannoskov
2025-12-29 10:12:13 +03:00
parent 8e6a1fa83f
commit f64cf02100
84 changed files with 11023 additions and 11486 deletions

View File

@@ -5,15 +5,25 @@
// ============================================================================
import * as React from "react";
import { useParams } from "next/navigation";
import {
BarChart3,
Building2,
Check,
ChevronsUpDown,
ClipboardList,
ExternalLink,
Folder,
HelpCircle,
LayoutDashboard,
Megaphone,
MessageCircle,
Plus,
Settings,
ShoppingCart,
TrendingUp,
Settings2,
ExternalLink,
Tv,
BookOpen,
type LucideIcon,
} from "lucide-react";
import { NavMain } from "@/components/nav-main";
@@ -23,126 +33,419 @@ import {
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
SidebarGroup,
SidebarGroupContent,
} from "@/components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
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, BOT_USERNAME } from "@/lib/utils/constants";
const data = {
navMain: [
{
title: "Главная",
url: "/",
icon: LayoutDashboard,
isActive: true,
},
{
title: "Внешние каналы",
url: "/external-channels",
icon: ExternalLink,
items: [
{
title: "Каталог",
url: "/external-channels",
},
{
title: "Импорт",
url: "/external-channels/import",
},
],
},
{
title: "Креативы",
url: "/creatives",
icon: Folder,
items: [
{
title: "Все креативы",
url: "/creatives",
},
{
title: "Создать",
url: "/creatives/new",
},
],
},
{
title: "Закупы",
url: "/purchases",
icon: ShoppingCart,
items: [
{
title: "Все закупы",
url: "/purchases",
},
{
title: "Создать",
url: "/purchases/new",
},
],
},
{
title: "Аналитика",
url: "/analytics",
icon: BarChart3,
items: [
{
title: "Обзор",
url: "/analytics",
},
{
title: "Затраты",
url: "/analytics/costs",
},
{
title: "Целевые каналы",
url: "/analytics/target-channels",
},
{
title: "Креативы",
url: "/analytics/creatives",
},
],
},
],
};
// ============================================================================
// 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;
}[];
}
interface FooterLink {
title: string;
url: string;
icon: LucideIcon;
external?: boolean;
}
// ============================================================================
// Footer Links Configuration
// ============================================================================
const footerLinks: FooterLink[] = [
{
title: "Поддержка",
url: `https://t.me/${BOT_USERNAME}`,
icon: MessageCircle,
external: true,
},
{
title: "Информационный канал",
url: "https://t.me/tgex_news",
icon: Megaphone,
external: true,
},
{
title: "База знаний",
url: "https://docs.tgex.io",
icon: BookOpen,
external: true,
},
];
// ============================================================================
// 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,
} = useWorkspace();
const [showCreateDialog, setShowCreateDialog] = React.useState(false);
const [newWorkspaceName, setNewWorkspaceName] = React.useState("");
const [isCreating, setIsCreating] = React.useState(false);
// Build navigation items based on permissions
const navItems: NavItem[] = React.useMemo(() => {
if (!workspaceId) return [];
const items: NavItem[] = [
{
title: "Главная",
url: buildRoute.dashboard(workspaceId),
icon: LayoutDashboard,
isActive: true,
},
];
// Projects - always visible (contains instructions how to add projects via bot)
items.push({
title: "Проекты",
url: buildRoute.projects(workspaceId),
icon: Tv,
});
// Channels catalog (available to all)
items.push({
title: "Каталог каналов",
url: buildRoute.channels(workspaceId),
icon: Building2,
});
// Purchase Plans (requires placements_read or admin)
if (hasPermission("placements_read")) {
items.push({
title: "Планы закупок",
url: buildRoute.purchasePlans(workspaceId),
icon: ClipboardList,
requiredPermission: "placements_read",
});
}
// Creatives (requires placements_read or admin)
if (hasPermission("placements_read")) {
items.push({
title: "Креативы",
url: buildRoute.creatives(workspaceId),
icon: Folder,
requiredPermission: "placements_read",
items: [
{
title: "Все креативы",
url: buildRoute.creatives(workspaceId),
},
...(hasPermission("placements_write")
? [
{
title: "Создать",
url: buildRoute.creativeNew(workspaceId),
},
]
: []),
],
});
}
// Placements (requires placements_read or admin)
if (hasPermission("placements_read")) {
items.push({
title: "Размещения",
url: buildRoute.placements(workspaceId),
icon: ShoppingCart,
requiredPermission: "placements_read",
items: [
{
title: "Все размещения",
url: buildRoute.placements(workspaceId),
},
...(hasPermission("placements_write")
? [
{
title: "Создать",
url: buildRoute.placementNew(workspaceId),
},
]
: []),
],
});
}
// Analytics (requires analytics_read or admin)
if (hasPermission("analytics_read")) {
items.push({
title: "Аналитика",
url: buildRoute.analytics(workspaceId),
icon: BarChart3,
requiredPermission: "analytics_read",
items: [
{
title: "Обзор",
url: buildRoute.analytics(workspaceId),
},
{
title: "Расходы",
url: buildRoute.analyticsSpending(workspaceId),
},
{
title: "Каналы",
url: buildRoute.analyticsChannels(workspaceId),
},
{
title: "Креативы",
url: buildRoute.analyticsCreatives(workspaceId),
},
],
});
}
// Settings (admin only)
if (isAdmin) {
items.push({
title: "Настройки",
url: buildRoute.settings(workspaceId),
icon: Settings,
requiredPermission: "admin_full",
items: [
{
title: "Участники",
url: buildRoute.members(workspaceId),
},
{
title: "Приглашения",
url: buildRoute.invites(workspaceId),
},
],
});
}
return items;
}, [workspaceId, hasPermission, isAdmin]);
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>
<div className="flex items-center gap-2 px-2 py-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Megaphone className="h-4 w-4" />
<>
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
{/* Workspace Switcher */}
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Megaphone className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{currentWorkspace?.name || "TGEX"}
</span>
<span className="truncate text-xs text-muted-foreground">
{workspaces.length} воркспейс{workspaces.length === 1 ? "" : workspaces.length < 5 ? "а" : "ов"}
</span>
</div>
<ChevronsUpDown className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
align="start"
side="bottom"
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Воркспейсы
</DropdownMenuLabel>
{workspaces.map((workspace) => (
<DropdownMenuItem
key={workspace.id}
onClick={() => setCurrentWorkspace(workspace)}
className="gap-2 p-2"
>
<div className="flex size-6 items-center justify-center rounded-sm border">
<Building2 className="size-4 shrink-0" />
</div>
<span className="flex-1 truncate">{workspace.name}</span>
{currentWorkspace?.id === workspace.id && (
<Check className="size-4 ml-auto" />
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowCreateDialog(true)}
className="gap-2 p-2"
>
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
<Plus className="size-4" />
</div>
<span className="text-muted-foreground">
Создать воркспейс
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={navItems} />
</SidebarContent>
<SidebarFooter>
{/* Footer Links */}
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{footerLinks.map((link) => (
<SidebarMenuItem key={link.title}>
<SidebarMenuButton asChild size="sm">
<a
href={link.url}
target={link.external ? "_blank" : undefined}
rel={link.external ? "noopener noreferrer" : undefined}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
>
<link.icon className="size-4" />
<span>{link.title}</span>
{link.external && (
<ExternalLink className="size-3 ml-auto opacity-50" />
)}
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator />
{/* User */}
{user && (
<NavUser
user={{
name: user.username || `User ${user.telegram_id}`,
email: user.username
? `@${user.username}`
: `ID: ${user.telegram_id}`,
avatar: user.username
? `https://ui-avatars.com/api/?name=${user.username}`
: `https://ui-avatars.com/api/?name=User`,
}}
/>
)}
</SidebarFooter>
<SidebarRail />
</Sidebar>
{/* 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>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">TGEX</span>
<span className="truncate text-xs text-muted-foreground">
Управление закупками
</span>
</div>
</div>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
</SidebarContent>
<SidebarFooter>
{user && (
<NavUser
user={{
name: user.username || `User ${user.telegram_id}`,
email: user.username
? `@${user.username}`
: `ID: ${user.telegram_id}`,
avatar: user.username
? `https://ui-avatars.com/api/?name=${user.username}`
: `https://ui-avatars.com/api/?name=User`,
}}
/>
)}
</SidebarFooter>
<SidebarRail />
</Sidebar>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateDialog(false)}
>
Отмена
</Button>
<Button
onClick={handleCreateWorkspace}
disabled={!newWorkspaceName.trim() || isCreating}
>
{isCreating ? "Создание..." : "Создать"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}