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)
This commit is contained in:
NW
2026-08-08 01:31:45 +01:00
parent 62534dbe85
commit a4a5fd449d
161 changed files with 25681 additions and 5 deletions

View File

@@ -0,0 +1,332 @@
"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>
);
}