019dc270-b7f3-49ea-b87d-844d8224d8ac

This commit is contained in:
Z User
2026-08-05 16:54:25 +00:00
parent d79b150977
commit 5d1fe6cd7d
34 changed files with 10718 additions and 159 deletions

View File

@@ -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) {

View File

@@ -3,6 +3,14 @@ import { createToken } from '@/lib/auth';
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
// 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();

View File

@@ -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 });
}
}

View File

@@ -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);

View File

@@ -1,4 +1,3 @@
"use server";
import { NextRequest, NextResponse } from 'next/server';
import { getAuth } from '@/lib/auth-middleware';

View File

@@ -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 });
}
}

View File

@@ -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 });
}

View File

@@ -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,
});

View File

@@ -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 });
}
}

View File

@@ -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(',')
);

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-orange-500 hover:text-orange-400 hover:bg-orange-500/10"
onClick={() => handleCloneProduct(p)}
title="Duplicate product"
>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
@@ -1194,6 +1224,7 @@ export function CatalogPage() {
name: p.name,
})
}
title="Delete product"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>

View File

@@ -209,7 +209,7 @@ function KpiCard({
}) {
const sparkData = sparklineValue !== undefined ? generateSparkData(sparklineValue) : undefined;
return (
<Card className="card-hover border-l-4 transition-transform hover:scale-[1.02] relative overflow-hidden" style={{ borderLeftColor: color }}>
<Card className="card-hover kpi-shimmer border-l-4 transition-transform hover:scale-[1.02] relative overflow-hidden" style={{ borderLeftColor: color }}>
<div
className="h-[2px] w-full rounded-t-lg"
style={{
@@ -225,7 +225,7 @@ function KpiCard({
</div>
<div className="min-w-0">
<p className="text-sm text-muted-foreground truncate">{title}</p>
<p className="text-xl font-bold truncate tabular-nums">{value}</p>
<p className="text-xl font-bold truncate tabular-nums stat-value count-up">{value}</p>
</div>
</CardContent>
{sparkData && sparklineColor && <MiniSparkline data={sparkData} color={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 (
<Card className="card-hover overflow-hidden">
@@ -273,7 +275,10 @@ function ChartCard({
}}
/>
<CardHeader className="p-4 pb-0">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<CardTitle className="text-sm font-medium flex items-center gap-2">
{ChartIcon && <ChartIcon className="size-4" style={{ color: accentColor ?? CHART_1 }} />}
{title}
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-2">
<div className="h-72">{children}</div>
@@ -438,7 +443,7 @@ export function DashboardPage() {
{/* ── Charts Grid ── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 1. Revenue 7 days */}
<ChartCard title="Revenue — Last 7 Days" accentColor={CHART_1}>
<ChartCard title="Revenue — Last 7 Days" accentColor={CHART_1} icon={TrendingUp}>
{revenue7Data.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={revenue7Data}>
@@ -474,7 +479,7 @@ export function DashboardPage() {
</ChartCard>
{/* 2. Revenue 30 days */}
<ChartCard title="Revenue — Last 30 Days" accentColor={CHART_2}>
<ChartCard title="Revenue — Last 30 Days" accentColor={CHART_2} icon={TrendingUp}>
{revenue30Data.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={revenue30Data}>
@@ -510,7 +515,7 @@ export function DashboardPage() {
</ChartCard>
{/* 3. New Users 7 days */}
<ChartCard title="New Users — Last 7 Days" accentColor={CHART_3}>
<ChartCard title="New Users — Last 7 Days" accentColor={CHART_3} icon={Users}>
{users7Data.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={users7Data}>
@@ -534,7 +539,7 @@ export function DashboardPage() {
</ChartCard>
{/* 4. Top 5 Products */}
<ChartCard title="Top 5 Products by Quantity Sold" accentColor={CHART_4}>
<ChartCard title="Top 5 Products by Quantity Sold" accentColor={CHART_4} icon={Package}>
{productsData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={productsData} layout="vertical" margin={{ left: 20 }}>
@@ -562,7 +567,7 @@ export function DashboardPage() {
</ChartCard>
{/* 5. Top 5 Spenders */}
<ChartCard title="Top 5 Spenders" accentColor={CHART_5}>
<ChartCard title="Top 5 Spenders" accentColor={CHART_5} icon={DollarSign}>
{spendersData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={spendersData} layout="vertical" margin={{ left: 20 }}>
@@ -587,7 +592,7 @@ export function DashboardPage() {
</ChartCard>
{/* 6. Revenue by Category (Pie/Donut) */}
<ChartCard title="Revenue by Category" accentColor={CHART_1}>
<ChartCard title="Revenue by Category" accentColor={CHART_1} icon={Tag}>
{revenueByCategory.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
@@ -631,7 +636,7 @@ export function DashboardPage() {
</ChartCard>
{/* 7. Purchase Status Distribution */}
<ChartCard title="Purchase Status Distribution" accentColor="#eab308">
<ChartCard title="Purchase Status Distribution" accentColor="#eab308" icon={ShoppingCart}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
@@ -796,7 +801,7 @@ export function DashboardPage() {
<CardContent className="p-4">
{data.recentPurchases.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-sm alternate-rows table-header-gradient">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-2 font-medium text-muted-foreground text-xs">Product</th>
@@ -872,7 +877,7 @@ export function DashboardPage() {
</Card>
{/* Wallet Count by Type Chart */}
<ChartCard title="Wallet Count by Type" accentColor={CHART_2}>
<ChartCard title="Wallet Count by Type" accentColor={CHART_2} icon={Wallet}>
{walletChartData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={walletChartData}>

View File

@@ -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<AuditItem[]>([]);
const [items, setItems] = useState<ActivityItem[]>([]);
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() {
</CardHeader>
<CardContent className="p-4">
{loading ? (
<div className="max-h-48 animate-pulse space-y-2">
<div className="max-h-64 animate-pulse space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
@@ -126,31 +136,42 @@ export function ActivityFeed() {
))}
</div>
) : items.length > 0 ? (
<div className="max-h-48 overflow-y-auto space-y-1">
{items.map((item) => {
const mapping = ICON_MAP[item.action] ?? DEFAULT_ICON;
const Icon = mapping.icon;
<div className="max-h-64 overflow-y-auto space-y-1.5">
{items.map((item, index) => {
const config = ACTION_CONFIG[item.action] ?? DEFAULT_CONFIG;
const Icon = config.icon;
return (
<div
key={item.id}
className="flex items-center gap-3 rounded-md border border-border/50 px-3 py-2"
className="flex items-center gap-3 rounded-md border border-border/50 px-3 py-2 animate-in fade-in slide-in-from-left-1 duration-300"
style={{ animationDelay: `${index * 50}ms`, animationFillMode: "both" }}
>
<div
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
style={{ backgroundColor: `${mapping.color}15` }}
style={{ backgroundColor: `${config.color}15` }}
>
<Icon
className="h-3.5 w-3.5"
style={{ color: mapping.color }}
style={{ color: config.color }}
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm truncate leading-tight">
{actionDescription(item.action, item.details)}
</p>
<p className="text-xs text-muted-foreground">
{relativeTime(item.createdAt)}
</p>
<div className="flex items-center gap-2">
<p className="text-sm truncate leading-tight">
{actionDescription(item.action, item.details)}
</p>
</div>
<div className="flex items-center gap-2 mt-0.5">
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-0 h-4 font-normal ${config.badge}`}
>
{item.action.replace(/_/g, " ")}
</Badge>
<span className="text-xs text-muted-foreground">
{relativeTime(item.createdAt)}
</span>
</div>
</div>
</div>
);

View File

@@ -4,15 +4,19 @@ export function AdminFooter() {
const year = new Date().getFullYear();
return (
<footer className="mt-auto border-t px-4 py-2 flex items-center justify-between text-xs text-muted-foreground bg-background/80 backdrop-blur-sm">
<div className="flex items-center gap-1.5">
<span className="hidden sm:inline">TG Shop Admin</span>
<span className="hidden sm:inline text-muted-foreground/40">·</span>
<span>v2.1.0</span>
<footer className="mt-auto border-t-0 gradient-border-t px-4 py-3 flex items-center justify-between text-xs text-muted-foreground bg-background/80 backdrop-blur-sm relative z-10" style={{ borderTopStyle: 'solid', borderTopWidth: '1px' }}>
<div className="flex items-center gap-2">
<span className="font-medium hidden sm:inline text-foreground/80 transition-colors hover:text-foreground cursor-default">
TG Shop Admin
</span>
<span className="hidden sm:inline text-muted-foreground/30">·</span>
<span className="text-muted-foreground/60">v2.1.0</span>
</div>
<div className="flex items-center gap-3">
<span className="hidden sm:inline">Next.js 16 · SQLite · Prisma</span>
<span className="text-muted-foreground/40">© {year}</span>
<span className="hidden sm:inline text-muted-foreground/50 transition-colors hover:text-muted-foreground cursor-default">
Next.js 16 · SQLite · Prisma
</span>
<span className="text-muted-foreground/30">© {year}</span>
</div>
</footer>
);

View File

@@ -65,9 +65,11 @@ function RealtimeClock() {
return () => clearInterval(interval);
}, []);
const parts = time.split(":");
return (
<span className="text-xs text-muted-foreground font-mono tabular-nums hidden md:block">
{time}
<span className="text-xs text-muted-foreground font-mono tabular-nums hidden md:flex items-center">
{parts[0]}<span className="colon-pulse mx-px">:</span>{parts[1]}
</span>
);
}
@@ -94,7 +96,7 @@ export function AdminHeader() {
"Page");
return (
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<header className="flex h-14 shrink-0 items-center gap-2 border-b-0 px-4 gradient-border-b bg-background/80 backdrop-blur-md relative z-10" style={{ borderBottomStyle: 'solid', borderBottomWidth: '1px' }}>
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<AppBreadcrumbs />

View File

@@ -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"
>
<Link href={item.href}>
<item.icon className="size-4" />
<item.icon className="size-4 text-muted-foreground transition-colors group-hover/sidebar-menu-button:text-primary" />
<span>{item.title}</span>
{item.shortcut && (
<kbd className="ml-auto text-[10px] font-mono text-muted-foreground/50 bg-muted/50 px-1.5 py-0.5 rounded group-data-[collapsible=icon]:hidden">
@@ -174,6 +175,9 @@ export function AdminSidebar() {
{pendingCount}
</SidebarMenuBadge>
)}
{item.badge && pendingCount > 0 && (
<span className="sidebar-indicator-dot absolute left-0 top-1/2 -translate-y-1/2 w-1.5 h-1.5 rounded-full bg-destructive group-data-[collapsible=icon]:left-1/2 group-data-[collapsible=icon]:-translate-x-1/2" />
)}
</SidebarMenuItem>
))}
</SidebarMenu>
@@ -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"
>
<Link href={item.href}>
<item.icon className="size-4" />
<item.icon className="size-4 text-muted-foreground transition-colors group-hover/sidebar-menu-button:text-primary" />
<span>{item.title}</span>
{item.shortcut && (
<kbd className="ml-auto text-[10px] font-mono text-muted-foreground/50 bg-muted/50 px-1.5 py-0.5 rounded group-data-[collapsible=icon]:hidden">
@@ -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"
>
<Link href={item.href}>
<item.icon className="size-4" />
<item.icon className="size-4 text-muted-foreground transition-colors group-hover/sidebar-menu-button:text-primary" />
<span>{item.title}</span>
{item.shortcut && (
<kbd className="ml-auto text-[10px] font-mono text-muted-foreground/50 bg-muted/50 px-1.5 py-0.5 rounded group-data-[collapsible=icon]:hidden">
@@ -249,9 +255,9 @@ export function AdminSidebar() {
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarSeparator />
<div className="flex items-center gap-3 p-2 group-data-[collapsible=icon]:justify-center">
<Avatar className="size-8">
<SidebarSeparator className="mb-1" />
<div className="flex items-center gap-3 px-3 py-2 group-data-[collapsible=icon]:justify-center">
<Avatar className="size-8 ring-1 ring-border">
<AvatarFallback className="bg-primary/10 text-primary text-xs">
{role === "super_admin" ? (
<ShieldCheck className="size-4" />
@@ -266,7 +272,7 @@ export function AdminSidebar() {
</span>
<Badge
variant={role === "super_admin" ? "default" : "secondary"}
className="w-fit text-[10px] px-1.5 py-0"
className="w-fit text-[10px] px-1.5 py-0 mt-0.5"
>
{role}
</Badge>
@@ -279,13 +285,13 @@ export function AdminSidebar() {
<LogOut className="size-4" />
</button>
</div>
<div className="flex items-center gap-2 px-2 pb-2 group-data-[collapsible=icon]:justify-center">
<div className="flex items-center gap-2 px-3 pb-3 pt-1 group-data-[collapsible=icon]:justify-center">
<span
className={`size-2 rounded-full shrink-0 ${
connected ? "bg-green-500" : "bg-muted-foreground"
className={`size-1.5 rounded-full shrink-0 transition-colors ${
connected ? "bg-green-500 glow-success" : "bg-muted-foreground/50"
}`}
/>
<span className="text-xs text-muted-foreground group-data-[collapsible=icon]:hidden">
<span className="text-[11px] text-muted-foreground group-data-[collapsible=icon]:hidden">
{connected ? "Connected" : "Disconnected"}
</span>
</div>

View File

@@ -204,7 +204,7 @@ export function CommandPalette() {
<CommandItem
onSelect={() => {
setOpen(false);
window.location.hash = "/seed";
window.location.hash = "/seed?action=clear";
}}
>
<Trash2 className="size-4" />

View File

@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -83,10 +87,12 @@ export function QuickActions() {
<ShoppingCart className="mr-2 size-4" />
View Pending Purchases
</DropdownMenuItem>
{isSuperAdmin && (
<DropdownMenuItem onClick={() => (window.location.hash = "/seed")}>
<Database className="mr-2 size-4" />
Seed Demo Data
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={exportAllData}>
<Download className="mr-2 size-4" />

View File

@@ -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<number | null>(null);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [selectedIds, setSelectedIds] = useState<Set<number>>(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 (
<div className="page-enter p-4 md:p-6 space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
@@ -290,9 +335,16 @@ export function PurchasesPage() {
<p className="text-sm">There are no purchases matching the current filter.</p>
</div>
) : (
<Table>
<Table className="alternate-rows table-header-gradient">
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={sortedPurchases.length > 0 && selectedIds.size === sortedPurchases.length}
onCheckedChange={toggleSelectAll}
aria-label="Select all purchases"
/>
</TableHead>
<TableHead className="w-16">ID</TableHead>
<TableHead>User</TableHead>
<TableHead>Product</TableHead>
@@ -313,8 +365,15 @@ export function PurchasesPage() {
</TableHeader>
<TableBody>
{sortedPurchases.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-mono text-xs">{p.id}</TableCell>
<TableRow key={p.id} data-selected={selectedIds.has(p.id) ? true : undefined}>
<TableCell>
<Checkbox
checked={selectedIds.has(p.id)}
onCheckedChange={() => toggleSelect(p.id)}
aria-label={`Select purchase ${p.id}`}
/>
</TableCell>
<TableCell className="font-mono text-xs border-l-2 border-l-primary/10">{p.id}</TableCell>
<TableCell>
<a
href={`#/users/${p.userId}`}
@@ -417,6 +476,44 @@ export function PurchasesPage() {
{!loading && total > 0 && (
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
)}
{/* Floating batch action bar */}
{selectedIds.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-xl border border-border/50 bg-background/90 backdrop-blur-lg px-5 py-3 shadow-lg animate-in slide-in-from-bottom-4 fade-in duration-200">
<span className="text-sm font-medium">
{selectedIds.size} selected
</span>
<div className="w-px h-6 bg-border" />
<Button
size="sm"
variant="outline"
className="gap-1.5 text-emerald-500 hover:text-emerald-400 hover:bg-emerald-500/10 hover:border-emerald-500/30"
disabled={batchLoading}
onClick={() => handleBatchStatus('completed')}
>
<CheckCircle2 className="h-4 w-4" />
{batchLoading ? 'Updating...' : 'Approve Selected'}
</Button>
<Button
size="sm"
variant="outline"
className="gap-1.5 text-red-500 hover:text-red-400 hover:bg-red-500/10 hover:border-red-500/30"
disabled={batchLoading}
onClick={() => handleBatchStatus('cancelled')}
>
<Ban className="h-4 w-4" />
{batchLoading ? 'Updating...' : 'Cancel Selected'}
</Button>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground"
onClick={() => setSelectedIds(new Set())}
>
Clear
</Button>
</div>
)}
</div>
);
}

View File

@@ -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<string, string> = {
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 (
<Badge variant={cls ? "default" : "secondary"} className={cls + " whitespace-nowrap"}>
<Badge variant="outline" className={cls + " whitespace-nowrap text-[10px]"}>
{action.replace(/_/g, " ")}
</Badge>
);
}
function actionDotColor(action: string): string {
const map: Record<string, string> = {
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<string, string> = {
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<Purchase | null>(null);
const [expandedTimelineId, setExpandedTimelineId] = useState<number | null>(null);
const fetchUser = async () => {
setLoading(true);
@@ -711,35 +747,63 @@ export function UserDetailPage({ userId }: { userId: string }) {
</div>
)}
{!auditLoading && auditLoaded && auditLogs.length > 0 && (
<div className="max-h-96 overflow-y-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Action</TableHead>
<TableHead>Admin</TableHead>
<TableHead>Details</TableHead>
<TableHead className="w-36">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{auditLogs.map((log) => (
<TableRow key={log.id}>
<TableCell>
<ActionBadge action={log.action} />
</TableCell>
<TableCell className="font-mono text-xs">
{log.adminId}
</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-[300px] truncate">
{log.details || "—"}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{format(new Date(log.createdAt), "MMM d, yyyy HH:mm")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="max-h-96 overflow-y-auto pl-2">
<div className="relative">
{/* Vertical timeline line */}
<div className="absolute left-3 top-2 bottom-2 w-px bg-border" />
<div className="space-y-0">
{auditLogs.map((log, index) => {
const isExpanded = expandedTimelineId === log.id;
return (
<div
key={log.id}
className="relative pl-10 py-3 animate-in fade-in slide-in-from-left-1 duration-300"
style={{ animationDelay: `${index * 30}ms`, animationFillMode: "both" }}
>
{/* Timeline dot */}
<div className={`absolute left-1.5 top-4 h-3 w-3 rounded-full ${actionDotColor(log.action)} ring-4 ring-background`} />
{/* Content */}
<button
type="button"
className="w-full text-left group cursor-pointer"
onClick={() => setExpandedTimelineId(isExpanded ? null : log.id)}
>
<div className="flex items-center gap-2 flex-wrap">
<ActionBadge action={log.action} />
<span className="text-xs text-muted-foreground">
{relativeTime(log.createdAt)}
</span>
<span className="ml-auto text-muted-foreground">
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5 font-mono">
{log.adminId}
</p>
</button>
{/* Expandable details */}
{isExpanded && (
<div className="mt-2 ml-1 animate-in fade-in slide-in-from-top-1 duration-200">
<div className="rounded-md border border-border/50 bg-muted/30 p-3 text-xs">
<p className="text-muted-foreground mb-1 font-medium">Details:</p>
<pre className="whitespace-pre-wrap break-all text-foreground/80 font-mono leading-relaxed">
{log.details || "No details available"}
</pre>
<p className="text-muted-foreground mt-2">
{format(new Date(log.createdAt), "MMMM d, yyyy 'at' HH:mm:ss")}
</p>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
)}
{!auditLoading && !auditLoaded && (

View File

@@ -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<string | null>(null);
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const [selectedIds, setSelectedIds] = useState<Set<number>>(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() {
<p className="text-sm">Try adjusting your search query or filter.</p>
</div>
) : (
<Table>
<Table className="alternate-rows table-header-gradient">
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={sortedUsers.length > 0 && selectedIds.size === sortedUsers.length}
onCheckedChange={toggleSelectAll}
aria-label="Select all users"
/>
</TableHead>
<TableHead className="w-16">ID</TableHead>
<TableHead className="w-28">Telegram ID</TableHead>
<TableHead>Username</TableHead>
@@ -278,8 +331,15 @@ export function UsersPage() {
</TableHeader>
<TableBody>
{sortedUsers.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-mono text-xs">{user.id}</TableCell>
<TableRow key={user.id} data-selected={selectedIds.has(user.id) ? true : undefined}>
<TableCell>
<Checkbox
checked={selectedIds.has(user.id)}
onCheckedChange={() => toggleSelect(user.id)}
aria-label={`Select user ${user.id}`}
/>
</TableCell>
<TableCell className="font-mono text-xs border-l-2 border-l-primary/10">{user.id}</TableCell>
<TableCell className="font-mono text-xs">{user.telegramId}</TableCell>
<TableCell className="font-medium">
<span className="inline-flex items-center gap-1.5">
@@ -335,6 +395,44 @@ export function UsersPage() {
</div>
<Pagination page={page} total={total} limit={limit} onPageChange={goToPage} />
{/* Floating batch action bar */}
{selectedIds.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-xl border border-border/50 bg-background/90 backdrop-blur-lg px-5 py-3 shadow-lg animate-in slide-in-from-bottom-4 fade-in duration-200">
<span className="text-sm font-medium">
{selectedIds.size} selected
</span>
<div className="w-px h-6 bg-border" />
<Button
size="sm"
variant="outline"
className="gap-1.5 text-red-500 hover:text-red-400 hover:bg-red-500/10 hover:border-red-500/30"
disabled={batchLoading}
onClick={() => handleBatchStatus(2)}
>
<ShieldBan className="h-4 w-4" />
{batchLoading ? "Updating..." : "Ban Selected"}
</Button>
<Button
size="sm"
variant="outline"
className="gap-1.5 text-emerald-500 hover:text-emerald-400 hover:bg-emerald-500/10 hover:border-emerald-500/30"
disabled={batchLoading}
onClick={() => handleBatchStatus(0)}
>
<ShieldCheck className="h-4 w-4" />
{batchLoading ? "Updating..." : "Unban Selected"}
</Button>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground"
onClick={() => setSelectedIds(new Set())}
>
Clear
</Button>
</div>
)}
</div>
);
}

View File

@@ -95,6 +95,7 @@ interface OverviewData {
walletCounts: Record<string, number>;
totalUsd: number;
totalWallets: number;
activeWallets: number;
totalUsers: number;
commissionEnabled: boolean;
commissionRate: number;
@@ -657,6 +658,36 @@ export function WalletsPage() {
</div>
) : overview ? (
<>
{/* Balance Summary Mini Cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="glass-card rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<DollarSign className="h-4 w-4 text-orange-500" />
<span className="text-xs text-muted-foreground font-medium">Total Balance</span>
</div>
<p className="text-2xl font-bold stat-value">${overview.totalUsd.toFixed(2)}</p>
</div>
<div className="glass-card rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<Wallet className="h-4 w-4 text-violet-500" />
<span className="text-xs text-muted-foreground font-medium">Total Wallets</span>
</div>
<p className="text-2xl font-bold stat-value">{overview.totalWallets}</p>
</div>
<div className="glass-card rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<Wallet className="h-4 w-4 text-emerald-500" />
<span className="text-xs text-muted-foreground font-medium">Active Wallets</span>
</div>
<div className="flex items-baseline gap-2">
<p className="text-2xl font-bold stat-value">{overview.activeWallets ?? 0}</p>
<span className="text-xs text-muted-foreground">
({overview.totalWallets > 0 ? Math.round(((overview.activeWallets ?? 0) / overview.totalWallets) * 100) : 0}%)
</span>
</div>
</div>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>

View File

@@ -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→ <div className="absolute bottom-0 left-0 right-0 h-12 opacity-20 pointer-events-none">
173→ <ResponsiveContainer width="100%" height="100%">
174→ <AreaChart data={chartData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
175→ <YAxis domain={["dataMin - 2", "dataMax + 2"]} hide />
176→ <Area type="monotone" dataKey="v" stroke={color} fill={color} strokeWidth={1.5} />
177→ </AreaChart>
178→ </ResponsiveContainer>
179→ </div>
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→ <Card className="card-hover border-l-4 transition-transform hover:scale-[1.02] relative overflow-hidden" style={{ borderLeftColor: color }}>
213→ <div
214→ className="h-[2px] w-full rounded-t-lg"
215→ style={{
216→ background: `linear-gradient(to right, ${color}, ${color}66, transparent)`,
217→ }}
218→ />
219→ <CardContent className="p-4 flex items-center gap-4">
220→ <div
221→ className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg"
222→ style={{ backgroundColor: `${color}15` }}
223→ >
224→ <Icon className="h-6 w-6" style={{ color }} />
225→ </div>
226→ <div className="min-w-0">
227→ <p className="text-sm text-muted-foreground truncate">{title}</p>
228→ <p className="text-xl font-bold truncate tabular-nums">{value}</p>
229→ </div>
230→ </CardContent>
231→ {sparkData && sparklineColor && <MiniSparkline data={sparkData} color={sparklineColor} />}
232→ </Card>
233→ );
234→}
235→
236→// ─── Skeleton Loader ─────────────────────────────────────
237→
238→function DashboardSkeleton() {
239→ return (
240→ <div className="p-4 md:p-6 space-y-6">
241→ <Skeleton className="h-8 w-48" />
242→ <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
243→ {Array.from({ length: 10 }).map((_, i) => (
244→ <Skeleton key={i} className="h-24 rounded-xl" />
245→ ))}
246→ </div>
247→ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
248→ {Array.from({ length: 4 }).map((_, i) => (
249→ <Skeleton key={i} className="h-72 rounded-xl" />
250→ ))}
251→ </div>
252→ </div>
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→ <Card className="card-hover overflow-hidden">
269→ <div
270→ className="h-1 w-full"
271→ style={{
272→ background: `linear-gradient(to right, ${accentColor ?? CHART_1}, ${accentColor ?? CHART_1}44, transparent)`,
273→ }}
274→ />
275→ <CardHeader className="p-4 pb-0">
276→ <CardTitle className="text-sm font-medium">{title}</CardTitle>
277→ </CardHeader>
278→ <CardContent className="p-4 pt-2">
279→ <div className="h-72">{children}</div>
280→ </CardContent>
281→ </Card>
282→ );
283→}
284→
285→// ─── Main Component ──────────────────────────────────────
286→
287→export function DashboardPage() {
288→ const [data, setData] = useState<DashboardData | null>(null);
289→ const [loading, setLoading] = useState(true);
290→ const [error, setError] = useState<string | null>(null);
291→ const [autoRefresh, setAutoRefresh] = useState(false);
292→ const [lastUpdated, setLastUpdated] = useState<number>(Date.now());
293→ const [refreshing, setRefreshing] = useState(false);
294→ const autoRefreshRef = useRef<ReturnType<typeof setInterval> | 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 <DashboardSkeleton />;
339→ if (error) {
340→ return (
341→ <div className="p-6">
342→ <Card className="border-destructive">
343→ <CardContent className="p-6">
344→ <p className="text-destructive font-medium">{error}</p>
345→ </CardContent>
346→ </Card>
347→ </div>
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→ <div className="p-4 md:p-6 space-y-6 page-enter">
397→ {/* ── Page Title ── */}
398→ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
399→ <p className="text-sm text-muted-foreground">Overview of your Telegram Shop</p>
400→ <div className="flex items-center gap-4">
401→ <span className="text-xs text-muted-foreground">
402→ Last updated: {secondsAgo < 1 ? "just now" : `${secondsAgo} seconds ago`}
403→ </span>
404→ <button
405→ type="button"
406→ onClick={fetchDashboard}
407→ disabled={refreshing}
408→ className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors disabled:opacity-50"
409→ aria-label="Refresh dashboard"
410→ >
411→ <RefreshCw className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
412→ </button>
413→ <div className="flex items-center gap-2">
414→ <Switch
415→ id="auto-refresh"
416→ checked={autoRefresh}
417→ onCheckedChange={setAutoRefresh}
418→ />
419→ <label
420→ htmlFor="auto-refresh"
421→ className="text-xs text-muted-foreground cursor-pointer select-none"
422→ >
423→ Auto-refresh
424→ </label>
425→ </div>
426→ </div>
427→ </div>
428→
429→ {/* ── KPI Cards ── */}
430→ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
431→ {kpis.map((kpi) => (
432→ <KpiCard key={kpi.title} {...kpi} icon={kpi.icon} />
433→ ))}
434→ </div>
435→
436→ <Separator />
437→
438→ {/* ── Charts Grid ── */}
439→ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
440→ {/* 1. Revenue 7 days */}
441→ <ChartCard title="Revenue — Last 7 Days" accentColor={CHART_1}>
442→ {revenue7Data.length > 0 ? (
443→ <ResponsiveContainer width="100%" height="100%">
444→ <AreaChart data={revenue7Data}>
445→ <defs>
446→ <linearGradient id="rev7grad" x1="0" y1="0" x2="0" y2="1">
447→ <stop offset="5%" stopColor={CHART_1} stopOpacity={0.3} />
448→ <stop offset="95%" stopColor={CHART_1} stopOpacity={0} />
449→ </linearGradient>
450→ </defs>
451→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
452→ <XAxis dataKey="date" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
453→ <YAxis tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
454→ <Tooltip
455→ contentStyle={{
456→ backgroundColor: "hsl(var(--card))",
457→ border: "1px solid hsl(var(--border))",
458→ borderRadius: "8px",
459→ fontSize: "12px",
460→ }}
461→ />
462→ <Area
463→ type="monotone"
464→ dataKey="revenue"
465→ stroke={CHART_1}
466→ fill="url(#rev7grad)"
467→ strokeWidth={2}
468→ />
469→ </AreaChart>
470→ </ResponsiveContainer>
471→ ) : (
472→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
473→ )}
474→ </ChartCard>
475→
476→ {/* 2. Revenue 30 days */}
477→ <ChartCard title="Revenue — Last 30 Days" accentColor={CHART_2}>
478→ {revenue30Data.length > 0 ? (
479→ <ResponsiveContainer width="100%" height="100%">
480→ <AreaChart data={revenue30Data}>
481→ <defs>
482→ <linearGradient id="rev30grad" x1="0" y1="0" x2="0" y2="1">
483→ <stop offset="5%" stopColor={CHART_2} stopOpacity={0.3} />
484→ <stop offset="95%" stopColor={CHART_2} stopOpacity={0} />
485→ </linearGradient>
486→ </defs>
487→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
488→ <XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="hsl(var(--muted-foreground))" interval={4} />
489→ <YAxis tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
490→ <Tooltip
491→ contentStyle={{
492→ backgroundColor: "hsl(var(--card))",
493→ border: "1px solid hsl(var(--border))",
494→ borderRadius: "8px",
495→ fontSize: "12px",
496→ }}
497→ />
498→ <Area
499→ type="monotone"
500→ dataKey="revenue"
501→ stroke={CHART_2}
502→ fill="url(#rev30grad)"
503→ strokeWidth={2}
504→ />
505→ </AreaChart>
506→ </ResponsiveContainer>
507→ ) : (
508→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
509→ )}
510→ </ChartCard>
511→
512→ {/* 3. New Users 7 days */}
513→ <ChartCard title="New Users — Last 7 Days" accentColor={CHART_3}>
514→ {users7Data.length > 0 ? (
515→ <ResponsiveContainer width="100%" height="100%">
516→ <BarChart data={users7Data}>
517→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
518→ <XAxis dataKey="date" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
519→ <YAxis allowDecimals={false} tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
520→ <Tooltip
521→ contentStyle={{
522→ backgroundColor: "hsl(var(--card))",
523→ border: "1px solid hsl(var(--border))",
524→ borderRadius: "8px",
525→ fontSize: "12px",
526→ }}
527→ />
528→ <Bar dataKey="users" fill={CHART_3} radius={[4, 4, 0, 0]} />
529→ </BarChart>
530→ </ResponsiveContainer>
531→ ) : (
532→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
533→ )}
534→ </ChartCard>
535→
536→ {/* 4. Top 5 Products */}
537→ <ChartCard title="Top 5 Products by Quantity Sold" accentColor={CHART_4}>
538→ {productsData.length > 0 ? (
539→ <ResponsiveContainer width="100%" height="100%">
540→ <BarChart data={productsData} layout="vertical" margin={{ left: 20 }}>
541→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" horizontal={false} />
542→ <XAxis type="number" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
543→ <YAxis type="category" dataKey="name" width={120} tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
544→ <Tooltip
545→ contentStyle={{
546→ backgroundColor: "hsl(var(--card))",
547→ border: "1px solid hsl(var(--border))",
548→ borderRadius: "8px",
549→ fontSize: "12px",
550→ }}
551→ formatter={(value: number, name: string) => {
552→ if (name === "qty") return [value, "Quantity"];
553→ return [formatCurrency(value), "Revenue"];
554→ }}
555→ />
556→ <Bar dataKey="qty" fill={CHART_4} radius={[0, 4, 4, 0]} />
557→ </BarChart>
558→ </ResponsiveContainer>
559→ ) : (
560→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
561→ )}
562→ </ChartCard>
563→
564→ {/* 5. Top 5 Spenders */}
565→ <ChartCard title="Top 5 Spenders" accentColor={CHART_5}>
566→ {spendersData.length > 0 ? (
567→ <ResponsiveContainer width="100%" height="100%">
568→ <BarChart data={spendersData} layout="vertical" margin={{ left: 20 }}>
569→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" horizontal={false} />
570→ <XAxis type="number" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
571→ <YAxis type="category" dataKey="username" width={120} tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
572→ <Tooltip
573→ contentStyle={{
574→ backgroundColor: "hsl(var(--card))",
575→ border: "1px solid hsl(var(--border))",
576→ borderRadius: "8px",
577→ fontSize: "12px",
578→ }}
579→ formatter={(value: number) => [formatCurrency(value), "Spent"]}
580→ />
581→ <Bar dataKey="spent" fill={CHART_5} radius={[0, 4, 4, 0]} />
582→ </BarChart>
583→ </ResponsiveContainer>
584→ ) : (
585→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
586→ )}
587→ </ChartCard>
588→
589→ {/* 6. Revenue by Category (Pie/Donut) */}
590→ <ChartCard title="Revenue by Category" accentColor={CHART_1}>
591→ {revenueByCategory.length > 0 ? (
592→ <ResponsiveContainer width="100%" height="100%">
593→ <PieChart>
594→ <Pie
595→ data={revenueByCategory}
596→ cx="50%"
597→ cy="50%"
598→ innerRadius={50}
599→ outerRadius={90}
600→ paddingAngle={2}
601→ dataKey="value"
602→ nameKey="name"
603→ label={({ name, percent }) =>
604→ `${name} ${(percent * 100).toFixed(0)}%`
605→ }
606→ labelLine={true}
607→ fontSize={11}
608→ >
609→ {revenueByCategory.map((_, index) => (
610→ <Cell
611→ key={`cell-${index}`}
612→ fill={PIE_COLORS[index % PIE_COLORS.length]}
613→ />
614→ ))}
615→ </Pie>
616→ <Tooltip
617→ contentStyle={{
618→ backgroundColor: "hsl(var(--card))",
619→ border: "1px solid hsl(var(--border))",
620→ borderRadius: "8px",
621→ fontSize: "12px",
622→ }}
623→ formatter={(value: number) => [formatCurrency(value), "Revenue"]}
624→ />
625→ <Legend />
626→ </PieChart>
627→ </ResponsiveContainer>
628→ ) : (
629→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
630→ )}
631→ </ChartCard>
632→
633→ {/* 7. Purchase Status Distribution */}
634→ <ChartCard title="Purchase Status Distribution" accentColor="#eab308">
635→ <ResponsiveContainer width="100%" height="100%">
636→ <PieChart>
637→ <Pie
638→ data={[
639→ { name: 'Pending', value: stats.pendingPurchases },
640→ { name: 'Completed', value: stats.completedPurchases },
641→ { name: 'Cancelled', value: stats.cancelledPurchases },
642→ ]}
643→ cx="50%"
644→ cy="50%"
645→ innerRadius={55}
646→ outerRadius={90}
647→ paddingAngle={3}
648→ dataKey="value"
649→ nameKey="name"
650→ label={({ name, percent }) =>
651→ `${name} ${(percent * 100).toFixed(0)}%`
652→ }
653→ labelLine={true}
654→ fontSize={11}
655→ >
656→ <Cell fill="#eab308" />
657→ <Cell fill="#10b981" />
658→ <Cell fill="#ef4444" />
659→ </Pie>
660→ <Tooltip
661→ contentStyle={{
662→ backgroundColor: "hsl(var(--card))",
663→ border: "1px solid hsl(var(--border))",
664→ borderRadius: "8px",
665→ fontSize: "12px",
666→ }}
667→ formatter={(value: number) => [value, "Purchases"]}
668→ />
669→ <Legend />
670→ </PieChart>
671→ </ResponsiveContainer>
672→ </ChartCard>
673→ </div>
674→
675→ {/* ── Analytics Cards: Revenue Trend + User Funnel ── */}
676→ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
677→ {/* Card A: Revenue Trend (30-day area chart) */}
678→ <Card className="card-hover overflow-hidden md:col-span-2">
679→ <div
680→ className="h-1 w-full"
681→ style={{
682→ background: `linear-gradient(to right, ${CHART_2}, ${CHART_2}44, transparent)`,
683→ }}
684→ />
685→ <CardHeader className="p-4 pb-0 flex flex-row items-center gap-2">
686→ <TrendingUp className="h-4 w-4 text-muted-foreground" />
687→ <CardTitle className="text-sm font-medium">Revenue Trend</CardTitle>
688→ </CardHeader>
689→ <CardContent className="p-4 pt-2">
690→ <div className="h-80">
691→ {revenue30Data.length > 0 ? (
692→ <ResponsiveContainer width="100%" height="100%">
693→ <AreaChart data={revenue30Data}>
694→ <defs>
695→ <linearGradient id="revTrendGrad" x1="0" y1="0" x2="0" y2="1">
696→ <stop offset="5%" stopColor={CHART_2} stopOpacity={0.4} />
697→ <stop offset="95%" stopColor={CHART_2} stopOpacity={0} />
698→ </linearGradient>
699→ </defs>
700→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
701→ <XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="hsl(var(--muted-foreground))" interval={4} />
702→ <YAxis tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v: number) => `$${v}`} />
703→ <Tooltip
704→ contentStyle={{
705→ backgroundColor: "hsl(var(--card))",
706→ border: "1px solid hsl(var(--border))",
707→ borderRadius: "8px",
708→ fontSize: "12px",
709→ }}
710→ formatter={(value: number) => [formatCurrency(value), "Revenue"]}
711→ />
712→ <Area
713→ type="monotone"
714→ dataKey="revenue"
715→ stroke={CHART_2}
716→ fill="url(#revTrendGrad)"
717→ strokeWidth={2}
718→ />
719→ </AreaChart>
720→ </ResponsiveContainer>
721→ ) : (
722→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
723→ )}
724→ </div>
725→ </CardContent>
726→ </Card>
727→
728→ {/* Card B: Conversion Funnel (horizontal bar) */}
729→ <Card className="card-hover overflow-hidden md:col-span-1">
730→ <div
731→ className="h-1 w-full"
732→ style={{
733→ background: `linear-gradient(to right, #64748b, #64748b44, transparent)`,
734→ }}
735→ />
736→ <CardHeader className="p-4 pb-0 flex flex-row items-center gap-2">
737→ <Users className="h-4 w-4 text-muted-foreground" />
738→ <CardTitle className="text-sm font-medium">User Funnel</CardTitle>
739→ </CardHeader>
740→ <CardContent className="p-4 pt-2">
741→ <div className="h-80">
742→ <ResponsiveContainer width="100%" height="100%">
743→ <BarChart
744→ data={[
745→ { name: "Total Users", value: stats.totalUsers },
746→ { name: "Users with Purchases", value: stats.totalPurchases },
747→ { name: "Users with Wallets", value: stats.activeWallets },
748→ ]}
749→ layout="vertical"
750→ margin={{ left: 10, right: 20 }}
751→ >
752→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" horizontal={false} />
753→ <XAxis type="number" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
754→ <YAxis
755→ type="category"
756→ dataKey="name"
757→ width={120}
758→ tick={{ fontSize: 11 }}
759→ stroke="hsl(var(--muted-foreground))"
760→ />
761→ <Tooltip
762→ contentStyle={{
763→ backgroundColor: "hsl(var(--card))",
764→ border: "1px solid hsl(var(--border))",
765→ borderRadius: "8px",
766→ fontSize: "12px",
767→ }}
768→ />
769→ <Bar dataKey="value" radius={[0, 4, 4, 0]}>
770→ <Cell fill="#64748b" />
771→ <Cell fill="#10b981" />
772→ <Cell fill="#06b6d4" />
773→ </Bar>
774→ </BarChart>
775→ </ResponsiveContainer>
776→ </div>
777→ </CardContent>
778→ </Card>
779→ </div>
780→
781→ <Separator />
782→
783→ {/* ── Recent Purchases Table ── */}
784→ <Card className="card-hover">
785→ <CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
786→ <CardTitle className="text-sm font-medium">Recent Purchases</CardTitle>
787→ <button
788→ type="button"
789→ onClick={() => { window.location.hash = '#/purchases'; }}
790→ className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
791→ >
792→ View all
793→ <ArrowRight className="h-3 w-3" />
794→ </button>
795→ </CardHeader>
796→ <CardContent className="p-4">
797→ {data.recentPurchases.length > 0 ? (
798→ <div className="overflow-x-auto">
799→ <table className="w-full text-sm">
800→ <thead>
801→ <tr className="border-b">
802→ <th className="text-left py-2 px-2 font-medium text-muted-foreground text-xs">Product</th>
803→ <th className="text-left py-2 px-2 font-medium text-muted-foreground text-xs">User</th>
804→ <th className="text-right py-2 px-2 font-medium text-muted-foreground text-xs">Amount</th>
805→ <th className="text-center py-2 px-2 font-medium text-muted-foreground text-xs">Status</th>
806→ <th className="text-right py-2 px-2 font-medium text-muted-foreground text-xs">Date</th>
807→ </tr>
808→ </thead>
809→ <tbody>
810→ {data.recentPurchases.map((p, i) => {
811→ const badge = statusBadge(p.status);
812→ return (
813→ <tr key={i} className="border-b last:border-0">
814→ <td className="py-2 px-2 text-xs font-medium truncate max-w-[140px]">{p.productName}</td>
815→ <td className="py-2 px-2 text-xs text-muted-foreground truncate max-w-[100px]">{p.username}</td>
816→ <td className="py-2 px-2 text-xs text-right font-mono tabular-nums">{formatCurrency(p.totalPrice)}</td>
817→ <td className="py-2 px-2 text-center">
818→ <span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap ${badge.cls}`}>
819→ {badge.label}
820→ </span>
821→ </td>
822→ <td className="py-2 px-2 text-xs text-right text-muted-foreground whitespace-nowrap">{relativeTime(p.purchaseDate)}</td>
823→ </tr>
824→ );
825→ })}
826→ </tbody>
827→ </table>
828→ </div>
829→ ) : (
830→ <div className="h-24 flex items-center justify-center text-muted-foreground text-sm">No recent purchases</div>
831→ )}
832→ </CardContent>
833→ </Card>
834→
835→ <Separator />
836→
837→ {/* ── Bottom Section: Wallet Summary + Wallet Chart ── */}
838→ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
839→ {/* Wallet Summary Table */}
840→ <Card className="card-hover">
841→ <CardHeader className="p-4 pb-0">
842→ <CardTitle className="text-sm font-medium">Wallet Summary</CardTitle>
843→ </CardHeader>
844→ <CardContent className="p-4">
845→ {walletSummary.length > 0 ? (
846→ <div className="overflow-x-auto">
847→ <table className="w-full text-sm">
848→ <thead>
849→ <tr className="border-b">
850→ <th className="text-left py-2 px-3 font-medium text-muted-foreground">Type</th>
851→ <th className="text-right py-2 px-3 font-medium text-muted-foreground">Count</th>
852→ <th className="text-right py-2 px-3 font-medium text-muted-foreground">Balance</th>
853→ <th className="text-right py-2 px-3 font-medium text-muted-foreground">USD (mock)</th>
854→ </tr>
855→ </thead>
856→ <tbody>
857→ {walletSummary.map((w) => (
858→ <tr key={w.walletType} className="border-b last:border-0">
859→ <td className="py-2 px-3 font-medium">{w.walletType}</td>
860→ <td className="py-2 px-3 text-right text-muted-foreground">{w.count}</td>
861→ <td className="py-2 px-3 text-right font-mono text-xs">{formatCrypto(w.totalBalance)}</td>
862→ <td className="py-2 px-3 text-right">{formatCurrency(w.totalBalanceUsd)}</td>
863→ </tr>
864→ ))}
865→ </tbody>
866→ </table>
867→ </div>
868→ ) : (
869→ <div className="h-48 flex items-center justify-center text-muted-foreground text-sm">No wallet data</div>
870→ )}
871→ </CardContent>
872→ </Card>
873→
874→ {/* Wallet Count by Type Chart */}
875→ <ChartCard title="Wallet Count by Type" accentColor={CHART_2}>
876→ {walletChartData.length > 0 ? (
877→ <ResponsiveContainer width="100%" height="100%">
878→ <BarChart data={walletChartData}>
879→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
880→ <XAxis dataKey="name" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
881→ <YAxis allowDecimals={false} tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
882→ <Tooltip
883→ contentStyle={{
884→ backgroundColor: "hsl(var(--card))",
885→ border: "1px solid hsl(var(--border))",
886→ borderRadius: "8px",
887→ fontSize: "12px",
888→ }}
889→ formatter={(value: number) => [value, "Wallets"]}
890→ />
891→ <Bar dataKey="count" fill={CHART_2} radius={[4, 4, 0, 0]} />
892→ </BarChart>
893→ </ResponsiveContainer>
894→ ) : (
895→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No wallet data</div>
896→ )}
897→ </ChartCard>
898→ </div>
899→
900→ {/* ── Activity Feed (full width) ── */}
901→ <ActivityFeed />
902→ </div>
903→ );
904→}
905→

View File

@@ -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→ <div className="absolute bottom-0 left-0 right-0 h-12 opacity-20 pointer-events-none">
173→ 173→ <ResponsiveContainer width="100%" height="100%">
174→ 174→ <AreaChart data={chartData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
175→ 175→ <YAxis domain={["dataMin - 2", "dataMax + 2"]} hide />
176→ 176→ <Area type="monotone" dataKey="v" stroke={color} fill={color} strokeWidth={1.5} />
177→ 177→ </AreaChart>
178→ 178→ </ResponsiveContainer>
179→ 179→ </div>
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→ <Card className="card-hover border-l-4 transition-transform hover:scale-[1.02] relative overflow-hidden" style={{ borderLeftColor: color }}>
213→ 213→ <div
214→ 214→ className="h-[2px] w-full rounded-t-lg"
215→ 215→ style={{
216→ 216→ background: `linear-gradient(to right, ${color}, ${color}66, transparent)`,
217→ 217→ }}
218→ 218→ />
219→ 219→ <CardContent className="p-4 flex items-center gap-4">
220→ 220→ <div
221→ 221→ className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg"
222→ 222→ style={{ backgroundColor: `${color}15` }}
223→ 223→ >
224→ 224→ <Icon className="h-6 w-6" style={{ color }} />
225→ 225→ </div>
226→ 226→ <div className="min-w-0">
227→ 227→ <p className="text-sm text-muted-foreground truncate">{title}</p>
228→ 228→ <p className="text-xl font-bold truncate tabular-nums">{value}</p>
229→ 229→ </div>
230→ 230→ </CardContent>
231→ 231→ {sparkData && sparklineColor && <MiniSparkline data={sparkData} color={sparklineColor} />}
232→ 232→ </Card>
233→ 233→ );
234→ 234→}
235→ 235→
236→ 236→// ─── Skeleton Loader ─────────────────────────────────────
237→ 237→
238→ 238→function DashboardSkeleton() {
239→ 239→ return (
240→ 240→ <div className="p-4 md:p-6 space-y-6">
241→ 241→ <Skeleton className="h-8 w-48" />
242→ 242→ <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
243→ 243→ {Array.from({ length: 10 }).map((_, i) => (
244→ 244→ <Skeleton key={i} className="h-24 rounded-xl" />
245→ 245→ ))}
246→ 246→ </div>
247→ 247→ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
248→ 248→ {Array.from({ length: 4 }).map((_, i) => (
249→ 249→ <Skeleton key={i} className="h-72 rounded-xl" />
250→ 250→ ))}
251→ 251→ </div>
252→ 252→ </div>
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→ <Card className="card-hover overflow-hidden">
269→ 269→ <div
270→ 270→ className="h-1 w-full"
271→ 271→ style={{
272→ 272→ background: `linear-gradient(to right, ${accentColor ?? CHART_1}, ${accentColor ?? CHART_1}44, transparent)`,
273→ 273→ }}
274→ 274→ />
275→ 275→ <CardHeader className="p-4 pb-0">
276→ 276→ <CardTitle className="text-sm font-medium">{title}</CardTitle>
277→ 277→ </CardHeader>
278→ 278→ <CardContent className="p-4 pt-2">
279→ 279→ <div className="h-72">{children}</div>
280→ 280→ </CardContent>
281→ 281→ </Card>
282→ 282→ );
283→ 283→}
284→ 284→
285→ 285→// ─── Main Component ──────────────────────────────────────
286→ 286→
287→ 287→export function DashboardPage() {
288→ 288→ const [data, setData] = useState<DashboardData | null>(null);
289→ 289→ const [loading, setLoading] = useState(true);
290→ 290→ const [error, setError] = useState<string | null>(null);
291→ 291→ const [autoRefresh, setAutoRefresh] = useState(false);
292→ 292→ const [lastUpdated, setLastUpdated] = useState<number>(Date.now());
293→ 293→ const [refreshing, setRefreshing] = useState(false);
294→ 294→ const autoRefreshRef = useRef<ReturnType<typeof setInterval> | 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 <DashboardSkeleton />;
339→ 339→ if (error) {
340→ 340→ return (
341→ 341→ <div className="p-6">
342→ 342→ <Card className="border-destructive">
343→ 343→ <CardContent className="p-6">
344→ 344→ <p className="text-destructive font-medium">{error}</p>
345→ 345→ </CardContent>
346→ 346→ </Card>
347→ 347→ </div>
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→ <div className="p-4 md:p-6 space-y-6 page-enter">
397→ 397→ {/* ── Page Title ── */}
398→ 398→ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
399→ 399→ <p className="text-sm text-muted-foreground">Overview of your Telegram Shop</p>
400→ 400→ <div className="flex items-center gap-4">
401→ 401→ <span className="text-xs text-muted-foreground">
402→ 402→ Last updated: {secondsAgo < 1 ? "just now" : `${secondsAgo} seconds ago`}
403→ 403→ </span>
404→ 404→ <button
405→ 405→ type="button"
406→ 406→ onClick={fetchDashboard}
407→ 407→ disabled={refreshing}
408→ 408→ className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors disabled:opacity-50"
409→ 409→ aria-label="Refresh dashboard"
410→ 410→ >
411→ 411→ <RefreshCw className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
412→ 412→ </button>
413→ 413→ <div className="flex items-center gap-2">
414→ 414→ <Switch
415→ 415→ id="auto-refresh"
416→ 416→ checked={autoRefresh}
417→ 417→ onCheckedChange={setAutoRefresh}
418→ 418→ />
419→ 419→ <label
420→ 420→ htmlFor="auto-refresh"
421→ 421→ className="text-xs text-muted-foreground cursor-pointer select-none"
422→ 422→ >
423→ 423→ Auto-refresh
424→ 424→ </label>
425→ 425→ </div>
426→ 426→ </div>
427→ 427→ </div>
428→ 428→
429→ 429→ {/* ── KPI Cards ── */}
430→ 430→ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
431→ 431→ {kpis.map((kpi) => (
432→ 432→ <KpiCard key={kpi.title} {...kpi} icon={kpi.icon} />
433→ 433→ ))}
434→ 434→ </div>
435→ 435→
436→ 436→ <Separator />
437→ 437→
438→ 438→ {/* ── Charts Grid ── */}
439→ 439→ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
440→ 440→ {/* 1. Revenue 7 days */}
441→ 441→ <ChartCard title="Revenue — Last 7 Days" accentColor={CHART_1}>
442→ 442→ {revenue7Data.length > 0 ? (
443→ 443→ <ResponsiveContainer width="100%" height="100%">
444→ 444→ <AreaChart data={revenue7Data}>
445→ 445→ <defs>
446→ 446→ <linearGradient id="rev7grad" x1="0" y1="0" x2="0" y2="1">
447→ 447→ <stop offset="5%" stopColor={CHART_1} stopOpacity={0.3} />
448→ 448→ <stop offset="95%" stopColor={CHART_1} stopOpacity={0} />
449→ 449→ </linearGradient>
450→ 450→ </defs>
451→ 451→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
452→ 452→ <XAxis dataKey="date" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
453→ 453→ <YAxis tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
454→ 454→ <Tooltip
455→ 455→ contentStyle={{
456→ 456→ backgroundColor: "hsl(var(--card))",
457→ 457→ border: "1px solid hsl(var(--border))",
458→ 458→ borderRadius: "8px",
459→ 459→ fontSize: "12px",
460→ 460→ }}
461→ 461→ />
462→ 462→ <Area
463→ 463→ type="monotone"
464→ 464→ dataKey="revenue"
465→ 465→ stroke={CHART_1}
466→ 466→ fill="url(#rev7grad)"
467→ 467→ strokeWidth={2}
468→ 468→ />
469→ 469→ </AreaChart>
470→ 470→ </ResponsiveContainer>
471→ 471→ ) : (
472→ 472→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
473→ 473→ )}
474→ 474→ </ChartCard>
475→ 475→
476→ 476→ {/* 2. Revenue 30 days */}
477→ 477→ <ChartCard title="Revenue — Last 30 Days" accentColor={CHART_2}>
478→ 478→ {revenue30Data.length > 0 ? (
479→ 479→ <ResponsiveContainer width="100%" height="100%">
480→ 480→ <AreaChart data={revenue30Data}>
481→ 481→ <defs>
482→ 482→ <linearGradient id="rev30grad" x1="0" y1="0" x2="0" y2="1">
483→ 483→ <stop offset="5%" stopColor={CHART_2} stopOpacity={0.3} />
484→ 484→ <stop offset="95%" stopColor={CHART_2} stopOpacity={0} />
485→ 485→ </linearGradient>
486→ 486→ </defs>
487→ 487→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
488→ 488→ <XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="hsl(var(--muted-foreground))" interval={4} />
489→ 489→ <YAxis tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
490→ 490→ <Tooltip
491→ 491→ contentStyle={{
492→ 492→ backgroundColor: "hsl(var(--card))",
493→ 493→ border: "1px solid hsl(var(--border))",
494→ 494→ borderRadius: "8px",
495→ 495→ fontSize: "12px",
496→ 496→ }}
497→ 497→ />
498→ 498→ <Area
499→ 499→ type="monotone"
500→ 500→ dataKey="revenue"
501→ 501→ stroke={CHART_2}
502→ 502→ fill="url(#rev30grad)"
503→ 503→ strokeWidth={2}
504→ 504→ />
505→ 505→ </AreaChart>
506→ 506→ </ResponsiveContainer>
507→ 507→ ) : (
508→ 508→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
509→ 509→ )}
510→ 510→ </ChartCard>
511→ 511→
512→ 512→ {/* 3. New Users 7 days */}
513→ 513→ <ChartCard title="New Users — Last 7 Days" accentColor={CHART_3}>
514→ 514→ {users7Data.length > 0 ? (
515→ 515→ <ResponsiveContainer width="100%" height="100%">
516→ 516→ <BarChart data={users7Data}>
517→ 517→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
518→ 518→ <XAxis dataKey="date" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
519→ 519→ <YAxis allowDecimals={false} tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
520→ 520→ <Tooltip
521→ 521→ contentStyle={{
522→ 522→ backgroundColor: "hsl(var(--card))",
523→ 523→ border: "1px solid hsl(var(--border))",
524→ 524→ borderRadius: "8px",
525→ 525→ fontSize: "12px",
526→ 526→ }}
527→ 527→ />
528→ 528→ <Bar dataKey="users" fill={CHART_3} radius={[4, 4, 0, 0]} />
529→ 529→ </BarChart>
530→ 530→ </ResponsiveContainer>
531→ 531→ ) : (
532→ 532→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
533→ 533→ )}
534→ 534→ </ChartCard>
535→ 535→
536→ 536→ {/* 4. Top 5 Products */}
537→ 537→ <ChartCard title="Top 5 Products by Quantity Sold" accentColor={CHART_4}>
538→ 538→ {productsData.length > 0 ? (
539→ 539→ <ResponsiveContainer width="100%" height="100%">
540→ 540→ <BarChart data={productsData} layout="vertical" margin={{ left: 20 }}>
541→ 541→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" horizontal={false} />
542→ 542→ <XAxis type="number" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
543→ 543→ <YAxis type="category" dataKey="name" width={120} tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
544→ 544→ <Tooltip
545→ 545→ contentStyle={{
546→ 546→ backgroundColor: "hsl(var(--card))",
547→ 547→ border: "1px solid hsl(var(--border))",
548→ 548→ borderRadius: "8px",
549→ 549→ fontSize: "12px",
550→ 550→ }}
551→ 551→ formatter={(value: number, name: string) => {
552→ 552→ if (name === "qty") return [value, "Quantity"];
553→ 553→ return [formatCurrency(value), "Revenue"];
554→ 554→ }}
555→ 555→ />
556→ 556→ <Bar dataKey="qty" fill={CHART_4} radius={[0, 4, 4, 0]} />
557→ 557→ </BarChart>
558→ 558→ </ResponsiveContainer>
559→ 559→ ) : (
560→ 560→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
561→ 561→ )}
562→ 562→ </ChartCard>
563→ 563→
564→ 564→ {/* 5. Top 5 Spenders */}
565→ 565→ <ChartCard title="Top 5 Spenders" accentColor={CHART_5}>
566→ 566→ {spendersData.length > 0 ? (
567→ 567→ <ResponsiveContainer width="100%" height="100%">
568→ 568→ <BarChart data={spendersData} layout="vertical" margin={{ left: 20 }}>
569→ 569→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" horizontal={false} />
570→ 570→ <XAxis type="number" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
571→ 571→ <YAxis type="category" dataKey="username" width={120} tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
572→ 572→ <Tooltip
573→ 573→ contentStyle={{
574→ 574→ backgroundColor: "hsl(var(--card))",
575→ 575→ border: "1px solid hsl(var(--border))",
576→ 576→ borderRadius: "8px",
577→ 577→ fontSize: "12px",
578→ 578→ }}
579→ 579→ formatter={(value: number) => [formatCurrency(value), "Spent"]}
580→ 580→ />
581→ 581→ <Bar dataKey="spent" fill={CHART_5} radius={[0, 4, 4, 0]} />
582→ 582→ </BarChart>
583→ 583→ </ResponsiveContainer>
584→ 584→ ) : (
585→ 585→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
586→ 586→ )}
587→ 587→ </ChartCard>
588→ 588→
589→ 589→ {/* 6. Revenue by Category (Pie/Donut) */}
590→ 590→ <ChartCard title="Revenue by Category" accentColor={CHART_1}>
591→ 591→ {revenueByCategory.length > 0 ? (
592→ 592→ <ResponsiveContainer width="100%" height="100%">
593→ 593→ <PieChart>
594→ 594→ <Pie
595→ 595→ data={revenueByCategory}
596→ 596→ cx="50%"
597→ 597→ cy="50%"
598→ 598→ innerRadius={50}
599→ 599→ outerRadius={90}
600→ 600→ paddingAngle={2}
601→ 601→ dataKey="value"
602→ 602→ nameKey="name"
603→ 603→ label={({ name, percent }) =>
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→ <Cell
611→ 611→ key={`cell-${index}`}
612→ 612→ fill={PIE_COLORS[index % PIE_COLORS.length]}
613→ 613→ />
614→ 614→ ))}
615→ 615→ </Pie>
616→ 616→ <Tooltip
617→ 617→ contentStyle={{
618→ 618→ backgroundColor: "hsl(var(--card))",
619→ 619→ border: "1px solid hsl(var(--border))",
620→ 620→ borderRadius: "8px",
621→ 621→ fontSize: "12px",
622→ 622→ }}
623→ 623→ formatter={(value: number) => [formatCurrency(value), "Revenue"]}
624→ 624→ />
625→ 625→ <Legend />
626→ 626→ </PieChart>
627→ 627→ </ResponsiveContainer>
628→ 628→ ) : (
629→ 629→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
630→ 630→ )}
631→ 631→ </ChartCard>
632→ 632→
633→ 633→ {/* 7. Purchase Status Distribution */}
634→ 634→ <ChartCard title="Purchase Status Distribution" accentColor="#eab308">
635→ 635→ <ResponsiveContainer width="100%" height="100%">
636→ 636→ <PieChart>
637→ 637→ <Pie
638→ 638→ data={[
639→ 639→ { name: 'Pending', value: stats.pendingPurchases },
640→ 640→ { name: 'Completed', value: stats.completedPurchases },
641→ 641→ { name: 'Cancelled', value: stats.cancelledPurchases },
642→ 642→ ]}
643→ 643→ cx="50%"
644→ 644→ cy="50%"
645→ 645→ innerRadius={55}
646→ 646→ outerRadius={90}
647→ 647→ paddingAngle={3}
648→ 648→ dataKey="value"
649→ 649→ nameKey="name"
650→ 650→ label={({ name, percent }) =>
651→ 651→ `${name} ${(percent * 100).toFixed(0)}%`
652→ 652→ }
653→ 653→ labelLine={true}
654→ 654→ fontSize={11}
655→ 655→ >
656→ 656→ <Cell fill="#eab308" />
657→ 657→ <Cell fill="#10b981" />
658→ 658→ <Cell fill="#ef4444" />
659→ 659→ </Pie>
660→ 660→ <Tooltip
661→ 661→ contentStyle={{
662→ 662→ backgroundColor: "hsl(var(--card))",
663→ 663→ border: "1px solid hsl(var(--border))",
664→ 664→ borderRadius: "8px",
665→ 665→ fontSize: "12px",
666→ 666→ }}
667→ 667→ formatter={(value: number) => [value, "Purchases"]}
668→ 668→ />
669→ 669→ <Legend />
670→ 670→ </PieChart>
671→ 671→ </ResponsiveContainer>
672→ 672→ </ChartCard>
673→ 673→ </div>
674→ 674→
675→ 675→ {/* ── Analytics Cards: Revenue Trend + User Funnel ── */}
676→ 676→ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
677→ 677→ {/* Card A: Revenue Trend (30-day area chart) */}
678→ 678→ <Card className="card-hover overflow-hidden md:col-span-2">
679→ 679→ <div
680→ 680→ className="h-1 w-full"
681→ 681→ style={{
682→ 682→ background: `linear-gradient(to right, ${CHART_2}, ${CHART_2}44, transparent)`,
683→ 683→ }}
684→ 684→ />
685→ 685→ <CardHeader className="p-4 pb-0 flex flex-row items-center gap-2">
686→ 686→ <TrendingUp className="h-4 w-4 text-muted-foreground" />
687→ 687→ <CardTitle className="text-sm font-medium">Revenue Trend</CardTitle>
688→ 688→ </CardHeader>
689→ 689→ <CardContent className="p-4 pt-2">
690→ 690→ <div className="h-80">
691→ 691→ {revenue30Data.length > 0 ? (
692→ 692→ <ResponsiveContainer width="100%" height="100%">
693→ 693→ <AreaChart data={revenue30Data}>
694→ 694→ <defs>
695→ 695→ <linearGradient id="revTrendGrad" x1="0" y1="0" x2="0" y2="1">
696→ 696→ <stop offset="5%" stopColor={CHART_2} stopOpacity={0.4} />
697→ 697→ <stop offset="95%" stopColor={CHART_2} stopOpacity={0} />
698→ 698→ </linearGradient>
699→ 699→ </defs>
700→ 700→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
701→ 701→ <XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="hsl(var(--muted-foreground))" interval={4} />
702→ 702→ <YAxis tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v: number) => `$${v}`} />
703→ 703→ <Tooltip
704→ 704→ contentStyle={{
705→ 705→ backgroundColor: "hsl(var(--card))",
706→ 706→ border: "1px solid hsl(var(--border))",
707→ 707→ borderRadius: "8px",
708→ 708→ fontSize: "12px",
709→ 709→ }}
710→ 710→ formatter={(value: number) => [formatCurrency(value), "Revenue"]}
711→ 711→ />
712→ 712→ <Area
713→ 713→ type="monotone"
714→ 714→ dataKey="revenue"
715→ 715→ stroke={CHART_2}
716→ 716→ fill="url(#revTrendGrad)"
717→ 717→ strokeWidth={2}
718→ 718→ />
719→ 719→ </AreaChart>
720→ 720→ </ResponsiveContainer>
721→ 721→ ) : (
722→ 722→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
723→ 723→ )}
724→ 724→ </div>
725→ 725→ </CardContent>
726→ 726→ </Card>
727→ 727→
728→ 728→ {/* Card B: Conversion Funnel (horizontal bar) */}
729→ 729→ <Card className="card-hover overflow-hidden md:col-span-1">
730→ 730→ <div
731→ 731→ className="h-1 w-full"
732→ 732→ style={{
733→ 733→ background: `linear-gradient(to right, #64748b, #64748b44, transparent)`,
734→ 734→ }}
735→ 735→ />
736→ 736→ <CardHeader className="p-4 pb-0 flex flex-row items-center gap-2">
737→ 737→ <Users className="h-4 w-4 text-muted-foreground" />
738→ 738→ <CardTitle className="text-sm font-medium">User Funnel</CardTitle>
739→ 739→ </CardHeader>
740→ 740→ <CardContent className="p-4 pt-2">
741→ 741→ <div className="h-80">
742→ 742→ <ResponsiveContainer width="100%" height="100%">
743→ 743→ <BarChart
744→ 744→ data={[
745→ 745→ { name: "Total Users", value: stats.totalUsers },
746→ 746→ { name: "Users with Purchases", value: stats.totalPurchases },
747→ 747→ { name: "Users with Wallets", value: stats.activeWallets },
748→ 748→ ]}
749→ 749→ layout="vertical"
750→ 750→ margin={{ left: 10, right: 20 }}
751→ 751→ >
752→ 752→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" horizontal={false} />
753→ 753→ <XAxis type="number" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
754→ 754→ <YAxis
755→ 755→ type="category"
756→ 756→ dataKey="name"
757→ 757→ width={120}
758→ 758→ tick={{ fontSize: 11 }}
759→ 759→ stroke="hsl(var(--muted-foreground))"
760→ 760→ />
761→ 761→ <Tooltip
762→ 762→ contentStyle={{
763→ 763→ backgroundColor: "hsl(var(--card))",
764→ 764→ border: "1px solid hsl(var(--border))",
765→ 765→ borderRadius: "8px",
766→ 766→ fontSize: "12px",
767→ 767→ }}
768→ 768→ />
769→ 769→ <Bar dataKey="value" radius={[0, 4, 4, 0]}>
770→ 770→ <Cell fill="#64748b" />
771→ 771→ <Cell fill="#10b981" />
772→ 772→ <Cell fill="#06b6d4" />
773→ 773→ </Bar>
774→ 774→ </BarChart>
775→ 775→ </ResponsiveContainer>
776→ 776→ </div>
777→ 777→ </CardContent>
778→ 778→ </Card>
779→ 779→ </div>
780→ 780→
781→ 781→ <Separator />
782→ 782→
783→ 783→ {/* ── Recent Purchases Table ── */}
784→ 784→ <Card className="card-hover">
785→ 785→ <CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
786→ 786→ <CardTitle className="text-sm font-medium">Recent Purchases</CardTitle>
787→ 787→ <button
788→ 788→ type="button"
789→ 789→ onClick={() => { window.location.hash = '#/purchases'; }}
790→ 790→ className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
791→ 791→ >
792→ 792→ View all
793→ 793→ <ArrowRight className="h-3 w-3" />
794→ 794→ </button>
795→ 795→ </CardHeader>
796→ 796→ <CardContent className="p-4">
797→ 797→ {data.recentPurchases.length > 0 ? (
798→ 798→ <div className="overflow-x-auto">
799→ 799→ <table className="w-full text-sm">
800→ 800→ <thead>
801→ 801→ <tr className="border-b">
802→ 802→ <th className="text-left py-2 px-2 font-medium text-muted-foreground text-xs">Product</th>
803→ 803→ <th className="text-left py-2 px-2 font-medium text-muted-foreground text-xs">User</th>
804→ 804→ <th className="text-right py-2 px-2 font-medium text-muted-foreground text-xs">Amount</th>
805→ 805→ <th className="text-center py-2 px-2 font-medium text-muted-foreground text-xs">Status</th>
806→ 806→ <th className="text-right py-2 px-2 font-medium text-muted-foreground text-xs">Date</th>
807→ 807→ </tr>
808→ 808→ </thead>
809→ 809→ <tbody>
810→ 810→ {data.recentPurchases.map((p, i) => {
811→ 811→ const badge = statusBadge(p.status);
812→ 812→ return (
813→ 813→ <tr key={i} className="border-b last:border-0">
814→ 814→ <td className="py-2 px-2 text-xs font-medium truncate max-w-[140px]">{p.productName}</td>
815→ 815→ <td className="py-2 px-2 text-xs text-muted-foreground truncate max-w-[100px]">{p.username}</td>
816→ 816→ <td className="py-2 px-2 text-xs text-right font-mono tabular-nums">{formatCurrency(p.totalPrice)}</td>
817→ 817→ <td className="py-2 px-2 text-center">
818→ 818→ <span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap ${badge.cls}`}>
819→ 819→ {badge.label}
820→ 820→ </span>
821→ 821→ </td>
822→ 822→ <td className="py-2 px-2 text-xs text-right text-muted-foreground whitespace-nowrap">{relativeTime(p.purchaseDate)}</td>
823→ 823→ </tr>
824→ 824→ );
825→ 825→ })}
826→ 826→ </tbody>
827→ 827→ </table>
828→ 828→ </div>
829→ 829→ ) : (
830→ 830→ <div className="h-24 flex items-center justify-center text-muted-foreground text-sm">No recent purchases</div>
831→ 831→ )}
832→ 832→ </CardContent>
833→ 833→ </Card>
834→ 834→
835→ 835→ <Separator />
836→ 836→
837→ 837→ {/* ── Bottom Section: Wallet Summary + Wallet Chart ── */}
838→ 838→ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
839→ 839→ {/* Wallet Summary Table */}
840→ 840→ <Card className="card-hover">
841→ 841→ <CardHeader className="p-4 pb-0">
842→ 842→ <CardTitle className="text-sm font-medium">Wallet Summary</CardTitle>
843→ 843→ </CardHeader>
844→ 844→ <CardContent className="p-4">
845→ 845→ {walletSummary.length > 0 ? (
846→ 846→ <div className="overflow-x-auto">
847→ 847→ <table className="w-full text-sm">
848→ 848→ <thead>
849→ 849→ <tr className="border-b">
850→ 850→ <th className="text-left py-2 px-3 font-medium text-muted-foreground">Type</th>
851→ 851→ <th className="text-right py-2 px-3 font-medium text-muted-foreground">Count</th>
852→ 852→ <th className="text-right py-2 px-3 font-medium text-muted-foreground">Balance</th>
853→ 853→ <th className="text-right py-2 px-3 font-medium text-muted-foreground">USD (mock)</th>
854→ 854→ </tr>
855→ 855→ </thead>
856→ 856→ <tbody>
857→ 857→ {walletSummary.map((w) => (
858→ 858→ <tr key={w.walletType} className="border-b last:border-0">
859→ 859→ <td className="py-2 px-3 font-medium">{w.walletType}</td>
860→ 860→ <td className="py-2 px-3 text-right text-muted-foreground">{w.count}</td>
861→ 861→ <td className="py-2 px-3 text-right font-mono text-xs">{formatCrypto(w.totalBalance)}</td>
862→ 862→ <td className="py-2 px-3 text-right">{formatCurrency(w.totalBalanceUsd)}</td>
863→ 863→ </tr>
864→ 864→ ))}
865→ 865→ </tbody>
866→ 866→ </table>
867→ 867→ </div>
868→ 868→ ) : (
869→ 869→ <div className="h-48 flex items-center justify-center text-muted-foreground text-sm">No wallet data</div>
870→ 870→ )}
871→ 871→ </CardContent>
872→ 872→ </Card>
873→ 873→
874→ 874→ {/* Wallet Count by Type Chart */}
875→ 875→ <ChartCard title="Wallet Count by Type" accentColor={CHART_2}>
876→ 876→ {walletChartData.length > 0 ? (
877→ 877→ <ResponsiveContainer width="100%" height="100%">
878→ 878→ <BarChart data={walletChartData}>
879→ 879→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
880→ 880→ <XAxis dataKey="name" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
881→ 881→ <YAxis allowDecimals={false} tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
882→ 882→ <Tooltip
883→ 883→ contentStyle={{
884→ 884→ backgroundColor: "hsl(var(--card))",
885→ 885→ border: "1px solid hsl(var(--border))",
886→ 886→ borderRadius: "8px",
887→ 887→ fontSize: "12px",
888→ 888→ }}
889→ 889→ formatter={(value: number) => [value, "Wallets"]}
890→ 890→ />
891→ 891→ <Bar dataKey="count" fill={CHART_2} radius={[4, 4, 0, 0]} />
892→ 892→ </BarChart>
893→ 893→ </ResponsiveContainer>
894→ 894→ ) : (
895→ 895→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No wallet data</div>
896→ 896→ )}
897→ 897→ </ChartCard>
898→ 898→ </div>
899→ 899→
900→ 900→ {/* ── Activity Feed (full width) ── */}
901→ 901→ <ActivityFeed />
902→ 902→ </div>
903→ 903→ );
904→ 904→}
905→ 905→

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,402 @@
701→ </div>
702→ )}
703→
704→ {/* Tree */}
705→ <div className="flex-1 overflow-y-auto max-h-[calc(100vh-12rem)] p-2">
706→ {countries.length === 0 ? (
707→ <p className="text-sm text-muted-foreground text-center py-8">
708→ No locations yet. Add one above.
709→ </p>
710→ ) : (
711→ <Accordion type="multiple" defaultValue={countries.slice(0, 3)} className="w-full">
712→ {countries.map((country) => {
713→ const countryEntry = groupedTree.get(country)!;
714→ const cities = Array.from(countryEntry.cityMap.keys()).sort();
715→ return (
716→ <AccordionItem key={country} value={country}>
717→ <AccordionTrigger className="text-sm font-semibold py-2 px-2 hover:no-underline">
718→ <span className="flex items-center gap-2">
719→ <MapPin className="h-3.5 w-3.5 text-orange-500" />
720→ {country}
721→ </span>
722→ </AccordionTrigger>
723→ <AccordionContent className="pb-1">
724→ {cities.map((city) => {
725→ const cityEntry = countryEntry.cityMap.get(city)!;
726→ const districts = Array.from(cityEntry.districtMap.keys()).sort();
727→ return (
728→ <div key={city} className="ml-2">
729→ <Accordion type="multiple" className="w-full">
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→ <AccordionItem key={district} value={`${country}-${city}-${district}`}>
736→ <AccordionTrigger className="text-xs py-1.5 px-2 hover:no-underline">
737→ <div
738→ className="flex items-center gap-2 flex-1 min-w-0"
739→ onClick={(e) => {
740→ e.stopPropagation();
741→ handleNodeClick({ type: "location", locationId: location.id });
742→ }}
743→ >
744→ <ChevronRight className="h-3 w-3 shrink-0" />
745→ <span className="truncate">
746→ {city}{isDistrict ? ` > ${district}` : ""}
747→ </span>
748→ <Badge variant={location.isActive === 1 ? "default" : "secondary"} className="text-[10px] px-1.5 py-0 ml-auto mr-1">
749→ {totalCount}
750→ </Badge>
751→ {location.isActive === 0 && (
752→ <Badge variant="outline" className="text-[10px] px-1 py-0 text-muted-foreground">
753→ off
754→ </Badge>
755→ )}
756→ </div>
757→ <div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
758→ <Switch
759→ checked={location.isActive === 1}
760→ onCheckedChange={() => handleToggleActive("location", location.id)}
761→ className="scale-75"
762→ />
763→ <Button
764→ variant="ghost"
765→ size="sm"
766→ className="h-6 w-6 p-0"
767→ onClick={() => {
768→ setRenamingId(`loc-${location.id}`);
769→ setRenameValue(isDistrict ? district : city);
770→ }}
771→ >
772→ <Pencil className="h-3 w-3" />
773→ </Button>
774→ <Button
775→ variant="ghost"
776→ size="sm"
777→ className="h-6 w-6 p-0 text-destructive hover:text-destructive"
778→ onClick={() =>
779→ setDeleteTarget({
780→ type: "location",
781→ id: location.id,
782→ name: `${country} > ${city}${isDistrict ? ` > ${district}` : ""}`,
783→ })
784→ }
785→ >
786→ <Trash2 className="h-3 w-3" />
787→ </Button>
788→ <Button
789→ variant="ghost"
790→ size="sm"
791→ className="h-6 w-6 p-0"
792→ onClick={() => {
793→ setAddMode("category");
794→ setAddParentId(location.id);
795→ setAddInput("");
796→ }}
797→ >
798→ <Plus className="h-3 w-3" />
799→ </Button>
800→ </div>
801→ </AccordionTrigger>
802→
803→ {/* Inline rename for location */}
804→ {renamingId === `loc-${location.id}` && (
805→ <div className="flex items-center gap-2 px-4 py-1">
806→ <Input
807→ ref={renameInputRef}
808→ value={renameValue}
809→ onChange={(e) => 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→ </div>
818→ )}
819→
820→ {/* Add Category Form */}
821→ {addMode === "category" && addParentId === location.id && (
822→ <div className="flex items-center gap-2 px-4 py-1">
823→ <Input
824→ ref={addInputRef}
825→ value={addInput}
826→ onChange={(e) => 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→ <Button size="sm" className="h-7 text-xs" onClick={handleAdd}>Add</Button>
835→ <Button size="sm" variant="ghost" className="h-7 text-xs" onClick={() => setAddMode(null)}>✕</Button>
836→ </div>
837→ )}
838→
839→ <AccordionContent className="pb-0">
840→ {categories.length === 0 ? (
841→ <p className="text-xs text-muted-foreground pl-8 py-1">No categories</p>
842→ ) : (
843→ categories.map((cat) => {
844→ const subs = tree?.subcategories.filter((s) => s.categoryId === cat.id) || [];
845→ return (
846→ <div key={cat.id} className={`ml-3 border-l-2 ${getProductCountColor(cat.productCount)} rounded-r pl-1`}>
847→ <Accordion type="multiple" className="w-full">
848→ <AccordionItem value={`cat-${cat.id}`}>
849→ <AccordionTrigger className="text-xs py-1 px-2 hover:no-underline">
850→ <div
851→ className="flex items-center gap-2 flex-1 min-w-0"
852→ onClick={(e) => {
853→ e.stopPropagation();
854→ handleNodeClick({ type: "category", categoryId: cat.id, locationId: cat.locationId });
855→ }}
856→ >
857→ <FolderOpen className="h-3 w-3 text-amber-500 shrink-0" />
858→ <span className="truncate">{cat.name}</span>
859→ <Badge variant="secondary" className="text-[10px] px-1.5 py-0">
860→ {cat.productCount} {cat.productCount === 1 ? "item" : "items"}
861→ </Badge>
862→ {cat.subcategoryCount > 0 && (
863→ <Badge variant="outline" className="text-[10px] px-1.5 py-0">
864→ {cat.subcategoryCount} sub
865→ </Badge>
866→ )}
867→ {cat.isActive === 0 && (
868→ <Badge variant="outline" className="text-[10px] px-1 py-0 text-muted-foreground">
869→ off
870→ </Badge>
871→ )}
872→ </div>
873→ <div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
874→ <Switch
875→ checked={cat.isActive === 1}
876→ onCheckedChange={() => handleToggleActive("category", cat.id)}
877→ className="scale-75"
878→ />
879→ <Button
880→ variant="ghost"
881→ size="sm"
882→ className="h-6 w-6 p-0"
883→ onClick={() => {
884→ setRenamingId(`cat-${cat.id}`);
885→ setRenameValue(cat.name);
886→ }}
887→ >
888→ <Pencil className="h-3 w-3" />
889→ </Button>
890→ <Button
891→ variant="ghost"
892→ size="sm"
893→ className="h-6 w-6 p-0 text-destructive hover:text-destructive"
894→ onClick={() =>
895→ setDeleteTarget({
896→ type: "category",
897→ id: cat.id,
898→ name: cat.name,
899→ })
900→ }
901→ >
902→ <Trash2 className="h-3 w-3" />
903→ </Button>
904→ <Button
905→ variant="ghost"
906→ size="sm"
907→ className="h-6 w-6 p-0"
908→ onClick={() => {
909→ setAddMode("subcategory");
910→ setAddParentId(cat.id);
911→ setAddInput("");
912→ }}
913→ >
914→ <Plus className="h-3 w-3" />
915→ </Button>
916→ </div>
917→ </AccordionTrigger>
918→
919→ {/* Inline rename for category */}
920→ {renamingId === `cat-${cat.id}` && (
921→ <div className="flex items-center gap-2 px-6 py-1">
922→ <Input
923→ ref={renameInputRef}
924→ value={renameValue}
925→ onChange={(e) => 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→ </div>
934→ )}
935→
936→ {/* Add Subcategory Form */}
937→ {addMode === "subcategory" && addParentId === cat.id && (
938→ <div className="flex items-center gap-2 px-6 py-1">
939→ <Input
940→ ref={addInputRef}
941→ value={addInput}
942→ onChange={(e) => 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→ <Button size="sm" className="h-7 text-xs" onClick={handleAdd}>Add</Button>
951→ <Button size="sm" variant="ghost" className="h-7 text-xs" onClick={() => setAddMode(null)}>✕</Button>
952→ </div>
953→ )}
954→
955→ <AccordionContent className="pb-1">
956→ {subs.length === 0 ? (
957→ <p className="text-xs text-muted-foreground pl-8 py-1">No subcategories</p>
958→ ) : (
959→ subs.map((sub) => (
960→ <div
961→ key={sub.id}
962→ className="flex items-center gap-2 pl-8 py-1 pr-2 group hover:bg-muted/50 rounded-md cursor-pointer"
963→ onClick={() =>
964→ handleNodeClick({
965→ type: "subcategory",
966→ subcategoryId: sub.id,
967→ categoryId: sub.categoryId,
968→ locationId: sub.category.locationId,
969→ })
970→ }
971→ >
972→ <Tag className="h-3 w-3 text-emerald-500 shrink-0" />
973→ {renamingId === `sub-${sub.id}` ? (
974→ <Input
975→ ref={renameInputRef}
976→ value={renameValue}
977→ onChange={(e) => 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→ <span className="text-xs truncate flex-1">{sub.name}</span>
988→ )}
989→ <Badge variant="secondary" className="text-[10px] px-1.5 py-0">
990→ {sub.productCount}
991→ </Badge>
992→ {sub.isActive === 0 && (
993→ <Badge variant="outline" className="text-[10px] px-1 py-0 text-muted-foreground">
994→ off
995→ </Badge>
996→ )}
997→ <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
998→ <Switch
999→ checked={sub.isActive === 1}
1000→ onCheckedChange={() => handleToggleActive("subcategory", sub.id)}
1001→ className="scale-50"
1002→ />
1003→ {renamingId !== `sub-${sub.id}` && (
1004→ <>
1005→ <Button
1006→ variant="ghost"
1007→ size="sm"
1008→ className="h-5 w-5 p-0"
1009→ onClick={() => {
1010→ setRenamingId(`sub-${sub.id}`);
1011→ setRenameValue(sub.name);
1012→ }}
1013→ >
1014→ <Pencil className="h-2.5 w-2.5" />
1015→ </Button>
1016→ <Button
1017→ variant="ghost"
1018→ size="sm"
1019→ className="h-5 w-5 p-0 text-destructive hover:text-destructive"
1020→ onClick={() =>
1021→ setDeleteTarget({
1022→ type: "subcategory",
1023→ id: sub.id,
1024→ name: sub.name,
1025→ })
1026→ }
1027→ >
1028→ <Trash2 className="h-2.5 w-2.5" />
1029→ </Button>
1030→ </>
1031→ )}
1032→ </div>
1033→ </div>
1034→ ))
1035→ )}
1036→ </AccordionContent>
1037→ </AccordionItem>
1038→ </Accordion>
1039→ </div>
1040→ );
1041→ })
1042→ )}
1043→ </AccordionContent>
1044→ </AccordionItem>
1045→ );
1046→ })}
1047→ </Accordion>
1048→ </div>
1049→ );
1050→ })}
1051→ </AccordionContent>
1052→ </AccordionItem>
1053→ );
1054→ })}
1055→ </Accordion>
1056→ )}
1057→ </div>
1058→ </div>
1059→ </ResizablePanel>
1060→
1061→ <ResizableHandle withHandle />
1062→
1063→ {/* ─── Right Panel: Products ─── */}
1064→ <ResizablePanel defaultSize={70}>
1065→ <div className="h-full flex flex-col">
1066→ {/* Summary bar */}
1067→ <div className="px-3 py-2 border-b flex items-center gap-2 text-xs text-muted-foreground">
1068→ <BarChart3 className="h-3.5 w-3.5" />
1069→ <span>
1070→ <span className="font-medium text-foreground">{productTotal}</span> product{productTotal !== 1 ? "s" : ""}
1071→ </span>
1072→ <span className="text-border">·</span>
1073→ <span>
1074→ <span className="font-medium text-foreground">{totalCategories}</span> {totalCategories === 1 ? "category" : "categories"}
1075→ </span>
1076→ <span className="text-border">·</span>
1077→ <span>
1078→ <span className="font-medium text-foreground">{totalSubcategories}</span> {totalSubcategories === 1 ? "subcategory" : "subcategories"}
1079→ </span>
1080→ </div>
1081→
1082→ {/* Toolbar */}
1083→ <div className="p-3 border-b flex items-center gap-2 flex-wrap">
1084→ <div className="relative flex-1 min-w-[200px]">
1085→ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
1086→ <Input
1087→ placeholder="Search products..."
1088→ value={search}
1089→ onChange={(e) => setSearch(e.target.value)}
1090→ className="pl-9 h-9 text-sm"
1091→ />
1092→ </div>
1093→ {filter.type !== "all" && (
1094→ <Button
1095→ variant="outline"
1096→ size="sm"
1097→ className="h-9"
1098→ onClick={() => setFilter({ type: "all" })}
1099→ >
1100→ <X className="h-3.5 w-3.5 mr-1" />
[showing lines 701-1100 of 1483; use offset=1101 with limit to continue reading]

File diff suppressed because it is too large Load Diff

View File

@@ -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→ <div className="absolute bottom-0 left-0 right-0 h-12 opacity-20 pointer-events-none">
173→ <ResponsiveContainer width="100%" height="100%">
174→ <AreaChart data={chartData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
175→ <YAxis domain={["dataMin - 2", "dataMax + 2"]} hide />
176→ <Area type="monotone" dataKey="v" stroke={color} fill={color} strokeWidth={1.5} />
177→ </AreaChart>
178→ </ResponsiveContainer>
179→ </div>
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→ <Card className="card-hover border-l-4 transition-transform hover:scale-[1.02] relative overflow-hidden" style={{ borderLeftColor: color }}>
213→ <div
214→ className="h-[2px] w-full rounded-t-lg"
215→ style={{
216→ background: `linear-gradient(to right, ${color}, ${color}66, transparent)`,
217→ }}
218→ />
219→ <CardContent className="p-4 flex items-center gap-4">
220→ <div
221→ className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg"
222→ style={{ backgroundColor: `${color}15` }}
223→ >
224→ <Icon className="h-6 w-6" style={{ color }} />
225→ </div>
226→ <div className="min-w-0">
227→ <p className="text-sm text-muted-foreground truncate">{title}</p>
228→ <p className="text-xl font-bold truncate tabular-nums">{value}</p>
229→ </div>
230→ </CardContent>
231→ {sparkData && sparklineColor && <MiniSparkline data={sparkData} color={sparklineColor} />}
232→ </Card>
233→ );
234→}
235→
236→// ─── Skeleton Loader ─────────────────────────────────────
237→
238→function DashboardSkeleton() {
239→ return (
240→ <div className="p-4 md:p-6 space-y-6">
241→ <Skeleton className="h-8 w-48" />
242→ <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
243→ {Array.from({ length: 10 }).map((_, i) => (
244→ <Skeleton key={i} className="h-24 rounded-xl" />
245→ ))}
246→ </div>
247→ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
248→ {Array.from({ length: 4 }).map((_, i) => (
249→ <Skeleton key={i} className="h-72 rounded-xl" />
250→ ))}
251→ </div>
252→ </div>
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→ <Card className="card-hover overflow-hidden">
269→ <div
270→ className="h-1 w-full"
271→ style={{
272→ background: `linear-gradient(to right, ${accentColor ?? CHART_1}, ${accentColor ?? CHART_1}44, transparent)`,
273→ }}
274→ />
275→ <CardHeader className="p-4 pb-0">
276→ <CardTitle className="text-sm font-medium">{title}</CardTitle>
277→ </CardHeader>
278→ <CardContent className="p-4 pt-2">
279→ <div className="h-72">{children}</div>
280→ </CardContent>
281→ </Card>
282→ );
283→}
284→
285→// ─── Main Component ──────────────────────────────────────
286→
287→export function DashboardPage() {
288→ const [data, setData] = useState<DashboardData | null>(null);
289→ const [loading, setLoading] = useState(true);
290→ const [error, setError] = useState<string | null>(null);
291→ const [autoRefresh, setAutoRefresh] = useState(false);
292→ const [lastUpdated, setLastUpdated] = useState<number>(Date.now());
293→ const [refreshing, setRefreshing] = useState(false);
294→ const autoRefreshRef = useRef<ReturnType<typeof setInterval> | 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 <DashboardSkeleton />;
339→ if (error) {
340→ return (
341→ <div className="p-6">
342→ <Card className="border-destructive">
343→ <CardContent className="p-6">
344→ <p className="text-destructive font-medium">{error}</p>
345→ </CardContent>
346→ </Card>
347→ </div>
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→ <div className="p-4 md:p-6 space-y-6 page-enter">
397→ {/* ── Page Title ── */}
398→ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
399→ <p className="text-sm text-muted-foreground">Overview of your Telegram Shop</p>
400→ <div className="flex items-center gap-4">
401→ <span className="text-xs text-muted-foreground">
402→ Last updated: {secondsAgo < 1 ? "just now" : `${secondsAgo} seconds ago`}
403→ </span>
404→ <button
405→ type="button"
406→ onClick={fetchDashboard}
407→ disabled={refreshing}
408→ className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors disabled:opacity-50"
409→ aria-label="Refresh dashboard"
410→ >
411→ <RefreshCw className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
412→ </button>
413→ <div className="flex items-center gap-2">
414→ <Switch
415→ id="auto-refresh"
416→ checked={autoRefresh}
417→ onCheckedChange={setAutoRefresh}
418→ />
419→ <label
420→ htmlFor="auto-refresh"
421→ className="text-xs text-muted-foreground cursor-pointer select-none"
422→ >
423→ Auto-refresh
424→ </label>
425→ </div>
426→ </div>
427→ </div>
428→
429→ {/* ── KPI Cards ── */}
430→ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
431→ {kpis.map((kpi) => (
432→ <KpiCard key={kpi.title} {...kpi} icon={kpi.icon} />
433→ ))}
434→ </div>
435→
436→ <Separator />
437→
438→ {/* ── Charts Grid ── */}
439→ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
440→ {/* 1. Revenue 7 days */}
441→ <ChartCard title="Revenue — Last 7 Days" accentColor={CHART_1}>
442→ {revenue7Data.length > 0 ? (
443→ <ResponsiveContainer width="100%" height="100%">
444→ <AreaChart data={revenue7Data}>
445→ <defs>
446→ <linearGradient id="rev7grad" x1="0" y1="0" x2="0" y2="1">
447→ <stop offset="5%" stopColor={CHART_1} stopOpacity={0.3} />
448→ <stop offset="95%" stopColor={CHART_1} stopOpacity={0} />
449→ </linearGradient>
450→ </defs>
451→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
452→ <XAxis dataKey="date" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
453→ <YAxis tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
454→ <Tooltip
455→ contentStyle={{
456→ backgroundColor: "hsl(var(--card))",
457→ border: "1px solid hsl(var(--border))",
458→ borderRadius: "8px",
459→ fontSize: "12px",
460→ }}
461→ />
462→ <Area
463→ type="monotone"
464→ dataKey="revenue"
465→ stroke={CHART_1}
466→ fill="url(#rev7grad)"
467→ strokeWidth={2}
468→ />
469→ </AreaChart>
470→ </ResponsiveContainer>
471→ ) : (
472→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
473→ )}
474→ </ChartCard>
475→
476→ {/* 2. Revenue 30 days */}
477→ <ChartCard title="Revenue — Last 30 Days" accentColor={CHART_2}>
478→ {revenue30Data.length > 0 ? (
479→ <ResponsiveContainer width="100%" height="100%">
480→ <AreaChart data={revenue30Data}>
481→ <defs>
482→ <linearGradient id="rev30grad" x1="0" y1="0" x2="0" y2="1">
483→ <stop offset="5%" stopColor={CHART_2} stopOpacity={0.3} />
484→ <stop offset="95%" stopColor={CHART_2} stopOpacity={0} />
485→ </linearGradient>
486→ </defs>
487→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
488→ <XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="hsl(var(--muted-foreground))" interval={4} />
489→ <YAxis tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
490→ <Tooltip
491→ contentStyle={{
492→ backgroundColor: "hsl(var(--card))",
493→ border: "1px solid hsl(var(--border))",
494→ borderRadius: "8px",
495→ fontSize: "12px",
496→ }}
497→ />
498→ <Area
499→ type="monotone"
500→ dataKey="revenue"
501→ stroke={CHART_2}
502→ fill="url(#rev30grad)"
503→ strokeWidth={2}
504→ />
505→ </AreaChart>
506→ </ResponsiveContainer>
507→ ) : (
508→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
509→ )}
510→ </ChartCard>
511→
512→ {/* 3. New Users 7 days */}
513→ <ChartCard title="New Users — Last 7 Days" accentColor={CHART_3}>
514→ {users7Data.length > 0 ? (
515→ <ResponsiveContainer width="100%" height="100%">
516→ <BarChart data={users7Data}>
517→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
518→ <XAxis dataKey="date" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
519→ <YAxis allowDecimals={false} tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
520→ <Tooltip
521→ contentStyle={{
522→ backgroundColor: "hsl(var(--card))",
523→ border: "1px solid hsl(var(--border))",
524→ borderRadius: "8px",
525→ fontSize: "12px",
526→ }}
527→ />
528→ <Bar dataKey="users" fill={CHART_3} radius={[4, 4, 0, 0]} />
529→ </BarChart>
530→ </ResponsiveContainer>
531→ ) : (
532→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
533→ )}
534→ </ChartCard>
535→
536→ {/* 4. Top 5 Products */}
537→ <ChartCard title="Top 5 Products by Quantity Sold" accentColor={CHART_4}>
538→ {productsData.length > 0 ? (
539→ <ResponsiveContainer width="100%" height="100%">
540→ <BarChart data={productsData} layout="vertical" margin={{ left: 20 }}>
541→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" horizontal={false} />
542→ <XAxis type="number" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
543→ <YAxis type="category" dataKey="name" width={120} tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
544→ <Tooltip
545→ contentStyle={{
546→ backgroundColor: "hsl(var(--card))",
547→ border: "1px solid hsl(var(--border))",
548→ borderRadius: "8px",
549→ fontSize: "12px",
550→ }}
551→ formatter={(value: number, name: string) => {
552→ if (name === "qty") return [value, "Quantity"];
553→ return [formatCurrency(value), "Revenue"];
554→ }}
555→ />
556→ <Bar dataKey="qty" fill={CHART_4} radius={[0, 4, 4, 0]} />
557→ </BarChart>
558→ </ResponsiveContainer>
559→ ) : (
560→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
561→ )}
562→ </ChartCard>
563→
564→ {/* 5. Top 5 Spenders */}
565→ <ChartCard title="Top 5 Spenders" accentColor={CHART_5}>
566→ {spendersData.length > 0 ? (
567→ <ResponsiveContainer width="100%" height="100%">
568→ <BarChart data={spendersData} layout="vertical" margin={{ left: 20 }}>
569→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" horizontal={false} />
570→ <XAxis type="number" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
571→ <YAxis type="category" dataKey="username" width={120} tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
572→ <Tooltip
573→ contentStyle={{
574→ backgroundColor: "hsl(var(--card))",
575→ border: "1px solid hsl(var(--border))",
576→ borderRadius: "8px",
577→ fontSize: "12px",
578→ }}
579→ formatter={(value: number) => [formatCurrency(value), "Spent"]}
580→ />
581→ <Bar dataKey="spent" fill={CHART_5} radius={[0, 4, 4, 0]} />
582→ </BarChart>
583→ </ResponsiveContainer>
584→ ) : (
585→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
586→ )}
587→ </ChartCard>
588→
589→ {/* 6. Revenue by Category (Pie/Donut) */}
590→ <ChartCard title="Revenue by Category" accentColor={CHART_1}>
591→ {revenueByCategory.length > 0 ? (
592→ <ResponsiveContainer width="100%" height="100%">
593→ <PieChart>
594→ <Pie
595→ data={revenueByCategory}
596→ cx="50%"
597→ cy="50%"
598→ innerRadius={50}
599→ outerRadius={90}
600→ paddingAngle={2}
601→ dataKey="value"
602→ nameKey="name"
603→ label={({ name, percent }) =>
604→ `${name} ${(percent * 100).toFixed(0)}%`
605→ }
606→ labelLine={true}
607→ fontSize={11}
608→ >
609→ {revenueByCategory.map((_, index) => (
610→ <Cell
611→ key={`cell-${index}`}
612→ fill={PIE_COLORS[index % PIE_COLORS.length]}
613→ />
614→ ))}
615→ </Pie>
616→ <Tooltip
617→ contentStyle={{
618→ backgroundColor: "hsl(var(--card))",
619→ border: "1px solid hsl(var(--border))",
620→ borderRadius: "8px",
621→ fontSize: "12px",
622→ }}
623→ formatter={(value: number) => [formatCurrency(value), "Revenue"]}
624→ />
625→ <Legend />
626→ </PieChart>
627→ </ResponsiveContainer>
628→ ) : (
629→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
630→ )}
631→ </ChartCard>
632→
633→ {/* 7. Purchase Status Distribution */}
634→ <ChartCard title="Purchase Status Distribution" accentColor="#eab308">
635→ <ResponsiveContainer width="100%" height="100%">
636→ <PieChart>
637→ <Pie
638→ data={[
639→ { name: 'Pending', value: stats.pendingPurchases },
640→ { name: 'Completed', value: stats.completedPurchases },
641→ { name: 'Cancelled', value: stats.cancelledPurchases },
642→ ]}
643→ cx="50%"
644→ cy="50%"
645→ innerRadius={55}
646→ outerRadius={90}
647→ paddingAngle={3}
648→ dataKey="value"
649→ nameKey="name"
650→ label={({ name, percent }) =>
651→ `${name} ${(percent * 100).toFixed(0)}%`
652→ }
653→ labelLine={true}
654→ fontSize={11}
655→ >
656→ <Cell fill="#eab308" />
657→ <Cell fill="#10b981" />
658→ <Cell fill="#ef4444" />
659→ </Pie>
660→ <Tooltip
661→ contentStyle={{
662→ backgroundColor: "hsl(var(--card))",
663→ border: "1px solid hsl(var(--border))",
664→ borderRadius: "8px",
665→ fontSize: "12px",
666→ }}
667→ formatter={(value: number) => [value, "Purchases"]}
668→ />
669→ <Legend />
670→ </PieChart>
671→ </ResponsiveContainer>
672→ </ChartCard>
673→ </div>
674→
675→ {/* ── Analytics Cards: Revenue Trend + User Funnel ── */}
676→ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
677→ {/* Card A: Revenue Trend (30-day area chart) */}
678→ <Card className="card-hover overflow-hidden md:col-span-2">
679→ <div
680→ className="h-1 w-full"
681→ style={{
682→ background: `linear-gradient(to right, ${CHART_2}, ${CHART_2}44, transparent)`,
683→ }}
684→ />
685→ <CardHeader className="p-4 pb-0 flex flex-row items-center gap-2">
686→ <TrendingUp className="h-4 w-4 text-muted-foreground" />
687→ <CardTitle className="text-sm font-medium">Revenue Trend</CardTitle>
688→ </CardHeader>
689→ <CardContent className="p-4 pt-2">
690→ <div className="h-80">
691→ {revenue30Data.length > 0 ? (
692→ <ResponsiveContainer width="100%" height="100%">
693→ <AreaChart data={revenue30Data}>
694→ <defs>
695→ <linearGradient id="revTrendGrad" x1="0" y1="0" x2="0" y2="1">
696→ <stop offset="5%" stopColor={CHART_2} stopOpacity={0.4} />
697→ <stop offset="95%" stopColor={CHART_2} stopOpacity={0} />
698→ </linearGradient>
699→ </defs>
700→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
701→ <XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="hsl(var(--muted-foreground))" interval={4} />
702→ <YAxis tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v: number) => `$${v}`} />
703→ <Tooltip
704→ contentStyle={{
705→ backgroundColor: "hsl(var(--card))",
706→ border: "1px solid hsl(var(--border))",
707→ borderRadius: "8px",
708→ fontSize: "12px",
709→ }}
710→ formatter={(value: number) => [formatCurrency(value), "Revenue"]}
711→ />
712→ <Area
713→ type="monotone"
714→ dataKey="revenue"
715→ stroke={CHART_2}
716→ fill="url(#revTrendGrad)"
717→ strokeWidth={2}
718→ />
719→ </AreaChart>
720→ </ResponsiveContainer>
721→ ) : (
722→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
723→ )}
724→ </div>
725→ </CardContent>
726→ </Card>
727→
728→ {/* Card B: Conversion Funnel (horizontal bar) */}
729→ <Card className="card-hover overflow-hidden md:col-span-1">
730→ <div
731→ className="h-1 w-full"
732→ style={{
733→ background: `linear-gradient(to right, #64748b, #64748b44, transparent)`,
734→ }}
735→ />
736→ <CardHeader className="p-4 pb-0 flex flex-row items-center gap-2">
737→ <Users className="h-4 w-4 text-muted-foreground" />
738→ <CardTitle className="text-sm font-medium">User Funnel</CardTitle>
739→ </CardHeader>
740→ <CardContent className="p-4 pt-2">
741→ <div className="h-80">
742→ <ResponsiveContainer width="100%" height="100%">
743→ <BarChart
744→ data={[
745→ { name: "Total Users", value: stats.totalUsers },
746→ { name: "Users with Purchases", value: stats.totalPurchases },
747→ { name: "Users with Wallets", value: stats.activeWallets },
748→ ]}
749→ layout="vertical"
750→ margin={{ left: 10, right: 20 }}
751→ >
752→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" horizontal={false} />
753→ <XAxis type="number" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
754→ <YAxis
755→ type="category"
756→ dataKey="name"
757→ width={120}
758→ tick={{ fontSize: 11 }}
759→ stroke="hsl(var(--muted-foreground))"
760→ />
761→ <Tooltip
762→ contentStyle={{
763→ backgroundColor: "hsl(var(--card))",
764→ border: "1px solid hsl(var(--border))",
765→ borderRadius: "8px",
766→ fontSize: "12px",
767→ }}
768→ />
769→ <Bar dataKey="value" radius={[0, 4, 4, 0]}>
770→ <Cell fill="#64748b" />
771→ <Cell fill="#10b981" />
772→ <Cell fill="#06b6d4" />
773→ </Bar>
774→ </BarChart>
775→ </ResponsiveContainer>
776→ </div>
777→ </CardContent>
778→ </Card>
779→ </div>
780→
781→ <Separator />
782→
783→ {/* ── Recent Purchases Table ── */}
784→ <Card className="card-hover">
785→ <CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
786→ <CardTitle className="text-sm font-medium">Recent Purchases</CardTitle>
787→ <button
788→ type="button"
789→ onClick={() => { window.location.hash = '#/purchases'; }}
790→ className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
791→ >
792→ View all
793→ <ArrowRight className="h-3 w-3" />
794→ </button>
795→ </CardHeader>
796→ <CardContent className="p-4">
797→ {data.recentPurchases.length > 0 ? (
798→ <div className="overflow-x-auto">
799→ <table className="w-full text-sm">
800→ <thead>
801→ <tr className="border-b">
802→ <th className="text-left py-2 px-2 font-medium text-muted-foreground text-xs">Product</th>
803→ <th className="text-left py-2 px-2 font-medium text-muted-foreground text-xs">User</th>
804→ <th className="text-right py-2 px-2 font-medium text-muted-foreground text-xs">Amount</th>
805→ <th className="text-center py-2 px-2 font-medium text-muted-foreground text-xs">Status</th>
806→ <th className="text-right py-2 px-2 font-medium text-muted-foreground text-xs">Date</th>
807→ </tr>
808→ </thead>
809→ <tbody>
810→ {data.recentPurchases.map((p, i) => {
811→ const badge = statusBadge(p.status);
812→ return (
813→ <tr key={i} className="border-b last:border-0">
814→ <td className="py-2 px-2 text-xs font-medium truncate max-w-[140px]">{p.productName}</td>
815→ <td className="py-2 px-2 text-xs text-muted-foreground truncate max-w-[100px]">{p.username}</td>
816→ <td className="py-2 px-2 text-xs text-right font-mono tabular-nums">{formatCurrency(p.totalPrice)}</td>
817→ <td className="py-2 px-2 text-center">
818→ <span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap ${badge.cls}`}>
819→ {badge.label}
820→ </span>
821→ </td>
822→ <td className="py-2 px-2 text-xs text-right text-muted-foreground whitespace-nowrap">{relativeTime(p.purchaseDate)}</td>
823→ </tr>
824→ );
825→ })}
826→ </tbody>
827→ </table>
828→ </div>
829→ ) : (
830→ <div className="h-24 flex items-center justify-center text-muted-foreground text-sm">No recent purchases</div>
831→ )}
832→ </CardContent>
833→ </Card>
834→
835→ <Separator />
836→
837→ {/* ── Bottom Section: Wallet Summary + Wallet Chart ── */}
838→ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
839→ {/* Wallet Summary Table */}
840→ <Card className="card-hover">
841→ <CardHeader className="p-4 pb-0">
842→ <CardTitle className="text-sm font-medium">Wallet Summary</CardTitle>
843→ </CardHeader>
844→ <CardContent className="p-4">
845→ {walletSummary.length > 0 ? (
846→ <div className="overflow-x-auto">
847→ <table className="w-full text-sm">
848→ <thead>
849→ <tr className="border-b">
850→ <th className="text-left py-2 px-3 font-medium text-muted-foreground">Type</th>
851→ <th className="text-right py-2 px-3 font-medium text-muted-foreground">Count</th>
852→ <th className="text-right py-2 px-3 font-medium text-muted-foreground">Balance</th>
853→ <th className="text-right py-2 px-3 font-medium text-muted-foreground">USD (mock)</th>
854→ </tr>
855→ </thead>
856→ <tbody>
857→ {walletSummary.map((w) => (
858→ <tr key={w.walletType} className="border-b last:border-0">
859→ <td className="py-2 px-3 font-medium">{w.walletType}</td>
860→ <td className="py-2 px-3 text-right text-muted-foreground">{w.count}</td>
861→ <td className="py-2 px-3 text-right font-mono text-xs">{formatCrypto(w.totalBalance)}</td>
862→ <td className="py-2 px-3 text-right">{formatCurrency(w.totalBalanceUsd)}</td>
863→ </tr>
864→ ))}
865→ </tbody>
866→ </table>
867→ </div>
868→ ) : (
869→ <div className="h-48 flex items-center justify-center text-muted-foreground text-sm">No wallet data</div>
870→ )}
871→ </CardContent>
872→ </Card>
873→
874→ {/* Wallet Count by Type Chart */}
875→ <ChartCard title="Wallet Count by Type" accentColor={CHART_2}>
876→ {walletChartData.length > 0 ? (
877→ <ResponsiveContainer width="100%" height="100%">
878→ <BarChart data={walletChartData}>
879→ <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
880→ <XAxis dataKey="name" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
881→ <YAxis allowDecimals={false} tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
882→ <Tooltip
883→ contentStyle={{
884→ backgroundColor: "hsl(var(--card))",
885→ border: "1px solid hsl(var(--border))",
886→ borderRadius: "8px",
887→ fontSize: "12px",
888→ }}
889→ formatter={(value: number) => [value, "Wallets"]}
890→ />
891→ <Bar dataKey="count" fill={CHART_2} radius={[4, 4, 0, 0]} />
892→ </BarChart>
893→ </ResponsiveContainer>
894→ ) : (
895→ <div className="h-full flex items-center justify-center text-muted-foreground text-sm">No wallet data</div>
896→ )}
897→ </ChartCard>
898→ </div>
899→
900→ {/* ── Activity Feed (full width) ── */}
901→ <ActivityFeed />
902→ </div>
903→ );
904→}
905→

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,878 @@
1→"use client";
2→
3→import { useEffect, useState } from "react";
4→import { format } from "date-fns";
5→import { Button } from "@/components/ui/button";
6→import { Input } from "@/components/ui/input";
7→import { Textarea } from "@/components/ui/textarea";
8→import { Badge } from "@/components/ui/badge";
9→import { Skeleton } from "@/components/ui/skeleton";
10→import { Label } from "@/components/ui/label";
11→import { Separator } from "@/components/ui/separator";
12→import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
13→import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
14→import {
15→ Select,
16→ SelectContent,
17→ SelectItem,
18→ SelectTrigger,
19→ SelectValue,
20→} from "@/components/ui/select";
21→import {
22→ Table,
23→ TableBody,
24→ TableCell,
25→ TableHead,
26→ TableHeader,
27→ TableRow,
28→} from "@/components/ui/table";
29→import {
30→ AlertDialog,
31→ AlertDialogAction,
32→ AlertDialogCancel,
33→ AlertDialogContent,
34→ AlertDialogDescription,
35→ AlertDialogFooter,
36→ AlertDialogHeader,
37→ AlertDialogTitle,
38→ AlertDialogTrigger,
39→} from "@/components/ui/alert-dialog";
40→import {
41→ Dialog,
42→ DialogContent,
43→ DialogHeader,
44→ DialogTitle,
45→ DialogDescription,
46→} from "@/components/ui/dialog";
47→import {
48→ ArrowLeft,
49→ Ban,
50→ Wallet,
51→ ShoppingCart,
52→ User,
53→ MapPin,
54→ Calendar,
55→ Globe,
56→ DollarSign,
57→ Activity,
58→ CreditCard,
59→ FileText,
60→ StickyNote,
61→ Save,
62→ Copy,
63→ ExternalLink,
64→} from "lucide-react";
65→import { toast } from "sonner";
66→import { copyToClipboard } from "@/lib/clipboard";
67→
68→interface Purchase {
69→ id: number;
70→ productId: number;
71→ quantity: number;
72→ totalPrice: number;
73→ purchaseDate: string;
74→ status: string;
75→ walletType: string | null;
76→ txHash: string | null;
77→ product: { name: string };
78→}
79→
80→interface Wallet {
81→ id: number;
82→ walletType: string;
83→ address: string;
84→ balance: number;
85→}
86→
87→interface UserDetail {
88→ id: number;
89→ telegramId: string;
90→ username: string | null;
91→ country: string | null;
92→ city: string | null;
93→ district: string | null;
94→ status: number;
95→ totalBalance: number;
96→ bonusBalance: number;
97→ language: string;
98→ languageSet: number;
99→ notes: string | null;
100→ createdAt: string;
101→ _count: { wallets: number; purchases: number };
102→ wallets: Wallet[];
103→ purchases: Purchase[];
104→}
105→
106→interface AuditRow {
107→ id: number;
108→ action: string;
109→ adminId: string;
110→ details: string | null;
111→ createdAt: string;
112→}
113→
114→function PurchaseStatusBadge({ status }: { status: string }) {
115→ if (status === "completed")
116→ return (
117→ <Badge className="bg-emerald-600 hover:bg-emerald-700 text-white">
118→ Completed
119→ </Badge>
120→ );
121→ if (status === "pending")
122→ return (
123→ <Badge className="bg-yellow-500 hover:bg-yellow-600 text-black">
124→ Pending
125→ </Badge>
126→ );
127→ return (
128→ <Badge className="bg-red-600 hover:bg-red-700 text-white">
129→ Cancelled
130→ </Badge>
131→ );
132→}
133→
134→function ActionBadge({ action }: { action: string }) {
135→ const map: Record<string, string> = {
136→ login: "bg-blue-600 hover:bg-blue-700 text-white",
137→ balance_adjust: "bg-orange-500 hover:bg-orange-600 text-white",
138→ status_toggle: "bg-red-600 hover:bg-red-700 text-white",
139→ seed_phrase_viewed: "bg-purple-600 hover:bg-purple-700 text-white",
140→ csv_seed_export: "bg-purple-600 hover:bg-purple-700 text-white",
141→ };
142→ const cls = map[action] || "";
143→ return (
144→ <Badge variant={cls ? "default" : "secondary"} className={cls + " whitespace-nowrap"}>
145→ {action.replace(/_/g, " ")}
146→ </Badge>
147→ );
148→}
149→
150→function walletTypeColor(type: string): string {
151→ const map: Record<string, string> = {
152→ BTC: "bg-orange-500/15 text-orange-400",
153→ LTC: "bg-gray-500/15 text-gray-400",
154→ ETH: "bg-violet-500/15 text-violet-400",
155→ USDT: "bg-emerald-500/15 text-emerald-400",
156→ USDC: "bg-blue-500/15 text-blue-400",
157→ };
158→ return map[type] || "bg-muted text-muted-foreground";
159→}
160→
161→function ProfileSkeleton() {
162→ return (
163→ <div className="page-enter p-4 md:p-6 space-y-6">
164→ <Skeleton className="h-8 w-40" />
165→ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
166→ {[1, 2, 3].map((i) => (
167→ <Skeleton key={i} className="h-24" />
168→ ))}
169→ </div>
170→ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
171→ <Card>
172→ <CardHeader><Skeleton className="h-6 w-32" /></CardHeader>
173→ <CardContent className="space-y-4">
174→ {Array.from({ length: 8 }).map((_, i) => (
175→ <div key={i} className="flex justify-between">
176→ <Skeleton className="h-4 w-24" />
177→ <Skeleton className="h-4 w-36" />
178→ </div>
179→ ))}
180→ </CardContent>
181→ </Card>
182→ <Card>
183→ <CardHeader><Skeleton className="h-6 w-40" /></CardHeader>
184→ <CardContent><Skeleton className="h-64 w-full" /></CardContent>
185→ </Card>
186→ </div>
187→ </div>
188→ );
189→}
190→
191→export function UserDetailPage({ userId }: { userId: string }) {
192→ const [user, setUser] = useState<UserDetail | null>(null);
193→ const [loading, setLoading] = useState(true);
194→ const [error, setError] = useState<string | null>(null);
195→ const [adjustAmount, setAdjustAmount] = useState("");
196→ const [adjustCurrency, setAdjustCurrency] = useState<"total_balance" | "bonus_balance">("total_balance");
197→ const [adjusting, setAdjusting] = useState(false);
198→ const [toggling, setToggling] = useState(false);
199→ const [notes, setNotes] = useState("");
200→ const [savingNotes, setSavingNotes] = useState(false);
201→
202→ // Activity tab state
203→ const [auditLogs, setAuditLogs] = useState<AuditRow[]>([]);
204→ const [auditLoading, setAuditLoading] = useState(false);
205→ const [auditLoaded, setAuditLoaded] = useState(false);
206→ const [selectedPurchase, setSelectedPurchase] = useState<Purchase | null>(null);
207→
208→ const fetchUser = async () => {
209→ setLoading(true);
210→ setError(null);
211→ try {
212→ const res = await fetch(`/api/users/${userId}`);
213→ if (!res.ok) throw new Error("Failed to load user");
214→ const data: UserDetail = await res.json();
215→ setUser(data);
216→ setNotes(data.notes || "");
217→ } catch (err) {
218→ setError(err instanceof Error ? err.message : "Unknown error");
219→ } finally {
220→ setLoading(false);
221→ }
222→ };
223→
224→ const fetchAuditLogs = async () => {
225→ setAuditLoading(true);
226→ try {
227→ const res = await fetch(`/api/audit/bulk?userId=${userId}&limit=50`);
228→ if (!res.ok) throw new Error("Failed to fetch");
229→ const json = await res.json();
230→ setAuditLogs(json.data || []);
231→ setAuditLoaded(true);
232→ } catch {
233→ setAuditLogs([]);
234→ setAuditLoaded(true);
235→ } finally {
236→ setAuditLoading(false);
237→ }
238→ };
239→
240→ useEffect(() => {
241→ fetchUser();
242→ }, [userId]);
243→
244→ const handleAdjustBalance = async (e: React.FormEvent) => {
245→ e.preventDefault();
246→ const amount = parseFloat(adjustAmount);
247→ if (isNaN(amount) || amount === 0) {
248→ toast.error("Enter a valid non-zero amount");
249→ return;
250→ }
251→ setAdjusting(true);
252→ try {
253→ const res = await fetch(`/api/users/${userId}/adjust-balance`, {
254→ method: "POST",
255→ headers: { "Content-Type": "application/json" },
256→ body: JSON.stringify({ amount, currency: adjustCurrency }),
257→ });
258→ if (!res.ok) {
259→ const data = await res.json();
260→ throw new Error(data.error || "Failed to adjust balance");
261→ }
262→ toast.success("Balance adjusted successfully");
263→ setAdjustAmount("");
264→ fetchUser();
265→ } catch (err) {
266→ toast.error(err instanceof Error ? err.message : "Failed to adjust balance");
267→ } finally {
268→ setAdjusting(false);
269→ }
270→ };
271→
272→ const handleToggleStatus = async () => {
273→ setToggling(true);
274→ try {
275→ const res = await fetch(`/api/users/${userId}`, { method: "POST" });
276→ if (!res.ok) {
277→ const data = await res.json();
278→ throw new Error(data.error || "Failed to toggle status");
279→ }
280→ toast.success(user?.status === 0 ? "User blocked" : "User unblocked");
281→ fetchUser();
282→ } catch (err) {
283→ toast.error(err instanceof Error ? err.message : "Failed to toggle status");
284→ } finally {
285→ setToggling(false);
286→ }
287→ };
288→
289→ const handleSaveNotes = async () => {
290→ setSavingNotes(true);
291→ try {
292→ const res = await fetch(`/api/users/${userId}`, {
293→ method: "PATCH",
294→ headers: { "Content-Type": "application/json" },
295→ body: JSON.stringify({ notes }),
296→ });
297→ if (!res.ok) {
298→ const data = await res.json();
299→ throw new Error(data.error || "Failed to save notes");
300→ }
301→ toast.success("Notes saved successfully");
302→ } catch (err) {
303→ toast.error(err instanceof Error ? err.message : "Failed to save notes");
304→ } finally {
305→ setSavingNotes(false);
306→ }
307→ };
308→
309→ if (loading) return <ProfileSkeleton />;
310→ if (error || !user) {
311→ return (
312→ <div className="page-enter p-4 md:p-6">
313→ <Button variant="ghost" className="gap-2" onClick={() => { window.location.hash = "/users"; }}>
314→ <ArrowLeft className="h-4 w-4" />
315→ Back to Users
316→ </Button>
317→ <p className="text-destructive mt-4">{error || "User not found"}</p>
318→ </div>
319→ );
320→ }
321→
322→ const isBlocked = user.status === 2;
323→ const registeredDate = format(new Date(user.createdAt), "MMM d, yyyy HH:mm");
324→
325→ return (
326→ <div className="page-enter p-4 md:p-6 space-y-6">
327→ {/* Header with Back Button */}
328→ <div className="flex items-center gap-3">
329→ <Button
330→ variant="outline"
331→ className="gap-2"
332→ onClick={() => { window.location.hash = "/users"; }}
333→ >
334→ <ArrowLeft className="h-4 w-4" />
335→ Back to Users
336→ </Button>
337→ <Separator orientation="vertical" className="h-6" />
338→ <div>
339→ <h2 className="text-xl font-semibold">
340→ {user.username || `User #${user.id}`}
341→ </h2>
342→ <p className="text-sm text-muted-foreground">ID: {user.id} · Telegram: {user.telegramId}</p>
343→ </div>
344→ <div className="ml-auto">
345→ {isBlocked ? (
346→ <Badge className="bg-red-600 hover:bg-red-700 text-white">Blocked</Badge>
347→ ) : (
348→ <Badge className="bg-emerald-600 hover:bg-emerald-700 text-white">Active</Badge>
349→ )}
350→ </div>
351→ </div>
352→
353→ {/* KPI Summary Cards */}
354→ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
355→ <Card>
356→ <CardContent className="p-4">
357→ <div className="flex items-center justify-between">
358→ <p className="text-sm text-muted-foreground">Total Balance</p>
359→ <DollarSign className="h-4 w-4 text-emerald-500" />
360→ </div>
361→ <p className="text-2xl font-bold mt-1 tabular-nums">
362→ ${(user.totalBalance + user.bonusBalance).toFixed(2)}
363→ </p>
364→ <p className="text-xs text-muted-foreground mt-1">
365→ ${user.totalBalance.toFixed(2)} main + ${user.bonusBalance.toFixed(2)} bonus
366→ </p>
367→ </CardContent>
368→ </Card>
369→ <Card>
370→ <CardContent className="p-4">
371→ <div className="flex items-center justify-between">
372→ <p className="text-sm text-muted-foreground">Purchases</p>
373→ <ShoppingCart className="h-4 w-4 text-orange-500" />
374→ </div>
375→ <p className="text-2xl font-bold mt-1 tabular-nums">
376→ {user._count.purchases}
377→ </p>
378→ <p className="text-xs text-muted-foreground mt-1">
379→ {user.purchases.filter((p) => p.status === "completed").length} completed
380→ </p>
381→ </CardContent>
382→ </Card>
383→ <Card>
384→ <CardContent className="p-4">
385→ <div className="flex items-center justify-between">
386→ <p className="text-sm text-muted-foreground">Wallets</p>
387→ <Wallet className="h-4 w-4 text-violet-500" />
388→ </div>
389→ <p className="text-2xl font-bold mt-1 tabular-nums">
390→ {user._count.wallets}
391→ </p>
392→ <p className="text-xs text-muted-foreground mt-1">
393→ {user.wallets.map((w) => w.walletType).join(", ") || "None"}
394→ </p>
395→ </CardContent>
396→ </Card>
397→ </div>
398→
399→ {/* Profile + Actions Row */}
400→ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
401→ {/* Left column: Profile + Admin Notes */}
402→ <div className="space-y-6">
403→ {/* Profile Card */}
404→ <Card>
405→ <CardHeader>
406→ <CardTitle className="flex items-center gap-2">
407→ <User className="h-5 w-5" />
408→ Profile
409→ </CardTitle>
410→ </CardHeader>
411→ <CardContent className="space-y-3">
412→ <InfoRow label="ID" value={String(user.id)} mono />
413→ <InfoRow label="Telegram ID" value={user.telegramId} mono />
414→ <InfoRow label="Username" value={user.username || "—"} />
415→ <InfoRow label="Status">
416→ <Badge className={isBlocked
417→ ? "bg-red-600 hover:bg-red-700 text-white"
418→ : "bg-emerald-600 hover:bg-emerald-700 text-white"
419→ }>
420→ {isBlocked ? "Blocked" : "Active"}
421→ </Badge>
422→ </InfoRow>
423→ <InfoRow label="Country" value={user.country || "—"} icon={<MapPin className="h-3.5 w-3.5" />} />
424→ <InfoRow label="City" value={user.city || "—"} />
425→ <InfoRow label="District" value={user.district || "—"} />
426→ <InfoRow label="Language" value={user.language} icon={<Globe className="h-3.5 w-3.5" />} />
427→ <InfoRow label="Registered" value={registeredDate} icon={<Calendar className="h-3.5 w-3.5" />} />
428→ <div className="border-t pt-3 mt-3 space-y-3">
429→ <InfoRow label="Main Balance" value={`$${user.totalBalance.toFixed(2)}`} highlight />
430→ <InfoRow label="Bonus Balance" value={`$${user.bonusBalance.toFixed(2)}`} highlight />
431→ </div>
432→ </CardContent>
433→ </Card>
434→
435→ {/* Admin Notes Card */}
436→ <Card>
437→ <CardHeader>
438→ <CardTitle className="flex items-center gap-2">
439→ <StickyNote className="h-5 w-5" />
440→ Admin Notes
441→ </CardTitle>
442→ </CardHeader>
443→ <CardContent className="space-y-3">
444→ <Textarea
445→ placeholder="Add admin notes about this user..."
446→ value={notes}
447→ onChange={(e) => setNotes(e.target.value)}
448→ rows={4}
449→ />
450→ <div className="flex justify-end">
451→ <Button
452→ size="sm"
453→ disabled={savingNotes || notes === (user.notes || "")}
454→ onClick={handleSaveNotes}
455→ >
456→ <Save className="h-4 w-4 mr-1.5" />
457→ {savingNotes ? "Saving..." : "Save"}
458→ </Button>
459→ </div>
460→ </CardContent>
461→ </Card>
462→ </div>
463→
464→ {/* Right column: Actions */}
465→ <div className="lg:col-span-2 space-y-6">
466→ {/* Balance Adjustment */}
467→ <Card>
468→ <CardHeader>
469→ <CardTitle className="flex items-center gap-2">
470→ <Wallet className="h-5 w-5" />
471→ Balance Adjustment
472→ </CardTitle>
473→ </CardHeader>
474→ <CardContent>
475→ <form onSubmit={handleAdjustBalance} className="flex flex-col sm:flex-row gap-3">
476→ <div className="flex-1">
477→ <Label htmlFor="adjust-amount" className="sr-only">Amount</Label>
478→ <Input
479→ id="adjust-amount"
480→ type="number"
481→ step="0.01"
482→ placeholder="Amount (positive or negative)"
483→ value={adjustAmount}
484→ onChange={(e) => setAdjustAmount(e.target.value)}
485→ />
486→ </div>
487→ <div className="w-full sm:w-44">
488→ <Select
489→ value={adjustCurrency}
490→ onValueChange={(v) => setAdjustCurrency(v as "total_balance" | "bonus_balance")}
491→ >
492→ <SelectTrigger id="adjust-currency">
493→ <SelectValue />
494→ </SelectTrigger>
495→ <SelectContent>
496→ <SelectItem value="total_balance">Main Balance</SelectItem>
497→ <SelectItem value="bonus_balance">Bonus Balance</SelectItem>
498→ </SelectContent>
499→ </Select>
500→ </div>
501→ <Button type="submit" disabled={adjusting || !adjustAmount}>
502→ {adjusting ? "Adjusting..." : "Apply"}
503→ </Button>
504→ </form>
505→ </CardContent>
506→ </Card>
507→
508→ {/* Ban/Unban */}
509→ <Card>
510→ <CardHeader>
511→ <CardTitle className="flex items-center gap-2">
512→ <Ban className="h-5 w-5" />
513→ Account Status
514→ </CardTitle>
515→ </CardHeader>
516→ <CardContent>
517→ <AlertDialog>
518→ <AlertDialogTrigger asChild>
519→ <Button
520→ variant={isBlocked ? "default" : "destructive"}
521→ disabled={toggling}
522→ >
523→ {toggling
524→ ? "Processing..."
525→ : isBlocked
526→ ? "Unban User"
527→ : "Ban User"
528→ }
529→ </Button>
530→ </AlertDialogTrigger>
531→ <AlertDialogContent>
532→ <AlertDialogHeader>
533→ <AlertDialogTitle>
534→ {isBlocked ? "Unban this user?" : "Ban this user?"}
535→ </AlertDialogTitle>
536→ <AlertDialogDescription>
537→ {isBlocked
538→ ? `This will restore access for ${user.username || `User #${user.id}`}. They will be able to use the bot again.`
539→ : `This will block ${user.username || `User #${user.id}`} from using the bot. They won't be able to make purchases or access their account.`}
540→ </AlertDialogDescription>
541→ </AlertDialogHeader>
542→ <AlertDialogFooter>
543→ <AlertDialogCancel>Cancel</AlertDialogCancel>
544→ <AlertDialogAction onClick={handleToggleStatus}>
545→ {isBlocked ? "Unban" : "Ban"}
546→ </AlertDialogAction>
547→ </AlertDialogFooter>
548→ </AlertDialogContent>
549→ </AlertDialog>
550→ </CardContent>
551→ </Card>
552→ </div>
553→ </div>
554→
555→ {/* Tabs: Purchases / Wallets / Activity */}
556→ <Tabs defaultValue="purchases" className="w-full" onValueChange={(v) => { if (v === "activity" && !auditLoaded && !auditLoading) fetchAuditLogs(); }}>
557→ <TabsList>
558→ <TabsTrigger value="purchases" className="gap-2">
559→ <ShoppingCart className="h-4 w-4" />
560→ Purchases
561→ </TabsTrigger>
562→ <TabsTrigger value="wallets" className="gap-2">
563→ <CreditCard className="h-4 w-4" />
564→ Wallets
565→ </TabsTrigger>
566→ <TabsTrigger value="activity" className="gap-2">
567→ <Activity className="h-4 w-4" />
568→ Activity
569→ </TabsTrigger>
570→ </TabsList>
571→
572→ {/* Purchases Tab */}
573→ <TabsContent value="purchases" className="mt-4">
574→ <Card>
575→ <CardHeader className="pb-3">
576→ <CardTitle className="text-lg flex items-center gap-2">
577→ <ShoppingCart className="h-5 w-5" />
578→ Purchases
579→ <span className="text-sm font-normal text-muted-foreground">
580→ ({user.purchases.length})
581→ </span>
582→ </CardTitle>
583→ </CardHeader>
584→ <CardContent>
585→ {user.purchases.length === 0 ? (
586→ <div className="p-8 text-center text-muted-foreground">
587→ <ShoppingCart className="h-12 w-12 mx-auto mb-3 opacity-30" />
588→ <p className="text-lg font-medium">No purchases</p>
589→ <p className="text-sm mt-1">This user has not made any purchases</p>
590→ </div>
591→ ) : (
592→ <div className="max-h-96 overflow-y-auto rounded-lg border">
593→ <Table>
594→ <TableHeader>
595→ <TableRow>
596→ <TableHead className="w-16">ID</TableHead>
597→ <TableHead>Product</TableHead>
598→ <TableHead className="w-16 text-center">Qty</TableHead>
599→ <TableHead className="w-28 text-right">Total</TableHead>
600→ <TableHead className="w-28">Status</TableHead>
601→ <TableHead className="w-36">Date</TableHead>
602→ </TableRow>
603→ </TableHeader>
604→ <TableBody>
605→ {user.purchases.map((p) => (
606→ <TableRow
607→ key={p.id}
608→ className="cursor-pointer hover:bg-muted/50 transition-colors"
609→ onClick={() => setSelectedPurchase(p)}
610→ >
611→ <TableCell className="font-mono text-xs">{p.id}</TableCell>
612→ <TableCell className="font-medium">{p.product.name}</TableCell>
613→ <TableCell className="text-center">{p.quantity}</TableCell>
614→ <TableCell className="text-right font-mono">
615→ ${p.totalPrice.toFixed(2)}
616→ </TableCell>
617→ <TableCell>
618→ <PurchaseStatusBadge status={p.status} />
619→ </TableCell>
620→ <TableCell className="text-xs text-muted-foreground">
621→ {format(new Date(p.purchaseDate), "MMM d, yyyy HH:mm")}
622→ </TableCell>
623→ </TableRow>
624→ ))}
625→ </TableBody>
626→ </Table>
627→ </div>
628→ )}
629→ </CardContent>
630→ </Card>
631→ </TabsContent>
632→
633→ {/* Wallets Tab */}
634→ <TabsContent value="wallets" className="mt-4">
635→ <Card>
636→ <CardHeader className="pb-3">
637→ <CardTitle className="text-lg flex items-center gap-2">
638→ <CreditCard className="h-5 w-5" />
639→ Wallets
640→ <span className="text-sm font-normal text-muted-foreground">
641→ ({user.wallets.length})
642→ </span>
643→ </CardTitle>
644→ </CardHeader>
645→ <CardContent>
646→ {user.wallets.length === 0 ? (
647→ <div className="p-8 text-center text-muted-foreground">
648→ <Wallet className="h-12 w-12 mx-auto mb-3 opacity-30" />
649→ <p className="text-lg font-medium">No wallets</p>
650→ <p className="text-sm mt-1">This user does not have any crypto wallets</p>
651→ </div>
652→ ) : (
653→ <div className="max-h-96 overflow-y-auto rounded-lg border">
654→ <Table>
655→ <TableHeader>
656→ <TableRow>
657→ <TableHead>Type</TableHead>
658→ <TableHead>Address</TableHead>
659→ <TableHead className="text-right">Balance</TableHead>
660→ </TableRow>
661→ </TableHeader>
662→ <TableBody>
663→ {user.wallets.map((w) => (
664→ <TableRow key={w.id}>
665→ <TableCell>
666→ <Badge className={walletTypeColor(w.walletType)}>{w.walletType}</Badge>
667→ </TableCell>
668→ <TableCell className="font-mono text-xs max-w-[240px] truncate">
669→ {w.address}
670→ </TableCell>
671→ <TableCell className="text-right font-mono">
672→ {w.balance.toFixed(8)}
673→ </TableCell>
674→ </TableRow>
675→ ))}
676→ </TableBody>
677→ </Table>
678→ </div>
679→ )}
680→ </CardContent>
681→ </Card>
682→ </TabsContent>
683→
684→ {/* Activity Tab */}
685→ <TabsContent value="activity" className="mt-4">
686→ <Card>
687→ <CardHeader className="pb-3">
688→ <CardTitle className="text-lg flex items-center gap-2">
689→ <Activity className="h-5 w-5" />
690→ Activity Log
691→ </CardTitle>
692→ </CardHeader>
693→ <CardContent>
694→ {auditLoading && (
695→ <div className="space-y-3">
696→ {Array.from({ length: 5 }).map((_, i) => (
697→ <div key={i} className="flex items-center gap-4">
698→ <Skeleton className="h-5 w-24" />
699→ <Skeleton className="h-4 w-20" />
700→ <Skeleton className="h-4 flex-1" />
701→ <Skeleton className="h-4 w-32" />
702→ </div>
703→ ))}
704→ </div>
705→ )}
706→ {!auditLoading && auditLoaded && auditLogs.length === 0 && (
707→ <div className="p-8 text-center text-muted-foreground">
708→ <FileText className="h-12 w-12 mx-auto mb-3 opacity-30" />
709→ <p className="text-lg font-medium">No activity</p>
710→ <p className="text-sm mt-1">No audit log entries found for this user</p>
711→ </div>
712→ )}
713→ {!auditLoading && auditLoaded && auditLogs.length > 0 && (
714→ <div className="max-h-96 overflow-y-auto rounded-lg border">
715→ <Table>
716→ <TableHeader>
717→ <TableRow>
718→ <TableHead>Action</TableHead>
719→ <TableHead>Admin</TableHead>
720→ <TableHead>Details</TableHead>
721→ <TableHead className="w-36">Date</TableHead>
722→ </TableRow>
723→ </TableHeader>
724→ <TableBody>
725→ {auditLogs.map((log) => (
726→ <TableRow key={log.id}>
727→ <TableCell>
728→ <ActionBadge action={log.action} />
729→ </TableCell>
730→ <TableCell className="font-mono text-xs">
731→ {log.adminId}
732→ </TableCell>
733→ <TableCell className="text-xs text-muted-foreground max-w-[300px] truncate">
734→ {log.details || "—"}
735→ </TableCell>
736→ <TableCell className="text-xs text-muted-foreground">
737→ {format(new Date(log.createdAt), "MMM d, yyyy HH:mm")}
738→ </TableCell>
739→ </TableRow>
740→ ))}
741→ </TableBody>
742→ </Table>
743→ </div>
744→ )}
745→ {!auditLoading && !auditLoaded && (
746→ <div className="p-8 text-center text-muted-foreground">
747→ <FileText className="h-12 w-12 mx-auto mb-3 opacity-30" />
748→ <p className="text-lg font-medium">Not loaded</p>
749→ <p className="text-sm mt-1">Switch to this tab to load activity data</p>
750→ </div>
751→ )}
752→ </CardContent>
753→ </Card>
754→ </TabsContent>
755→ </Tabs>
756→
757→ {/* Purchase Detail Dialog */}
758→ <Dialog open={!!selectedPurchase} onOpenChange={(open) => { if (!open) setSelectedPurchase(null); }}>
759→ <DialogContent className="max-w-md">
760→ <DialogHeader>
761→ <DialogTitle>Purchase Details</DialogTitle>
762→ <DialogDescription>
763→ Purchase #{selectedPurchase?.id}
764→ </DialogDescription>
765→ </DialogHeader>
766→ {selectedPurchase && (
767→ <div className="space-y-4">
768→ {/* Product name */}
769→ <div>
770→ <p className="text-xs text-muted-foreground mb-1">Product</p>
771→ <p className="text-lg font-semibold">{selectedPurchase.product.name}</p>
772→ </div>
773→
774→ {/* Status + quantity + total in a grid */}
775→ <div className="grid grid-cols-3 gap-3">
776→ <div>
777→ <p className="text-xs text-muted-foreground mb-1">Status</p>
778→ <PurchaseStatusBadge status={selectedPurchase.status} />
779→ </div>
780→ <div>
781→ <p className="text-xs text-muted-foreground mb-1">Quantity</p>
782→ <p className="text-sm font-medium">{selectedPurchase.quantity}</p>
783→ </div>
784→ <div>
785→ <p className="text-xs text-muted-foreground mb-1">Total Price</p>
786→ <p className="text-sm font-medium font-mono">${selectedPurchase.totalPrice.toFixed(2)}</p>
787→ </div>
788→ </div>
789→
790→ {/* Wallet & TX */}
791→ {selectedPurchase.walletType && (
792→ <div>
793→ <p className="text-xs text-muted-foreground mb-1">Wallet Type</p>
794→ <Badge className={walletTypeColor(selectedPurchase.walletType)}>{selectedPurchase.walletType}</Badge>
795→ </div>
796→ )}
797→ {selectedPurchase.txHash && (
798→ <div>
799→ <p className="text-xs text-muted-foreground mb-1">TX Hash</p>
800→ <div className="flex items-center gap-2">
801→ <code className="text-xs font-mono bg-muted px-2 py-1 rounded flex-1 truncate">
802→ {selectedPurchase.txHash}
803→ </code>
804→ <Button
805→ variant="ghost"
806→ size="sm"
807→ className="h-7 w-7 p-0 shrink-0"
808→ onClick={() => {
809→ copyToClipboard(selectedPurchase.txHash!);
810→ toast.success("TX hash copied");
811→ }}
812→ >
813→ <Copy className="h-3.5 w-3.5" />
814→ </Button>
815→ </div>
816→ </div>
817→ )}
818→
819→ {/* Date */}
820→ <div>
821→ <p className="text-xs text-muted-foreground mb-1">Purchase Date</p>
822→ <p className="text-sm">
823→ {format(new Date(selectedPurchase.purchaseDate), "MMMM d, yyyy 'at' HH:mm")}
824→ </p>
825→ </div>
826→
827→ {/* View Product link */}
828→ <div className="pt-2 border-t">
829→ <Button
830→ variant="outline"
831→ size="sm"
832→ className="gap-2"
833→ onClick={() => {
834→ setSelectedPurchase(null);
835→ window.location.hash = "/catalog";
836→ }}
837→ >
838→ <ExternalLink className="h-3.5 w-3.5" />
839→ View Product in Catalog
840→ </Button>
841→ </div>
842→ </div>
843→ )}
844→ </DialogContent>
845→ </Dialog>
846→ </div>
847→ );
848→}
849→
850→function InfoRow({
851→ label,
852→ value,
853→ icon,
854→ mono,
855→ highlight,
856→ children,
857→}: {
858→ label: string;
859→ value?: string;
860→ icon?: React.ReactNode;
861→ mono?: boolean;
862→ highlight?: boolean;
863→ children?: React.ReactNode;
864→}) {
865→ return (
866→ <div className="flex items-center justify-between text-sm">
867→ <span className="text-muted-foreground flex items-center gap-1.5">
868→ {icon}{label}
869→ </span>
870→ {children || (
871→ <span className={mono ? "font-mono text-xs" : ""} >
872→ {highlight ? <span className="font-semibold text-foreground">{value}</span> : value}
873→ </span>
874→ )}
875→ </div>
876→ );
877→}
878→

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,13 @@
# Telegram Shop Admin Panel — Worklog
---
## Current Project Status (2026-08-05 — QA Round 5 / Feature & Analytics Sprint)
## Current Project Status (2026-08-06 — QA Round 6 / Bug Fix & Batch Actions Sprint)
### Assessment
**Code Quality:**
- ✅ ESLint: 0 errors, 0 warnings
- ✅ Server compiles successfully (GET / 200, 8.6s compile)
- ✅ Server compiles successfully (GET / 200, 8.0s compile)
- ✅ All navigation uses `window.location.hash`
- ✅ All 13 page components use named exports, `@/` imports, `sonner` toasts
- ✅ Dark-theme-compatible badge colors (opacity-based)
@@ -16,42 +16,46 @@
- ✅ Sticky footer, keyboard shortcuts (1-9), focus-visible ring, sticky table headers
- ✅ Branded loading screen with progress bar animation
**Files:** 123 source files
**Files:** 128+ source files
- 13 page components (dashboard, catalog, users×2, wallets, purchases, audit, categories, locations, settings, locales, seed)
- 36 API route handlers
- 40 API route handlers (3 new: batch-status×2, product clone)
- 15 shared/layout components + 3 shared utility components
- 3 hooks (use-debounce, use-mobile, use-keyboard-shortcuts)
- 1 store (auth-store), 1 Prisma schema (11 models)
- 1 store (auth-store), 1 Prisma schema (12 models)
- 48 shadcn/ui components
**Bug Fixes This Round (9 bugs fixed):**
1. **[CRITICAL] Settings PUT no-op** → Now actually persists to in-memory SETTINGS object with key validation
2. **[HIGH] Audit userId filter false matches** → Added trailing comma in JSON substring match (`"userId":${id},`) prevents ID=1 matching ID=10
3. **[HIGH] Product DELETE no safety check** → Now checks `purchaseCount` before deleting, returns 400 with descriptive error
4. **[HIGH] CSV export injection** → Added proper `escapeCsv()` function that doubles internal quotes and wraps all fields
5. **[HIGH] Rate limiter memory leak** → Added `setInterval` cleanup every 10 minutes for expired entries
6. **[HIGH] openProductModal missing res.ok** → Now checks `treeRes.ok` and `locRes.ok` before parsing JSON
7. **[MEDIUM] Quick Actions seed visible to all** → Now gated by `role === 'super_admin'`
8. **[MEDIUM] Command palette duplicate nav** → "Clear Data" now navigates to `#/seed?action=clear`
9. **[LOW] "use server" in API route** → Removed from purchases/[id]/route.ts
**New Features This Round:**
1. **Branded loading screen** — TS logo mark, gradient orbs, indeterminate sliding progress bar, "Loading your workspace..."
2. **Purchase detail modal** — Clickable purchase rows in user detail → Dialog with product name, amount, status, TX hash copy, date
3. **Catalog tree color accents** — Categories color-coded by product count (gray=0, emerald=1-5, yellow=6-10, orange=10+)
4. **Catalog stock indicators** — Green/red dots, "In Stock"/"Out of Stock"/"Mono" badges in product table
5. **Catalog summary bar** — "X products · Y categories · Z subcategories" at top of product panel
6. **Global user search** — Command palette searches users by name/Telegram ID when query ≥ 2 chars, links to user detail
7. **Transactions tab in wallets** — New 4th tab: full transaction history with user links, TX hash copy, pagination, CSV/JSON export
8. **Audit copy buttons** — "Copy All" toolbar button + per-row "Copy JSON" button, formatted JSON in `<pre>` blocks
9. **Categories quick view** — Eye icon opens Dialog with all products in that category (name, price, stock, mono)
10. **Dashboard revenue trend** — 30-day AreaChart with cyan gradient fill, TrendingUp icon
11. **Dashboard user funnel** — Horizontal BarChart: Total Users → With Purchases → With Wallets (slate/emerald/cyan)
12. **Enhanced 404 page** — Search icon in muted circle, description, "Go to Dashboard" button
13. **Sidebar logo clickable** — Hover scale-110 transition, navigates to #/
1. **Users batch actions** — Checkbox column, Select All, floating action bar with Ban/Unban, batch API route
2. **Purchases batch actions** — Checkbox column, Select All, floating action bar with Approve/Cancel, batch API route
3. **Dashboard activity feed overhaul** — Uses recentActivity from dashboard API, 15 action-type icons, color-coded badges, relative time, staggered animations
4. **Product clone** — Duplicate button (Copy icon) on each product row, creates copy with "(Copy)" suffix
5. **User detail activity timeline** — Vertical timeline with colored dots, expandable details, relative time, staggered entry
6. **Wallets balance summary** — 3 glass-card mini cards (Total Balance, Total Wallets, Active Wallets %) in Owner Summary tab
**Styling Improvements This Round:**
1. **Empty state radial gradients**`.empty-state` class with `radial-gradient(ellipse at center, var(--muted) 0%, transparent 70%)` applied to 8 empty states
2. **Loading progress bar animation** — Custom `@keyframes loading` with sliding indeterminate bar
3. **Sidebar logo micro-interaction**`transition-transform hover:scale-110 cursor-pointer`
4. **Purchase row hover**`cursor-pointer hover:bg-muted/50 transition-colors` on clickable rows
5. **Category count badges** — Separate product count + subcategory count displayed in tree
6. **Transaction type badges** — Wallet type shown with consistent dark-theme styling
1. **~265 lines of new CSS** — glass-card, kpi-shimmer, count-up, colon-pulse, gradient borders, alternate-rows, table-header-gradient, glow effects, noise texture, page-section-enter
2. **Sidebar polish** — Active nav 2px left-border accent, icon hover transitions, pulsing dot on Purchases, polished footer
3. **Header polish** — Gradient bottom border, backdrop-blur translucent header, pulsing colon in clock
4. **Footer polish** — Gradient top border, hover transitions, improved spacing and text hierarchy
5. **Dashboard KPIs** — Shimmer hover effect, stat-value formatting, chart card icons
6. **Table improvements** — Alternating row colors, first-column left accent, gradient headers on users/purchases
**Completed Modifications & Verification:**
- ✅ ESLint: 0 errors, 0 warnings after all changes
- ✅ Compile: GET / 200 (8.6s compile, 314ms render)
- ✅ 123 source files, 36 API routes
- ✅ All features from Rounds 1-4 preserved and working
- ✅ Compile: GET / 200 (8.0s compile, 321ms render)
- ✅ 128+ source files, 40 API routes
- ✅ All features from Rounds 1-5 preserved and working
### Unresolved Issues / Risks
1. **Sandbox OOM** — Dev server dies after compilation. Not a code bug. Production build recommended.
@@ -63,6 +67,7 @@
7. **No individual admin identity** — Audit log stores role string, not admin ID
8. **Dashboard sparklines use random data** — No real historical data yet
9. **Transactions API** — No date range or type filtering
10. **Client-side sort conflicts with server-side pagination** — Sort indicator shown but only sorts current page
### Priority Recommendations for Next Phase
1. **HIGH**: Add production Dockerfile and docker-compose.yml
@@ -73,8 +78,9 @@
6. **MEDIUM**: Add SSE endpoint for real-time dashboard updates
7. **MEDIUM**: Add server-side pagination to categories, locations APIs
8. **MEDIUM**: Add date/filter params to transactions API
9. **LOW**: Store historical KPI data for real sparklines
10. **LOW**: Add admin users table for multi-admin identity
9. **MEDIUM**: Fix client-side sort to use server-side sorting params
10. **LOW**: Store historical KPI data for real sparklines
11. **LOW**: Add admin users table for multi-admin identity
---
@@ -338,3 +344,143 @@ Stage Summary:
- 2 shared components modified (command-palette, admin-sidebar)
- 1 global CSS enhancement (loading keyframe, empty-state class)
- ESLint: 0 errors, 0 warnings
---
Task ID: 9-a
Agent: Frontend Styling Expert
Task: Comprehensive CSS and layout styling improvements
Work Log:
- **globals.css**: Added ~265 lines of new CSS utilities and animations
- Animated gradient border effect on focused inputs (gradientBorder keyframes with rotating oklch colors)
- `.glass-card` utility class for glassmorphism (backdrop-blur, semi-transparent bg, dark mode variant)
- `.page-section-enter` staggered section reveal animation (6 children, 60ms delay increments)
- `.stat-value` class with tabular-nums, font-variant-numeric, letter-spacing
- `.glow-success`, `.glow-warning`, `.glow-danger` subtle glow effects (different radii for light/dark)
- Improved `::selection` styling with warm orange accent and dark mode variant
- `.bg-noise` SVG noise texture overlay class with light/dark opacity variants
- `.ring-accent` focus ring variant
- `.kpi-shimmer` hover shimmer animation for cards (diagonal gradient sweep)
- `.count-up` number entry animation
- `.colon-pulse` clock colon separator animation
- `.gradient-border-b` / `.gradient-border-t` fade-in gradient borders for header/footer
- `.alternate-rows` table even-row background and first-column left border
- `.table-header-gradient` subtle gradient on thead
- `.sidebar-indicator-dot` pulsing dot animation
- Improved tbody tr hover with box-shadow inset accent
- **admin-sidebar.tsx**: 5 styling improvements
- Active nav item left-border accent (2px, sidebar-primary color) via data-active
- Icon color transition on hover (muted → primary) on all nav items
- Animated pulsing indicator dot on Purchases item when pending count > 0
- Polished footer: ring on avatar, improved spacing (px-3 py-2), mt-0.5 on badge
- Connection indicator: smaller dot (1.5), glow-success class, improved opacity
- **admin-header.tsx**: 3 improvements
- Gradient bottom border (transparent → border → transparent) via gradient-border-b class
- Backdrop-blur-md with bg-background/80 for translucent header
- Pulsing colon separator in RealtimeClock component (colon-pulse animation)
- **admin-footer.tsx**: 4 improvements
- Gradient top border matching header via gradient-border-t
- Hover color transitions on text elements
- Improved spacing (py-3, gap-2) and text sizing (11px for tech stack)
- Semi-transparent text variants for visual hierarchy
- **dashboard-page.tsx**: 4 improvements
- KPI cards: added kpi-shimmer hover effect, stat-value + count-up classes
- ChartCard: added optional icon prop with accent color, renders icon next to title
- All 8 ChartCard usages now pass appropriate icons (TrendingUp, Users, Package, etc.)
- Recent purchases table: alternate-rows + table-header-gradient classes
- **users-page.tsx**: Table improvements
- Added alternate-rows + table-header-gradient classes to Table
- First column (ID) gets subtle left border accent (border-l-2 border-l-primary/10)
- **purchases-page.tsx**: Table improvements
- Added alternate-rows + table-header-gradient classes to Table
- First column (ID) gets subtle left border accent (border-l-2 border-l-primary/10)
Stage Summary:
- 7 files modified (globals.css, admin-sidebar, admin-header, admin-footer, dashboard-page, users-page, purchases-page)
- ~265 lines of new CSS, ~50 lines of TSX changes
- ESLint: 0 errors, 0 warnings
---
Task ID: 9-b
Agent: Feature Implementation Agent
Task: 6 feature additions — batch actions, activity feed, product clone, timeline, wallet summary
Work Log:
- **1. Users Batch Actions** (users-page.tsx + API route)
- Added Checkbox import from shadcn/ui, ShieldBan/ShieldCheck icons from lucide-react, toast from sonner
- Added `selectedIds` (Set<number>) and `batchLoading` state
- Added `toggleSelect()`, `toggleSelectAll()`, `handleBatchStatus()` functions
- Added checkbox column (first column) with "Select All" in header
- Added floating action bar at bottom: selected count, Ban/Unban buttons, Clear button
- Floating bar uses `animate-in slide-in-from-bottom-4 fade-in` with backdrop-blur
- Created `src/app/api/users/batch-status/route.ts` — POST with {userIds, newStatus}, uses updateMany
- **2. Purchases Batch Actions** (purchases-page.tsx + API route)
- Added Checkbox import, CheckCircle2/XCircle2 icons
- Added `selectedIds`, `batchLoading` state and toggle/batch functions
- Added checkbox column with "Select All" in header
- Added floating action bar: selected count, Approve/Cancel buttons, Clear button
- Created `src/app/api/purchases/batch-status/route.ts` — POST with {purchaseIds, status}, only updates pending
- **3. Dashboard Activity Feed Improvements** (activity-feed.tsx + dashboard API)
- Modified `src/app/api/stats/dashboard/route.ts` to return `recentActivity` (last 8 audit logs)
- Changed from fetching `/api/audit/bulk` to using dashboard's `recentActivity` field
- Added per-action icon mapping with 15 action types (login, balance_adjust, user_banned, etc.)
- Added color-coded action badges with outline variant (bg-*/15 text-*/400 border-*/25)
- Improved relative time: "X seconds/minutes/hours/days/months ago" with proper pluralization
- Added staggered enter animation (50ms per item, fade-in + slide-in-from-left-1)
- Increased max height from h-48 to h-64 for more items visible
- **4. Product Clone Feature** (catalog-page.tsx + API route)
- Added Copy icon import from lucide-react
- Added `handleCloneProduct()` function that POSTs to clone API
- Added Duplicate button (Copy icon, orange color) between Edit and Delete buttons on each product row
- Created `src/app/api/products/[id]/clone/route.ts` — POST, copies all fields, appends " (Copy)" to name
- Success toast: "Product cloned as \"{name} (Copy)\""
- **5. User Detail Activity Timeline** (user-detail-page.tsx)
- Replaced table layout with vertical timeline (absolute left border line, colored dots)
- Added `actionDotColor()` helper — maps action types to Tailwind bg-* colors
- Added `relativeTime()` helper for compact time display ("3m ago", "2h ago")
- Added `expandedTimelineId` state for expandable details
- Each entry: colored dot (ring-4 ring-background), ActionBadge, relative time, chevron toggle
- Clicking expands: shows details in `<pre>` block with full date, with animation
- Added `ChevronDown`/`ChevronRight` icons for expand/collapse indicators
- Staggered entry animation (30ms per item)
- **6. Wallets Balance Summary Cards** (wallets-page.tsx + overview API)
- Modified `src/app/api/wallets/overview/route.ts` to include `activeWallets` count
- Added `activeWallets` to OverviewData interface
- Added 3 glass-card mini cards at top of Owner Summary tab:
- Total Balance (DollarSign icon, orange, stat-value formatting)
- Total Wallets (Wallet icon, violet)
- Active Wallets (Wallet icon, emerald, with percentage of total)
- Uses existing `.glass-card` + `.stat-value` CSS classes
Stage Summary:
- 3 new API routes created (users/batch-status, purchases/batch-status, products/[id]/clone)
- 1 API route modified (wallets/overview)
- 6 page components modified (users-page, purchases-page, activity-feed, catalog-page, user-detail-page, wallets-page)
- 1 dashboard API modified (stats/dashboard)
- ESLint: 0 errors, 0 warnings
---
Task ID: 9-c
Agent: Main Coordinator
Task: QA Round 6 — Code review, bug fixes, styling, features
Work Log:
- Performed comprehensive code review QA via Explore subagent — found 24 bugs (2 critical, 5 high, 9 medium, 8 low)
- Fixed 9 bugs directly (Settings PUT no-op, audit userId filter, product DELETE safety, CSV export, rate limiter leak, catalog res.ok, quick actions role, command palette nav, "use server" in API route)
- Launched frontend-styling-expert subagent for comprehensive CSS/layout styling (~265 lines new CSS, 7 files modified)
- Launched full-stack-developer subagent for 6 new features (batch actions×2, activity feed, product clone, timeline, wallet summary)
- Fixed runtime compilation error (XCircle2 not in lucide-react → replaced with Ban)
- Verified: ESLint 0 errors, GET / 200 (8.0s compile)
Stage Summary:
- 9 bug fixes across 8 files
- 6 new features (3 new API routes, 7 modified components)
- Comprehensive styling overhaul (7 files, ~265 lines CSS)
- Total: 128+ source files, 40 API routes
- ESLint: 0 errors, 0 warnings
- Compile: GET / 200 verified