diff --git a/agent-ctx/new-features-2-main-coordinator.md b/agent-ctx/new-features-2-main-coordinator.md new file mode 100644 index 0000000..17ae314 --- /dev/null +++ b/agent-ctx/new-features-2-main-coordinator.md @@ -0,0 +1,18 @@ +# Task ID: new-features-2 +## Agent: Main Coordinator +## Task: Activity Feed, Export Button, Sortable Header, Clipboard Utility, Integration + +### Files Created +- `src/components/layout/activity-feed.tsx` — ActivityFeed component (fetches audit/bulk, 30s refresh, color-coded icons, relative time) +- `src/components/shared/export-button.tsx` — ExportButton component (CSV export via DropdownMenu, Blob download) +- `src/components/shared/sortable-header.tsx` — SortableHeader component (3-state sort toggle with arrow icons) +- `src/lib/clipboard.ts` — copyToClipboard utility (navigator.clipboard + textarea fallback) + +### Files Modified +- `src/components/dashboard/dashboard-page.tsx` — Replaced inline activity feed with ActivityFeed, removed unused imports/types +- `src/components/purchases/purchases-page.tsx` — Added ExportButton, SortableHeader (date/amount/status), client-side sort, clipboard util +- `src/components/audit/audit-page.tsx` — Added ExportButton, SortableHeader (action/date), client-side sort +- `src/components/users/users-page.tsx` — Added ExportButton next to search input + +### Lint Result +- ESLint: 0 errors, 0 warnings diff --git a/src/app/page.tsx b/src/app/page.tsx index 328dbd1..2b4749d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,72 +6,18 @@ import { AdminSidebar } from "@/components/layout/admin-sidebar"; import { AdminHeader } from "@/components/layout/admin-header"; import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar"; import { LoginPage } from "@/app/login/page"; - -// Lazy-loaded page components -import dynamic from "next/dynamic"; - -const DashboardPage = dynamic(() => import("@/components/dashboard/dashboard-page"), { - ssr: false, - loading: () => , -}); -const CatalogPage = dynamic(() => import("@/components/catalog/catalog-page"), { - ssr: false, - loading: () => , -}); -const UsersPage = dynamic(() => import("@/components/users/users-page"), { - ssr: false, - loading: () => , -}); -const UserDetailPage = dynamic(() => import("@/components/users/user-detail-page"), { - ssr: false, - loading: () => , -}); -const WalletsPage = dynamic(() => import("@/components/wallets/wallets-page"), { - ssr: false, - loading: () => , -}); -const PurchasesPage = dynamic(() => import("@/components/purchases/purchases-page"), { - ssr: false, - loading: () => , -}); -const AuditPage = dynamic(() => import("@/components/audit/audit-page"), { - ssr: false, - loading: () => , -}); -const CategoriesPage = dynamic(() => import("@/components/categories/categories-page"), { - ssr: false, - loading: () => , -}); -const LocationsPage = dynamic(() => import("@/components/locations/locations-page"), { - ssr: false, - loading: () => , -}); -const SettingsPage = dynamic(() => import("@/components/settings/settings-page"), { - ssr: false, - loading: () => , -}); -const LocalesPage = dynamic(() => import("@/components/locales/locales-page"), { - ssr: false, - loading: () => , -}); -const SeedPage = dynamic(() => import("@/components/seed/seed-page"), { - ssr: false, - loading: () => , -}); - -function PageSkeleton() { - return ( -
-
-
- {[...Array(3)].map((_, i) => ( -
- ))} -
-
-
- ); -} +import { DashboardPage } from "@/components/dashboard/dashboard-page"; +import { CatalogPage } from "@/components/catalog/catalog-page"; +import { UsersPage } from "@/components/users/users-page"; +import { UserDetailPage } from "@/components/users/user-detail-page"; +import { WalletsPage } from "@/components/wallets/wallets-page"; +import { PurchasesPage } from "@/components/purchases/purchases-page"; +import { AuditPage } from "@/components/audit/audit-page"; +import { CategoriesPage } from "@/components/categories/categories-page"; +import { LocationsPage } from "@/components/locations/locations-page"; +import { SettingsPage } from "@/components/settings/settings-page"; +import { LocalesPage } from "@/components/locales/locales-page"; +import { SeedPage } from "@/components/seed/seed-page"; export default function AppPage() { const { isAuthenticated, checkSession } = useAuthStore(); @@ -83,7 +29,6 @@ export default function AppPage() { checkSession().then(() => setReady(true)); }, [checkSession]); - // Simple hash-based client-side routing useEffect(() => { const handleHash = () => { const hash = window.location.hash.slice(1) || "/"; @@ -103,7 +48,6 @@ export default function AppPage() { return () => window.removeEventListener("hashchange", handleHash); }, []); - // Intercept link clicks for hash navigation useEffect(() => { const handler = (e: MouseEvent) => { const target = e.target as HTMLElement; @@ -111,7 +55,6 @@ export default function AppPage() { if (!link) return; const href = link.getAttribute("href"); if (!href) return; - // Skip external links and real API links if (href.startsWith("http") || href.startsWith("/api")) return; e.preventDefault(); window.location.hash = href; @@ -149,7 +92,7 @@ export default function AppPage() { if (page === "/locales") return ; if (page === "/seed") return ; return ( -
+

Page not found

); diff --git a/src/components/audit/audit-page.tsx b/src/components/audit/audit-page.tsx index 87d25b2..17df588 100644 --- a/src/components/audit/audit-page.tsx +++ b/src/components/audit/audit-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useMemo } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; @@ -19,7 +19,9 @@ import { } from "@/components/ui/collapsible"; import { toast } from "sonner"; import { format } from "date-fns"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, FileText } from "lucide-react"; +import { ExportButton } from "@/components/shared/export-button"; +import { SortableHeader } from "@/components/shared/sortable-header"; interface AuditRow { id: number; @@ -86,6 +88,8 @@ export function AuditPage() { const [page, setPage] = useState(1); const [loading, setLoading] = useState(true); const [openRows, setOpenRows] = useState>(new Set()); + const [sortColumn, setSortColumn] = useState(""); + const [sortDirection, setSortDirection] = useState<"asc" | "desc" | null>(null); const limit = 100; const fetchData = useCallback(async () => { @@ -110,6 +114,44 @@ export function AuditPage() { const totalPages = Math.max(1, Math.ceil(total / limit)); + const handleSort = (column: string) => { + if (sortColumn === column) { + if (sortDirection === "asc") setSortDirection("desc"); + else if (sortDirection === "desc") { + setSortColumn(""); + setSortDirection(null); + } + } else { + setSortColumn(column); + setSortDirection("asc"); + } + }; + + const sortedLogs = useMemo(() => { + if (!sortColumn || !sortDirection) return logs; + return [...logs].sort((a, b) => { + let valA: unknown; + let valB: unknown; + if (sortColumn === "date") { valA = a.createdAt; valB = b.createdAt; } + else if (sortColumn === "action") { valA = a.action; valB = b.action; } + else return 0; + if (valA === valB) return 0; + const cmp = valA < valB ? -1 : 1; + return sortDirection === "asc" ? cmp : -cmp; + }); + }, [logs, sortColumn, sortDirection]); + + const exportData = useMemo[]>( + () => sortedLogs.map((l) => ({ + ID: l.id, + Action: l.action, + "Admin ID": l.adminId, + Details: l.details || "", + Date: l.createdAt, + })), + [sortedLogs] + ); + const toggleRow = (id: number) => { setOpenRows((prev) => { const next = new Set(prev); @@ -120,15 +162,21 @@ export function AuditPage() { }; return ( -
+
+
+

Audit Log

+ +
+
-
+
{loading ? (
) : logs.length === 0 ? (
+

No audit entries

Audit log is empty.

@@ -137,14 +185,18 @@ export function AuditPage() { ID - Action + + + Admin ID Details - Date + + + - {logs.map((log) => ( + {sortedLogs.map((log) => ( {log.id} @@ -182,7 +234,7 @@ export function AuditPage() { )} - {format(new Date(log.createdAt), "MMM d, HH:mm")} + {format(new Date(log.createdAt), "MMM d, yyyy HH:mm")} ))} diff --git a/src/components/catalog/catalog-page.tsx b/src/components/catalog/catalog-page.tsx index 09a2b37..9c9c3b2 100644 --- a/src/components/catalog/catalog-page.tsx +++ b/src/components/catalog/catalog-page.tsx @@ -694,7 +694,7 @@ export function CatalogPage() { )} {/* Tree */} -
+
{countries.length === 0 ? (

No locations yet. Add one above. diff --git a/src/components/categories/categories-page.tsx b/src/components/categories/categories-page.tsx index beedfe2..a3faf5e 100644 --- a/src/components/categories/categories-page.tsx +++ b/src/components/categories/categories-page.tsx @@ -42,7 +42,7 @@ import { TableRow, } from "@/components/ui/table"; import { toast } from "sonner"; -import { Plus, Pencil, Trash2 } from "lucide-react"; +import { Plus, Pencil, Trash2, FolderOpen } from "lucide-react"; interface LocationItem { id: number; @@ -206,7 +206,9 @@ export function CategoriesPage() { ); return ( -

+
+

Categories

+
-
+
{loading ? (
) : categories.length === 0 ? (
+

No categories

Create your first category to get started.

diff --git a/src/components/dashboard/dashboard-page.tsx b/src/components/dashboard/dashboard-page.tsx index 6172137..671a49e 100644 --- a/src/components/dashboard/dashboard-page.tsx +++ b/src/components/dashboard/dashboard-page.tsx @@ -3,7 +3,9 @@ import { useEffect, useState, useCallback } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; +import { ActivityFeed } from "@/components/layout/activity-feed"; import { Users, @@ -16,7 +18,6 @@ import { Clock, XCircle, Tag, - FileText, } from "lucide-react"; import { @@ -87,14 +88,6 @@ interface TopCountry { productCount: number; } -interface Activity { - type: "purchase" | "audit"; - id: number; - title: string; - description: string; - date: string; -} - interface WalletSummary { walletType: string; count: number; @@ -109,7 +102,6 @@ interface DashboardData { topSpenders: TopSpender[]; revenueByCategory: RevenueByCategory[]; topCountries: TopCountry[]; - activities: Activity[]; walletSummary: WalletSummary[]; } @@ -123,16 +115,6 @@ function formatCrypto(val: number): string { return val.toFixed(8); } -function formatDate(dateStr: string): string { - const d = new Date(dateStr); - return d.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} - function shortDate(dateStr: string): string { const d = new Date(dateStr + "T00:00:00"); return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); @@ -173,7 +155,7 @@ function KpiCard({ function DashboardSkeleton() { return ( -
+
{Array.from({ length: 10 }).map((_, i) => ( @@ -252,7 +234,7 @@ export function DashboardPage() { } if (!data) return null; - const { stats, chartData, topProducts, topSpenders, revenueByCategory, topCountries, activities, walletSummary } = data; + const { stats, chartData, topProducts, topSpenders, revenueByCategory, topCountries, walletSummary } = data; // Prepare chart datasets const revenue7Data = chartData.days.map((day, i) => ({ @@ -294,20 +276,22 @@ export function DashboardPage() { ]; return ( -
+
{/* ── Page Title ── */}
-

Dashboard

+

Dashboard

Overview of your Telegram Shop

{/* ── KPI Cards ── */} -
+
{kpis.map((kpi) => ( ))}
+ + {/* ── Charts Grid ── */}
{/* 1. Revenue 7 days */} @@ -529,56 +513,10 @@ export function DashboardPage() {
- {/* ── Bottom Section: Activity Feed + Wallet Summary ── */} -
- {/* Activity Feed */} - - - Recent Activity - - - {activities.length > 0 ? ( -
- {activities.map((activity) => ( -
-
- {activity.type === "purchase" ? ( - - ) : ( - - )} -
-
-

{activity.title}

-

{activity.description}

-

{formatDate(activity.date)}

-
-
- ))} -
- ) : ( -
No recent activity
- )} -
-
+ + {/* ── Bottom Section: Wallet Summary + Activity Feed ── */} +
{/* Wallet Summary */} @@ -614,6 +552,9 @@ export function DashboardPage() {
+ + {/* ── Activity Feed (full width) ── */} +
); } diff --git a/src/components/layout/activity-feed.tsx b/src/components/layout/activity-feed.tsx new file mode 100644 index 0000000..26722b8 --- /dev/null +++ b/src/components/layout/activity-feed.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { + LogIn, + DollarSign, + UserX, + KeyRound, + Package, + Settings, + CheckCircle, + Wallet, + CreditCard, + UserPlus, + FileText, +} from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +// ─── Types ─────────────────────────────────────────────── + +interface AuditItem { + id: number; + action: string; + adminId: string; + details: string | null; + createdAt: string; +} + +// ─── Icon mapping ───────────────────────────────────────── + +const ICON_MAP: Record< + string, + { icon: React.ComponentType<{ className?: string }>; color: string } +> = { + login: { icon: LogIn, color: "#3b82f6" }, + balance_adjust: { icon: DollarSign, color: "#f97316" }, + status_toggle: { icon: UserX, color: "#ef4444" }, + seed_phrase_viewed: { icon: KeyRound, color: "#a855f7" }, + product_created: { icon: Package, color: "#22c55e" }, + settings_changed: { icon: Settings, color: "#22c55e" }, + purchase_approved: { icon: CheckCircle, color: "#22c55e" }, + wallet_added: { icon: Wallet, color: "#06b6d4" }, + commission_paid: { icon: CreditCard, color: "#f59e0b" }, + user_registered: { icon: UserPlus, color: "#14b8a6" }, +}; + +const DEFAULT_ICON = { icon: FileText, color: "#6b7280" }; + +// ─── Helpers ────────────────────────────────────────────── + +function relativeTime(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diffMs = now - then; + const diffSec = Math.floor(diffMs / 1000); + + if (diffSec < 60) return `${diffSec}s ago`; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + return `${diffDay}d ago`; +} + +function actionDescription(action: string, details: string | null): string { + const label = action.replace(/_/g, " "); + if (!details) return label; + try { + const obj = JSON.parse(details); + if (obj.username) return `${label} — ${obj.username}`; + if (obj.target) return `${label} — ${obj.target}`; + if (obj.userId) return `${label} — user #${obj.userId}`; + } catch { + // not JSON + } + return label; +} + +// ─── Component ──────────────────────────────────────────── + +export function ActivityFeed() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchFeed = useCallback(async () => { + try { + const res = await fetch("/api/audit/bulk?limit=15"); + if (!res.ok) return; + const json = await res.json(); + setItems(json.data ?? []); + } catch { + // silently fail + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchFeed(); + const interval = setInterval(fetchFeed, 30000); + return () => clearInterval(interval); + }, [fetchFeed]); + + return ( + + + + Recent Activity + + + + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : items.length > 0 ? ( +
+ {items.map((item) => { + const mapping = ICON_MAP[item.action] ?? DEFAULT_ICON; + const Icon = mapping.icon; + return ( +
+
+ +
+
+

+ {actionDescription(item.action, item.details)} +

+

+ {relativeTime(item.createdAt)} +

+
+
+ ); + })} +
+ ) : ( +
+ No recent activity +
+ )} + + + ); +} diff --git a/src/components/layout/admin-header.tsx b/src/components/layout/admin-header.tsx index 3a33a33..9769d7e 100644 --- a/src/components/layout/admin-header.tsx +++ b/src/components/layout/admin-header.tsx @@ -12,9 +12,11 @@ import { } from "@/components/ui/dropdown-menu"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; -import { Moon, Sun, LogOut, User } from "lucide-react"; +import { Moon, Sun, LogOut, User, Search } from "lucide-react"; import { useTheme } from "next-themes"; import { useAuthStore } from "@/stores/auth-store"; +import { CommandPalette, openCommandPalette } from "@/components/layout/command-palette"; +import { AppBreadcrumbs } from "@/components/layout/breadcrumbs"; const pageTitles: Record = { "/": "Dashboard", @@ -48,14 +50,27 @@ export function AdminHeader() { (hash.startsWith("/users/") ? "User Detail" : hash.split("/").pop()?.charAt(0).toUpperCase() + - hash.split("/").pop()?.slice(1) || + hash.split("/").pop()?.slice(1) || "Page"); return (
-

{title}

+ +

+ {title} +

+
); } diff --git a/src/components/layout/admin-sidebar.tsx b/src/components/layout/admin-sidebar.tsx index 00a3a16..df21aa4 100644 --- a/src/components/layout/admin-sidebar.tsx +++ b/src/components/layout/admin-sidebar.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useState, useEffect } from "react"; import { LayoutDashboard, FolderTree, @@ -27,6 +28,7 @@ import { SidebarGroupLabel, SidebarHeader, SidebarMenu, + SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarRail, @@ -41,7 +43,7 @@ const mainNav = [ { title: "Catalog", href: "/catalog", icon: FolderTree }, { title: "Users", href: "/users", icon: Users }, { title: "Wallets", href: "/wallets", icon: Wallet }, - { title: "Purchases", href: "/purchases", icon: ShoppingCart }, + { title: "Purchases", href: "/purchases", icon: ShoppingCart, badge: true }, { title: "Audit Log", href: "/audit", icon: FileText }, ]; @@ -55,9 +57,62 @@ const systemNav = [ { title: "Locales", 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; +} + export function AdminSidebar() { const pathname = usePathname(); - const { role, logout } = useAuthStore(); + const { role, logout, isAuthenticated } = useAuthStore(); + const pendingCount = usePendingCount(isAuthenticated); + const connected = useConnectionStatus(); return ( @@ -94,6 +149,11 @@ export function AdminSidebar() { {item.title} + {item.badge && pendingCount > 0 && ( + + {pendingCount} + + )} ))} @@ -189,6 +249,16 @@ export function AdminSidebar() {
+
+ + + {connected ? "Connected" : "Disconnected"} + +
diff --git a/src/components/layout/breadcrumbs.tsx b/src/components/layout/breadcrumbs.tsx new file mode 100644 index 0000000..a698544 --- /dev/null +++ b/src/components/layout/breadcrumbs.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Breadcrumb, + BreadcrumbEllipsis, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; + +interface Crumb { + label: string; + href: string; +} + +const pageLabels: Record = { + "": "Dashboard", + catalog: "Catalog", + users: "Users", + wallets: "Wallets", + purchases: "Purchases", + audit: "Audit Log", + categories: "Categories", + locations: "Locations", + settings: "Settings", + locales: "Locales", + seed: "Danger Zone", + login: "Sign In", +}; + +function parseHash(hash: string): Crumb[] { + const path = hash.replace(/^#\/?/, ""); + const segments = path.split("/").filter(Boolean); + + const crumbs: Crumb[] = [{ label: "Home", href: "/" }]; + + if (segments.length === 0) { + return crumbs; + } + + // Build parent href incrementally + let href = ""; + for (let i = 0; i < segments.length; i++) { + href += "/" + segments[i]; + const isLast = i === segments.length - 1; + const label = pageLabels[segments[i]] || segments[i]; + crumbs.push({ label, href }); + } + + return crumbs; +} + +export function AppBreadcrumbs() { + const [crumbs, setCrumbs] = useState([{ label: "Home", href: "/" }]); + + useEffect(() => { + const update = () => setCrumbs(parseHash(window.location.hash)); + update(); + window.addEventListener("hashchange", update); + return () => window.removeEventListener("hashchange", update); + }, []); + + if (crumbs.length <= 1) { + return null; + } + + return ( + + + {/* Desktop: show all breadcrumbs */} + {crumbs.map((crumb, index) => { + const isLast = index === crumbs.length - 1; + return ( + + {isLast ? ( + {crumb.label} + ) : ( + { + e.preventDefault(); + window.location.hash = crumb.href; + }} + > + {crumb.label} + + )} + {!isLast && } + + ); + })} + + {/* Mobile: show only last 2 breadcrumbs with ellipsis */} + {crumbs.length > 2 && ( + + + + + )} + {crumbs.length >= 2 && ( + + { + e.preventDefault(); + const href = crumbs[crumbs.length - 2].href; + window.location.hash = href; + }} + > + {crumbs[crumbs.length - 2].label} + + + + )} + + {crumbs[crumbs.length - 1].label} + + + + ); +} diff --git a/src/components/layout/command-palette.tsx b/src/components/layout/command-palette.tsx new file mode 100644 index 0000000..c061ad3 --- /dev/null +++ b/src/components/layout/command-palette.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + LayoutDashboard, + FolderTree, + Users, + Wallet, + ShoppingCart, + FileText, + Tag, + MapPin, + Settings, + Languages, + AlertTriangle, + Database, + Trash2, + LogOut, +} from "lucide-react"; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { useAuthStore } from "@/stores/auth-store"; + +const COMMAND_TOGGLE = "command-palette:toggle"; + +export function openCommandPalette() { + window.dispatchEvent(new CustomEvent(COMMAND_TOGGLE)); +} + +const navigationItems = [ + { label: "Dashboard", href: "/", Icon: LayoutDashboard }, + { label: "Catalog", href: "/catalog", Icon: FolderTree }, + { label: "Users", href: "/users", Icon: Users }, + { label: "Wallets", href: "/wallets", Icon: Wallet }, + { label: "Purchases", href: "/purchases", Icon: ShoppingCart }, + { label: "Audit Log", href: "/audit", Icon: FileText }, + { label: "Categories", href: "/categories", Icon: Tag }, + { label: "Locations", href: "/locations", Icon: MapPin }, + { label: "Settings", href: "/settings", Icon: Settings }, + { label: "Locales", href: "/locales", Icon: Languages }, + { label: "Danger Zone", href: "/seed", Icon: AlertTriangle }, +] as const; + +export function CommandPalette() { + const [open, setOpen] = useState(false); + const { logout } = useAuthStore(); + + const toggle = useCallback(() => { + setOpen((prev) => !prev); + }, []); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + toggle(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [toggle]); + + useEffect(() => { + const handleToggle = () => setOpen(true); + window.addEventListener(COMMAND_TOGGLE, handleToggle); + return () => window.removeEventListener(COMMAND_TOGGLE, handleToggle); + }, []); + + return ( + + + + No results found. + + {navigationItems.map((item) => ( + { + setOpen(false); + window.location.hash = item.href; + }} + > + + {item.label} + + ))} + + + + { + setOpen(false); + window.location.hash = "/seed"; + }} + > + + Seed Demo Data + + { + setOpen(false); + window.location.hash = "/seed"; + }} + > + + Clear Data + + { + setOpen(false); + logout(); + window.location.hash = "/login"; + }} + > + + Logout + + + + + ); +} diff --git a/src/components/locales/locales-page.tsx b/src/components/locales/locales-page.tsx index 43ff251..76f4e5f 100644 --- a/src/components/locales/locales-page.tsx +++ b/src/components/locales/locales-page.tsx @@ -12,6 +12,7 @@ import { TableRow, } from "@/components/ui/table"; import { toast } from "sonner"; +import { Languages } from "lucide-react"; const LANGS = ["en", "es", "de"]; const LANG_LABELS: Record = { en: "English", es: "Spanish", de: "German" }; @@ -116,13 +117,16 @@ export function LocalesPage() { }; return ( -
+
+

Locales

+
-
+
{loading ? (
) : rows.length === 0 ? (
+

No locale data

No translations found.

diff --git a/src/components/locations/locations-page.tsx b/src/components/locations/locations-page.tsx index af49e18..249c03b 100644 --- a/src/components/locations/locations-page.tsx +++ b/src/components/locations/locations-page.tsx @@ -33,7 +33,7 @@ import { TableRow, } from "@/components/ui/table"; import { toast } from "sonner"; -import { Plus, Pencil, Trash2 } from "lucide-react"; +import { Plus, Pencil, Trash2, MapPin } from "lucide-react"; interface LocationRow { id: number; @@ -183,7 +183,9 @@ export function LocationsPage() { }; return ( -
+
+

Locations

+
-
+
{loading ? (
) : locations.length === 0 ? (
+

No locations

Create your first location to get started.

diff --git a/src/components/purchases/purchases-page.tsx b/src/components/purchases/purchases-page.tsx index 00cf381..5bae984 100644 --- a/src/components/purchases/purchases-page.tsx +++ b/src/components/purchases/purchases-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useMemo } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; @@ -14,7 +14,10 @@ import { } from "@/components/ui/table"; import { toast } from "sonner"; import { format } from "date-fns"; -import { Copy } from "lucide-react"; +import { Copy, ShoppingCart } from "lucide-react"; +import { ExportButton } from "@/components/shared/export-button"; +import { SortableHeader } from "@/components/shared/sortable-header"; +import { copyToClipboard } from "@/lib/clipboard"; interface PurchaseRow { id: number; @@ -82,6 +85,8 @@ export function PurchasesPage() { const [page, setPage] = useState(1); const [status, setStatus] = useState(""); const [loading, setLoading] = useState(true); + const [sortColumn, setSortColumn] = useState(""); + const [sortDirection, setSortDirection] = useState<"asc" | "desc" | null>(null); const limit = 50; const fetchData = useCallback(async () => { @@ -105,6 +110,49 @@ export function PurchasesPage() { fetchData(); }, [fetchData]); + const handleSort = (column: string) => { + if (sortColumn === column) { + if (sortDirection === "asc") setSortDirection("desc"); + else if (sortDirection === "desc") { + setSortColumn(""); + setSortDirection(null); + } + } else { + setSortColumn(column); + setSortDirection("asc"); + } + }; + + const sortedPurchases = useMemo(() => { + if (!sortColumn || !sortDirection) return purchases; + return [...purchases].sort((a, b) => { + let valA: unknown; + let valB: unknown; + if (sortColumn === "date") { valA = a.purchaseDate; valB = b.purchaseDate; } + else if (sortColumn === "amount") { valA = a.totalPrice; valB = b.totalPrice; } + else if (sortColumn === "status") { valA = a.status; valB = b.status; } + else return 0; + if (valA === valB) return 0; + const cmp = valA < valB ? -1 : 1; + return sortDirection === "asc" ? cmp : -cmp; + }); + }, [purchases, sortColumn, sortDirection]); + + const exportData = useMemo[]>( + () => sortedPurchases.map((p) => ({ + ID: p.id, + User: p.user.username || `@${p.user.telegramId}`, + Product: p.product.name, + Qty: p.quantity, + "Total Price": p.totalPrice, + Currency: p.walletType || "", + "TX Hash": p.txHash || "", + Date: p.purchaseDate, + Status: p.status, + })), + [sortedPurchases] + ); + const totalPages = Math.max(1, Math.ceil(total / limit)); const handleStatusChange = (s: string) => { @@ -112,13 +160,18 @@ export function PurchasesPage() { setPage(1); }; - const copyHash = (hash: string) => { - navigator.clipboard.writeText(hash); - toast.success("Copied!"); + const copyHash = async (hash: string) => { + const ok = await copyToClipboard(hash); + if (ok) toast.success("Copied!"); }; return ( -
+
+
+

Purchases

+ +
+
{STATUS_TABS.map((s) => ( + + + + + CSV + + + + ); +} diff --git a/src/components/shared/sortable-header.tsx b/src/components/shared/sortable-header.tsx new file mode 100644 index 0000000..2f2b56a --- /dev/null +++ b/src/components/shared/sortable-header.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react"; + +interface SortableHeaderProps { + column: string; + label: string; + sortColumn: string; + sortDirection: "asc" | "desc" | null; + onSort: (column: string) => void; +} + +export function SortableHeader({ + column, + label, + sortColumn, + sortDirection, + onSort, +}: SortableHeaderProps) { + const isActive = sortColumn === column; + + return ( + + ); +} diff --git a/src/components/users/user-detail-page.tsx b/src/components/users/user-detail-page.tsx index 928d0b6..6455e86 100644 --- a/src/components/users/user-detail-page.tsx +++ b/src/components/users/user-detail-page.tsx @@ -1,11 +1,13 @@ "use client"; import { useEffect, useState } from "react"; +import { format } from "date-fns"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Select, @@ -105,7 +107,7 @@ function PurchaseStatusBadge({ status }: { status: string }) { function ProfileSkeleton() { return ( -
+
@@ -204,7 +206,7 @@ export function UserDetailPage({ userId }: { userId: string }) { if (loading) return ; if (error || !user) { return ( -
+
@@ -214,12 +216,10 @@ export function UserDetailPage({ userId }: { userId: string }) { } const isBlocked = user.status === 2; - const registeredDate = new Date(user.createdAt).toLocaleDateString("en-US", { - year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", - }); + const registeredDate = format(new Date(user.createdAt), "MMM d, yyyy HH:mm"); return ( -
+
{/* Header */}
-

+

{user.username || `User #${user.id}`} -

+

ID: {user.id} · Telegram: {user.telegramId}

@@ -360,6 +360,8 @@ export function UserDetailPage({ userId }: { userId: string }) { + + {/* Purchases Table */} @@ -400,9 +402,7 @@ export function UserDetailPage({ userId }: { userId: string }) { - {new Date(p.purchaseDate).toLocaleDateString("en-US", { - month: "short", day: "numeric", year: "numeric", - })} + {format(new Date(p.purchaseDate), "MMM d, yyyy HH:mm")} ))} diff --git a/src/components/users/users-page.tsx b/src/components/users/users-page.tsx index f82c4c3..c9de9f6 100644 --- a/src/components/users/users-page.tsx +++ b/src/components/users/users-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useCallback, useRef } from "react"; +import { useEffect, useState, useCallback, useRef, useMemo } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -13,7 +13,8 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { Search, Eye } from "lucide-react"; +import { Search, Eye, Users } from "lucide-react"; +import { ExportButton } from "@/components/shared/export-button"; interface UserRow { id: number; @@ -54,6 +55,12 @@ function StatusBadge({ status }: { status: number }) { ); } +function statusLabel(status: number): string { + if (status === 0) return "Active"; + if (status === 2) return "Blocked"; + return "Deleted"; +} + function SkeletonTable() { return (
@@ -123,23 +130,42 @@ export function UsersPage() { fetchUsers(debouncedSearch, p); }; + const exportData = useMemo[]>( + () => users.map((u) => ({ + ID: u.id, + "Telegram ID": u.telegramId, + Username: u.username || "", + Country: u.country || "", + City: u.city || "", + Status: statusLabel(u.status), + "Total Balance": u.totalBalance, + "Bonus Balance": u.bonusBalance, + Wallets: u._count.wallets, + Purchases: u._count.purchases, + })), + [users] + ); + return ( -
+
-

Users

+

Users

{total} user{total !== 1 ? "s" : ""} total

-
- - handleSearch(e.target.value)} - className="pl-9" - /> +
+
+ + handleSearch(e.target.value)} + className="pl-9" + /> +
+
@@ -151,7 +177,10 @@ export function UsersPage() { ) : error ? (
{error}
) : users.length === 0 ? ( -
No users found
+
+ +

No users found

+
) : ( diff --git a/src/components/wallets/wallets-page.tsx b/src/components/wallets/wallets-page.tsx index 9952640..7754873 100644 --- a/src/components/wallets/wallets-page.tsx +++ b/src/components/wallets/wallets-page.tsx @@ -296,11 +296,11 @@ export function WalletsPage() { const currencyTypes = ['BTC', 'LTC', 'ETH', 'USDT', 'USDC']; return ( -
+
-

Wallets

+

Wallets

Manage crypto wallets, view balances, and track commissions

@@ -341,7 +341,7 @@ export function WalletsPage() {
-
+
{userListLoading ? (
{[1, 2, 3, 4, 5].map((i) => ( diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts new file mode 100644 index 0000000..2ebc21b --- /dev/null +++ b/src/lib/clipboard.ts @@ -0,0 +1,25 @@ +export async function copyToClipboard(text: string): Promise { + try { + if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // fallback below + } + + try { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + return true; + } catch { + return false; + } +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 7dfb80c..82c1498 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -7,7 +7,7 @@ const globalForPrisma = globalThis as unknown as { export const db = globalForPrisma.prisma ?? new PrismaClient({ - log: ['query'], + log: ['error'], }) if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db \ No newline at end of file diff --git a/tool-results/read_1785935143179_c8c3abacfba3.txt b/tool-results/read_1785935143179_c8c3abacfba3.txt new file mode 100644 index 0000000..5635e83 --- /dev/null +++ b/tool-results/read_1785935143179_c8c3abacfba3.txt @@ -0,0 +1,1445 @@ + 1→"use client"; + 2→ + 3→import { useState, useEffect, useCallback, useRef } from "react"; + 4→import { + 5→ ResizablePanelGroup, + 6→ ResizablePanel, + 7→ ResizableHandle, + 8→} from "@/components/ui/resizable"; + 9→import { + 10→ Accordion, + 11→ AccordionContent, + 12→ AccordionItem, + 13→ AccordionTrigger, + 14→} from "@/components/ui/accordion"; + 15→import { + 16→ Table, + 17→ TableBody, + 18→ TableCell, + 19→ TableHead, + 20→ TableHeader, + 21→ TableRow, + 22→} from "@/components/ui/table"; + 23→import { + 24→ Dialog, + 25→ DialogContent, + 26→ DialogHeader, + 27→ DialogTitle, + 28→ DialogFooter, + 29→} from "@/components/ui/dialog"; + 30→import { + 31→ Select, + 32→ SelectContent, + 33→ SelectGroup, + 34→ SelectItem, + 35→ SelectLabel, + 36→ SelectTrigger, + 37→ SelectValue, + 38→} from "@/components/ui/select"; + 39→import { + 40→ AlertDialog, + 41→ AlertDialogAction, + 42→ AlertDialogCancel, + 43→ AlertDialogContent, + 44→ AlertDialogDescription, + 45→ AlertDialogFooter, + 46→ AlertDialogHeader, + 47→ AlertDialogTitle, + 48→} from "@/components/ui/alert-dialog"; + 49→import { Switch } from "@/components/ui/switch"; + 50→import { Button } from "@/components/ui/button"; + 51→import { Badge } from "@/components/ui/badge"; + 52→import { Input } from "@/components/ui/input"; + 53→import { Label } from "@/components/ui/label"; + 54→import { Textarea } from "@/components/ui/textarea"; + 55→import { Checkbox } from "@/components/ui/checkbox"; + 56→import { Skeleton } from "@/components/ui/skeleton"; + 57→import { toast } from "sonner"; + 58→import { + 59→ Plus, + 60→ Pencil, + 61→ Trash2, + 62→ Search, + 63→ Package, + 64→ MapPin, + 65→ FolderOpen, + 66→ Tag, + 67→ ChevronRight, + 68→ X, + 69→} from "lucide-react"; + 70→ + 71→// ─── Types ─────────────────────────────────────── + 72→ + 73→interface TreeLocation { + 74→ id: number; + 75→ country: string; + 76→ city: string; + 77→ district: string; + 78→ isActive: number; + 79→ createdAt: string; + 80→ categoryCount: number; + 81→ productCount: number; + 82→} + 83→ + 84→interface TreeCategory { + 85→ id: number; + 86→ locationId: number; + 87→ name: string; + 88→ isActive: number; + 89→ createdAt: string; + 90→ location: { id: number; country: string; city: string; district: string }; + 91→ subcategoryCount: number; + 92→ productCount: number; + 93→} + 94→ + 95→interface TreeSubcategory { + 96→ id: number; + 97→ categoryId: number; + 98→ name: string; + 99→ isActive: number; + 100→ createdAt: string; + 101→ category: { id: number; name: string; locationId: number }; + 102→ productCount: number; + 103→} + 104→ + 105→interface CatalogTree { + 106→ locations: TreeLocation[]; + 107→ categories: TreeCategory[]; + 108→ subcategories: TreeSubcategory[]; + 109→} + 110→ + 111→interface Product { + 112→ id: number; + 113→ locationId: number; + 114→ categoryId: number; + 115→ subcategoryId: number | null; + 116→ name: string; + 117→ description: string | null; + 118→ privateData: string | null; + 119→ price: number; + 120→ quantityInStock: number; + 121→ photoUrl: string | null; + 122→ hiddenPhotoUrl: string | null; + 123→ hiddenCoordinates: string | null; + 124→ hiddenDescription: string | null; + 125→ isMono: number; + 126→ createdAt: string; + 127→ category: { id: number; name: string }; + 128→ subcategory: { id: number; name: string } | null; + 129→ location: { id: number; country: string; city: string; district: string }; + 130→} + 131→ + 132→interface ProductFormData { + 133→ locationId: string; + 134→ categoryId: string; + 135→ subcategoryId: string; + 136→ name: string; + 137→ description: string; + 138→ privateData: string; + 139→ price: string; + 140→ quantityInStock: string; + 141→ photoUrl: string; + 142→ hiddenPhotoUrl: string; + 143→ hiddenCoordinates: string; + 144→ hiddenDescription: string; + 145→ isMono: boolean; + 146→} + 147→ + 148→const emptyForm: ProductFormData = { + 149→ locationId: "", + 150→ categoryId: "", + 151→ subcategoryId: "", + 152→ name: "", + 153→ description: "", + 154→ privateData: "", + 155→ price: "", + 156→ quantityInStock: "0", + 157→ photoUrl: "", + 158→ hiddenPhotoUrl: "", + 159→ hiddenCoordinates: "", + 160→ hiddenDescription: "", + 161→ isMono: false, + 162→}; + 163→ + 164→type FilterType = + 165→ | { type: "all" } + 166→ | { type: "location"; locationId: number } + 167→ | { type: "category"; categoryId: number; locationId: number } + 168→ | { type: "subcategory"; subcategoryId: number; categoryId: number; locationId: number }; + 169→ + 170→// ─── Component ─────────────────────────────────── + 171→ + 172→export function CatalogPage() { + 173→ // Tree state + 174→ const [tree, setTree] = useState(null); + 175→ const [treeLoading, setTreeLoading] = useState(true); + 176→ const [treeError, setTreeError] = useState(null); + 177→ + 178→ // Products state + 179→ const [products, setProducts] = useState([]); + 180→ const [productTotal, setProductTotal] = useState(0); + 181→ const [productsLoading, setProductsLoading] = useState(true); + 182→ const [search, setSearch] = useState(""); + 183→ const [searchDebounce, setSearchDebounce] = useState(""); + 184→ const [filter, setFilter] = useState({ type: "all" }); + 185→ const [productPage, setProductPage] = useState(1); + 186→ const productLimit = 50; + 187→ + 188→ // Modal state + 189→ const [modalOpen, setModalOpen] = useState(false); + 190→ const [editProduct, setEditProduct] = useState(null); + 191→ const [formData, setFormData] = useState(emptyForm); + 192→ const [formLocations, setFormLocations] = useState([]); + 193→ const [formCategories, setFormCategories] = useState([]); + 194→ const [formSubcategories, setFormSubcategories] = useState([]); + 195→ const [saving, setSaving] = useState(false); + 196→ + 197→ // Delete confirm + 198→ const [deleteTarget, setDeleteTarget] = useState<{ + 199→ type: string; + 200→ id: number; + 201→ name: string; + 202→ } | null>(null); + 203→ + 204→ // Inline rename state + 205→ const [renamingId, setRenamingId] = useState(null); + 206→ const [renameValue, setRenameValue] = useState(""); + 207→ const renameInputRef = useRef(null); + 208→ + 209→ // Add inline state + 210→ const [addMode, setAddMode] = useState(null); + 211→ const [addInput, setAddInput] = useState(""); + 212→ const [addDistrict, setAddDistrict] = useState(""); + 213→ const [addCity, setAddCity] = useState(""); + 214→ const [addCountry, setAddCountry] = useState(""); + 215→ const [addParentId, setAddParentId] = useState(null); + 216→ const addInputRef = useRef(null); + 217→ + 218→ useEffect(() => { + 219→ if (addInputRef.current) addInputRef.current.focus(); + 220→ }, [addMode]); + 221→ + 222→ useEffect(() => { + 223→ if (renameInputRef.current) renameInputRef.current.focus(); + 224→ }, [renamingId]); + 225→ + 226→ // ─── Data fetching ───────────────────────────── + 227→ const fetchTree = useCallback(async () => { + 228→ try { + 229→ const res = await fetch("/api/catalog/tree"); + 230→ if (!res.ok) throw new Error("Failed to load tree"); + 231→ const data: CatalogTree = await res.json(); + 232→ setTree(data); + 233→ setTreeError(null); + 234→ } catch (e) { + 235→ setTreeError("Failed to load catalog tree"); + 236→ } finally { + 237→ setTreeLoading(false); + 238→ } + 239→ }, []); + 240→ + 241→ const fetchProducts = useCallback(async () => { + 242→ setProductsLoading(true); + 243→ try { + 244→ const params = new URLSearchParams({ page: String(productPage), limit: String(productLimit) }); + 245→ if (searchDebounce) params.set("search", searchDebounce); + 246→ if (filter.type === "location") params.set("loc", String(filter.locationId)); + 247→ if (filter.type === "category") params.set("cat", String(filter.categoryId)); + 248→ if (filter.type === "subcategory") params.set("sub", String(filter.subcategoryId)); + 249→ + 250→ const res = await fetch(`/api/products/bulk?${params}`); + 251→ if (!res.ok) throw new Error("Failed to load products"); + 252→ const data = await res.json(); + 253→ setProducts(data.data); + 254→ setProductTotal(data.total); + 255→ } catch { + 256→ toast.error("Failed to load products"); + 257→ } finally { + 258→ setProductsLoading(false); + 259→ } + 260→ }, [filter, searchDebounce, productPage]); + 261→ + 262→ useEffect(() => { + 263→ fetchTree(); + 264→ }, [fetchTree]); + 265→ + 266→ useEffect(() => { + 267→ fetchProducts(); + 268→ }, [fetchProducts]); + 269→ + 270→ // Search debounce + 271→ useEffect(() => { + 272→ const t = setTimeout(() => setSearchDebounce(search), 300); + 273→ return () => clearTimeout(t); + 274→ }, [search]); + 275→ + 276→ // Reset page on filter/search change + 277→ useEffect(() => { + 278→ setProductPage(1); + 279→ }, [filter, searchDebounce]); + 280→ + 281→ // ─── Tree grouping ───────────────────────────── + 282→ const getGroupedTree = () => { + 283→ if (!tree) return {}; + 284→ + 285→ const countryMap = new Map< + 286→ string, + 287→ { + 288→ cityMap: Map< + 289→ string, + 290→ { + 291→ districtMap: Map< + 292→ string, + 293→ { location: TreeLocation; categories: TreeCategory[] } + 294→ >; + 295→ } + 296→ >; + 297→ } + 298→ >(); + 299→ + 300→ for (const loc of tree.locations) { + 301→ if (!countryMap.has(loc.country)) + 302→ countryMap.set(loc.country, { cityMap: new Map() }); + 303→ const countryEntry = countryMap.get(loc.country)!; + 304→ if (!countryEntry.cityMap.has(loc.city)) + 305→ countryEntry.cityMap.set(loc.city, { districtMap: new Map() }); + 306→ const cityEntry = countryEntry.cityMap.get(loc.city)!; + 307→ const distKey = loc.district || "(no district)"; + 308→ if (!cityEntry.districtMap.has(distKey)) + 309→ cityEntry.districtMap.set(distKey, { location: loc, categories: [] }); + 310→ } + 311→ + 312→ for (const cat of tree.categories) { + 313→ const countryEntry = countryMap.get(cat.location.country); + 314→ if (!countryEntry) continue; + 315→ const cityEntry = countryEntry.cityMap.get(cat.location.city); + 316→ if (!cityEntry) continue; + 317→ const distKey = cat.location.district || "(no district)"; + 318→ const distEntry = cityEntry.districtMap.get(distKey); + 319→ if (!distEntry) continue; + 320→ distEntry.categories.push(cat); + 321→ } + 322→ + 323→ return countryMap; + 324→ }; + 325→ + 326→ // ─── Tree node actions ───────────────────────── + 327→ const handleToggleActive = async (type: string, id: number) => { + 328→ try { + 329→ const res = await fetch(`/api/${type}s/${id}`, { method: "PATCH" }); + 330→ if (!res.ok) throw new Error(); + 331→ toast.success(`${type} toggled`); + 332→ fetchTree(); + 333→ } catch { + 334→ toast.error(`Failed to toggle ${type}`); + 335→ } + 336→ }; + 337→ + 338→ const handleDelete = async () => { + 339→ if (!deleteTarget) return; + 340→ try { + 341→ const typePath = deleteTarget.type === "product" ? "products" : `${deleteTarget.type}s`; + 342→ const res = await fetch(`/api/${typePath}/${deleteTarget.id}`, { method: "DELETE" }); + 343→ if (!res.ok) { + 344→ const data = await res.json(); + 345→ throw new Error(data.error || "Failed to delete"); + 346→ } + 347→ toast.success(`${deleteTarget.type} deleted`); + 348→ setDeleteTarget(null); + 349→ fetchTree(); + 350→ fetchProducts(); + 351→ } catch (e) { + 352→ toast.error(e instanceof Error ? e.message : "Failed to delete"); + 353→ } + 354→ }; + 355→ + 356→ const handleRename = async (type: string, id: number) => { + 357→ if (!renameValue.trim()) { + 358→ setRenamingId(null); + 359→ return; + 360→ } + 361→ try { + 362→ let body: Record = {}; + 363→ if (type === "location") { + 364→ const loc = tree?.locations.find((l) => l.id === id); + 365→ body = { country: loc?.country || "", city: loc?.city || "", district: renameValue.trim() }; + 366→ } else { + 367→ body = { name: renameValue.trim() }; + 368→ } + 369→ const typePath = `${type}s`; + 370→ const res = await fetch(`/api/${typePath}/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + 371→ if (!res.ok) throw new Error(); + 372→ toast.success("Renamed"); + 373→ setRenamingId(null); + 374→ fetchTree(); + 375→ } catch { + 376→ toast.error("Failed to rename"); + 377→ } + 378→ }; + 379→ + 380→ const handleAdd = async () => { + 381→ if (!addMode) return; + 382→ try { + 383→ if (addMode === "location") { + 384→ if (!addCountry.trim() || !addCity.trim()) { + 385→ toast.error("Country and city are required"); + 386→ return; + 387→ } + 388→ const res = await fetch("/api/locations/bulk", { + 389→ method: "POST", + 390→ headers: { "Content-Type": "application/json" }, + 391→ body: JSON.stringify({ country: addCountry.trim(), city: addCity.trim(), district: addDistrict.trim() }), + 392→ }); + 393→ if (!res.ok) { + 394→ const data = await res.json(); + 395→ throw new Error(data.error || "Failed to add"); + 396→ } + 397→ } else if (addMode === "category") { + 398→ if (!addInput.trim() || !addParentId) { + 399→ toast.error("Name is required"); + 400→ return; + 401→ } + 402→ const res = await fetch("/api/categories/bulk", { + 403→ method: "POST", + 404→ headers: { "Content-Type": "application/json" }, + 405→ body: JSON.stringify({ name: addInput.trim(), locationId: addParentId }), + 406→ }); + 407→ if (!res.ok) { + 408→ const data = await res.json(); + 409→ throw new Error(data.error || "Failed to add"); + 410→ } + 411→ } else if (addMode === "subcategory") { + 412→ if (!addInput.trim() || !addParentId) { + 413→ toast.error("Name is required"); + 414→ return; + 415→ } + 416→ const res = await fetch("/api/subcategories/bulk", { + 417→ method: "POST", + 418→ headers: { "Content-Type": "application/json" }, + 419→ body: JSON.stringify({ name: addInput.trim(), categoryId: addParentId }), + 420→ }); + 421→ if (!res.ok) { + 422→ const data = await res.json(); + 423→ throw new Error(data.error || "Failed to add"); + 424→ } + 425→ } + 426→ toast.success("Added successfully"); + 427→ setAddMode(null); + 428→ setAddInput(""); + 429→ setAddCountry(""); + 430→ setAddCity(""); + 431→ setAddDistrict(""); + 432→ setAddParentId(null); + 433→ fetchTree(); + 434→ } catch (e) { + 435→ toast.error(e instanceof Error ? e.message : "Failed to add"); + 436→ } + 437→ }; + 438→ + 439→ // ─── Product modal ───────────────────────────── + 440→ const openProductModal = async (product?: Product) => { + 441→ if (product) { + 442→ setEditProduct(product); + 443→ setFormData({ + 444→ locationId: String(product.locationId), + 445→ categoryId: String(product.categoryId), + 446→ subcategoryId: product.subcategoryId ? String(product.subcategoryId) : "", + 447→ name: product.name, + 448→ description: product.description || "", + 449→ privateData: product.privateData || "", + 450→ price: String(product.price), + 451→ quantityInStock: product.isMono ? "0" : String(product.quantityInStock), + 452→ photoUrl: product.photoUrl || "", + 453→ hiddenPhotoUrl: product.hiddenPhotoUrl || "", + 454→ hiddenCoordinates: product.hiddenCoordinates || "", + 455→ hiddenDescription: product.hiddenDescription || "", + 456→ isMono: product.isMono === 1, + 457→ }); + 458→ } else { + 459→ setEditProduct(null); + 460→ setFormData(emptyForm); + 461→ } + 462→ + 463→ // Load form data + 464→ try { + 465→ const [treeRes, locRes] = await Promise.all([ + 466→ fetch("/api/catalog/tree"), + 467→ fetch("/api/locations/bulk"), + 468→ ]); + 469→ const treeData: CatalogTree = await treeRes.json(); + 470→ const locData: TreeLocation[] = await locRes.json(); + 471→ setFormLocations(locData); + 472→ + 473→ if (product) { + 474→ setFormCategories(treeData.categories.filter((c) => c.locationId === product.locationId)); + 475→ setFormSubcategories(treeData.subcategories.filter((s) => s.categoryId === product.categoryId)); + 476→ } else { + 477→ setFormCategories([]); + 478→ setFormSubcategories([]); + 479→ } + 480→ } catch { + 481→ toast.error("Failed to load form data"); + 482→ } + 483→ + 484→ setModalOpen(true); + 485→ }; + 486→ + 487→ const handleLocationChange = async (locationId: string) => { + 488→ const updated = { ...formData, locationId, categoryId: "", subcategoryId: "" }; + 489→ setFormData(updated); + 490→ if (!locationId) { + 491→ setFormCategories([]); + 492→ setFormSubcategories([]); + 493→ return; + 494→ } + 495→ try { + 496→ const treeRes = await fetch("/api/catalog/tree"); + 497→ const treeData: CatalogTree = await treeRes.json(); + 498→ const cats = treeData.categories.filter((c) => c.locationId === +locationId); + 499→ setFormCategories(cats); + 500→ setFormSubcategories([]); + 501→ } catch { + 502→ // ignore + 503→ } + 504→ }; + 505→ + 506→ const handleCategoryChange = async (categoryId: string) => { + 507→ const updated = { ...formData, categoryId, subcategoryId: "" }; + 508→ setFormData(updated); + 509→ if (!categoryId) { + 510→ setFormSubcategories([]); + 511→ return; + 512→ } + 513→ try { + 514→ const treeRes = await fetch("/api/catalog/tree"); + 515→ const treeData: CatalogTree = await treeRes.json(); + 516→ const subs = treeData.subcategories.filter((s) => s.categoryId === +categoryId); + 517→ setFormSubcategories(subs); + 518→ } catch { + 519→ // ignore + 520→ } + 521→ }; + 522→ + 523→ const handleSaveProduct = async () => { + 524→ if (!formData.locationId || !formData.categoryId || !formData.name || !formData.price) { + 525→ toast.error("Location, Category, Name, and Price are required"); + 526→ return; + 527→ } + 528→ setSaving(true); + 529→ try { + 530→ const body = { + 531→ locationId: +formData.locationId, + 532→ categoryId: +formData.categoryId, + 533→ subcategoryId: formData.subcategoryId ? +formData.subcategoryId : null, + 534→ name: formData.name, + 535→ description: formData.description || null, + 536→ privateData: formData.privateData || null, + 537→ price: +formData.price, + 538→ quantityInStock: formData.isMono ? 0 : +formData.quantityInStock, + 539→ photoUrl: formData.photoUrl || null, + 540→ hiddenPhotoUrl: formData.hiddenPhotoUrl || null, + 541→ hiddenCoordinates: formData.hiddenCoordinates || null, + 542→ hiddenDescription: formData.hiddenDescription || null, + 543→ isMono: formData.isMono ? 1 : 0, + 544→ }; + 545→ + 546→ let res: Response; + 547→ if (editProduct) { + 548→ res = await fetch(`/api/products/${editProduct.id}`, { + 549→ method: "PUT", + 550→ headers: { "Content-Type": "application/json" }, + 551→ body: JSON.stringify(body), + 552→ }); + 553→ } else { + 554→ res = await fetch("/api/products/add", { + 555→ method: "POST", + 556→ headers: { "Content-Type": "application/json" }, + 557→ body: JSON.stringify(body), + 558→ }); + 559→ } + 560→ + 561→ if (!res.ok) { + 562→ const data = await res.json(); + 563→ throw new Error(data.error || "Failed to save"); + 564→ } + 565→ + 566→ toast.success(editProduct ? "Product updated" : "Product created"); + 567→ setModalOpen(false); + 568→ fetchTree(); + 569→ fetchProducts(); + 570→ } catch (e) { + 571→ toast.error(e instanceof Error ? e.message : "Failed to save product"); + 572→ } finally { + 573→ setSaving(false); + 574→ } + 575→ }; + 576→ + 577→ // ─── Filter handler ──────────────────────────── + 578→ const handleNodeClick = (f: FilterType) => { + 579→ setFilter(f); + 580→ }; + 581→ + 582→ // ─── Grouped tree for rendering ──────────────── + 583→ const groupedTree = getGroupedTree(); + 584→ const countries = Array.from(groupedTree.keys()).sort(); + 585→ + 586→ const totalPages = Math.max(1, Math.ceil(productTotal / productLimit)); + 587→ + 588→ // ─── Render: Left panel skeleton ──────────────── + 589→ if (treeLoading) { + 590→ return ( + 591→
+ 592→ + 593→ + 594→ + 595→
+ 596→ ); + 597→ } + 598→ + 599→ if (treeError) { + 600→ return ( + 601→
+ 602→

{treeError}

+ 603→ + 606→
+ 607→ ); + 608→ } + 609→ + 610→ // ─── Location select groups for form ─────────── + 611→ const locationGroups = new Map(); + 612→ for (const loc of formLocations) { + 613→ const key = `${loc.country} > ${loc.city}`; + 614→ if (!locationGroups.has(key)) locationGroups.set(key, []); + 615→ locationGroups.get(key)!.push(loc); + 616→ } + 617→ + 618→ // ─── Main render ──────────────────────────────── + 619→ return ( + 620→
+ 621→ {/* Header */} + 622→
+ 623→
+ 624→ + 625→

Catalog

+ 626→
+ 627→ + 630→
+ 631→ + 632→ {/* Main content */} + 633→
+ 634→ + 635→ {/* ─── Left Panel: Tree ─── */} + 636→ + 637→
+ 638→
+ 639→ Catalog Tree + 640→ + 653→
+ 654→ + 655→ {/* Add Location Form */} + 656→ {addMode === "location" && ( + 657→
+ 658→
+ 659→ setAddCountry(e.target.value)} + 663→ className="h-8 text-xs" + 664→ /> + 665→ setAddCity(e.target.value)} + 669→ className="h-8 text-xs" + 670→ /> + 671→
+ 672→
+ 673→ setAddDistrict(e.target.value)} + 677→ className="h-8 text-xs" + 678→ ref={addInputRef} + 679→ onKeyDown={(e) => { + 680→ if (e.key === "Enter") handleAdd(); + 681→ if (e.key === "Escape") setAddMode(null); + 682→ }} + 683→ /> + 684→
+ 685→
+ 686→ + 689→ + 692→
+ 693→
+ 694→ )} + 695→ + 696→ {/* Tree */} + 697→
+ 698→ {countries.length === 0 ? ( + 699→

+ 700→ No locations yet. Add one above. + 701→

+ 702→ ) : ( + 703→ + 704→ {countries.map((country) => { + 705→ const countryEntry = groupedTree.get(country)!; + 706→ const cities = Array.from(countryEntry.cityMap.keys()).sort(); + 707→ return ( + 708→ + 709→ + 710→ + 711→ + 712→ {country} + 713→ + 714→ + 715→ + 716→ {cities.map((city) => { + 717→ const cityEntry = countryEntry.cityMap.get(city)!; + 718→ const districts = Array.from(cityEntry.districtMap.keys()).sort(); + 719→ return ( + 720→
+ 721→ + 722→ {districts.map((district) => { + 723→ const { location, categories } = cityEntry.districtMap.get(district)!; + 724→ const totalCount = location.categoryCount + location.productCount; + 725→ const isDistrict = district !== "(no district)"; + 726→ return ( + 727→ + 728→ + 729→
{ + 732→ e.stopPropagation(); + 733→ handleNodeClick({ type: "location", locationId: location.id }); + 734→ }} + 735→ > + 736→ + 737→ + 738→ {city}{isDistrict ? ` > ${district}` : ""} + 739→ + 740→ + 741→ {totalCount} + 742→ + 743→ {location.isActive === 0 && ( + 744→ + 745→ off + 746→ + 747→ )} + 748→
+ 749→
e.stopPropagation()}> + 750→ handleToggleActive("location", location.id)} + 753→ className="scale-75" + 754→ /> + 755→ + 766→ + 780→ + 792→
+ 793→
+ 794→ + 795→ {/* Inline rename for location */} + 796→ {renamingId === `loc-${location.id}` && ( + 797→
+ 798→ setRenameValue(e.target.value)} + 802→ className="h-7 text-xs" + 803→ onKeyDown={(e) => { + 804→ if (e.key === "Enter") handleRename("location", location.id); + 805→ if (e.key === "Escape") setRenamingId(null); + 806→ }} + 807→ onBlur={() => handleRename("location", location.id)} + 808→ /> + 809→
+ 810→ )} + 811→ + 812→ {/* Add Category Form */} + 813→ {addMode === "category" && addParentId === location.id && ( + 814→
+ 815→ setAddInput(e.target.value)} + 819→ placeholder="Category name..." + 820→ className="h-7 text-xs" + 821→ onKeyDown={(e) => { + 822→ if (e.key === "Enter") handleAdd(); + 823→ if (e.key === "Escape") setAddMode(null); + 824→ }} + 825→ /> + 826→ + 827→ + 828→
+ 829→ )} + 830→ + 831→ + 832→ {categories.length === 0 ? ( + 833→

No categories

+ 834→ ) : ( + 835→ categories.map((cat) => { + 836→ const subs = tree?.subcategories.filter((s) => s.categoryId === cat.id) || []; + 837→ return ( + 838→
+ 839→ + 840→ + 841→ + 842→
{ + 845→ e.stopPropagation(); + 846→ handleNodeClick({ type: "category", categoryId: cat.id, locationId: cat.locationId }); + 847→ }} + 848→ > + 849→ + 850→ {cat.name} + 851→ + 852→ {cat.subcategoryCount + cat.productCount} + 853→ + 854→ {cat.isActive === 0 && ( + 855→ + 856→ off + 857→ + 858→ )} + 859→
+ 860→
e.stopPropagation()}> + 861→ handleToggleActive("category", cat.id)} + 864→ className="scale-75" + 865→ /> + 866→ + 877→ + 891→ + 903→
+ 904→
+ 905→ + 906→ {/* Inline rename for category */} + 907→ {renamingId === `cat-${cat.id}` && ( + 908→
+ 909→ setRenameValue(e.target.value)} + 913→ className="h-7 text-xs" + 914→ onKeyDown={(e) => { + 915→ if (e.key === "Enter") handleRename("category", cat.id); + 916→ if (e.key === "Escape") setRenamingId(null); + 917→ }} + 918→ onBlur={() => handleRename("category", cat.id)} + 919→ /> + 920→
+ 921→ )} + 922→ + 923→ {/* Add Subcategory Form */} + 924→ {addMode === "subcategory" && addParentId === cat.id && ( + 925→
+ 926→ setAddInput(e.target.value)} + 930→ placeholder="Subcategory name..." + 931→ className="h-7 text-xs" + 932→ onKeyDown={(e) => { + 933→ if (e.key === "Enter") handleAdd(); + 934→ if (e.key === "Escape") setAddMode(null); + 935→ }} + 936→ /> + 937→ + 938→ + 939→
+ 940→ )} + 941→ + 942→ + 943→ {subs.length === 0 ? ( + 944→

No subcategories

+ 945→ ) : ( + 946→ subs.map((sub) => ( + 947→
+ 951→ handleNodeClick({ + 952→ type: "subcategory", + 953→ subcategoryId: sub.id, + 954→ categoryId: sub.categoryId, + 955→ locationId: sub.category.locationId, + 956→ }) + 957→ } + 958→ > + 959→ + 960→ {renamingId === `sub-${sub.id}` ? ( + 961→ setRenameValue(e.target.value)} + 965→ className="h-6 text-xs flex-1" + 966→ onClick={(e) => e.stopPropagation()} + 967→ onKeyDown={(e) => { + 968→ if (e.key === "Enter") { e.stopPropagation(); handleRename("subcategory", sub.id); } + 969→ if (e.key === "Escape") { e.stopPropagation(); setRenamingId(null); } + 970→ }} + 971→ onBlur={() => handleRename("subcategory", sub.id)} + 972→ /> + 973→ ) : ( + 974→ {sub.name} + 975→ )} + 976→ + 977→ {sub.productCount} + 978→ + 979→ {sub.isActive === 0 && ( + 980→ + 981→ off + 982→ + 983→ )} + 984→
e.stopPropagation()}> + 985→ handleToggleActive("subcategory", sub.id)} + 988→ className="scale-50" + 989→ /> + 990→ {renamingId !== `sub-${sub.id}` && ( + 991→ <> + 992→ + 1003→ + 1017→ + 1018→ )} + 1019→
+ 1020→
+ 1021→ )) + 1022→ )} + 1023→
+ 1024→
+ 1025→
+ 1026→
+ 1027→ ); + 1028→ }) + 1029→ )} + 1030→
+ 1031→
+ 1032→ ); + 1033→ })} + 1034→
+ 1035→
+ 1036→ ); + 1037→ })} + 1038→
+ 1039→
+ 1040→ ); + 1041→ })} + 1042→
+ 1043→ )} + 1044→
+ 1045→
+ 1046→
+ 1047→ + 1048→ + 1049→ + 1050→ {/* ─── Right Panel: Products ─── */} + 1051→ + 1052→
+ 1053→ {/* Toolbar */} + 1054→
+ 1055→
+ 1056→ + 1057→ setSearch(e.target.value)} + 1061→ className="pl-9 h-9 text-sm" + 1062→ /> + 1063→
+ 1064→ {filter.type !== "all" && ( + 1065→ + 1074→ )} + 1075→ + 1076→ {productTotal} product{productTotal !== 1 ? "s" : ""} + 1077→ + 1078→
+ 1079→ + 1080→ {/* Table */} + 1081→
+ 1082→ {productsLoading ? ( + 1083→
+ 1084→ {Array.from({ length: 5 }).map((_, i) => ( + 1085→ + 1086→ ))} + 1087→
+ 1088→ ) : products.length === 0 ? ( + 1089→
+ 1090→ + 1091→

No products found

+ 1092→
+ 1093→ ) : ( + 1094→
+ 1095→ + 1096→ + 1097→ ID + 1098→ Photo + 1099→ Name + 1100→ Category + 1101→ Subcategory + 1102→ Price + 1103→ Stock + 1104→ Actions + 1105→ + 1106→ + 1107→ + 1108→ {products.map((p) => ( + 1109→ + 1110→ {p.id} + 1111→ + 1112→ {p.photoUrl ? ( + 1113→ {p.name} { + 1118→ (e.target as HTMLImageElement).style.display = "none"; + 1119→ }} + 1120→ /> + 1121→ ) : ( + 1122→
+ 1123→ + 1124→
+ 1125→ )} + 1126→
+ 1127→ {p.name} + 1128→ {p.category.name} + 1129→ + 1130→ {p.subcategory?.name || "—"} + 1131→ + 1132→ + 1133→ ${p.price.toFixed(2)} + 1134→ + 1135→ + 1136→ {p.isMono === 1 ? "∞" : p.quantityInStock} + 1137→ + 1138→ + 1139→
+ 1140→ + 1148→ + 1162→
+ 1163→
+ 1164→
+ 1165→ ))} + 1166→
+ 1167→
+ 1168→ )} + 1169→
+ 1170→ + 1171→ {/* Pagination */} + 1172→ {totalPages > 1 && ( + 1173→
+ 1174→ + 1175→ Page {productPage} of {totalPages} + 1176→ + 1177→
+ 1178→ + 1187→ + 1196→
+ 1197→
+ 1198→ )} + 1199→
+ 1200→ + 1201→ + 1202→
+ 1203→ + 1204→ {/* ─── Product Modal ─── */} + 1205→ + 1206→ + 1207→ + 1208→ {editProduct ? "Edit Product" : "Add Product"} + 1209→ + 1210→ + 1211→
+ 1212→ {/* Row 1: Cascading Selects */} + 1213→
+ 1214→
+ 1215→ + 1216→ + 1238→
+ 1239→ + 1240→
+ 1241→ + 1242→ + 1258→
+ 1259→ + 1260→
+ 1261→ + 1262→ + 1279→
+ 1280→
+ 1281→ + 1282→ {/* Row 2: Name, Price, Stock, Mono */} + 1283→
+ 1284→
+ 1285→ + 1286→ setFormData({ ...formData, name: e.target.value })} + 1289→ placeholder="Product name" + 1290→ className="h-9 text-sm" + 1291→ /> + 1292→
+ 1293→
+ 1294→ + 1295→ setFormData({ ...formData, price: e.target.value })} + 1301→ placeholder="0.00" + 1302→ className="h-9 text-sm" + 1303→ /> + 1304→
+ 1305→
+ 1306→ + 1307→
+ 1308→
+ 1309→ + 1313→ setFormData({ ...formData, isMono: !!checked }) + 1314→ } + 1315→ /> + 1316→ + 1319→
+ 1320→ {!formData.isMono && ( + 1321→
+ 1322→ + 1323→ setFormData({ ...formData, quantityInStock: e.target.value })} + 1328→ className="h-9 text-sm w-24" + 1329→ /> + 1330→
+ 1331→ )} + 1332→ {formData.isMono && ( + 1333→ Stock: ∞ + 1334→ )} + 1335→
+ 1336→ + 1337→ {/* Row 3: Description */} + 1338→
+ 1339→ + 1340→