Files
telegram-shop/admin-next/src/components/layout/admin-sidebar.tsx
NW a4a5fd449d feat(admin): integrate Next.js admin panel (admin-next/) from feat/nextjs-admin
- Add admin-next/: full Next.js + Prisma + shadcn admin panel (45 API routes, 76 components)
- docker-compose: tg_shop_admin service (port 3000, shared db/shop.db, prisma), bot talks to it via ADMIN_CHAT_URL=http://tg_shop_admin:3000/api/chat
- tor-proxy now proxies onion admin to tg_shop_admin:3000 (new panel)
- chatbotService: ADMIN_CHAT_URL default = http://tg_shop_admin:3000/api/chat (removed localhost:3100 anachronism)
- .env admin secrets gitignored (admin-next/.env)
2026-08-08 01:31:45 +01:00

333 lines
12 KiB
TypeScript
Executable File
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";
import { useState, useEffect } from "react";
import {
LayoutDashboard,
Package,
Users,
Wallet,
ShoppingCart,
FileText,
Settings,
Languages,
AlertTriangle,
LogOut,
ShieldCheck,
Shield,
Bot,
Target,
FolderTree,
} from "lucide-react";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
} from "@/components/ui/sidebar";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { useAuthStore } from "@/stores/auth-store";
const mainNav = [
{ title: "Dashboard", href: "/", icon: LayoutDashboard, shortcut: "1" },
{ title: "Пользователи", href: "/users", icon: Users, shortcut: "2" },
{ title: "Кошельки", href: "/wallets", icon: Wallet, shortcut: "3" },
{ title: "Покупки", href: "/purchases", icon: ShoppingCart, badge: true, shortcut: "4" },
{ title: "Аудит", href: "/audit", icon: FileText, shortcut: "5" },
];
const catalogNav = [
{ title: "Каталог товаров", href: "/catalog", icon: FolderTree, shortcut: "6" },
];
const automationNav = [
{ title: "AI Chatbot", href: "/chatbot", icon: Bot },
{ title: "Лиды", href: "/leads", icon: Target },
];
const systemNav = [
{ title: "Настройки", href: "/settings", icon: Settings, shortcut: "9" },
{ title: "Локали", href: "/locales", icon: Languages },
];
function usePendingCount(isAuthenticated: boolean) {
const [count, setCount] = useState(0);
useEffect(() => {
if (!isAuthenticated) return;
let cancelled = false;
const load = () => {
fetch("/api/purchases/bulk?status=pending&limit=1")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (!cancelled && data) setCount(data.total ?? 0);
})
.catch(() => {});
};
load();
window.addEventListener("focus", load);
return () => {
cancelled = true;
window.removeEventListener("focus", load);
};
}, [isAuthenticated]);
return count;
}
function useConnectionStatus() {
const { checkSession } = useAuthStore();
const [connected, setConnected] = useState(false);
useEffect(() => {
let cancelled = false;
const check = () => {
checkSession().then((valid) => {
if (!cancelled) setConnected(valid);
});
};
check();
window.addEventListener("focus", check);
return () => {
cancelled = true;
window.removeEventListener("focus", check);
};
}, [checkSession]);
return connected;
}
function useHashPath() {
const [hash, setHash] = useState("");
useEffect(() => {
const update = () => setHash(window.location.hash.slice(1) || "/");
update();
window.addEventListener("hashchange", update);
return () => window.removeEventListener("hashchange", update);
}, []);
return hash;
}
export function AdminSidebar() {
const pathname = useHashPath();
const { role, logout, isAuthenticated } = useAuthStore();
const pendingCount = usePendingCount(isAuthenticated);
const connected = useConnectionStatus();
return (
<Sidebar collapsible="icon">
<SidebarHeader className="p-4">
<button
type="button"
className="flex items-center gap-3 group-data-[collapsible=icon]:justify-center w-full transition-transform hover:scale-110 cursor-pointer"
onClick={() => { window.location.hash = '#/'; }}
aria-label="Go to Dashboard"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground text-sm font-bold">
TS
</div>
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
<span className="text-sm font-semibold truncate">TG Shop</span>
<span className="text-xs text-muted-foreground">Admin Panel</span>
</div>
</button>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Основное</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="stagger-in">
{mainNav.map((item) => (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
asChild
isActive={
item.href === "/"
? pathname === "/"
: pathname.startsWith(item.href)
}
tooltip={item.title}
className="[&[data-active='true']]:border-l-2 [&[data-active='true']]:border-l-sidebar-primary relative"
>
<a href={item.href}>
<item.icon className="size-4 text-muted-foreground transition-colors group-hover/sidebar-menu-button:text-primary" />
<span>{item.title}</span>
{item.shortcut && (
<kbd className="ml-auto text-[10px] font-mono text-muted-foreground/50 bg-muted/50 px-1.5 py-0.5 rounded group-data-[collapsible=icon]:hidden">
{item.shortcut}
</kbd>
)}
</a>
</SidebarMenuButton>
{item.badge && pendingCount > 0 && (
<SidebarMenuBadge className="bg-destructive text-destructive-foreground">
{pendingCount}
</SidebarMenuBadge>
)}
{item.badge && pendingCount > 0 && (
<span className="sidebar-indicator-dot absolute left-0 top-1/2 -translate-y-1/2 w-1.5 h-1.5 rounded-full bg-destructive group-data-[collapsible=icon]:left-1/2 group-data-[collapsible=icon]:-translate-x-1/2" />
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Каталог товаров</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="stagger-in">
{catalogNav.map((item) => (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
asChild
isActive={
item.href === "/catalog"
? (pathname === "/catalog" || pathname.startsWith("/catalog?"))
: pathname === item.href || pathname.startsWith(item.href)
}
tooltip={item.title}
className="[&[data-active='true']]:border-l-2 [&[data-active='true']]:border-l-sidebar-primary relative"
>
<a href={item.href}>
<item.icon className="size-4 text-muted-foreground transition-colors group-hover/sidebar-menu-button:text-primary" />
<span>{item.title}</span>
{item.shortcut && (
<kbd className="ml-auto text-[10px] font-mono text-muted-foreground/50 bg-muted/50 px-1.5 py-0.5 rounded group-data-[collapsible=icon]:hidden">
{item.shortcut}
</kbd>
)}
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Автоматизация</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="stagger-in">
{automationNav.map((item) => (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
asChild
isActive={pathname.startsWith(item.href)}
tooltip={item.title}
className="[&[data-active='true']]:border-l-2 [&[data-active='true']]:border-l-sidebar-primary relative"
>
<a href={item.href}>
<item.icon className="size-4 text-muted-foreground transition-colors group-hover/sidebar-menu-button:text-primary" />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Система</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="stagger-in">
{systemNav.map((item) => (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
asChild
isActive={pathname.startsWith(item.href)}
tooltip={item.title}
className="[&[data-active='true']]:border-l-2 [&[data-active='true']]:border-l-sidebar-primary relative"
>
<a href={item.href}>
<item.icon className="size-4 text-muted-foreground transition-colors group-hover/sidebar-menu-button:text-primary" />
<span>{item.title}</span>
{item.shortcut && (
<kbd className="ml-auto text-[10px] font-mono text-muted-foreground/50 bg-muted/50 px-1.5 py-0.5 rounded group-data-[collapsible=icon]:hidden">
{item.shortcut}
</kbd>
)}
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
{role === "super_admin" && (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={pathname.startsWith("/seed")}
tooltip="Danger Zone"
>
<a href="/seed">
<AlertTriangle className="size-4 text-destructive" />
<span className="text-destructive">Danger Zone</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarSeparator className="mb-1" />
<div className="flex items-center gap-3 px-3 py-2 group-data-[collapsible=icon]:justify-center">
<Avatar className="size-8 ring-1 ring-border">
<AvatarFallback className="bg-primary/10 text-primary text-xs">
{role === "super_admin" ? (
<ShieldCheck className="size-4" />
) : (
<Shield className="size-4" />
)}
</AvatarFallback>
</Avatar>
<div className="flex flex-1 flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
<span className="text-sm font-medium truncate">
{role === "super_admin" ? "Super Admin" : "Admin"}
</span>
<Badge
variant={role === "super_admin" ? "default" : "secondary"}
className="w-fit text-[10px] px-1.5 py-0 mt-0.5"
>
{role}
</Badge>
</div>
<button
onClick={logout}
className="shrink-0 rounded-md p-1.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors group-data-[collapsible=icon]:hidden"
title="Logout"
>
<LogOut className="size-4" />
</button>
</div>
<div className="flex items-center gap-2 px-3 pb-3 pt-1 group-data-[collapsible=icon]:justify-center">
<span
className={`size-1.5 rounded-full shrink-0 transition-colors ${
connected ? "bg-green-500 glow-success" : "bg-muted-foreground/50"
}`}
/>
<span className="text-[11px] text-muted-foreground group-data-[collapsible=icon]:hidden">
{connected ? "Connected" : "Disconnected"}
</span>
</div>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}