From 5d1fe6cd7df70c4d5c53148360397229ae36909a Mon Sep 17 00:00:00 2001 From: Z User Date: Wed, 5 Aug 2026 16:54:25 +0000 Subject: [PATCH] 019dc270-b7f3-49ea-b87d-844d8224d8ac --- src/app/api/audit/bulk/route.ts | 2 +- src/app/api/auth/login/route.ts | 8 + src/app/api/products/[id]/clone/route.ts | 48 + src/app/api/products/[id]/route.ts | 14 +- src/app/api/purchases/[id]/route.ts | 1 - src/app/api/purchases/batch-status/route.ts | 36 + src/app/api/settings/route.ts | 6 +- src/app/api/stats/dashboard/route.ts | 17 +- src/app/api/users/batch-status/route.ts | 33 + src/app/api/wallets/export-seeds/route.ts | 15 +- src/app/api/wallets/overview/route.ts | 2 + src/app/globals.css | 269 ++- src/components/catalog/catalog-page.tsx | 31 + src/components/dashboard/dashboard-page.tsx | 29 +- src/components/layout/activity-feed.tsx | 95 +- src/components/layout/admin-footer.tsx | 18 +- src/components/layout/admin-header.tsx | 8 +- src/components/layout/admin-sidebar.tsx | 28 +- src/components/layout/command-palette.tsx | 2 +- src/components/layout/quick-actions.tsx | 6 + src/components/purchases/purchases-page.tsx | 105 +- src/components/users/user-detail-page.tsx | 134 +- src/components/users/users-page.tsx | 106 +- src/components/wallets/wallets-page.tsx | 31 + .../read_1785946621272_d02fae467278.txt | 905 ++++++++++ .../read_1785946624584_8dff49b52aac.txt | 905 ++++++++++ .../read_1785946648945_1cd34bae3f38.txt | 1483 ++++++++++++++++ .../read_1785946663351_eacf8370a346.txt | 402 +++++ .../read_1785947150570_1cd34bae3f38.txt | 1483 ++++++++++++++++ .../read_1785947353130_d02fae467278.txt | 905 ++++++++++ .../read_1785947870364_70c12b74ed7a.txt | 1487 +++++++++++++++++ .../read_1785947928344_4c14f27ccb4c.txt | 878 ++++++++++ .../read_1785947942990_f22960b673da.txt | 1181 +++++++++++++ worklog.md | 204 ++- 34 files changed, 10718 insertions(+), 159 deletions(-) create mode 100644 src/app/api/products/[id]/clone/route.ts create mode 100644 src/app/api/purchases/batch-status/route.ts create mode 100644 src/app/api/users/batch-status/route.ts create mode 100644 tool-results/read_1785946621272_d02fae467278.txt create mode 100644 tool-results/read_1785946624584_8dff49b52aac.txt create mode 100644 tool-results/read_1785946648945_1cd34bae3f38.txt create mode 100644 tool-results/read_1785946663351_eacf8370a346.txt create mode 100644 tool-results/read_1785947150570_1cd34bae3f38.txt create mode 100644 tool-results/read_1785947353130_d02fae467278.txt create mode 100644 tool-results/read_1785947870364_70c12b74ed7a.txt create mode 100644 tool-results/read_1785947928344_4c14f27ccb4c.txt create mode 100644 tool-results/read_1785947942990_f22960b673da.txt diff --git a/src/app/api/audit/bulk/route.ts b/src/app/api/audit/bulk/route.ts index d9b9021..1035df3 100644 --- a/src/app/api/audit/bulk/route.ts +++ b/src/app/api/audit/bulk/route.ts @@ -18,7 +18,7 @@ export async function GET(request: NextRequest) { const action = searchParams.get('action'); const conditions: Prisma.AuditLogWhereInput[] = []; - if (userId) conditions.push({ details: { contains: `"userId":${userId}` } }); + if (userId) conditions.push({ details: { contains: `"userId":${userId},` } }); if (from) conditions.push({ createdAt: { gte: new Date(from) } }); if (to) conditions.push({ createdAt: { lte: new Date(to + 'T23:59:59.999Z') } }); if (search) { diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 6af4845..89a9d59 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -3,6 +3,14 @@ import { createToken } from '@/lib/auth'; const loginAttempts = new Map(); +// Periodic cleanup of expired rate-limit entries (every 10 minutes) +setInterval(() => { + const now = Date.now(); + for (const [key, val] of loginAttempts) { + if (val.resetAt <= now) loginAttempts.delete(key); + } +}, 600000); + export async function POST(request: NextRequest) { try { const { token } = await request.json(); diff --git a/src/app/api/products/[id]/clone/route.ts b/src/app/api/products/[id]/clone/route.ts new file mode 100644 index 0000000..391c193 --- /dev/null +++ b/src/app/api/products/[id]/clone/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getAuth } from '@/lib/auth-middleware'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = getAuth(request); + if ('status' in auth) return auth; + + try { + const { id } = await params; + const productId = +id; + + const original = await db.product.findUnique({ where: { id: productId } }); + if (!original) { + return NextResponse.json({ error: 'Product not found' }, { status: 404 }); + } + + const cloned = await db.product.create({ + data: { + locationId: original.locationId, + categoryId: original.categoryId, + subcategoryId: original.subcategoryId, + name: `${original.name} (Copy)`, + description: original.description, + privateData: original.privateData, + price: original.price, + quantityInStock: original.quantityInStock, + photoUrl: original.photoUrl, + hiddenPhotoUrl: original.hiddenPhotoUrl, + hiddenCoordinates: original.hiddenCoordinates, + hiddenDescription: original.hiddenDescription, + isMono: original.isMono, + }, + include: { + category: { select: { id: true, name: true } }, + subcategory: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json(cloned); + } catch (error) { + console.error('Product clone API error:', error); + return NextResponse.json({ error: 'Failed to clone product' }, { status: 500 }); + } +} diff --git a/src/app/api/products/[id]/route.ts b/src/app/api/products/[id]/route.ts index dffc839..dfdea60 100644 --- a/src/app/api/products/[id]/route.ts +++ b/src/app/api/products/[id]/route.ts @@ -105,7 +105,19 @@ export async function DELETE( try { const { id } = await params; - await db.product.delete({ where: { id: +id } }); + const productId = +id; + + const purchaseCount = await db.purchase.count({ + where: { productId }, + }); + if (purchaseCount > 0) { + return NextResponse.json( + { error: `Cannot delete product with ${purchaseCount} existing purchase(s). Cancel or delete purchases first.` }, + { status: 400 } + ); + } + + await db.product.delete({ where: { id: productId } }); return NextResponse.json({ ok: true }); } catch (error) { console.error('Product delete API error:', error); diff --git a/src/app/api/purchases/[id]/route.ts b/src/app/api/purchases/[id]/route.ts index 2c444ef..a01d042 100644 --- a/src/app/api/purchases/[id]/route.ts +++ b/src/app/api/purchases/[id]/route.ts @@ -1,4 +1,3 @@ -"use server"; import { NextRequest, NextResponse } from 'next/server'; import { getAuth } from '@/lib/auth-middleware'; diff --git a/src/app/api/purchases/batch-status/route.ts b/src/app/api/purchases/batch-status/route.ts new file mode 100644 index 0000000..8a46cb3 --- /dev/null +++ b/src/app/api/purchases/batch-status/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getAuth } from '@/lib/auth-middleware'; + +export async function POST(request: NextRequest) { + const auth = getAuth(request); + if ('status' in auth) return auth; + + try { + const body = await request.json(); + const { purchaseIds, status } = body as { purchaseIds: number[]; status: 'completed' | 'cancelled' }; + + if (!Array.isArray(purchaseIds) || purchaseIds.length === 0) { + return NextResponse.json({ error: 'purchaseIds must be a non-empty array' }, { status: 400 }); + } + if (status !== 'completed' && status !== 'cancelled') { + return NextResponse.json({ error: 'status must be "completed" or "cancelled"' }, { status: 400 }); + } + + const result = await db.purchase.updateMany({ + where: { + id: { in: purchaseIds }, + status: 'pending', + }, + data: { status }, + }); + + return NextResponse.json({ + updated: result.count, + message: `${result.count} purchase(s) ${status === 'completed' ? 'approved' : 'cancelled'}`, + }); + } catch (error) { + console.error('Batch purchase status update error:', error); + return NextResponse.json({ error: 'Failed to update purchase statuses' }, { status: 500 }); + } +} diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 7ec45b4..7e21119 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -35,7 +35,11 @@ export async function PUT(request: NextRequest) { if (!key) { return NextResponse.json({ error: 'Missing key' }, { status: 400 }); } - return NextResponse.json({ ok: true, message: 'Settings saved. Restart required.' }); + if (key in SETTINGS) { + SETTINGS[key] = value; + return NextResponse.json({ ok: true, message: 'Settings saved. Restart required.' }); + } + return NextResponse.json({ error: 'Unknown setting key' }, { status: 400 }); } catch { return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); } diff --git a/src/app/api/stats/dashboard/route.ts b/src/app/api/stats/dashboard/route.ts index 92995f8..3147bc4 100644 --- a/src/app/api/stats/dashboard/route.ts +++ b/src/app/api/stats/dashboard/route.ts @@ -5,9 +5,8 @@ import { Prisma } from '@prisma/client'; function daysAgo(n: number): Date { const d = new Date(); - d.setHours(0, 0, 0, 0); - d.setDate(d.getDate() - n); - return d; + // Use UTC to avoid timezone shift in toISOString() + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - n)); } function formatDate(d: Date): string { @@ -243,7 +242,7 @@ export async function GET(request: NextRequest) { const recentAudits = await db.auditLog.findMany({ orderBy: { createdAt: 'desc' }, - take: 10, + take: 8, select: { id: true, action: true, @@ -253,6 +252,14 @@ export async function GET(request: NextRequest) { }, }); + const recentActivity = recentAudits.map((a) => ({ + id: a.id, + action: a.action, + createdAt: a.createdAt.toISOString(), + adminId: a.adminId, + details: a.details, + })); + // Merge and sort by date, take top 10 const activities = [ ...completedPurchasesForActivity.map((p) => ({ @@ -323,7 +330,7 @@ export async function GET(request: NextRequest) { topSpenders, revenueByCategory, topCountries, - activities, + recentActivity, walletSummary, recentPurchases: recentPurchasesFormatted, }); diff --git a/src/app/api/users/batch-status/route.ts b/src/app/api/users/batch-status/route.ts new file mode 100644 index 0000000..9fd5a7e --- /dev/null +++ b/src/app/api/users/batch-status/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getAuth } from '@/lib/auth-middleware'; + +export async function POST(request: NextRequest) { + const auth = getAuth(request); + if ('status' in auth) return auth; + + try { + const body = await request.json(); + const { userIds, newStatus } = body as { userIds: number[]; newStatus: number }; + + if (!Array.isArray(userIds) || userIds.length === 0) { + return NextResponse.json({ error: 'userIds must be a non-empty array' }, { status: 400 }); + } + if (newStatus !== 0 && newStatus !== 2) { + return NextResponse.json({ error: 'newStatus must be 0 (active) or 2 (banned)' }, { status: 400 }); + } + + const result = await db.tgUser.updateMany({ + where: { id: { in: userIds } }, + data: { status: newStatus }, + }); + + return NextResponse.json({ + updated: result.count, + message: `${result.count} user(s) updated to ${newStatus === 0 ? 'active' : 'banned'}`, + }); + } catch (error) { + console.error('Batch user status update error:', error); + return NextResponse.json({ error: 'Failed to update user statuses' }, { status: 500 }); + } +} diff --git a/src/app/api/wallets/export-seeds/route.ts b/src/app/api/wallets/export-seeds/route.ts index 8a3b730..ebf8e79 100644 --- a/src/app/api/wallets/export-seeds/route.ts +++ b/src/app/api/wallets/export-seeds/route.ts @@ -25,16 +25,21 @@ export async function GET(request: NextRequest) { orderBy: { id: 'desc' }, }); + const escapeCsv = (val: string | null | undefined) => { + if (val == null) return '""'; + return '"' + String(val).replace(/"/g, '""') + '"'; + }; + const header = 'WalletId,UserId,Username,WalletType,Address,DerivationPath,Mnemonic'; const rows = seeds.map((s) => [ s.id, s.userId, - s.user.username || `User#${s.userId}`, - s.walletType, - `"${s.address}"`, - `"${s.derivationPath || ''}"`, - `"${s.mnemonic}"`, + escapeCsv(s.user.username || `User#${s.userId}`), + escapeCsv(s.walletType), + escapeCsv(s.address), + escapeCsv(s.derivationPath), + escapeCsv(s.mnemonic), ].join(',') ); diff --git a/src/app/api/wallets/overview/route.ts b/src/app/api/wallets/overview/route.ts index 1065ddd..0f69db7 100644 --- a/src/app/api/wallets/overview/route.ts +++ b/src/app/api/wallets/overview/route.ts @@ -37,6 +37,7 @@ export async function GET(request: NextRequest) { const totalUsd = wallets.reduce((sum, w) => sum + w.balance, 0); const totalWallets = wallets.length; + const activeWallets = wallets.filter((w) => w.balance > 0).length; const totalUsers = userIdSet.size; const currentCommission = totalUsd * commissionRate; @@ -53,6 +54,7 @@ export async function GET(request: NextRequest) { walletCounts, totalUsd, totalWallets, + activeWallets, totalUsers, commissionEnabled, commissionRate, diff --git a/src/app/globals.css b/src/app/globals.css index 21b1e1d..3a6386b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -213,12 +213,16 @@ border-radius: 4px; } -/* Text selection */ +/* Enhanced text selection with warm accent */ ::selection { - background: oklch(0.488 0.243 264.376 / 30%); + background: oklch(0.646 0.222 41.116 / 25%); color: inherit; } +.dark ::selection { + background: oklch(0.646 0.222 41.116 / 35%); +} + /* Stagger animation for list items */ @keyframes slideIn { from { opacity: 0; transform: translateX(-8px); } @@ -271,3 +275,264 @@ thead th { .empty-state { background: radial-gradient(ellipse at center, var(--muted) 0%, transparent 70%); } + +/* ═══════════════════════════════════════════════════════════ + Task 9-a: Comprehensive Styling Additions + ═══════════════════════════════════════════════════════════ */ + +/* ── 1. Animated gradient border on focused inputs ── */ +@keyframes gradientBorder { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +input:focus-visible, +textarea:focus-visible, +select:focus-visible { + outline: none; + border-image: linear-gradient( + 135deg, + oklch(0.646 0.222 41.116) 0%, + oklch(0.696 0.17 162.48) 25%, + oklch(0.769 0.188 70.08) 50%, + oklch(0.696 0.17 162.48) 75%, + oklch(0.646 0.222 41.116) 100% + ) 1; + animation: gradientBorder 3s ease infinite; + background-size: 300% 300%; +} + +/* ── 2. Glassmorphism card utility ── */ +.glass-card { + background: oklch(1 0 0 / 60%); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid oklch(1 0 0 / 20%); +} + +.dark .glass-card { + background: oklch(0.205 0 0 / 60%); + border: 1px solid oklch(1 0 0 / 8%); +} + +/* ── 3. Page section stagger reveal ── */ +@keyframes sectionEnter { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.page-section-enter { + animation: sectionEnter 0.35s ease-out both; +} + +.page-section-enter:nth-child(1) { animation-delay: 0ms; } +.page-section-enter:nth-child(2) { animation-delay: 60ms; } +.page-section-enter:nth-child(3) { animation-delay: 120ms; } +.page-section-enter:nth-child(4) { animation-delay: 180ms; } +.page-section-enter:nth-child(5) { animation-delay: 240ms; } +.page-section-enter:nth-child(6) { animation-delay: 300ms; } + +/* ── 4. Stat value with tabular-nums ── */ +.stat-value { + font-variant-numeric: tabular-nums; + font-feature-settings: 'tnum'; + letter-spacing: -0.02em; +} + +/* ── 5. Subtle glow effects for status indicators ── */ +.glow-success { + box-shadow: 0 0 8px 1px oklch(0.696 0.17 162.48 / 40%); +} + +.glow-warning { + box-shadow: 0 0 8px 1px oklch(0.828 0.189 84.429 / 40%); +} + +.glow-danger { + box-shadow: 0 0 8px 1px oklch(0.704 0.191 22.216 / 40%); +} + +.dark .glow-success { + box-shadow: 0 0 12px 2px oklch(0.696 0.17 162.48 / 25%); +} + +.dark .glow-warning { + box-shadow: 0 0 12px 2px oklch(0.828 0.189 84.429 / 25%); +} + +.dark .glow-danger { + box-shadow: 0 0 12px 2px oklch(0.704 0.191 22.216 / 25%); +} + +/* ── 6. Noise texture overlay ── */ +.bg-noise { + position: relative; +} + +.bg-noise::before { + content: ''; + position: absolute; + inset: 0; + z-index: 0; + opacity: 0.03; + pointer-events: none; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); + background-repeat: repeat; + background-size: 256px 256px; +} + +.dark .bg-noise::before { + opacity: 0.04; +} + +/* ── 7. Ring-accent focus variant ── */ +.ring-accent:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* ── 8. KPI card shimmer on hover ── */ +@keyframes kpiShimmer { + 0% { background-position: -100% 0; } + 100% { background-position: 200% 0; } +} + +.kpi-shimmer { + position: relative; + overflow: hidden; +} + +.kpi-shimmer::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 105deg, + transparent 40%, + oklch(1 0 0 / 6%) 45%, + oklch(1 0 0 / 12%) 50%, + oklch(1 0 0 / 6%) 55%, + transparent 60% + ); + background-size: 50% 100%; + background-position: -100% 0; + border-radius: inherit; + pointer-events: none; + opacity: 0; + transition: opacity 0.3s ease; +} + +.kpi-shimmer:hover::after { + opacity: 1; + animation: kpiShimmer 0.8s ease forwards; +} + +.dark .kpi-shimmer::after { + background: linear-gradient( + 105deg, + transparent 40%, + oklch(1 0 0 / 3%) 45%, + oklch(1 0 0 / 7%) 50%, + oklch(1 0 0 / 3%) 55%, + transparent 60% + ); + background-size: 50% 100%; + background-position: -100% 0; +} + +/* ── 9. Count-up number animation ── */ +@keyframes countUp { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.count-up { + animation: countUp 0.4s ease-out both; +} + +/* ── 10. Clock colon pulse ── */ +@keyframes colonPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.colon-pulse { + animation: colonPulse 1s ease-in-out infinite; +} + +/* ── 11. Gradient border (header/footer) ── */ +.gradient-border-b { + border-image: linear-gradient( + to right, + transparent 0%, + var(--border) 20%, + var(--border) 80%, + transparent 100% + ) 1; +} + +.gradient-border-t { + border-image: linear-gradient( + to right, + transparent 0%, + var(--border) 20%, + var(--border) 80%, + transparent 100% + ) 1; +} + +/* ── 12. Table improvements ── */ +.alternate-rows tbody tr:nth-child(even) { + background-color: oklch(0 0 0 / 3%); +} + +.dark .alternate-rows tbody tr:nth-child(even) { + background-color: oklch(1 0 0 / 3%); +} + +.alternate-rows tbody tr:first-child td:first-child { + border-left: 2px solid var(--primary); + border-image: linear-gradient(to bottom, var(--primary), transparent) 1; +} + +.table-header-gradient thead { + background: linear-gradient(to bottom, var(--muted), transparent); +} + +.table-header-gradient thead th { + background: transparent; +} + +/* ── 13. Sidebar active indicator dot (pulse) ── */ +@keyframes indicatorPulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.4); opacity: 0.6; } +} + +.sidebar-indicator-dot { + animation: indicatorPulse 2s ease-in-out infinite; +} + +/* ── 14. Improved table row hover ── */ +@layer base { + tbody tr { + transition: background-color 0.2s ease, box-shadow 0.2s ease; + } +} + +.alternate-rows tbody tr:hover { + background-color: oklch(0 0 0 / 6%); + box-shadow: inset 2px 0 0 var(--primary); +} + +.dark .alternate-rows tbody tr:hover { + background-color: oklch(1 0 0 / 5%); + box-shadow: inset 2px 0 0 var(--primary); +} diff --git a/src/components/catalog/catalog-page.tsx b/src/components/catalog/catalog-page.tsx index 27b7268..d5a0de6 100644 --- a/src/components/catalog/catalog-page.tsx +++ b/src/components/catalog/catalog-page.tsx @@ -67,6 +67,7 @@ import { ChevronRight, X, BarChart3, + Copy, } from "lucide-react"; // ─── Types ─────────────────────────────────────── @@ -363,6 +364,21 @@ export function CatalogPage() { } }; + const handleCloneProduct = async (product: Product) => { + try { + const res = await fetch(`/api/products/${product.id}/clone`, { method: "POST" }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Failed to clone product"); + } + toast.success(`Product cloned as "${product.name} (Copy)"`); + fetchTree(); + fetchProducts(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to clone product"); + } + }; + const handleRename = async (type: string, id: number) => { if (!renameValue.trim()) { setRenamingId(null); @@ -476,6 +492,10 @@ export function CatalogPage() { fetch("/api/catalog/tree"), fetch("/api/locations/bulk"), ]); + if (!treeRes.ok || !locRes.ok) { + toast.error("Failed to load form data"); + return; + } const treeData: CatalogTree = await treeRes.json(); const locData: TreeLocation[] = await locRes.json(); setFormLocations(locData); @@ -1180,9 +1200,19 @@ export function CatalogPage() { size="sm" className="h-7 w-7 p-0" onClick={() => openProductModal(p)} + title="Edit product" > + diff --git a/src/components/dashboard/dashboard-page.tsx b/src/components/dashboard/dashboard-page.tsx index 4ea5d4c..28a5d39 100644 --- a/src/components/dashboard/dashboard-page.tsx +++ b/src/components/dashboard/dashboard-page.tsx @@ -209,7 +209,7 @@ function KpiCard({ }) { const sparkData = sparklineValue !== undefined ? generateSparkData(sparklineValue) : undefined; return ( - +

{title}

-

{value}

+

{value}

{sparkData && sparklineColor && } @@ -259,10 +259,12 @@ function ChartCard({ title, children, accentColor, + icon: ChartIcon, }: { title: string; children: React.ReactNode; accentColor?: string; + icon?: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; }) { return ( @@ -273,7 +275,10 @@ function ChartCard({ }} /> - {title} + + {ChartIcon && } + {title} +
{children}
@@ -438,7 +443,7 @@ export function DashboardPage() { {/* ── Charts Grid ── */}
{/* 1. Revenue 7 days */} - + {revenue7Data.length > 0 ? ( @@ -474,7 +479,7 @@ export function DashboardPage() { {/* 2. Revenue 30 days */} - + {revenue30Data.length > 0 ? ( @@ -510,7 +515,7 @@ export function DashboardPage() { {/* 3. New Users 7 days */} - + {users7Data.length > 0 ? ( @@ -534,7 +539,7 @@ export function DashboardPage() { {/* 4. Top 5 Products */} - + {productsData.length > 0 ? ( @@ -562,7 +567,7 @@ export function DashboardPage() { {/* 5. Top 5 Spenders */} - + {spendersData.length > 0 ? ( @@ -587,7 +592,7 @@ export function DashboardPage() { {/* 6. Revenue by Category (Pie/Donut) */} - + {revenueByCategory.length > 0 ? ( @@ -631,7 +636,7 @@ export function DashboardPage() { {/* 7. Purchase Status Distribution */} - + {data.recentPurchases.length > 0 ? (
- +
@@ -872,7 +877,7 @@ export function DashboardPage() { {/* Wallet Count by Type Chart */} - + {walletChartData.length > 0 ? ( diff --git a/src/components/layout/activity-feed.tsx b/src/components/layout/activity-feed.tsx index 26722b8..99814ef 100644 --- a/src/components/layout/activity-feed.tsx +++ b/src/components/layout/activity-feed.tsx @@ -13,38 +13,47 @@ import { CreditCard, UserPlus, FileText, + ShoppingCart, + Ban, + Upload, } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; // ─── Types ─────────────────────────────────────────────── -interface AuditItem { +interface ActivityItem { id: number; action: string; + createdAt: string; adminId: string; details: string | null; - createdAt: string; } -// ─── Icon mapping ───────────────────────────────────────── +// ─── Icon + color mapping ───────────────────────────────── -const ICON_MAP: Record< +const ACTION_CONFIG: Record< string, - { icon: React.ComponentType<{ className?: string }>; color: string } + { icon: React.ComponentType<{ className?: string }>; color: string; badge: 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" }, + login: { icon: LogIn, color: "#06b6d4", badge: "bg-cyan-500/15 text-cyan-400 border-cyan-500/25" }, + balance_adjust: { icon: DollarSign, color: "#f97316", badge: "bg-orange-500/15 text-orange-400 border-orange-500/25" }, + status_toggle: { icon: UserX, color: "#ef4444", badge: "bg-red-500/15 text-red-400 border-red-500/25" }, + seed_phrase_viewed: { icon: KeyRound, color: "#a855f7", badge: "bg-violet-500/15 text-violet-400 border-violet-500/25" }, + csv_seed_export: { icon: Upload, color: "#a855f7", badge: "bg-violet-500/15 text-violet-400 border-violet-500/25" }, + product_created: { icon: Package, color: "#22c55e", badge: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25" }, + settings_changed: { icon: Settings, color: "#22c55e", badge: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25" }, + purchase_approved: { icon: CheckCircle, color: "#22c55e", badge: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25" }, + purchase_cancelled: { icon: Ban, color: "#ef4444", badge: "bg-red-500/15 text-red-400 border-red-500/25" }, + wallet_added: { icon: Wallet, color: "#06b6d4", badge: "bg-cyan-500/15 text-cyan-400 border-cyan-500/25" }, + commission_paid: { icon: CreditCard, color: "#f59e0b", badge: "bg-yellow-500/15 text-yellow-400 border-yellow-500/25" }, + user_registered: { icon: UserPlus, color: "#14b8a6", badge: "bg-teal-500/15 text-teal-400 border-teal-500/25" }, + user_banned: { icon: Ban, color: "#ef4444", badge: "bg-red-500/15 text-red-400 border-red-500/25" }, + user_unbanned: { icon: CheckCircle, color: "#22c55e", badge: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25" }, + purchase_created: { icon: ShoppingCart, color: "#f59e0b", badge: "bg-yellow-500/15 text-yellow-400 border-yellow-500/25" }, }; -const DEFAULT_ICON = { icon: FileText, color: "#6b7280" }; +const DEFAULT_CONFIG = { icon: FileText, color: "#6b7280", badge: "bg-muted text-muted-foreground border-border" }; // ─── Helpers ────────────────────────────────────────────── @@ -54,13 +63,14 @@ function relativeTime(dateStr: string): string { const diffMs = now - then; const diffSec = Math.floor(diffMs / 1000); - if (diffSec < 60) return `${diffSec}s ago`; + if (diffSec < 60) return `${diffSec} second${diffSec !== 1 ? "s" : ""} ago`; const diffMin = Math.floor(diffSec / 60); - if (diffMin < 60) return `${diffMin}m ago`; + if (diffMin < 60) return `${diffMin} minute${diffMin !== 1 ? "s" : ""} ago`; const diffHr = Math.floor(diffMin / 60); - if (diffHr < 24) return `${diffHr}h ago`; + if (diffHr < 24) return `${diffHr} hour${diffHr !== 1 ? "s" : ""} ago`; const diffDay = Math.floor(diffHr / 24); - return `${diffDay}d ago`; + if (diffDay < 30) return `${diffDay} day${diffDay !== 1 ? "s" : ""} ago`; + return `${Math.floor(diffDay / 30)} month${Math.floor(diffDay / 30) !== 1 ? "s" : ""} ago`; } function actionDescription(action: string, details: string | null): string { @@ -80,15 +90,15 @@ function actionDescription(action: string, details: string | null): string { // ─── Component ──────────────────────────────────────────── export function ActivityFeed() { - const [items, setItems] = useState([]); + const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const fetchFeed = useCallback(async () => { try { - const res = await fetch("/api/audit/bulk?limit=15"); + const res = await fetch("/api/stats/dashboard"); if (!res.ok) return; const json = await res.json(); - setItems(json.data ?? []); + setItems(json.recentActivity ?? []); } catch { // silently fail } finally { @@ -111,7 +121,7 @@ export function ActivityFeed() { {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; +
+ {items.map((item, index) => { + const config = ACTION_CONFIG[item.action] ?? DEFAULT_CONFIG; + const Icon = config.icon; return (
-

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

-

- {relativeTime(item.createdAt)} -

+
+

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

+
+
+ + {item.action.replace(/_/g, " ")} + + + {relativeTime(item.createdAt)} + +
); diff --git a/src/components/layout/admin-footer.tsx b/src/components/layout/admin-footer.tsx index 1f40303..7d94f47 100644 --- a/src/components/layout/admin-footer.tsx +++ b/src/components/layout/admin-footer.tsx @@ -4,15 +4,19 @@ export function AdminFooter() { const year = new Date().getFullYear(); return ( -
-
- TG Shop Admin - · - v2.1.0 +
+
+ + TG Shop Admin + + · + v2.1.0
- Next.js 16 · SQLite · Prisma - © {year} + + Next.js 16 · SQLite · Prisma + + © {year}
); diff --git a/src/components/layout/admin-header.tsx b/src/components/layout/admin-header.tsx index 61294b1..9fd2066 100644 --- a/src/components/layout/admin-header.tsx +++ b/src/components/layout/admin-header.tsx @@ -65,9 +65,11 @@ function RealtimeClock() { return () => clearInterval(interval); }, []); + const parts = time.split(":"); + return ( - - {time} + + {parts[0]}:{parts[1]} ); } @@ -94,7 +96,7 @@ export function AdminHeader() { "Page"); return ( -
+
diff --git a/src/components/layout/admin-sidebar.tsx b/src/components/layout/admin-sidebar.tsx index 47e0474..a9bf3b1 100644 --- a/src/components/layout/admin-sidebar.tsx +++ b/src/components/layout/admin-sidebar.tsx @@ -158,9 +158,10 @@ export function AdminSidebar() { : pathname.startsWith(item.href) } tooltip={item.title} + className="[&[data-active='true']]:border-l-2 [&[data-active='true']]:border-l-sidebar-primary relative" > - + {item.title} {item.shortcut && ( @@ -174,6 +175,9 @@ export function AdminSidebar() { {pendingCount} )} + {item.badge && pendingCount > 0 && ( + + )} ))} @@ -190,9 +194,10 @@ export function AdminSidebar() { asChild isActive={pathname.startsWith(item.href)} tooltip={item.title} + className="[&[data-active='true']]:border-l-2 [&[data-active='true']]:border-l-sidebar-primary relative" > - + {item.title} {item.shortcut && ( @@ -217,9 +222,10 @@ export function AdminSidebar() { asChild isActive={pathname.startsWith(item.href)} tooltip={item.title} + className="[&[data-active='true']]:border-l-2 [&[data-active='true']]:border-l-sidebar-primary relative" > - + {item.title} {item.shortcut && ( @@ -249,9 +255,9 @@ export function AdminSidebar() { - -
- + +
+ {role === "super_admin" ? ( @@ -266,7 +272,7 @@ export function AdminSidebar() { {role} @@ -279,13 +285,13 @@ export function AdminSidebar() {
-
+
- + {connected ? "Connected" : "Disconnected"}
diff --git a/src/components/layout/command-palette.tsx b/src/components/layout/command-palette.tsx index 23d04a3..ebc18e4 100644 --- a/src/components/layout/command-palette.tsx +++ b/src/components/layout/command-palette.tsx @@ -204,7 +204,7 @@ export function CommandPalette() { { setOpen(false); - window.location.hash = "/seed"; + window.location.hash = "/seed?action=clear"; }} > diff --git a/src/components/layout/quick-actions.tsx b/src/components/layout/quick-actions.tsx index 54cc6e3..d9f8c1a 100644 --- a/src/components/layout/quick-actions.tsx +++ b/src/components/layout/quick-actions.tsx @@ -17,6 +17,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; +import { useAuthStore } from "@/stores/auth-store"; async function exportAllData() { try { @@ -55,6 +56,9 @@ async function exportAllData() { } export function QuickActions() { + const { role } = useAuthStore(); + const isSuperAdmin = role === 'super_admin'; + return ( @@ -83,10 +87,12 @@ export function QuickActions() { View Pending Purchases + {isSuperAdmin && ( (window.location.hash = "/seed")}> Seed Demo Data + )} diff --git a/src/components/purchases/purchases-page.tsx b/src/components/purchases/purchases-page.tsx index 80ea3bd..85584be 100644 --- a/src/components/purchases/purchases-page.tsx +++ b/src/components/purchases/purchases-page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, useCallback, useMemo } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Skeleton } from "@/components/ui/skeleton"; import { Table, @@ -15,7 +16,7 @@ import { import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { toast } from "sonner"; import { format } from "date-fns"; -import { Copy, ShoppingCart, CheckCircle, XCircle, Calendar } from "lucide-react"; +import { Copy, ShoppingCart, CheckCircle, XCircle, Calendar, CheckCircle2, Ban } from "lucide-react"; import { ExportButton } from "@/components/shared/export-button"; import { SortableHeader } from "@/components/shared/sortable-header"; import { Pagination } from "@/components/shared/pagination"; @@ -105,6 +106,8 @@ export function PurchasesPage() { const [updatingId, setUpdatingId] = useState(null); const [dateFrom, setDateFrom] = useState(''); const [dateTo, setDateTo] = useState(''); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [batchLoading, setBatchLoading] = useState(false); const limit = 50; const fetchData = useCallback(async () => { @@ -228,6 +231,48 @@ export function PurchasesPage() { if (ok) toast.success("Copied!"); }; + const toggleSelect = (id: number) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleSelectAll = () => { + if (selectedIds.size === sortedPurchases.length) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(sortedPurchases.map((p) => p.id))); + } + }; + + const handleBatchStatus = async (newStatus: 'completed' | 'cancelled') => { + if (selectedIds.size === 0) return; + setBatchLoading(true); + try { + const res = await fetch('/api/purchases/batch-status', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ purchaseIds: Array.from(selectedIds), status: newStatus }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Batch update failed'); + } + const data = await res.json(); + toast.success(data.message); + setSelectedIds(new Set()); + fetchData(); + fetchCounts(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Batch update failed'); + } finally { + setBatchLoading(false); + } + }; + return (
@@ -290,9 +335,16 @@ export function PurchasesPage() {

There are no purchases matching the current filter.

) : ( -
Product
+
+ + 0 && selectedIds.size === sortedPurchases.length} + onCheckedChange={toggleSelectAll} + aria-label="Select all purchases" + /> + ID User Product @@ -313,8 +365,15 @@ export function PurchasesPage() { {sortedPurchases.map((p) => ( - - {p.id} + + + toggleSelect(p.id)} + aria-label={`Select purchase ${p.id}`} + /> + + {p.id} 0 && ( )} + + {/* Floating batch action bar */} + {selectedIds.size > 0 && ( +
+ + {selectedIds.size} selected + +
+ + + +
+ )}
); } diff --git a/src/components/users/user-detail-page.tsx b/src/components/users/user-detail-page.tsx index 13e8b1a..716aca8 100644 --- a/src/components/users/user-detail-page.tsx +++ b/src/components/users/user-detail-page.tsx @@ -61,6 +61,8 @@ import { Save, Copy, ExternalLink, + ChevronDown, + ChevronRight, } from "lucide-react"; import { toast } from "sonner"; import { copyToClipboard } from "@/lib/clipboard"; @@ -133,20 +135,53 @@ function PurchaseStatusBadge({ status }: { status: string }) { function ActionBadge({ action }: { action: string }) { const map: Record = { - login: "bg-blue-600 hover:bg-blue-700 text-white", - balance_adjust: "bg-orange-500 hover:bg-orange-600 text-white", - status_toggle: "bg-red-600 hover:bg-red-700 text-white", - seed_phrase_viewed: "bg-purple-600 hover:bg-purple-700 text-white", - csv_seed_export: "bg-purple-600 hover:bg-purple-700 text-white", + login: "bg-cyan-500/15 text-cyan-400 border-cyan-500/25", + balance_adjust: "bg-orange-500/15 text-orange-400 border-orange-500/25", + status_toggle: "bg-red-500/15 text-red-400 border-red-500/25", + seed_phrase_viewed: "bg-violet-500/15 text-violet-400 border-violet-500/25", + csv_seed_export: "bg-violet-500/15 text-violet-400 border-violet-500/25", + user_banned: "bg-red-500/15 text-red-400 border-red-500/25", + user_unbanned: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25", + purchase_approved: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25", + purchase_cancelled: "bg-red-500/15 text-red-400 border-red-500/25", }; const cls = map[action] || ""; return ( - + {action.replace(/_/g, " ")} ); } +function actionDotColor(action: string): string { + const map: Record = { + login: "bg-cyan-500", + balance_adjust: "bg-orange-500", + status_toggle: "bg-red-500", + seed_phrase_viewed: "bg-violet-500", + csv_seed_export: "bg-violet-500", + user_banned: "bg-red-500", + user_unbanned: "bg-emerald-500", + purchase_approved: "bg-emerald-500", + purchase_cancelled: "bg-red-500", + }; + return map[action] || "bg-gray-400"; +} + +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 walletTypeColor(type: string): string { const map: Record = { BTC: "bg-orange-500/15 text-orange-400", @@ -204,6 +239,7 @@ export function UserDetailPage({ userId }: { userId: string }) { const [auditLoading, setAuditLoading] = useState(false); const [auditLoaded, setAuditLoaded] = useState(false); const [selectedPurchase, setSelectedPurchase] = useState(null); + const [expandedTimelineId, setExpandedTimelineId] = useState(null); const fetchUser = async () => { setLoading(true); @@ -711,35 +747,63 @@ export function UserDetailPage({ userId }: { userId: string }) { )} {!auditLoading && auditLoaded && auditLogs.length > 0 && ( -
-
- - - Action - Admin - Details - Date - - - - {auditLogs.map((log) => ( - - - - - - {log.adminId} - - - {log.details || "—"} - - - {format(new Date(log.createdAt), "MMM d, yyyy HH:mm")} - - - ))} - -
+
+
+ {/* Vertical timeline line */} +
+
+ {auditLogs.map((log, index) => { + const isExpanded = expandedTimelineId === log.id; + return ( +
+ {/* Timeline dot */} +
+ {/* Content */} + + {/* Expandable details */} + {isExpanded && ( +
+
+

Details:

+
+                                    {log.details || "No details available"}
+                                  
+

+ {format(new Date(log.createdAt), "MMMM d, yyyy 'at' HH:mm:ss")} +

+
+
+ )} +
+ ); + })} +
+
)} {!auditLoading && !auditLoaded && ( diff --git a/src/components/users/users-page.tsx b/src/components/users/users-page.tsx index 39392ed..5bdc072 100644 --- a/src/components/users/users-page.tsx +++ b/src/components/users/users-page.tsx @@ -5,6 +5,7 @@ import { format } from "date-fns"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { @@ -15,7 +16,8 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { Search, Eye, Users, ArrowUpDown } from "lucide-react"; +import { Search, Eye, Users, ArrowUpDown, ShieldBan, ShieldCheck } from "lucide-react"; +import { toast } from "sonner"; import { ExportButton } from "@/components/shared/export-button"; import { Pagination } from "@/components/shared/pagination"; @@ -102,6 +104,8 @@ export function UsersPage() { const [error, setError] = useState(null); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); const debounceRef = useRef>(); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [batchLoading, setBatchLoading] = useState(false); const limit = 50; @@ -165,6 +169,48 @@ export function UsersPage() { setSortDirection((prev) => (prev === "desc" ? "asc" : "desc")); }; + const toggleSelect = (id: number) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleSelectAll = () => { + if (selectedIds.size === sortedUsers.length) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(sortedUsers.map((u) => u.id))); + } + }; + + const handleBatchStatus = async (newStatus: number) => { + if (selectedIds.size === 0) return; + setBatchLoading(true); + try { + const res = await fetch("/api/users/batch-status", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userIds: Array.from(selectedIds), newStatus }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Batch update failed"); + } + const data = await res.json(); + toast.success(data.message); + setSelectedIds(new Set()); + fetchData(); + fetchCounts(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Batch update failed"); + } finally { + setBatchLoading(false); + } + }; + const goToPage = (p: number) => { setPage(p); }; @@ -253,9 +299,16 @@ export function UsersPage() {

Try adjusting your search query or filter.

) : ( - +
+ + 0 && selectedIds.size === sortedUsers.length} + onCheckedChange={toggleSelectAll} + aria-label="Select all users" + /> + ID Telegram ID Username @@ -278,8 +331,15 @@ export function UsersPage() { {sortedUsers.map((user) => ( - - {user.id} + + + toggleSelect(user.id)} + aria-label={`Select user ${user.id}`} + /> + + {user.id} {user.telegramId} @@ -335,6 +395,44 @@ export function UsersPage() { + + {/* Floating batch action bar */} + {selectedIds.size > 0 && ( +
+ + {selectedIds.size} selected + +
+ + + +
+ )}
); } diff --git a/src/components/wallets/wallets-page.tsx b/src/components/wallets/wallets-page.tsx index b7de121..3d6cfd1 100644 --- a/src/components/wallets/wallets-page.tsx +++ b/src/components/wallets/wallets-page.tsx @@ -95,6 +95,7 @@ interface OverviewData { walletCounts: Record; totalUsd: number; totalWallets: number; + activeWallets: number; totalUsers: number; commissionEnabled: boolean; commissionRate: number; @@ -657,6 +658,36 @@ export function WalletsPage() { ) : overview ? ( <> + {/* Balance Summary Mini Cards */} +
+
+
+ + Total Balance +
+

${overview.totalUsd.toFixed(2)}

+
+
+
+ + Total Wallets +
+

{overview.totalWallets}

+
+
+
+ + Active Wallets +
+
+

{overview.activeWallets ?? 0}

+ + ({overview.totalWallets > 0 ? Math.round(((overview.activeWallets ?? 0) / overview.totalWallets) * 100) : 0}%) + +
+
+
+ {/* KPI Cards */}
diff --git a/tool-results/read_1785946621272_d02fae467278.txt b/tool-results/read_1785946621272_d02fae467278.txt new file mode 100644 index 0000000..dcc66ce --- /dev/null +++ b/tool-results/read_1785946621272_d02fae467278.txt @@ -0,0 +1,905 @@ + 1→"use client"; + 2→ + 3→import { useEffect, useState, useCallback, useRef } from "react"; + 4→ + 5→import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + 6→import { Separator } from "@/components/ui/separator"; + 7→import { Skeleton } from "@/components/ui/skeleton"; + 8→import { Switch } from "@/components/ui/switch"; + 9→import { ActivityFeed } from "@/components/layout/activity-feed"; + 10→ + 11→import { + 12→ Users, + 13→ Package, + 14→ ShoppingCart, + 15→ DollarSign, + 16→ TrendingUp, + 17→ Percent, + 18→ CheckCircle, + 19→ Clock, + 20→ XCircle, + 21→ Tag, + 22→ RefreshCw, + 23→ ShieldBan, + 24→ Wallet, + 25→ ArrowRight, + 26→} from "lucide-react"; + 27→ + 28→import { + 29→ ResponsiveContainer, + 30→ AreaChart, + 31→ Area, + 32→ BarChart, + 33→ Bar, + 34→ PieChart, + 35→ Pie, + 36→ Cell, + 37→ XAxis, + 38→ YAxis, + 39→ CartesianGrid, + 40→ Tooltip, + 41→ Legend, + 42→} from "recharts"; + 43→ + 44→// Chart colors + 45→const CHART_1 = "#f97316"; + 46→const CHART_2 = "#06b6d4"; + 47→const CHART_3 = "#8b5cf6"; + 48→const CHART_4 = "#eab308"; + 49→const CHART_5 = "#ec4899"; + 50→const PIE_COLORS = [CHART_1, CHART_2, CHART_3, CHART_4, CHART_5]; + 51→ + 52→// ─── Types ─────────────────────────────────────────────── + 53→ + 54→interface RecentPurchase { + 55→ username: string; + 56→ productName: string; + 57→ totalPrice: number; + 58→ status: string; + 59→ purchaseDate: string; + 60→} + 61→ + 62→interface DashboardStats { + 63→ totalUsers: number; + 64→ totalProducts: number; + 65→ totalPurchases: number; + 66→ totalRevenue: number; + 67→ totalSubcategories: number; + 68→ aov: number; + 69→ conversionRate: number; + 70→ completedPurchases: number; + 71→ pendingPurchases: number; + 72→ cancelledPurchases: number; + 73→ bannedUsers: number; + 74→ activeWallets: number; + 75→} + 76→ + 77→interface ChartData { + 78→ days: string[]; + 79→ revenueData: number[]; + 80→ usersData: number[]; + 81→ days30: string[]; + 82→ revenueData30: number[]; + 83→} + 84→ + 85→interface TopProduct { + 86→ name: string; + 87→ qty: number; + 88→ revenue: number; + 89→} + 90→ + 91→interface TopSpender { + 92→ username: string; + 93→ spent: number; + 94→} + 95→ + 96→interface RevenueByCategory { + 97→ name: string; + 98→ value: number; + 99→} + 100→ + 101→interface TopCountry { + 102→ country: string; + 103→ productCount: number; + 104→} + 105→ + 106→interface WalletSummary { + 107→ walletType: string; + 108→ count: number; + 109→ totalBalance: number; + 110→ totalBalanceUsd: number; + 111→} + 112→ + 113→interface DashboardData { + 114→ stats: DashboardStats; + 115→ chartData: ChartData; + 116→ topProducts: TopProduct[]; + 117→ topSpenders: TopSpender[]; + 118→ revenueByCategory: RevenueByCategory[]; + 119→ topCountries: TopCountry[]; + 120→ walletSummary: WalletSummary[]; + 121→ recentPurchases: RecentPurchase[]; + 122→} + 123→ + 124→// ─── Helpers ───────────────────────────────────────────── + 125→ + 126→function formatCurrency(val: number): string { + 127→ return `$${val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + 128→} + 129→ + 130→function relativeTime(dateStr: string): string { + 131→ const now = Date.now(); + 132→ const then = new Date(dateStr).getTime(); + 133→ const diffMs = now - then; + 134→ const diffMin = Math.floor(diffMs / 60000); + 135→ const diffHr = Math.floor(diffMs / 3600000); + 136→ const diffDay = Math.floor(diffMs / 86400000); + 137→ if (diffMin < 1) return 'just now'; + 138→ if (diffMin < 60) return `${diffMin}m ago`; + 139→ if (diffHr < 24) return `${diffHr}h ago`; + 140→ if (diffDay < 7) return `${diffDay}d ago`; + 141→ return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + 142→} + 143→ + 144→function statusBadge(status: string): { label: string; cls: string } { + 145→ switch (status) { + 146→ case 'completed': + 147→ return { label: 'Completed', cls: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400' }; + 148→ case 'pending': + 149→ return { label: 'Pending', cls: 'bg-yellow-500/15 text-yellow-600 dark:text-yellow-400' }; + 150→ case 'cancelled': + 151→ return { label: 'Cancelled', cls: 'bg-red-500/15 text-red-600 dark:text-red-400' }; + 152→ default: + 153→ return { label: status, cls: 'bg-muted text-muted-foreground' }; + 154→ } + 155→} + 156→ + 157→function formatCrypto(val: number): string { + 158→ return val.toFixed(8); + 159→} + 160→ + 161→function shortDate(dateStr: string): string { + 162→ const d = new Date(dateStr + "T00:00:00"); + 163→ return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + 164→} + 165→ + 166→// ─── Mini Sparkline ───────────────────────────────────── + 167→ + 168→function MiniSparkline({ data, color }: { data: number[]; color: string }) { + 169→ if (data.length < 2) return null; + 170→ const chartData = data.map((v, i) => ({ i, v })); + 171→ return ( + 172→
+ 173→ + 174→ + 175→ + 176→ + 177→ + 178→ + 179→
+ 180→ ); + 181→} + 182→ + 183→function generateSparkData(value: number, points: number = 8): number[] { + 184→ const data: number[] = []; + 185→ let current = value * 0.6; + 186→ for (let i = 0; i < points; i++) { + 187→ current += (value - current) * (0.2 + Math.random() * 0.3); + 188→ data.push(Math.round(current * 10) / 10); + 189→ } + 190→ return data; + 191→} + 192→ + 193→// ─── KPI Card ──────────────────────────────────────────── + 194→ + 195→function KpiCard({ + 196→ title, + 197→ value, + 198→ icon: Icon, + 199→ color, + 200→ sparklineColor, + 201→ sparklineValue, + 202→}: { + 203→ title: string; + 204→ value: string; + 205→ icon: React.ComponentType<{ className?: string }>; + 206→ color: string; + 207→ sparklineColor?: string; + 208→ sparklineValue?: number; + 209→}) { + 210→ const sparkData = sparklineValue !== undefined ? generateSparkData(sparklineValue) : undefined; + 211→ return ( + 212→ + 213→
+ 219→ + 220→
+ 224→ + 225→
+ 226→
+ 227→

{title}

+ 228→

{value}

+ 229→
+ 230→
+ 231→ {sparkData && sparklineColor && } + 232→ + 233→ ); + 234→} + 235→ + 236→// ─── Skeleton Loader ───────────────────────────────────── + 237→ + 238→function DashboardSkeleton() { + 239→ return ( + 240→
+ 241→ + 242→
+ 243→ {Array.from({ length: 10 }).map((_, i) => ( + 244→ + 245→ ))} + 246→
+ 247→
+ 248→ {Array.from({ length: 4 }).map((_, i) => ( + 249→ + 250→ ))} + 251→
+ 252→
+ 253→ ); + 254→} + 255→ + 256→// ─── Chart Card wrapper ────────────────────────────────── + 257→ + 258→function ChartCard({ + 259→ title, + 260→ children, + 261→ accentColor, + 262→}: { + 263→ title: string; + 264→ children: React.ReactNode; + 265→ accentColor?: string; + 266→}) { + 267→ return ( + 268→ + 269→
+ 275→ + 276→ {title} + 277→ + 278→ + 279→
{children}
+ 280→
+ 281→ + 282→ ); + 283→} + 284→ + 285→// ─── Main Component ────────────────────────────────────── + 286→ + 287→export function DashboardPage() { + 288→ const [data, setData] = useState(null); + 289→ const [loading, setLoading] = useState(true); + 290→ const [error, setError] = useState(null); + 291→ const [autoRefresh, setAutoRefresh] = useState(false); + 292→ const [lastUpdated, setLastUpdated] = useState(Date.now()); + 293→ const [refreshing, setRefreshing] = useState(false); + 294→ const autoRefreshRef = useRef | null>(null); + 295→ + 296→ const fetchDashboard = useCallback(async () => { + 297→ try { + 298→ setRefreshing(true); + 299→ setError(null); + 300→ const res = await fetch("/api/stats/dashboard"); + 301→ if (!res.ok) { + 302→ throw new Error("Failed to load dashboard data"); + 303→ } + 304→ const json = await res.json(); + 305→ setData(json); + 306→ setLastUpdated(Date.now()); + 307→ } catch (err) { + 308→ setError(err instanceof Error ? err.message : "Unknown error"); + 309→ } finally { + 310→ setLoading(false); + 311→ setRefreshing(false); + 312→ } + 313→ }, []); + 314→ + 315→ useEffect(() => { + 316→ fetchDashboard(); + 317→ }, [fetchDashboard]); + 318→ + 319→ // Auto-refresh toggle + 320→ useEffect(() => { + 321→ if (autoRefresh) { + 322→ autoRefreshRef.current = setInterval(fetchDashboard, 30000); + 323→ } + 324→ return () => { + 325→ if (autoRefreshRef.current) clearInterval(autoRefreshRef.current); + 326→ }; + 327→ }, [autoRefresh, fetchDashboard]); + 328→ + 329→ // "X seconds ago" ticker + 330→ const [secondsAgo, setSecondsAgo] = useState(0); + 331→ useEffect(() => { + 332→ const tick = setInterval(() => { + 333→ setSecondsAgo(Math.floor((Date.now() - lastUpdated) / 1000)); + 334→ }, 1000); + 335→ return () => clearInterval(tick); + 336→ }, [lastUpdated]); + 337→ + 338→ if (loading) return ; + 339→ if (error) { + 340→ return ( + 341→
+ 342→ + 343→ + 344→

{error}

+ 345→
+ 346→
+ 347→
+ 348→ ); + 349→ } + 350→ if (!data) return null; + 351→ + 352→ const { stats, chartData, topProducts, topSpenders, revenueByCategory, walletSummary, recentPurchases } = data; + 353→ + 354→ // Prepare chart datasets + 355→ const revenue7Data = chartData.days.map((day, i) => ({ + 356→ date: shortDate(day), + 357→ revenue: chartData.revenueData[i], + 358→ })); + 359→ + 360→ const revenue30Data = chartData.days30.map((day, i) => ({ + 361→ date: shortDate(day), + 362→ revenue: chartData.revenueData30[i], + 363→ })); + 364→ + 365→ const users7Data = chartData.days.map((day, i) => ({ + 366→ date: shortDate(day), + 367→ users: chartData.usersData[i], + 368→ })); + 369→ + 370→ const productsData = [...topProducts].reverse(); // reverse for horizontal bar + 371→ + 372→ const spendersData = [...topSpenders].reverse(); + 373→ + 374→ const walletChartData = walletSummary.map((w) => ({ + 375→ name: w.walletType, + 376→ count: w.count, + 377→ })); + 378→ + 379→ // KPI definitions + 380→ const kpis = [ + 381→ { title: "Total Users", value: stats.totalUsers.toLocaleString(), icon: Users, color: CHART_1, sparklineColor: "#64748b", sparklineValue: stats.totalUsers }, + 382→ { title: "Total Products", value: stats.totalProducts.toLocaleString(), icon: Package, color: CHART_2, sparklineColor: "#64748b", sparklineValue: stats.totalProducts }, + 383→ { title: "Total Purchases", value: stats.totalPurchases.toLocaleString(), icon: ShoppingCart, color: CHART_3, sparklineColor: "#64748b", sparklineValue: stats.totalPurchases }, + 384→ { title: "Pending", value: stats.pendingPurchases.toLocaleString(), icon: Clock, color: "#eab308", sparklineColor: "#eab308", sparklineValue: stats.pendingPurchases }, + 385→ { title: "Total Revenue", value: formatCurrency(stats.totalRevenue), icon: DollarSign, color: "#22c55e", sparklineColor: "#22c55e", sparklineValue: stats.totalRevenue }, + 386→ { title: "Avg Order Value", value: formatCurrency(stats.aov), icon: TrendingUp, color: CHART_1, sparklineColor: "#64748b", sparklineValue: stats.aov }, + 387→ { title: "Conversion Rate", value: `${stats.conversionRate.toFixed(1)}%`, icon: Percent, color: CHART_5, sparklineColor: "#64748b", sparklineValue: stats.conversionRate }, + 388→ { title: "Completed", value: stats.completedPurchases.toLocaleString(), icon: CheckCircle, color: "#22c55e", sparklineColor: "#64748b", sparklineValue: stats.completedPurchases }, + 389→ { title: "Cancelled", value: stats.cancelledPurchases.toLocaleString(), icon: XCircle, color: "#ef4444", sparklineColor: "#64748b", sparklineValue: stats.cancelledPurchases }, + 390→ { title: "Banned Users", value: stats.bannedUsers.toLocaleString(), icon: ShieldBan, color: "#ef4444", sparklineColor: "#ef4444", sparklineValue: stats.bannedUsers }, + 391→ { title: "Active Wallets", value: stats.activeWallets.toLocaleString(), icon: Wallet, color: CHART_2, sparklineColor: "#06b6d4", sparklineValue: stats.activeWallets }, + 392→ { title: "Subcategories", value: stats.totalSubcategories.toLocaleString(), icon: Tag, color: CHART_2, sparklineColor: "#64748b", sparklineValue: stats.totalSubcategories }, + 393→ ]; + 394→ + 395→ return ( + 396→
+ 397→ {/* ── Page Title ── */} + 398→
+ 399→

Overview of your Telegram Shop

+ 400→
+ 401→ + 402→ Last updated: {secondsAgo < 1 ? "just now" : `${secondsAgo} seconds ago`} + 403→ + 404→ + 413→
+ 414→ + 419→ + 425→
+ 426→
+ 427→
+ 428→ + 429→ {/* ── KPI Cards ── */} + 430→
+ 431→ {kpis.map((kpi) => ( + 432→ + 433→ ))} + 434→
+ 435→ + 436→ + 437→ + 438→ {/* ── Charts Grid ── */} + 439→
+ 440→ {/* 1. Revenue 7 days */} + 441→ + 442→ {revenue7Data.length > 0 ? ( + 443→ + 444→ + 445→ + 446→ + 447→ + 448→ + 449→ + 450→ + 451→ + 452→ + 453→ + 454→ + 462→ + 469→ + 470→ + 471→ ) : ( + 472→
No data
+ 473→ )} + 474→
+ 475→ + 476→ {/* 2. Revenue 30 days */} + 477→ + 478→ {revenue30Data.length > 0 ? ( + 479→ + 480→ + 481→ + 482→ + 483→ + 484→ + 485→ + 486→ + 487→ + 488→ + 489→ + 490→ + 498→ + 505→ + 506→ + 507→ ) : ( + 508→
No data
+ 509→ )} + 510→
+ 511→ + 512→ {/* 3. New Users 7 days */} + 513→ + 514→ {users7Data.length > 0 ? ( + 515→ + 516→ + 517→ + 518→ + 519→ + 520→ + 528→ + 529→ + 530→ + 531→ ) : ( + 532→
No data
+ 533→ )} + 534→
+ 535→ + 536→ {/* 4. Top 5 Products */} + 537→ + 538→ {productsData.length > 0 ? ( + 539→ + 540→ + 541→ + 542→ + 543→ + 544→ { + 552→ if (name === "qty") return [value, "Quantity"]; + 553→ return [formatCurrency(value), "Revenue"]; + 554→ }} + 555→ /> + 556→ + 557→ + 558→ + 559→ ) : ( + 560→
No data
+ 561→ )} + 562→
+ 563→ + 564→ {/* 5. Top 5 Spenders */} + 565→ + 566→ {spendersData.length > 0 ? ( + 567→ + 568→ + 569→ + 570→ + 571→ + 572→ [formatCurrency(value), "Spent"]} + 580→ /> + 581→ + 582→ + 583→ + 584→ ) : ( + 585→
No data
+ 586→ )} + 587→
+ 588→ + 589→ {/* 6. Revenue by Category (Pie/Donut) */} + 590→ + 591→ {revenueByCategory.length > 0 ? ( + 592→ + 593→ + 594→ + 604→ `${name} ${(percent * 100).toFixed(0)}%` + 605→ } + 606→ labelLine={true} + 607→ fontSize={11} + 608→ > + 609→ {revenueByCategory.map((_, index) => ( + 610→ + 614→ ))} + 615→ + 616→ [formatCurrency(value), "Revenue"]} + 624→ /> + 625→ + 626→ + 627→ + 628→ ) : ( + 629→
No data
+ 630→ )} + 631→
+ 632→ + 633→ {/* 7. Purchase Status Distribution */} + 634→ + 635→ + 636→ + 637→ + 651→ `${name} ${(percent * 100).toFixed(0)}%` + 652→ } + 653→ labelLine={true} + 654→ fontSize={11} + 655→ > + 656→ + 657→ + 658→ + 659→ + 660→ [value, "Purchases"]} + 668→ /> + 669→ + 670→ + 671→ + 672→ + 673→
+ 674→ + 675→ {/* ── Analytics Cards: Revenue Trend + User Funnel ── */} + 676→
+ 677→ {/* Card A: Revenue Trend (30-day area chart) */} + 678→ + 679→
+ 685→ + 686→ + 687→ Revenue Trend + 688→ + 689→ + 690→
+ 691→ {revenue30Data.length > 0 ? ( + 692→ + 693→ + 694→ + 695→ + 696→ + 697→ + 698→ + 699→ + 700→ + 701→ + 702→ `$${v}`} /> + 703→ [formatCurrency(value), "Revenue"]} + 711→ /> + 712→ + 719→ + 720→ + 721→ ) : ( + 722→
No data
+ 723→ )} + 724→
+ 725→
+ 726→ + 727→ + 728→ {/* Card B: Conversion Funnel (horizontal bar) */} + 729→ + 730→
+ 736→ + 737→ + 738→ User Funnel + 739→ + 740→ + 741→
+ 742→ + 743→ + 752→ + 753→ + 754→ + 761→ + 769→ + 770→ + 771→ + 772→ + 773→ + 774→ + 775→ + 776→
+ 777→
+ 778→ + 779→
+ 780→ + 781→ + 782→ + 783→ {/* ── Recent Purchases Table ── */} + 784→ + 785→ + 786→ Recent Purchases + 787→ + 795→ + 796→ + 797→ {data.recentPurchases.length > 0 ? ( + 798→
+ 799→
+ 800→ + 801→ + 802→ + 803→ + 804→ + 805→ + 806→ + 807→ + 808→ + 809→ + 810→ {data.recentPurchases.map((p, i) => { + 811→ const badge = statusBadge(p.status); + 812→ return ( + 813→ + 814→ + 815→ + 816→ + 817→ + 822→ + 823→ + 824→ ); + 825→ })} + 826→ + 827→
ProductUserAmountStatusDate
{p.productName}{p.username}{formatCurrency(p.totalPrice)} + 818→ + 819→ {badge.label} + 820→ + 821→ {relativeTime(p.purchaseDate)}
+ 828→
+ 829→ ) : ( + 830→
No recent purchases
+ 831→ )} + 832→ + 833→ + 834→ + 835→ + 836→ + 837→ {/* ── Bottom Section: Wallet Summary + Wallet Chart ── */} + 838→
+ 839→ {/* Wallet Summary Table */} + 840→ + 841→ + 842→ Wallet Summary + 843→ + 844→ + 845→ {walletSummary.length > 0 ? ( + 846→
+ 847→ + 848→ + 849→ + 850→ + 851→ + 852→ + 853→ + 854→ + 855→ + 856→ + 857→ {walletSummary.map((w) => ( + 858→ + 859→ + 860→ + 861→ + 862→ + 863→ + 864→ ))} + 865→ + 866→
TypeCountBalanceUSD (mock)
{w.walletType}{w.count}{formatCrypto(w.totalBalance)}{formatCurrency(w.totalBalanceUsd)}
+ 867→
+ 868→ ) : ( + 869→
No wallet data
+ 870→ )} + 871→
+ 872→
+ 873→ + 874→ {/* Wallet Count by Type Chart */} + 875→ + 876→ {walletChartData.length > 0 ? ( + 877→ + 878→ + 879→ + 880→ + 881→ + 882→ [value, "Wallets"]} + 890→ /> + 891→ + 892→ + 893→ + 894→ ) : ( + 895→
No wallet data
+ 896→ )} + 897→
+ 898→
+ 899→ + 900→ {/* ── Activity Feed (full width) ── */} + 901→ + 902→
+ 903→ ); + 904→} + 905→ \ No newline at end of file diff --git a/tool-results/read_1785946624584_8dff49b52aac.txt b/tool-results/read_1785946624584_8dff49b52aac.txt new file mode 100644 index 0000000..ba2a484 --- /dev/null +++ b/tool-results/read_1785946624584_8dff49b52aac.txt @@ -0,0 +1,905 @@ + 1→ 1→"use client"; + 2→ 2→ + 3→ 3→import { useEffect, useState, useCallback, useRef } from "react"; + 4→ 4→ + 5→ 5→import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + 6→ 6→import { Separator } from "@/components/ui/separator"; + 7→ 7→import { Skeleton } from "@/components/ui/skeleton"; + 8→ 8→import { Switch } from "@/components/ui/switch"; + 9→ 9→import { ActivityFeed } from "@/components/layout/activity-feed"; + 10→ 10→ + 11→ 11→import { + 12→ 12→ Users, + 13→ 13→ Package, + 14→ 14→ ShoppingCart, + 15→ 15→ DollarSign, + 16→ 16→ TrendingUp, + 17→ 17→ Percent, + 18→ 18→ CheckCircle, + 19→ 19→ Clock, + 20→ 20→ XCircle, + 21→ 21→ Tag, + 22→ 22→ RefreshCw, + 23→ 23→ ShieldBan, + 24→ 24→ Wallet, + 25→ 25→ ArrowRight, + 26→ 26→} from "lucide-react"; + 27→ 27→ + 28→ 28→import { + 29→ 29→ ResponsiveContainer, + 30→ 30→ AreaChart, + 31→ 31→ Area, + 32→ 32→ BarChart, + 33→ 33→ Bar, + 34→ 34→ PieChart, + 35→ 35→ Pie, + 36→ 36→ Cell, + 37→ 37→ XAxis, + 38→ 38→ YAxis, + 39→ 39→ CartesianGrid, + 40→ 40→ Tooltip, + 41→ 41→ Legend, + 42→ 42→} from "recharts"; + 43→ 43→ + 44→ 44→// Chart colors + 45→ 45→const CHART_1 = "#f97316"; + 46→ 46→const CHART_2 = "#06b6d4"; + 47→ 47→const CHART_3 = "#8b5cf6"; + 48→ 48→const CHART_4 = "#eab308"; + 49→ 49→const CHART_5 = "#ec4899"; + 50→ 50→const PIE_COLORS = [CHART_1, CHART_2, CHART_3, CHART_4, CHART_5]; + 51→ 51→ + 52→ 52→// ─── Types ─────────────────────────────────────────────── + 53→ 53→ + 54→ 54→interface RecentPurchase { + 55→ 55→ username: string; + 56→ 56→ productName: string; + 57→ 57→ totalPrice: number; + 58→ 58→ status: string; + 59→ 59→ purchaseDate: string; + 60→ 60→} + 61→ 61→ + 62→ 62→interface DashboardStats { + 63→ 63→ totalUsers: number; + 64→ 64→ totalProducts: number; + 65→ 65→ totalPurchases: number; + 66→ 66→ totalRevenue: number; + 67→ 67→ totalSubcategories: number; + 68→ 68→ aov: number; + 69→ 69→ conversionRate: number; + 70→ 70→ completedPurchases: number; + 71→ 71→ pendingPurchases: number; + 72→ 72→ cancelledPurchases: number; + 73→ 73→ bannedUsers: number; + 74→ 74→ activeWallets: number; + 75→ 75→} + 76→ 76→ + 77→ 77→interface ChartData { + 78→ 78→ days: string[]; + 79→ 79→ revenueData: number[]; + 80→ 80→ usersData: number[]; + 81→ 81→ days30: string[]; + 82→ 82→ revenueData30: number[]; + 83→ 83→} + 84→ 84→ + 85→ 85→interface TopProduct { + 86→ 86→ name: string; + 87→ 87→ qty: number; + 88→ 88→ revenue: number; + 89→ 89→} + 90→ 90→ + 91→ 91→interface TopSpender { + 92→ 92→ username: string; + 93→ 93→ spent: number; + 94→ 94→} + 95→ 95→ + 96→ 96→interface RevenueByCategory { + 97→ 97→ name: string; + 98→ 98→ value: number; + 99→ 99→} + 100→ 100→ + 101→ 101→interface TopCountry { + 102→ 102→ country: string; + 103→ 103→ productCount: number; + 104→ 104→} + 105→ 105→ + 106→ 106→interface WalletSummary { + 107→ 107→ walletType: string; + 108→ 108→ count: number; + 109→ 109→ totalBalance: number; + 110→ 110→ totalBalanceUsd: number; + 111→ 111→} + 112→ 112→ + 113→ 113→interface DashboardData { + 114→ 114→ stats: DashboardStats; + 115→ 115→ chartData: ChartData; + 116→ 116→ topProducts: TopProduct[]; + 117→ 117→ topSpenders: TopSpender[]; + 118→ 118→ revenueByCategory: RevenueByCategory[]; + 119→ 119→ topCountries: TopCountry[]; + 120→ 120→ walletSummary: WalletSummary[]; + 121→ 121→ recentPurchases: RecentPurchase[]; + 122→ 122→} + 123→ 123→ + 124→ 124→// ─── Helpers ───────────────────────────────────────────── + 125→ 125→ + 126→ 126→function formatCurrency(val: number): string { + 127→ 127→ return `$${val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + 128→ 128→} + 129→ 129→ + 130→ 130→function relativeTime(dateStr: string): string { + 131→ 131→ const now = Date.now(); + 132→ 132→ const then = new Date(dateStr).getTime(); + 133→ 133→ const diffMs = now - then; + 134→ 134→ const diffMin = Math.floor(diffMs / 60000); + 135→ 135→ const diffHr = Math.floor(diffMs / 3600000); + 136→ 136→ const diffDay = Math.floor(diffMs / 86400000); + 137→ 137→ if (diffMin < 1) return 'just now'; + 138→ 138→ if (diffMin < 60) return `${diffMin}m ago`; + 139→ 139→ if (diffHr < 24) return `${diffHr}h ago`; + 140→ 140→ if (diffDay < 7) return `${diffDay}d ago`; + 141→ 141→ return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + 142→ 142→} + 143→ 143→ + 144→ 144→function statusBadge(status: string): { label: string; cls: string } { + 145→ 145→ switch (status) { + 146→ 146→ case 'completed': + 147→ 147→ return { label: 'Completed', cls: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400' }; + 148→ 148→ case 'pending': + 149→ 149→ return { label: 'Pending', cls: 'bg-yellow-500/15 text-yellow-600 dark:text-yellow-400' }; + 150→ 150→ case 'cancelled': + 151→ 151→ return { label: 'Cancelled', cls: 'bg-red-500/15 text-red-600 dark:text-red-400' }; + 152→ 152→ default: + 153→ 153→ return { label: status, cls: 'bg-muted text-muted-foreground' }; + 154→ 154→ } + 155→ 155→} + 156→ 156→ + 157→ 157→function formatCrypto(val: number): string { + 158→ 158→ return val.toFixed(8); + 159→ 159→} + 160→ 160→ + 161→ 161→function shortDate(dateStr: string): string { + 162→ 162→ const d = new Date(dateStr + "T00:00:00"); + 163→ 163→ return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + 164→ 164→} + 165→ 165→ + 166→ 166→// ─── Mini Sparkline ───────────────────────────────────── + 167→ 167→ + 168→ 168→function MiniSparkline({ data, color }: { data: number[]; color: string }) { + 169→ 169→ if (data.length < 2) return null; + 170→ 170→ const chartData = data.map((v, i) => ({ i, v })); + 171→ 171→ return ( + 172→ 172→
+ 173→ 173→ + 174→ 174→ + 175→ 175→ + 176→ 176→ + 177→ 177→ + 178→ 178→ + 179→ 179→
+ 180→ 180→ ); + 181→ 181→} + 182→ 182→ + 183→ 183→function generateSparkData(value: number, points: number = 8): number[] { + 184→ 184→ const data: number[] = []; + 185→ 185→ let current = value * 0.6; + 186→ 186→ for (let i = 0; i < points; i++) { + 187→ 187→ current += (value - current) * (0.2 + Math.random() * 0.3); + 188→ 188→ data.push(Math.round(current * 10) / 10); + 189→ 189→ } + 190→ 190→ return data; + 191→ 191→} + 192→ 192→ + 193→ 193→// ─── KPI Card ──────────────────────────────────────────── + 194→ 194→ + 195→ 195→function KpiCard({ + 196→ 196→ title, + 197→ 197→ value, + 198→ 198→ icon: Icon, + 199→ 199→ color, + 200→ 200→ sparklineColor, + 201→ 201→ sparklineValue, + 202→ 202→}: { + 203→ 203→ title: string; + 204→ 204→ value: string; + 205→ 205→ icon: React.ComponentType<{ className?: string }>; + 206→ 206→ color: string; + 207→ 207→ sparklineColor?: string; + 208→ 208→ sparklineValue?: number; + 209→ 209→}) { + 210→ 210→ const sparkData = sparklineValue !== undefined ? generateSparkData(sparklineValue) : undefined; + 211→ 211→ return ( + 212→ 212→ + 213→ 213→
+ 219→ 219→ + 220→ 220→
+ 224→ 224→ + 225→ 225→
+ 226→ 226→
+ 227→ 227→

{title}

+ 228→ 228→

{value}

+ 229→ 229→
+ 230→ 230→
+ 231→ 231→ {sparkData && sparklineColor && } + 232→ 232→ + 233→ 233→ ); + 234→ 234→} + 235→ 235→ + 236→ 236→// ─── Skeleton Loader ───────────────────────────────────── + 237→ 237→ + 238→ 238→function DashboardSkeleton() { + 239→ 239→ return ( + 240→ 240→
+ 241→ 241→ + 242→ 242→
+ 243→ 243→ {Array.from({ length: 10 }).map((_, i) => ( + 244→ 244→ + 245→ 245→ ))} + 246→ 246→
+ 247→ 247→
+ 248→ 248→ {Array.from({ length: 4 }).map((_, i) => ( + 249→ 249→ + 250→ 250→ ))} + 251→ 251→
+ 252→ 252→
+ 253→ 253→ ); + 254→ 254→} + 255→ 255→ + 256→ 256→// ─── Chart Card wrapper ────────────────────────────────── + 257→ 257→ + 258→ 258→function ChartCard({ + 259→ 259→ title, + 260→ 260→ children, + 261→ 261→ accentColor, + 262→ 262→}: { + 263→ 263→ title: string; + 264→ 264→ children: React.ReactNode; + 265→ 265→ accentColor?: string; + 266→ 266→}) { + 267→ 267→ return ( + 268→ 268→ + 269→ 269→
+ 275→ 275→ + 276→ 276→ {title} + 277→ 277→ + 278→ 278→ + 279→ 279→
{children}
+ 280→ 280→
+ 281→ 281→ + 282→ 282→ ); + 283→ 283→} + 284→ 284→ + 285→ 285→// ─── Main Component ────────────────────────────────────── + 286→ 286→ + 287→ 287→export function DashboardPage() { + 288→ 288→ const [data, setData] = useState(null); + 289→ 289→ const [loading, setLoading] = useState(true); + 290→ 290→ const [error, setError] = useState(null); + 291→ 291→ const [autoRefresh, setAutoRefresh] = useState(false); + 292→ 292→ const [lastUpdated, setLastUpdated] = useState(Date.now()); + 293→ 293→ const [refreshing, setRefreshing] = useState(false); + 294→ 294→ const autoRefreshRef = useRef | null>(null); + 295→ 295→ + 296→ 296→ const fetchDashboard = useCallback(async () => { + 297→ 297→ try { + 298→ 298→ setRefreshing(true); + 299→ 299→ setError(null); + 300→ 300→ const res = await fetch("/api/stats/dashboard"); + 301→ 301→ if (!res.ok) { + 302→ 302→ throw new Error("Failed to load dashboard data"); + 303→ 303→ } + 304→ 304→ const json = await res.json(); + 305→ 305→ setData(json); + 306→ 306→ setLastUpdated(Date.now()); + 307→ 307→ } catch (err) { + 308→ 308→ setError(err instanceof Error ? err.message : "Unknown error"); + 309→ 309→ } finally { + 310→ 310→ setLoading(false); + 311→ 311→ setRefreshing(false); + 312→ 312→ } + 313→ 313→ }, []); + 314→ 314→ + 315→ 315→ useEffect(() => { + 316→ 316→ fetchDashboard(); + 317→ 317→ }, [fetchDashboard]); + 318→ 318→ + 319→ 319→ // Auto-refresh toggle + 320→ 320→ useEffect(() => { + 321→ 321→ if (autoRefresh) { + 322→ 322→ autoRefreshRef.current = setInterval(fetchDashboard, 30000); + 323→ 323→ } + 324→ 324→ return () => { + 325→ 325→ if (autoRefreshRef.current) clearInterval(autoRefreshRef.current); + 326→ 326→ }; + 327→ 327→ }, [autoRefresh, fetchDashboard]); + 328→ 328→ + 329→ 329→ // "X seconds ago" ticker + 330→ 330→ const [secondsAgo, setSecondsAgo] = useState(0); + 331→ 331→ useEffect(() => { + 332→ 332→ const tick = setInterval(() => { + 333→ 333→ setSecondsAgo(Math.floor((Date.now() - lastUpdated) / 1000)); + 334→ 334→ }, 1000); + 335→ 335→ return () => clearInterval(tick); + 336→ 336→ }, [lastUpdated]); + 337→ 337→ + 338→ 338→ if (loading) return ; + 339→ 339→ if (error) { + 340→ 340→ return ( + 341→ 341→
+ 342→ 342→ + 343→ 343→ + 344→ 344→

{error}

+ 345→ 345→
+ 346→ 346→
+ 347→ 347→
+ 348→ 348→ ); + 349→ 349→ } + 350→ 350→ if (!data) return null; + 351→ 351→ + 352→ 352→ const { stats, chartData, topProducts, topSpenders, revenueByCategory, walletSummary, recentPurchases } = data; + 353→ 353→ + 354→ 354→ // Prepare chart datasets + 355→ 355→ const revenue7Data = chartData.days.map((day, i) => ({ + 356→ 356→ date: shortDate(day), + 357→ 357→ revenue: chartData.revenueData[i], + 358→ 358→ })); + 359→ 359→ + 360→ 360→ const revenue30Data = chartData.days30.map((day, i) => ({ + 361→ 361→ date: shortDate(day), + 362→ 362→ revenue: chartData.revenueData30[i], + 363→ 363→ })); + 364→ 364→ + 365→ 365→ const users7Data = chartData.days.map((day, i) => ({ + 366→ 366→ date: shortDate(day), + 367→ 367→ users: chartData.usersData[i], + 368→ 368→ })); + 369→ 369→ + 370→ 370→ const productsData = [...topProducts].reverse(); // reverse for horizontal bar + 371→ 371→ + 372→ 372→ const spendersData = [...topSpenders].reverse(); + 373→ 373→ + 374→ 374→ const walletChartData = walletSummary.map((w) => ({ + 375→ 375→ name: w.walletType, + 376→ 376→ count: w.count, + 377→ 377→ })); + 378→ 378→ + 379→ 379→ // KPI definitions + 380→ 380→ const kpis = [ + 381→ 381→ { title: "Total Users", value: stats.totalUsers.toLocaleString(), icon: Users, color: CHART_1, sparklineColor: "#64748b", sparklineValue: stats.totalUsers }, + 382→ 382→ { title: "Total Products", value: stats.totalProducts.toLocaleString(), icon: Package, color: CHART_2, sparklineColor: "#64748b", sparklineValue: stats.totalProducts }, + 383→ 383→ { title: "Total Purchases", value: stats.totalPurchases.toLocaleString(), icon: ShoppingCart, color: CHART_3, sparklineColor: "#64748b", sparklineValue: stats.totalPurchases }, + 384→ 384→ { title: "Pending", value: stats.pendingPurchases.toLocaleString(), icon: Clock, color: "#eab308", sparklineColor: "#eab308", sparklineValue: stats.pendingPurchases }, + 385→ 385→ { title: "Total Revenue", value: formatCurrency(stats.totalRevenue), icon: DollarSign, color: "#22c55e", sparklineColor: "#22c55e", sparklineValue: stats.totalRevenue }, + 386→ 386→ { title: "Avg Order Value", value: formatCurrency(stats.aov), icon: TrendingUp, color: CHART_1, sparklineColor: "#64748b", sparklineValue: stats.aov }, + 387→ 387→ { title: "Conversion Rate", value: `${stats.conversionRate.toFixed(1)}%`, icon: Percent, color: CHART_5, sparklineColor: "#64748b", sparklineValue: stats.conversionRate }, + 388→ 388→ { title: "Completed", value: stats.completedPurchases.toLocaleString(), icon: CheckCircle, color: "#22c55e", sparklineColor: "#64748b", sparklineValue: stats.completedPurchases }, + 389→ 389→ { title: "Cancelled", value: stats.cancelledPurchases.toLocaleString(), icon: XCircle, color: "#ef4444", sparklineColor: "#64748b", sparklineValue: stats.cancelledPurchases }, + 390→ 390→ { title: "Banned Users", value: stats.bannedUsers.toLocaleString(), icon: ShieldBan, color: "#ef4444", sparklineColor: "#ef4444", sparklineValue: stats.bannedUsers }, + 391→ 391→ { title: "Active Wallets", value: stats.activeWallets.toLocaleString(), icon: Wallet, color: CHART_2, sparklineColor: "#06b6d4", sparklineValue: stats.activeWallets }, + 392→ 392→ { title: "Subcategories", value: stats.totalSubcategories.toLocaleString(), icon: Tag, color: CHART_2, sparklineColor: "#64748b", sparklineValue: stats.totalSubcategories }, + 393→ 393→ ]; + 394→ 394→ + 395→ 395→ return ( + 396→ 396→
+ 397→ 397→ {/* ── Page Title ── */} + 398→ 398→
+ 399→ 399→

Overview of your Telegram Shop

+ 400→ 400→
+ 401→ 401→ + 402→ 402→ Last updated: {secondsAgo < 1 ? "just now" : `${secondsAgo} seconds ago`} + 403→ 403→ + 404→ 404→ + 413→ 413→
+ 414→ 414→ + 419→ 419→ + 425→ 425→
+ 426→ 426→
+ 427→ 427→
+ 428→ 428→ + 429→ 429→ {/* ── KPI Cards ── */} + 430→ 430→
+ 431→ 431→ {kpis.map((kpi) => ( + 432→ 432→ + 433→ 433→ ))} + 434→ 434→
+ 435→ 435→ + 436→ 436→ + 437→ 437→ + 438→ 438→ {/* ── Charts Grid ── */} + 439→ 439→
+ 440→ 440→ {/* 1. Revenue 7 days */} + 441→ 441→ + 442→ 442→ {revenue7Data.length > 0 ? ( + 443→ 443→ + 444→ 444→ + 445→ 445→ + 446→ 446→ + 447→ 447→ + 448→ 448→ + 449→ 449→ + 450→ 450→ + 451→ 451→ + 452→ 452→ + 453→ 453→ + 454→ 454→ + 462→ 462→ + 469→ 469→ + 470→ 470→ + 471→ 471→ ) : ( + 472→ 472→
No data
+ 473→ 473→ )} + 474→ 474→
+ 475→ 475→ + 476→ 476→ {/* 2. Revenue 30 days */} + 477→ 477→ + 478→ 478→ {revenue30Data.length > 0 ? ( + 479→ 479→ + 480→ 480→ + 481→ 481→ + 482→ 482→ + 483→ 483→ + 484→ 484→ + 485→ 485→ + 486→ 486→ + 487→ 487→ + 488→ 488→ + 489→ 489→ + 490→ 490→ + 498→ 498→ + 505→ 505→ + 506→ 506→ + 507→ 507→ ) : ( + 508→ 508→
No data
+ 509→ 509→ )} + 510→ 510→
+ 511→ 511→ + 512→ 512→ {/* 3. New Users 7 days */} + 513→ 513→ + 514→ 514→ {users7Data.length > 0 ? ( + 515→ 515→ + 516→ 516→ + 517→ 517→ + 518→ 518→ + 519→ 519→ + 520→ 520→ + 528→ 528→ + 529→ 529→ + 530→ 530→ + 531→ 531→ ) : ( + 532→ 532→
No data
+ 533→ 533→ )} + 534→ 534→
+ 535→ 535→ + 536→ 536→ {/* 4. Top 5 Products */} + 537→ 537→ + 538→ 538→ {productsData.length > 0 ? ( + 539→ 539→ + 540→ 540→ + 541→ 541→ + 542→ 542→ + 543→ 543→ + 544→ 544→ { + 552→ 552→ if (name === "qty") return [value, "Quantity"]; + 553→ 553→ return [formatCurrency(value), "Revenue"]; + 554→ 554→ }} + 555→ 555→ /> + 556→ 556→ + 557→ 557→ + 558→ 558→ + 559→ 559→ ) : ( + 560→ 560→
No data
+ 561→ 561→ )} + 562→ 562→
+ 563→ 563→ + 564→ 564→ {/* 5. Top 5 Spenders */} + 565→ 565→ + 566→ 566→ {spendersData.length > 0 ? ( + 567→ 567→ + 568→ 568→ + 569→ 569→ + 570→ 570→ + 571→ 571→ + 572→ 572→ [formatCurrency(value), "Spent"]} + 580→ 580→ /> + 581→ 581→ + 582→ 582→ + 583→ 583→ + 584→ 584→ ) : ( + 585→ 585→
No data
+ 586→ 586→ )} + 587→ 587→
+ 588→ 588→ + 589→ 589→ {/* 6. Revenue by Category (Pie/Donut) */} + 590→ 590→ + 591→ 591→ {revenueByCategory.length > 0 ? ( + 592→ 592→ + 593→ 593→ + 594→ 594→ + 604→ 604→ `${name} ${(percent * 100).toFixed(0)}%` + 605→ 605→ } + 606→ 606→ labelLine={true} + 607→ 607→ fontSize={11} + 608→ 608→ > + 609→ 609→ {revenueByCategory.map((_, index) => ( + 610→ 610→ + 614→ 614→ ))} + 615→ 615→ + 616→ 616→ [formatCurrency(value), "Revenue"]} + 624→ 624→ /> + 625→ 625→ + 626→ 626→ + 627→ 627→ + 628→ 628→ ) : ( + 629→ 629→
No data
+ 630→ 630→ )} + 631→ 631→
+ 632→ 632→ + 633→ 633→ {/* 7. Purchase Status Distribution */} + 634→ 634→ + 635→ 635→ + 636→ 636→ + 637→ 637→ + 651→ 651→ `${name} ${(percent * 100).toFixed(0)}%` + 652→ 652→ } + 653→ 653→ labelLine={true} + 654→ 654→ fontSize={11} + 655→ 655→ > + 656→ 656→ + 657→ 657→ + 658→ 658→ + 659→ 659→ + 660→ 660→ [value, "Purchases"]} + 668→ 668→ /> + 669→ 669→ + 670→ 670→ + 671→ 671→ + 672→ 672→ + 673→ 673→
+ 674→ 674→ + 675→ 675→ {/* ── Analytics Cards: Revenue Trend + User Funnel ── */} + 676→ 676→
+ 677→ 677→ {/* Card A: Revenue Trend (30-day area chart) */} + 678→ 678→ + 679→ 679→
+ 685→ 685→ + 686→ 686→ + 687→ 687→ Revenue Trend + 688→ 688→ + 689→ 689→ + 690→ 690→
+ 691→ 691→ {revenue30Data.length > 0 ? ( + 692→ 692→ + 693→ 693→ + 694→ 694→ + 695→ 695→ + 696→ 696→ + 697→ 697→ + 698→ 698→ + 699→ 699→ + 700→ 700→ + 701→ 701→ + 702→ 702→ `$${v}`} /> + 703→ 703→ [formatCurrency(value), "Revenue"]} + 711→ 711→ /> + 712→ 712→ + 719→ 719→ + 720→ 720→ + 721→ 721→ ) : ( + 722→ 722→
No data
+ 723→ 723→ )} + 724→ 724→
+ 725→ 725→
+ 726→ 726→ + 727→ 727→ + 728→ 728→ {/* Card B: Conversion Funnel (horizontal bar) */} + 729→ 729→ + 730→ 730→
+ 736→ 736→ + 737→ 737→ + 738→ 738→ User Funnel + 739→ 739→ + 740→ 740→ + 741→ 741→
+ 742→ 742→ + 743→ 743→ + 752→ 752→ + 753→ 753→ + 754→ 754→ + 761→ 761→ + 769→ 769→ + 770→ 770→ + 771→ 771→ + 772→ 772→ + 773→ 773→ + 774→ 774→ + 775→ 775→ + 776→ 776→
+ 777→ 777→
+ 778→ 778→ + 779→ 779→
+ 780→ 780→ + 781→ 781→ + 782→ 782→ + 783→ 783→ {/* ── Recent Purchases Table ── */} + 784→ 784→ + 785→ 785→ + 786→ 786→ Recent Purchases + 787→ 787→ + 795→ 795→ + 796→ 796→ + 797→ 797→ {data.recentPurchases.length > 0 ? ( + 798→ 798→
+ 799→ 799→ + 800→ 800→ + 801→ 801→ + 802→ 802→ + 803→ 803→ + 804→ 804→ + 805→ 805→ + 806→ 806→ + 807→ 807→ + 808→ 808→ + 809→ 809→ + 810→ 810→ {data.recentPurchases.map((p, i) => { + 811→ 811→ const badge = statusBadge(p.status); + 812→ 812→ return ( + 813→ 813→ + 814→ 814→ + 815→ 815→ + 816→ 816→ + 817→ 817→ + 822→ 822→ + 823→ 823→ + 824→ 824→ ); + 825→ 825→ })} + 826→ 826→ + 827→ 827→
ProductUserAmountStatusDate
{p.productName}{p.username}{formatCurrency(p.totalPrice)} + 818→ 818→ + 819→ 819→ {badge.label} + 820→ 820→ + 821→ 821→ {relativeTime(p.purchaseDate)}
+ 828→ 828→
+ 829→ 829→ ) : ( + 830→ 830→
No recent purchases
+ 831→ 831→ )} + 832→ 832→
+ 833→ 833→
+ 834→ 834→ + 835→ 835→ + 836→ 836→ + 837→ 837→ {/* ── Bottom Section: Wallet Summary + Wallet Chart ── */} + 838→ 838→
+ 839→ 839→ {/* Wallet Summary Table */} + 840→ 840→ + 841→ 841→ + 842→ 842→ Wallet Summary + 843→ 843→ + 844→ 844→ + 845→ 845→ {walletSummary.length > 0 ? ( + 846→ 846→
+ 847→ 847→ + 848→ 848→ + 849→ 849→ + 850→ 850→ + 851→ 851→ + 852→ 852→ + 853→ 853→ + 854→ 854→ + 855→ 855→ + 856→ 856→ + 857→ 857→ {walletSummary.map((w) => ( + 858→ 858→ + 859→ 859→ + 860→ 860→ + 861→ 861→ + 862→ 862→ + 863→ 863→ + 864→ 864→ ))} + 865→ 865→ + 866→ 866→
TypeCountBalanceUSD (mock)
{w.walletType}{w.count}{formatCrypto(w.totalBalance)}{formatCurrency(w.totalBalanceUsd)}
+ 867→ 867→
+ 868→ 868→ ) : ( + 869→ 869→
No wallet data
+ 870→ 870→ )} + 871→ 871→
+ 872→ 872→
+ 873→ 873→ + 874→ 874→ {/* Wallet Count by Type Chart */} + 875→ 875→ + 876→ 876→ {walletChartData.length > 0 ? ( + 877→ 877→ + 878→ 878→ + 879→ 879→ + 880→ 880→ + 881→ 881→ + 882→ 882→ [value, "Wallets"]} + 890→ 890→ /> + 891→ 891→ + 892→ 892→ + 893→ 893→ + 894→ 894→ ) : ( + 895→ 895→
No wallet data
+ 896→ 896→ )} + 897→ 897→
+ 898→ 898→
+ 899→ 899→ + 900→ 900→ {/* ── Activity Feed (full width) ── */} + 901→ 901→ + 902→ 902→
+ 903→ 903→ ); + 904→ 904→} + 905→ 905→ \ No newline at end of file diff --git a/tool-results/read_1785946648945_1cd34bae3f38.txt b/tool-results/read_1785946648945_1cd34bae3f38.txt new file mode 100644 index 0000000..8e507cc --- /dev/null +++ b/tool-results/read_1785946648945_1cd34bae3f38.txt @@ -0,0 +1,1483 @@ + 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→ BarChart3, + 70→} from "lucide-react"; + 71→ + 72→// ─── Types ─────────────────────────────────────── + 73→ + 74→interface TreeLocation { + 75→ id: number; + 76→ country: string; + 77→ city: string; + 78→ district: string; + 79→ isActive: number; + 80→ createdAt: string; + 81→ categoryCount: number; + 82→ productCount: number; + 83→} + 84→ + 85→interface TreeCategory { + 86→ id: number; + 87→ locationId: number; + 88→ name: string; + 89→ isActive: number; + 90→ createdAt: string; + 91→ location: { id: number; country: string; city: string; district: string }; + 92→ subcategoryCount: number; + 93→ productCount: number; + 94→} + 95→ + 96→interface TreeSubcategory { + 97→ id: number; + 98→ categoryId: number; + 99→ name: string; + 100→ isActive: number; + 101→ createdAt: string; + 102→ category: { id: number; name: string; locationId: number }; + 103→ productCount: number; + 104→} + 105→ + 106→interface CatalogTree { + 107→ locations: TreeLocation[]; + 108→ categories: TreeCategory[]; + 109→ subcategories: TreeSubcategory[]; + 110→} + 111→ + 112→interface Product { + 113→ id: number; + 114→ locationId: number; + 115→ categoryId: number; + 116→ subcategoryId: number | null; + 117→ name: string; + 118→ description: string | null; + 119→ privateData: string | null; + 120→ price: number; + 121→ quantityInStock: number; + 122→ photoUrl: string | null; + 123→ hiddenPhotoUrl: string | null; + 124→ hiddenCoordinates: string | null; + 125→ hiddenDescription: string | null; + 126→ isMono: number; + 127→ createdAt: string; + 128→ category: { id: number; name: string }; + 129→ subcategory: { id: number; name: string } | null; + 130→ location: { id: number; country: string; city: string; district: string }; + 131→} + 132→ + 133→interface ProductFormData { + 134→ locationId: string; + 135→ categoryId: string; + 136→ subcategoryId: string; + 137→ name: string; + 138→ description: string; + 139→ privateData: string; + 140→ price: string; + 141→ quantityInStock: string; + 142→ photoUrl: string; + 143→ hiddenPhotoUrl: string; + 144→ hiddenCoordinates: string; + 145→ hiddenDescription: string; + 146→ isMono: boolean; + 147→} + 148→ + 149→const emptyForm: ProductFormData = { + 150→ locationId: "", + 151→ categoryId: "", + 152→ subcategoryId: "", + 153→ name: "", + 154→ description: "", + 155→ privateData: "", + 156→ price: "", + 157→ quantityInStock: "0", + 158→ photoUrl: "", + 159→ hiddenPhotoUrl: "", + 160→ hiddenCoordinates: "", + 161→ hiddenDescription: "", + 162→ isMono: false, + 163→}; + 164→ + 165→type FilterType = + 166→ | { type: "all" } + 167→ | { type: "location"; locationId: number } + 168→ | { type: "category"; categoryId: number; locationId: number } + 169→ | { type: "subcategory"; subcategoryId: number; categoryId: number; locationId: number }; + 170→ + 171→// ─── Helpers ───────────────────────────────────── + 172→ + 173→function getProductCountColor(count: number): string { + 174→ if (count === 0) return "border-l-muted-foreground/30"; + 175→ if (count <= 5) return "border-l-emerald-500"; + 176→ if (count <= 10) return "border-l-yellow-500"; + 177→ return "border-l-orange-500"; + 178→} + 179→ + 180→// ─── Component ─────────────────────────────────── + 181→ + 182→export function CatalogPage() { + 183→ // Tree state + 184→ const [tree, setTree] = useState(null); + 185→ const [treeLoading, setTreeLoading] = useState(true); + 186→ const [treeError, setTreeError] = useState(null); + 187→ + 188→ // Products state + 189→ const [products, setProducts] = useState([]); + 190→ const [productTotal, setProductTotal] = useState(0); + 191→ const [productsLoading, setProductsLoading] = useState(true); + 192→ const [search, setSearch] = useState(""); + 193→ const [searchDebounce, setSearchDebounce] = useState(""); + 194→ const [filter, setFilter] = useState({ type: "all" }); + 195→ const [productPage, setProductPage] = useState(1); + 196→ const productLimit = 50; + 197→ + 198→ // Modal state + 199→ const [modalOpen, setModalOpen] = useState(false); + 200→ const [editProduct, setEditProduct] = useState(null); + 201→ const [formData, setFormData] = useState(emptyForm); + 202→ const [formLocations, setFormLocations] = useState([]); + 203→ const [formCategories, setFormCategories] = useState([]); + 204→ const [formSubcategories, setFormSubcategories] = useState([]); + 205→ const [saving, setSaving] = useState(false); + 206→ + 207→ // Delete confirm + 208→ const [deleteTarget, setDeleteTarget] = useState<{ + 209→ type: string; + 210→ id: number; + 211→ name: string; + 212→ } | null>(null); + 213→ + 214→ // Inline rename state + 215→ const [renamingId, setRenamingId] = useState(null); + 216→ const [renameValue, setRenameValue] = useState(""); + 217→ const renameInputRef = useRef(null); + 218→ + 219→ // Add inline state + 220→ const [addMode, setAddMode] = useState(null); + 221→ const [addInput, setAddInput] = useState(""); + 222→ const [addDistrict, setAddDistrict] = useState(""); + 223→ const [addCity, setAddCity] = useState(""); + 224→ const [addCountry, setAddCountry] = useState(""); + 225→ const [addParentId, setAddParentId] = useState(null); + 226→ const addInputRef = useRef(null); + 227→ + 228→ useEffect(() => { + 229→ if (addInputRef.current) addInputRef.current.focus(); + 230→ }, [addMode]); + 231→ + 232→ useEffect(() => { + 233→ if (renameInputRef.current) renameInputRef.current.focus(); + 234→ }, [renamingId]); + 235→ + 236→ // ─── Data fetching ───────────────────────────── + 237→ const fetchTree = useCallback(async () => { + 238→ try { + 239→ const res = await fetch("/api/catalog/tree"); + 240→ if (!res.ok) throw new Error("Failed to load tree"); + 241→ const data: CatalogTree = await res.json(); + 242→ setTree(data); + 243→ setTreeError(null); + 244→ } catch (e) { + 245→ setTreeError("Failed to load catalog tree"); + 246→ } finally { + 247→ setTreeLoading(false); + 248→ } + 249→ }, []); + 250→ + 251→ const fetchProducts = useCallback(async () => { + 252→ setProductsLoading(true); + 253→ try { + 254→ const params = new URLSearchParams({ page: String(productPage), limit: String(productLimit) }); + 255→ if (searchDebounce) params.set("search", searchDebounce); + 256→ if (filter.type === "location") params.set("loc", String(filter.locationId)); + 257→ if (filter.type === "category") params.set("cat", String(filter.categoryId)); + 258→ if (filter.type === "subcategory") params.set("sub", String(filter.subcategoryId)); + 259→ + 260→ const res = await fetch(`/api/products/bulk?${params}`); + 261→ if (!res.ok) throw new Error("Failed to load products"); + 262→ const data = await res.json(); + 263→ setProducts(data.data); + 264→ setProductTotal(data.total); + 265→ } catch { + 266→ toast.error("Failed to load products"); + 267→ } finally { + 268→ setProductsLoading(false); + 269→ } + 270→ }, [filter, searchDebounce, productPage]); + 271→ + 272→ useEffect(() => { + 273→ fetchTree(); + 274→ }, [fetchTree]); + 275→ + 276→ useEffect(() => { + 277→ fetchProducts(); + 278→ }, [fetchProducts]); + 279→ + 280→ // Search debounce + 281→ useEffect(() => { + 282→ const t = setTimeout(() => setSearchDebounce(search), 300); + 283→ return () => clearTimeout(t); + 284→ }, [search]); + 285→ + 286→ // Reset page on filter/search change + 287→ useEffect(() => { + 288→ setProductPage(1); + 289→ }, [filter, searchDebounce]); + 290→ + 291→ // ─── Tree grouping ───────────────────────────── + 292→ const getGroupedTree = () => { + 293→ if (!tree) return {}; + 294→ + 295→ const countryMap = new Map< + 296→ string, + 297→ { + 298→ cityMap: Map< + 299→ string, + 300→ { + 301→ districtMap: Map< + 302→ string, + 303→ { location: TreeLocation; categories: TreeCategory[] } + 304→ >; + 305→ } + 306→ >; + 307→ } + 308→ >(); + 309→ + 310→ for (const loc of tree.locations) { + 311→ if (!countryMap.has(loc.country)) + 312→ countryMap.set(loc.country, { cityMap: new Map() }); + 313→ const countryEntry = countryMap.get(loc.country)!; + 314→ if (!countryEntry.cityMap.has(loc.city)) + 315→ countryEntry.cityMap.set(loc.city, { districtMap: new Map() }); + 316→ const cityEntry = countryEntry.cityMap.get(loc.city)!; + 317→ const distKey = loc.district || "(no district)"; + 318→ if (!cityEntry.districtMap.has(distKey)) + 319→ cityEntry.districtMap.set(distKey, { location: loc, categories: [] }); + 320→ } + 321→ + 322→ for (const cat of tree.categories) { + 323→ const countryEntry = countryMap.get(cat.location.country); + 324→ if (!countryEntry) continue; + 325→ const cityEntry = countryEntry.cityMap.get(cat.location.city); + 326→ if (!cityEntry) continue; + 327→ const distKey = cat.location.district || "(no district)"; + 328→ const distEntry = cityEntry.districtMap.get(distKey); + 329→ if (!distEntry) continue; + 330→ distEntry.categories.push(cat); + 331→ } + 332→ + 333→ return countryMap; + 334→ }; + 335→ + 336→ // ─── Tree node actions ───────────────────────── + 337→ const handleToggleActive = async (type: string, id: number) => { + 338→ try { + 339→ const res = await fetch(`/api/${type}s/${id}`, { method: "PATCH" }); + 340→ if (!res.ok) throw new Error(); + 341→ toast.success(`${type} toggled`); + 342→ fetchTree(); + 343→ } catch { + 344→ toast.error(`Failed to toggle ${type}`); + 345→ } + 346→ }; + 347→ + 348→ const handleDelete = async () => { + 349→ if (!deleteTarget) return; + 350→ try { + 351→ const typePath = deleteTarget.type === "product" ? "products" : `${deleteTarget.type}s`; + 352→ const res = await fetch(`/api/${typePath}/${deleteTarget.id}`, { method: "DELETE" }); + 353→ if (!res.ok) { + 354→ const data = await res.json(); + 355→ throw new Error(data.error || "Failed to delete"); + 356→ } + 357→ toast.success(`${deleteTarget.type} deleted`); + 358→ setDeleteTarget(null); + 359→ fetchTree(); + 360→ fetchProducts(); + 361→ } catch (e) { + 362→ toast.error(e instanceof Error ? e.message : "Failed to delete"); + 363→ } + 364→ }; + 365→ + 366→ const handleRename = async (type: string, id: number) => { + 367→ if (!renameValue.trim()) { + 368→ setRenamingId(null); + 369→ return; + 370→ } + 371→ try { + 372→ let body: Record = {}; + 373→ if (type === "location") { + 374→ const loc = tree?.locations.find((l) => l.id === id); + 375→ body = { country: loc?.country || "", city: loc?.city || "", district: renameValue.trim() }; + 376→ } else { + 377→ body = { name: renameValue.trim() }; + 378→ } + 379→ const typePath = `${type}s`; + 380→ const res = await fetch(`/api/${typePath}/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + 381→ if (!res.ok) throw new Error(); + 382→ toast.success("Renamed"); + 383→ setRenamingId(null); + 384→ fetchTree(); + 385→ } catch { + 386→ toast.error("Failed to rename"); + 387→ } + 388→ }; + 389→ + 390→ const handleAdd = async () => { + 391→ if (!addMode) return; + 392→ try { + 393→ if (addMode === "location") { + 394→ if (!addCountry.trim() || !addCity.trim()) { + 395→ toast.error("Country and city are required"); + 396→ return; + 397→ } + 398→ const res = await fetch("/api/locations/bulk", { + 399→ method: "POST", + 400→ headers: { "Content-Type": "application/json" }, + 401→ body: JSON.stringify({ country: addCountry.trim(), city: addCity.trim(), district: addDistrict.trim() }), + 402→ }); + 403→ if (!res.ok) { + 404→ const data = await res.json(); + 405→ throw new Error(data.error || "Failed to add"); + 406→ } + 407→ } else if (addMode === "category") { + 408→ if (!addInput.trim() || !addParentId) { + 409→ toast.error("Name is required"); + 410→ return; + 411→ } + 412→ const res = await fetch("/api/categories/bulk", { + 413→ method: "POST", + 414→ headers: { "Content-Type": "application/json" }, + 415→ body: JSON.stringify({ name: addInput.trim(), locationId: addParentId }), + 416→ }); + 417→ if (!res.ok) { + 418→ const data = await res.json(); + 419→ throw new Error(data.error || "Failed to add"); + 420→ } + 421→ } else if (addMode === "subcategory") { + 422→ if (!addInput.trim() || !addParentId) { + 423→ toast.error("Name is required"); + 424→ return; + 425→ } + 426→ const res = await fetch("/api/subcategories/bulk", { + 427→ method: "POST", + 428→ headers: { "Content-Type": "application/json" }, + 429→ body: JSON.stringify({ name: addInput.trim(), categoryId: addParentId }), + 430→ }); + 431→ if (!res.ok) { + 432→ const data = await res.json(); + 433→ throw new Error(data.error || "Failed to add"); + 434→ } + 435→ } + 436→ toast.success("Added successfully"); + 437→ setAddMode(null); + 438→ setAddInput(""); + 439→ setAddCountry(""); + 440→ setAddCity(""); + 441→ setAddDistrict(""); + 442→ setAddParentId(null); + 443→ fetchTree(); + 444→ } catch (e) { + 445→ toast.error(e instanceof Error ? e.message : "Failed to add"); + 446→ } + 447→ }; + 448→ + 449→ // ─── Product modal ───────────────────────────── + 450→ const openProductModal = async (product?: Product) => { + 451→ if (product) { + 452→ setEditProduct(product); + 453→ setFormData({ + 454→ locationId: String(product.locationId), + 455→ categoryId: String(product.categoryId), + 456→ subcategoryId: product.subcategoryId ? String(product.subcategoryId) : "", + 457→ name: product.name, + 458→ description: product.description || "", + 459→ privateData: product.privateData || "", + 460→ price: String(product.price), + 461→ quantityInStock: product.isMono ? "0" : String(product.quantityInStock), + 462→ photoUrl: product.photoUrl || "", + 463→ hiddenPhotoUrl: product.hiddenPhotoUrl || "", + 464→ hiddenCoordinates: product.hiddenCoordinates || "", + 465→ hiddenDescription: product.hiddenDescription || "", + 466→ isMono: product.isMono === 1, + 467→ }); + 468→ } else { + 469→ setEditProduct(null); + 470→ setFormData(emptyForm); + 471→ } + 472→ + 473→ // Load form data + 474→ try { + 475→ const [treeRes, locRes] = await Promise.all([ + 476→ fetch("/api/catalog/tree"), + 477→ fetch("/api/locations/bulk"), + 478→ ]); + 479→ const treeData: CatalogTree = await treeRes.json(); + 480→ const locData: TreeLocation[] = await locRes.json(); + 481→ setFormLocations(locData); + 482→ + 483→ if (product) { + 484→ setFormCategories(treeData.categories.filter((c) => c.locationId === product.locationId)); + 485→ setFormSubcategories(treeData.subcategories.filter((s) => s.categoryId === product.categoryId)); + 486→ } else { + 487→ setFormCategories([]); + 488→ setFormSubcategories([]); + 489→ } + 490→ } catch { + 491→ toast.error("Failed to load form data"); + 492→ } + 493→ + 494→ setModalOpen(true); + 495→ }; + 496→ + 497→ const handleLocationChange = async (locationId: string) => { + 498→ const updated = { ...formData, locationId, categoryId: "", subcategoryId: "" }; + 499→ setFormData(updated); + 500→ if (!locationId) { + 501→ setFormCategories([]); + 502→ setFormSubcategories([]); + 503→ return; + 504→ } + 505→ try { + 506→ const treeRes = await fetch("/api/catalog/tree"); + 507→ const treeData: CatalogTree = await treeRes.json(); + 508→ const cats = treeData.categories.filter((c) => c.locationId === +locationId); + 509→ setFormCategories(cats); + 510→ setFormSubcategories([]); + 511→ } catch { + 512→ // ignore + 513→ } + 514→ }; + 515→ + 516→ const handleCategoryChange = async (categoryId: string) => { + 517→ const updated = { ...formData, categoryId, subcategoryId: "" }; + 518→ setFormData(updated); + 519→ if (!categoryId) { + 520→ setFormSubcategories([]); + 521→ return; + 522→ } + 523→ try { + 524→ const treeRes = await fetch("/api/catalog/tree"); + 525→ const treeData: CatalogTree = await treeRes.json(); + 526→ const subs = treeData.subcategories.filter((s) => s.categoryId === +categoryId); + 527→ setFormSubcategories(subs); + 528→ } catch { + 529→ // ignore + 530→ } + 531→ }; + 532→ + 533→ const handleSaveProduct = async () => { + 534→ if (!formData.locationId || !formData.categoryId || !formData.name || !formData.price) { + 535→ toast.error("Location, Category, Name, and Price are required"); + 536→ return; + 537→ } + 538→ setSaving(true); + 539→ try { + 540→ const body = { + 541→ locationId: +formData.locationId, + 542→ categoryId: +formData.categoryId, + 543→ subcategoryId: formData.subcategoryId ? +formData.subcategoryId : null, + 544→ name: formData.name, + 545→ description: formData.description || null, + 546→ privateData: formData.privateData || null, + 547→ price: +formData.price, + 548→ quantityInStock: formData.isMono ? 0 : +formData.quantityInStock, + 549→ photoUrl: formData.photoUrl || null, + 550→ hiddenPhotoUrl: formData.hiddenPhotoUrl || null, + 551→ hiddenCoordinates: formData.hiddenCoordinates || null, + 552→ hiddenDescription: formData.hiddenDescription || null, + 553→ isMono: formData.isMono ? 1 : 0, + 554→ }; + 555→ + 556→ let res: Response; + 557→ if (editProduct) { + 558→ res = await fetch(`/api/products/${editProduct.id}`, { + 559→ method: "PUT", + 560→ headers: { "Content-Type": "application/json" }, + 561→ body: JSON.stringify(body), + 562→ }); + 563→ } else { + 564→ res = await fetch("/api/products/add", { + 565→ method: "POST", + 566→ headers: { "Content-Type": "application/json" }, + 567→ body: JSON.stringify(body), + 568→ }); + 569→ } + 570→ + 571→ if (!res.ok) { + 572→ const data = await res.json(); + 573→ throw new Error(data.error || "Failed to save"); + 574→ } + 575→ + 576→ toast.success(editProduct ? "Product updated" : "Product created"); + 577→ setModalOpen(false); + 578→ fetchTree(); + 579→ fetchProducts(); + 580→ } catch (e) { + 581→ toast.error(e instanceof Error ? e.message : "Failed to save product"); + 582→ } finally { + 583→ setSaving(false); + 584→ } + 585→ }; + 586→ + 587→ // ─── Filter handler ──────────────────────────── + 588→ const handleNodeClick = (f: FilterType) => { + 589→ setFilter(f); + 590→ }; + 591→ + 592→ // ─── Grouped tree for rendering ──────────────── + 593→ const groupedTree = getGroupedTree(); + 594→ const countries = Array.from(groupedTree.keys()).sort(); + 595→ + 596→ const totalPages = Math.max(1, Math.ceil(productTotal / productLimit)); + 597→ const totalCategories = tree?.categories.length ?? 0; + 598→ const totalSubcategories = tree?.subcategories.length ?? 0; + 599→ + 600→ // ─── Render: Left panel skeleton ──────────────── + 601→ if (treeLoading) { + 602→ return ( + 603→
+ 604→ + 605→ + 606→ + 607→
+ 608→ ); + 609→ } + 610→ + 611→ if (treeError) { + 612→ return ( + 613→
+ 614→

{treeError}

+ 615→ + 618→
+ 619→ ); + 620→ } + 621→ + 622→ // ─── Location select groups for form ─────────── + 623→ const locationGroups = new Map(); + 624→ for (const loc of formLocations) { + 625→ const key = `${loc.country} > ${loc.city}`; + 626→ if (!locationGroups.has(key)) locationGroups.set(key, []); + 627→ locationGroups.get(key)!.push(loc); + 628→ } + 629→ + 630→ // ─── Main render ──────────────────────────────── + 631→ return ( + 632→
+ 633→ {/* Header */} + 634→
+ 635→ + 638→
+ 639→ + 640→ {/* Main content */} + 641→
+ 642→ + 643→ {/* ─── Left Panel: Tree ─── */} + 644→ + 645→
+ 646→
+ 647→ Catalog Tree + 648→ + 661→
+ 662→ + 663→ {/* Add Location Form */} + 664→ {addMode === "location" && ( + 665→
+ 666→
+ 667→ setAddCountry(e.target.value)} + 671→ className="h-8 text-xs" + 672→ /> + 673→ setAddCity(e.target.value)} + 677→ className="h-8 text-xs" + 678→ /> + 679→
+ 680→
+ 681→ setAddDistrict(e.target.value)} + 685→ className="h-8 text-xs" + 686→ ref={addInputRef} + 687→ onKeyDown={(e) => { + 688→ if (e.key === "Enter") handleAdd(); + 689→ if (e.key === "Escape") setAddMode(null); + 690→ }} + 691→ /> + 692→
+ 693→
+ 694→ + 697→ + 700→
+ 701→
+ 702→ )} + 703→ + 704→ {/* Tree */} + 705→
+ 706→ {countries.length === 0 ? ( + 707→

+ 708→ No locations yet. Add one above. + 709→

+ 710→ ) : ( + 711→ + 712→ {countries.map((country) => { + 713→ const countryEntry = groupedTree.get(country)!; + 714→ const cities = Array.from(countryEntry.cityMap.keys()).sort(); + 715→ return ( + 716→ + 717→ + 718→ + 719→ + 720→ {country} + 721→ + 722→ + 723→ + 724→ {cities.map((city) => { + 725→ const cityEntry = countryEntry.cityMap.get(city)!; + 726→ const districts = Array.from(cityEntry.districtMap.keys()).sort(); + 727→ return ( + 728→
+ 729→ + 730→ {districts.map((district) => { + 731→ const { location, categories } = cityEntry.districtMap.get(district)!; + 732→ const totalCount = location.categoryCount + location.productCount; + 733→ const isDistrict = district !== "(no district)"; + 734→ return ( + 735→ + 736→ + 737→
{ + 740→ e.stopPropagation(); + 741→ handleNodeClick({ type: "location", locationId: location.id }); + 742→ }} + 743→ > + 744→ + 745→ + 746→ {city}{isDistrict ? ` > ${district}` : ""} + 747→ + 748→ + 749→ {totalCount} + 750→ + 751→ {location.isActive === 0 && ( + 752→ + 753→ off + 754→ + 755→ )} + 756→
+ 757→
e.stopPropagation()}> + 758→ handleToggleActive("location", location.id)} + 761→ className="scale-75" + 762→ /> + 763→ + 774→ + 788→ + 800→
+ 801→
+ 802→ + 803→ {/* Inline rename for location */} + 804→ {renamingId === `loc-${location.id}` && ( + 805→
+ 806→ setRenameValue(e.target.value)} + 810→ className="h-7 text-xs" + 811→ onKeyDown={(e) => { + 812→ if (e.key === "Enter") handleRename("location", location.id); + 813→ if (e.key === "Escape") setRenamingId(null); + 814→ }} + 815→ onBlur={() => handleRename("location", location.id)} + 816→ /> + 817→
+ 818→ )} + 819→ + 820→ {/* Add Category Form */} + 821→ {addMode === "category" && addParentId === location.id && ( + 822→
+ 823→ setAddInput(e.target.value)} + 827→ placeholder="Category name..." + 828→ className="h-7 text-xs" + 829→ onKeyDown={(e) => { + 830→ if (e.key === "Enter") handleAdd(); + 831→ if (e.key === "Escape") setAddMode(null); + 832→ }} + 833→ /> + 834→ + 835→ + 836→
+ 837→ )} + 838→ + 839→ + 840→ {categories.length === 0 ? ( + 841→

No categories

+ 842→ ) : ( + 843→ categories.map((cat) => { + 844→ const subs = tree?.subcategories.filter((s) => s.categoryId === cat.id) || []; + 845→ return ( + 846→
+ 847→ + 848→ + 849→ + 850→
{ + 853→ e.stopPropagation(); + 854→ handleNodeClick({ type: "category", categoryId: cat.id, locationId: cat.locationId }); + 855→ }} + 856→ > + 857→ + 858→ {cat.name} + 859→ + 860→ {cat.productCount} {cat.productCount === 1 ? "item" : "items"} + 861→ + 862→ {cat.subcategoryCount > 0 && ( + 863→ + 864→ {cat.subcategoryCount} sub + 865→ + 866→ )} + 867→ {cat.isActive === 0 && ( + 868→ + 869→ off + 870→ + 871→ )} + 872→
+ 873→
e.stopPropagation()}> + 874→ handleToggleActive("category", cat.id)} + 877→ className="scale-75" + 878→ /> + 879→ + 890→ + 904→ + 916→
+ 917→
+ 918→ + 919→ {/* Inline rename for category */} + 920→ {renamingId === `cat-${cat.id}` && ( + 921→
+ 922→ setRenameValue(e.target.value)} + 926→ className="h-7 text-xs" + 927→ onKeyDown={(e) => { + 928→ if (e.key === "Enter") handleRename("category", cat.id); + 929→ if (e.key === "Escape") setRenamingId(null); + 930→ }} + 931→ onBlur={() => handleRename("category", cat.id)} + 932→ /> + 933→
+ 934→ )} + 935→ + 936→ {/* Add Subcategory Form */} + 937→ {addMode === "subcategory" && addParentId === cat.id && ( + 938→
+ 939→ setAddInput(e.target.value)} + 943→ placeholder="Subcategory name..." + 944→ className="h-7 text-xs" + 945→ onKeyDown={(e) => { + 946→ if (e.key === "Enter") handleAdd(); + 947→ if (e.key === "Escape") setAddMode(null); + 948→ }} + 949→ /> + 950→ + 951→ + 952→
+ 953→ )} + 954→ + 955→ + 956→ {subs.length === 0 ? ( + 957→

No subcategories

+ 958→ ) : ( + 959→ subs.map((sub) => ( + 960→
+ 964→ handleNodeClick({ + 965→ type: "subcategory", + 966→ subcategoryId: sub.id, + 967→ categoryId: sub.categoryId, + 968→ locationId: sub.category.locationId, + 969→ }) + 970→ } + 971→ > + 972→ + 973→ {renamingId === `sub-${sub.id}` ? ( + 974→ setRenameValue(e.target.value)} + 978→ className="h-6 text-xs flex-1" + 979→ onClick={(e) => e.stopPropagation()} + 980→ onKeyDown={(e) => { + 981→ if (e.key === "Enter") { e.stopPropagation(); handleRename("subcategory", sub.id); } + 982→ if (e.key === "Escape") { e.stopPropagation(); setRenamingId(null); } + 983→ }} + 984→ onBlur={() => handleRename("subcategory", sub.id)} + 985→ /> + 986→ ) : ( + 987→ {sub.name} + 988→ )} + 989→ + 990→ {sub.productCount} + 991→ + 992→ {sub.isActive === 0 && ( + 993→ + 994→ off + 995→ + 996→ )} + 997→
e.stopPropagation()}> + 998→ handleToggleActive("subcategory", sub.id)} + 1001→ className="scale-50" + 1002→ /> + 1003→ {renamingId !== `sub-${sub.id}` && ( + 1004→ <> + 1005→ + 1016→ + 1030→ + 1031→ )} + 1032→
+ 1033→
+ 1034→ )) + 1035→ )} + 1036→
+ 1037→
+ 1038→
+ 1039→
+ 1040→ ); + 1041→ }) + 1042→ )} + 1043→
+ 1044→
+ 1045→ ); + 1046→ })} + 1047→
+ 1048→
+ 1049→ ); + 1050→ })} + 1051→
+ 1052→
+ 1053→ ); + 1054→ })} + 1055→
+ 1056→ )} + 1057→
+ 1058→
+ 1059→
+ 1060→ + 1061→ + 1062→ + 1063→ {/* ─── Right Panel: Products ─── */} + 1064→ + 1065→
+ 1066→ {/* Summary bar */} + 1067→
+ 1068→ + 1069→ + 1070→ {productTotal} product{productTotal !== 1 ? "s" : ""} + 1071→ + 1072→ · + 1073→ + 1074→ {totalCategories} {totalCategories === 1 ? "category" : "categories"} + 1075→ + 1076→ · + 1077→ + 1078→ {totalSubcategories} {totalSubcategories === 1 ? "subcategory" : "subcategories"} + 1079→ + 1080→
+ 1081→ + 1082→ {/* Toolbar */} + 1083→
+ 1084→
+ 1085→ + 1086→ setSearch(e.target.value)} + 1090→ className="pl-9 h-9 text-sm" + 1091→ /> + 1092→
+ 1093→ {filter.type !== "all" && ( + 1094→ + 1103→ )} + 1104→ + 1105→ {productTotal} product{productTotal !== 1 ? "s" : ""} + 1106→ + 1107→
+ 1108→ + 1109→ {/* Table */} + 1110→
+ 1111→ {productsLoading ? ( + 1112→
+ 1113→ {Array.from({ length: 5 }).map((_, i) => ( + 1114→ + 1115→ ))} + 1116→
+ 1117→ ) : products.length === 0 ? ( + 1118→
+ 1119→ + 1120→

No products found

+ 1121→
+ 1122→ ) : ( + 1123→ + 1124→ + 1125→ + 1126→ ID + 1127→ Photo + 1128→ Name + 1129→ Category + 1130→ Subcategory + 1131→ Price + 1132→ Stock + 1133→ Status + 1134→ Actions + 1135→ + 1136→ + 1137→ + 1138→ {products.map((p) => ( + 1139→ + 1140→ {p.id} + 1141→ + 1142→ {p.photoUrl ? ( + 1143→ {p.name} { + 1148→ (e.target as HTMLImageElement).style.display = "none"; + 1149→ }} + 1150→ /> + 1151→ ) : ( + 1152→
+ 1153→ + 1154→
+ 1155→ )} + 1156→
+ 1157→ {p.name} + 1158→ {p.category.name} + 1159→ + 1160→ {p.subcategory?.name || "—"} + 1161→ + 1162→ + 1163→ ${p.price.toFixed(2)} + 1164→ + 1165→ + 1166→ {p.isMono === 1 ? "∞" : p.quantityInStock} + 1167→ + 1168→ + 1169→
+ 1170→ 0 ? "bg-emerald-500" : "bg-red-500"}`} /> + 1171→ + 1172→ {p.isMono === 1 ? "Mono" : p.quantityInStock > 0 ? `In Stock` : "Out"} + 1173→ + 1174→
+ 1175→
+ 1176→ + 1177→
+ 1178→ + 1186→ + 1200→
+ 1201→
+ 1202→
+ 1203→ ))} + 1204→
+ 1205→
+ 1206→ )} + 1207→
+ 1208→ + 1209→ {/* Pagination */} + 1210→ {totalPages > 1 && ( + 1211→
+ 1212→ + 1213→ Page {productPage} of {totalPages} + 1214→ + 1215→
+ 1216→ + 1225→ + 1234→
+ 1235→
+ 1236→ )} + 1237→
+ 1238→
+ 1239→
+ 1240→
+ 1241→ + 1242→ {/* ─── Product Modal ─── */} + 1243→ + 1244→ + 1245→ + 1246→ {editProduct ? "Edit Product" : "Add Product"} + 1247→ + 1248→ + 1249→
+ 1250→ {/* Row 1: Cascading Selects */} + 1251→
+ 1252→
+ 1253→ + 1254→ + 1276→
+ 1277→ + 1278→
+ 1279→ + 1280→ + 1296→
+ 1297→ + 1298→
+ 1299→ + 1300→ + 1317→
+ 1318→
+ 1319→ + 1320→ {/* Row 2: Name, Price, Stock, Mono */} + 1321→
+ 1322→
+ 1323→ + 1324→ setFormData({ ...formData, name: e.target.value })} + 1327→ placeholder="Product name" + 1328→ className="h-9 text-sm" + 1329→ /> + 1330→
+ 1331→
+ 1332→ + 1333→ setFormData({ ...formData, price: e.target.value })} + 1339→ placeholder="0.00" + 1340→ className="h-9 text-sm" + 1341→ /> + 1342→
+ 1343→
+ 1344→ + 1345→
+ 1346→
+ 1347→ + 1351→ setFormData({ ...formData, isMono: !!checked }) + 1352→ } + 1353→ /> + 1354→ + 1357→
+ 1358→ {!formData.isMono && ( + 1359→
+ 1360→ + 1361→ setFormData({ ...formData, quantityInStock: e.target.value })} + 1366→ className="h-9 text-sm w-24" + 1367→ /> + 1368→
+ 1369→ )} + 1370→ {formData.isMono && ( + 1371→ Stock: ∞ + 1372→ )} + 1373→
+ 1374→ + 1375→ {/* Row 3: Description */} + 1376→
+ 1377→ + 1378→