- 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)
333 lines
12 KiB
TypeScript
Executable File
333 lines
12 KiB
TypeScript
Executable File
"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>
|
||
);
|
||
}
|