feat: global platform v2 update
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user