019dc270-b7f3-49ea-b87d-844d8224d8ac
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
48
src/app/api/products/[id]/clone/route.ts
Normal file
48
src/app/api/products/[id]/clone/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
"use server";
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuth } from '@/lib/auth-middleware';
|
||||
|
||||
36
src/app/api/purchases/batch-status/route.ts
Normal file
36
src/app/api/purchases/batch-status/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
33
src/app/api/users/batch-status/route.ts
Normal file
33
src/app/api/users/batch-status/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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(',')
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
905
tool-results/read_1785946621272_d02fae467278.txt
Normal file
905
tool-results/read_1785946621272_d02fae467278.txt
Normal 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→
|
||||
905
tool-results/read_1785946624584_8dff49b52aac.txt
Normal file
905
tool-results/read_1785946624584_8dff49b52aac.txt
Normal 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→
|
||||
1483
tool-results/read_1785946648945_1cd34bae3f38.txt
Normal file
1483
tool-results/read_1785946648945_1cd34bae3f38.txt
Normal file
File diff suppressed because it is too large
Load Diff
402
tool-results/read_1785946663351_eacf8370a346.txt
Normal file
402
tool-results/read_1785946663351_eacf8370a346.txt
Normal 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]
|
||||
1483
tool-results/read_1785947150570_1cd34bae3f38.txt
Normal file
1483
tool-results/read_1785947150570_1cd34bae3f38.txt
Normal file
File diff suppressed because it is too large
Load Diff
905
tool-results/read_1785947353130_d02fae467278.txt
Normal file
905
tool-results/read_1785947353130_d02fae467278.txt
Normal 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→
|
||||
1487
tool-results/read_1785947870364_70c12b74ed7a.txt
Normal file
1487
tool-results/read_1785947870364_70c12b74ed7a.txt
Normal file
File diff suppressed because it is too large
Load Diff
878
tool-results/read_1785947928344_4c14f27ccb4c.txt
Normal file
878
tool-results/read_1785947928344_4c14f27ccb4c.txt
Normal 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→
|
||||
1181
tool-results/read_1785947942990_f22960b673da.txt
Normal file
1181
tool-results/read_1785947942990_f22960b673da.txt
Normal file
File diff suppressed because it is too large
Load Diff
204
worklog.md
204
worklog.md
@@ -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
|
||||
Reference in New Issue
Block a user