865 lines
36 KiB
TypeScript
865 lines
36 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useCallback, type ReactNode } from 'react';
|
|
import {
|
|
ArrowUpRight, ArrowDownRight, ArrowRight, RefreshCw, Calendar, ChevronDown,
|
|
TrendingUp, Filter, Handshake,
|
|
} from 'lucide-react';
|
|
import {
|
|
ComposedChart,
|
|
Bar,
|
|
Area,
|
|
XAxis,
|
|
YAxis,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
CartesianGrid,
|
|
} from 'recharts';
|
|
import {
|
|
adminService,
|
|
DashboardStats,
|
|
SessionInfo,
|
|
DashboardRevenuePoint,
|
|
DashboardMonthlyPoint,
|
|
DashboardRecentOrder,
|
|
DashboardTopProduct,
|
|
} from '@/services/api/adminService';
|
|
import { parseJwt, getAccessToken, apiFetch } from '@/services/api/client';
|
|
import { cn } from '@/lib/utils';
|
|
import Link from 'next/link';
|
|
import { withDashboardMock } from './mockData';
|
|
|
|
const PAGE_DATE_RANGE = 'Live Store & ERP Operations';
|
|
|
|
const CARD =
|
|
'bg-card crm-radius-section border border-border shadow-[0_1px_3px_rgba(16,24,40,0.06)] min-w-0';
|
|
|
|
const PIPELINE = [
|
|
{ key: 'pending', label: 'Pending', color: '#5BA4C7' },
|
|
{ key: 'processing', label: 'Processing', color: '#3B82F6' },
|
|
{ key: 'shipped', label: 'Shipped', color: '#7C3AED' },
|
|
{ key: 'delivered', label: 'Delivered', color: '#01B574' },
|
|
] as const;
|
|
|
|
const OVERVIEW = [
|
|
{ key: 'delivered', label: 'Successful orders', color: '#01B574' },
|
|
{ key: 'pending', label: 'Pending orders', color: '#5BA4C7' },
|
|
{ key: 'cancelled', label: 'Cancelled', color: '#7C3AED' },
|
|
{ key: 'shipped', label: 'In transit', color: '#3B82F6' },
|
|
] as const;
|
|
|
|
const DONUT_COLORS: Record<string, string> = {
|
|
delivered: '#01B574',
|
|
shipped: '#3B82F6',
|
|
processing: '#5BA4C7',
|
|
pending: '#7C3AED',
|
|
};
|
|
|
|
const PAYMENT_TAG: Record<string, string> = {
|
|
paid: 'border-[#01B574] text-[#01B574] bg-[#01B574]/8',
|
|
unpaid: 'border-[#5BA4C7] text-[#5BA4C7] bg-[#5BA4C7]/10',
|
|
refunded: 'border-primary text-primary bg-primary/8',
|
|
};
|
|
|
|
const PRODUCT_TONES = ['#5BA4C7', '#3B82F6', '#01B574', '#1B2559', '#7C3AED'];
|
|
|
|
function formatCompact(val: number) {
|
|
if (val >= 1_000_000) return `₹${(val / 1_000_000).toFixed(2)}M`;
|
|
if (val >= 1_000) return `₹${(val / 1_000).toFixed(val >= 10_000 ? 0 : 1)}K`;
|
|
return `₹${val.toFixed(0)}`;
|
|
}
|
|
|
|
function formatInr(val: number) {
|
|
return new Intl.NumberFormat('en-IN', {
|
|
style: 'currency',
|
|
currency: 'INR',
|
|
maximumFractionDigits: 0,
|
|
}).format(val);
|
|
}
|
|
|
|
function initials(name: string) {
|
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
return ((parts[0]?.[0] ?? '') + (parts[1]?.[0] ?? '')).toUpperCase() || '•';
|
|
}
|
|
|
|
function samplePoints<T>(items: T[], count: number) {
|
|
if (items.length <= count) return items;
|
|
const step = (items.length - 1) / (count - 1);
|
|
return Array.from({ length: count }, (_, i) => items[Math.round(i * step)]);
|
|
}
|
|
|
|
function deltaFromWeek(week: number, month: number) {
|
|
if (!month) return 2.5;
|
|
const actual = week / month;
|
|
const expected = 7 / 30;
|
|
return ((actual - expected) / expected) * 100;
|
|
}
|
|
|
|
function CardTitle({ children }: { children: ReactNode }) {
|
|
return (
|
|
<h3 className="flex items-center gap-2 text-[15px] font-bold text-foreground min-w-0">
|
|
<span className="w-[3px] h-4 rounded-full bg-warning shrink-0" />
|
|
<span className="truncate">{children}</span>
|
|
</h3>
|
|
);
|
|
}
|
|
|
|
function PeriodPills({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: 'weekly' | 'monthly' | 'yearly';
|
|
onChange: (next: 'weekly' | 'monthly' | 'yearly') => void;
|
|
}) {
|
|
return (
|
|
<div className="inline-flex items-center gap-1 shrink-0">
|
|
{(['weekly', 'monthly', 'yearly'] as const).map((mode) => (
|
|
<button
|
|
key={mode}
|
|
type="button"
|
|
onClick={() => onChange(mode)}
|
|
className={cn(
|
|
'h-7 px-3 crm-radius-control text-[12px] font-medium capitalize cursor-pointer transition-colors',
|
|
value === mode
|
|
? 'bg-primary text-white'
|
|
: 'bg-card text-muted-foreground hover:bg-muted'
|
|
)}
|
|
>
|
|
{mode}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GhostSelect({ label }: { label: string }) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 h-7 px-2.5 crm-radius-control border border-border text-[12px] text-muted-foreground shrink-0">
|
|
{label}
|
|
<ChevronDown className="w-3.5 h-3.5" />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function DeltaPill({ value }: { value: number }) {
|
|
const up = value >= 0;
|
|
return (
|
|
<span
|
|
className={cn(
|
|
'inline-flex items-center gap-0.5 h-[22px] px-1.5 crm-radius-badge text-[11px] font-semibold',
|
|
up ? 'bg-success/10 text-success' : 'bg-primary/10 text-primary'
|
|
)}
|
|
>
|
|
{up ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}
|
|
{up ? '+' : ''}
|
|
{Math.abs(value).toFixed(1)}%
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function StatusDonut({
|
|
slices,
|
|
}: {
|
|
slices: Array<{ label: string; value: number; color: string }>;
|
|
}) {
|
|
const total = slices.reduce((sum, slice) => sum + slice.value, 0) || 1;
|
|
const cx = 90;
|
|
const cy = 90;
|
|
const r = 58;
|
|
const stroke = 26;
|
|
const circ = 2 * Math.PI * r;
|
|
let offset = 0;
|
|
|
|
return (
|
|
<svg viewBox="0 0 180 180" className="w-[168px] h-[168px]">
|
|
<circle cx={cx} cy={cy} r={r} fill="none" stroke="var(--muted)" strokeWidth={stroke} />
|
|
{slices.map((slice) => {
|
|
const pct = slice.value / total;
|
|
const dash = Math.max(pct * circ, 0);
|
|
const currentOffset = offset;
|
|
offset += pct;
|
|
const mid = (currentOffset + pct / 2) * 2 * Math.PI - Math.PI / 2;
|
|
const lx = cx + Math.cos(mid) * r;
|
|
const ly = cy + Math.sin(mid) * r;
|
|
return (
|
|
<g key={slice.label}>
|
|
<circle
|
|
cx={cx}
|
|
cy={cy}
|
|
r={r}
|
|
fill="none"
|
|
stroke={slice.color}
|
|
strokeWidth={stroke}
|
|
strokeDasharray={`${dash} ${circ - dash}`}
|
|
strokeDashoffset={-currentOffset * circ}
|
|
transform={`rotate(-90 ${cx} ${cy})`}
|
|
/>
|
|
{pct >= 0.08 && (
|
|
<text
|
|
x={lx}
|
|
y={ly}
|
|
textAnchor="middle"
|
|
dominantBaseline="middle"
|
|
fill="#fff"
|
|
style={{ fontSize: 11, fontWeight: 700 }}
|
|
>
|
|
{Math.round(pct * 100)}%
|
|
</text>
|
|
)}
|
|
</g>
|
|
);
|
|
})}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export default function DashboardPage() {
|
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [userName, setUserName] = useState('Admin');
|
|
const [chartMode, setChartMode] = useState<'weekly' | 'monthly' | 'yearly'>('weekly');
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const token = getAccessToken();
|
|
if (token) {
|
|
const payload = parseJwt(token);
|
|
if (payload) {
|
|
setUserName((payload.email as string)?.split('@')[0] || 'Admin');
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const fetchData = useCallback(async (silent = false) => {
|
|
if (!silent) setLoading(true);
|
|
else setRefreshing(true);
|
|
try {
|
|
const [statsRes, ordersRes, productsRes, categoriesRes, brandsRes, sessionsRes] = await Promise.allSettled([
|
|
adminService.getDashboardStats().catch(() => null),
|
|
apiFetch<any[]>('/api/v1/admin/orders').catch(() => []),
|
|
apiFetch<any>('/api/v1/catalog/products/all?limit=200').catch(() => []),
|
|
apiFetch<any[]>('/api/v1/catalog/categories/all').catch(() => []),
|
|
apiFetch<any[]>('/api/v1/catalog/brands/all').catch(() => []),
|
|
adminService.listSessions(0, 50).catch(() => ({ sessions: [] })),
|
|
]);
|
|
|
|
const liveStats = statsRes.status === 'fulfilled' ? statsRes.value : null;
|
|
const liveOrders = ordersRes.status === 'fulfilled' && Array.isArray(ordersRes.value) ? ordersRes.value : [];
|
|
const liveProductsRaw = productsRes.status === 'fulfilled' ? productsRes.value : [];
|
|
const liveProducts = Array.isArray(liveProductsRaw) ? liveProductsRaw : (liveProductsRaw?.products || []);
|
|
const liveCategories = categoriesRes.status === 'fulfilled' && Array.isArray(categoriesRes.value) ? categoriesRes.value : [];
|
|
const liveBrands = brandsRes.status === 'fulfilled' && Array.isArray(brandsRes.value) ? brandsRes.value : [];
|
|
const liveSessions = sessionsRes.status === 'fulfilled' && sessionsRes.value?.sessions ? sessionsRes.value.sessions : [];
|
|
|
|
let mergedStats: DashboardStats;
|
|
|
|
if (liveStats) {
|
|
mergedStats = liveStats;
|
|
} else if (liveOrders.length > 0 || liveProducts.length > 0) {
|
|
const ordersByStatus: Record<string, number> = {
|
|
pending: 0,
|
|
confirmed: 0,
|
|
processing: 0,
|
|
shipped: 0,
|
|
delivered: 0,
|
|
cancelled: 0,
|
|
returned: 0,
|
|
};
|
|
|
|
let totalRevenue = 0;
|
|
const recentOrders: DashboardRecentOrder[] = [];
|
|
|
|
liveOrders.forEach((o: any) => {
|
|
const statusKey = (o.status || 'pending').toLowerCase();
|
|
ordersByStatus[statusKey] = (ordersByStatus[statusKey] || 0) + 1;
|
|
|
|
if (['DELIVERED', 'COMPLETED', 'CONFIRMED', 'PROCESSING', 'SHIPPED', 'PAYMENT_CAPTURED', 'PAID'].includes((o.status || '').toUpperCase()) || (o.payment_status || '').toUpperCase() === 'PAID') {
|
|
totalRevenue += Number(o.final_amount || 0);
|
|
}
|
|
|
|
if (recentOrders.length < 10) {
|
|
recentOrders.push({
|
|
order_no: o.order_no || 'ORD',
|
|
amount: Number(o.final_amount || 0),
|
|
status: (o.status || 'pending').toLowerCase(),
|
|
payment_status: (o.payment_status || 'paid').toLowerCase(),
|
|
created_at: o.created_at || null,
|
|
customer_name: o.customer_name || o.customer_email || 'Customer',
|
|
customer_email: o.customer_email || null,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Daily chart points from live orders
|
|
const dailyMap = new Map<string, { revenue: number; orders: number }>();
|
|
liveOrders.forEach((o: any) => {
|
|
if (!o.created_at) return;
|
|
const d = new Date(o.created_at);
|
|
const dayStr = `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
const existing = dailyMap.get(dayStr) || { revenue: 0, orders: 0 };
|
|
dailyMap.set(dayStr, {
|
|
revenue: existing.revenue + Number(o.final_amount || 0),
|
|
orders: existing.orders + 1,
|
|
});
|
|
});
|
|
|
|
const dynamicDaily = Array.from(dailyMap.entries()).map(([date, val]) => ({
|
|
date,
|
|
revenue: val.revenue,
|
|
orders: val.orders,
|
|
}));
|
|
|
|
const dynamicTopProducts: DashboardTopProduct[] = liveProducts.slice(0, 5).map((p: any) => ({
|
|
name: p.name || 'Product',
|
|
total_sold: p.variants?.length || 0,
|
|
total_revenue: p.variants?.reduce((s: number, v: any) => s + Number(v.price || 0), 0) || 0,
|
|
}));
|
|
|
|
mergedStats = {
|
|
total_users: liveOrders.length,
|
|
total_orders: liveOrders.length,
|
|
total_products: liveProducts.length,
|
|
total_brands: liveBrands.length,
|
|
total_device_models: 0,
|
|
total_device_series: 0,
|
|
total_categories: liveCategories.length,
|
|
revenue_all_time: totalRevenue,
|
|
revenue_last_30_days: totalRevenue,
|
|
revenue_last_7_days: totalRevenue,
|
|
orders_by_status: ordersByStatus,
|
|
revenue_chart_daily: dynamicDaily,
|
|
revenue_chart_monthly: [],
|
|
top_products: dynamicTopProducts,
|
|
recent_orders: recentOrders,
|
|
low_stock_variants: 0,
|
|
out_of_stock_variants: 0,
|
|
total_stock_units: liveProducts.reduce((acc: number, p: any) => acc + (p.variants?.length || 0), 0),
|
|
};
|
|
} else {
|
|
mergedStats = {
|
|
total_users: 0,
|
|
total_orders: 0,
|
|
total_products: 0,
|
|
total_brands: 0,
|
|
total_device_models: 0,
|
|
total_device_series: 0,
|
|
total_categories: 0,
|
|
revenue_all_time: 0,
|
|
revenue_last_30_days: 0,
|
|
revenue_last_7_days: 0,
|
|
orders_by_status: {},
|
|
revenue_chart_daily: [],
|
|
revenue_chart_monthly: [],
|
|
top_products: [],
|
|
recent_orders: [],
|
|
low_stock_variants: 0,
|
|
out_of_stock_variants: 0,
|
|
total_stock_units: 0,
|
|
};
|
|
}
|
|
|
|
setStats(mergedStats);
|
|
setSessions(liveSessions);
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
const { stats: displayStats } = withDashboardMock(stats, sessions);
|
|
const byStatus = displayStats.orders_by_status ?? {};
|
|
const countOf = (key: string) => byStatus[key] ?? 0;
|
|
const totalOrders = displayStats.total_orders ?? 0;
|
|
const delivered = countOf('delivered');
|
|
const conversion = totalOrders > 0 ? (delivered / totalOrders) * 100 : 0;
|
|
const revenueDelta = deltaFromWeek(
|
|
displayStats.revenue_last_7_days ?? 0,
|
|
displayStats.revenue_last_30_days ?? 0
|
|
);
|
|
const ordersDelta = conversion >= 40 ? 2.5 : -4.2;
|
|
const conversionDelta = 15.5;
|
|
const customersDelta = 2.5;
|
|
|
|
const daily: DashboardRevenuePoint[] = displayStats.revenue_chart_daily ?? [];
|
|
const monthly: DashboardMonthlyPoint[] = displayStats.revenue_chart_monthly ?? [];
|
|
const sourceChart: Array<DashboardRevenuePoint | DashboardMonthlyPoint> =
|
|
chartMode === 'weekly' ? samplePoints(daily, 12) : monthly;
|
|
|
|
const rawPoints = sourceChart.map((row) => ({
|
|
name:
|
|
'date' in row
|
|
? row.date?.replace('08-', '') ?? ''
|
|
: row.label?.slice(0, 3) ?? '',
|
|
revenue: row.revenue,
|
|
orders: row.orders,
|
|
}));
|
|
const maxRev = Math.max(...rawPoints.map((row) => row.revenue), 1);
|
|
const maxOrd = Math.max(...rawPoints.map((row) => row.orders), 1);
|
|
const salesScale = (maxRev * 0.72) / maxOrd;
|
|
const chartPoints = rawPoints.map((row) => ({
|
|
...row,
|
|
sales: row.orders * salesScale,
|
|
}));
|
|
|
|
const pipelineRows = PIPELINE.map((stage) => ({
|
|
...stage,
|
|
count: countOf(stage.key),
|
|
value: countOf(stage.key) * (displayStats.revenue_all_time / Math.max(totalOrders, 1)),
|
|
}));
|
|
const maxPipeline = Math.max(...pipelineRows.map((row) => row.count), 1);
|
|
|
|
const overviewRows = OVERVIEW.map((row) => ({
|
|
...row,
|
|
count: countOf(row.key),
|
|
}));
|
|
const overviewTotal = overviewRows.reduce((sum, row) => sum + row.count, 0) || 1;
|
|
|
|
const donutSlices = [
|
|
{ label: 'Delivered', value: countOf('delivered'), color: DONUT_COLORS.delivered },
|
|
{ label: 'Shipped', value: countOf('shipped'), color: DONUT_COLORS.shipped },
|
|
{ label: 'Processing', value: countOf('processing') + countOf('confirmed'), color: DONUT_COLORS.processing },
|
|
{ label: 'Pending', value: countOf('pending') + countOf('cancelled') + countOf('returned'), color: DONUT_COLORS.pending },
|
|
].filter((slice) => slice.value > 0);
|
|
|
|
const spark = daily.slice(-14).map((row) => row.orders);
|
|
const sparkMax = Math.max(...spark, 1);
|
|
const customerAvatars = (displayStats.recent_orders ?? []).slice(0, 4);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex flex-col gap-4 animate-pulse">
|
|
<div className="h-8 w-48 bg-muted crm-radius-section" />
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
<div className="lg:col-span-2 h-72 bg-muted crm-radius-section" />
|
|
<div className="h-72 bg-muted crm-radius-section" />
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
|
{[...Array(4)].map((_, i) => (
|
|
<div key={i} className="h-28 bg-muted crm-radius-section" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
<div>
|
|
<h1 className="text-lg font-semibold text-foreground">Dashboard</h1>
|
|
<p className="text-[13px] text-muted-foreground mt-0.5">
|
|
Welcome back, <span className="capitalize font-medium text-foreground">{userName}</span>
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-[13px] flex-wrap">
|
|
<div className="flex items-center gap-1.5 h-9 px-3 bg-card border border-border crm-radius-control text-muted-foreground">
|
|
<Calendar className="w-4 h-4" />
|
|
<span>{PAGE_DATE_RANGE}</span>
|
|
<ChevronDown className="w-3.5 h-3.5" />
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => fetchData(true)}
|
|
className={cn(
|
|
'w-9 h-9 bg-card border border-border crm-radius-control text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center',
|
|
refreshing && 'animate-spin'
|
|
)}
|
|
aria-label="Refresh dashboard"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
<div className={cn(CARD, 'lg:col-span-2 p-5 flex flex-col')}>
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<CardTitle>Revenue Analytics</CardTitle>
|
|
<PeriodPills value={chartMode} onChange={setChartMode} />
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap items-end justify-between gap-3">
|
|
<div className="flex flex-wrap items-baseline gap-2">
|
|
<p className="text-[28px] font-bold text-foreground tracking-tight leading-none">
|
|
{formatCompact(displayStats.revenue_last_30_days).replace('₹', '')}
|
|
</p>
|
|
<p className="text-[13px] text-muted-foreground">Revenue with orders (INR)</p>
|
|
</div>
|
|
<div className="flex items-center gap-4 text-[12px] text-muted-foreground">
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<span className="w-2 h-2 rounded-full bg-primary" /> Revenue
|
|
</span>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<span className="w-2 h-2 rounded-full bg-[#d9dee8]" /> Orders
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="h-[240px] w-full mt-3">
|
|
{chartPoints.length === 0 ? (
|
|
<div className="h-full flex items-center justify-center text-[13px] text-muted-foreground">
|
|
No order revenue data yet
|
|
</div>
|
|
) : (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<ComposedChart data={chartPoints} barCategoryGap="18%" margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
|
<CartesianGrid vertical stroke="var(--border)" strokeDasharray="4 4" horizontal={false} />
|
|
<XAxis
|
|
dataKey="name"
|
|
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
|
|
axisLine={false}
|
|
tickLine={false}
|
|
interval={0}
|
|
/>
|
|
<YAxis
|
|
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
|
|
axisLine={false}
|
|
tickLine={false}
|
|
width={42}
|
|
tickFormatter={(v: number) => (v >= 1000 ? `₹${(v / 1000).toFixed(0)}k` : `₹${v}`)}
|
|
/>
|
|
<Tooltip
|
|
content={({ active, payload, label }) => {
|
|
if (!active || !payload?.length) return null;
|
|
const row = payload[0]?.payload as { revenue: number; orders: number };
|
|
return (
|
|
<div className="bg-card border border-border crm-radius-control px-2.5 py-1.5 shadow-sm text-xs">
|
|
<p className="text-muted-foreground mb-0.5">{label}</p>
|
|
<p className="font-semibold text-foreground">{formatInr(row.revenue)}</p>
|
|
<p className="text-muted-foreground">{row.orders} orders</p>
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
<Area type="monotone" dataKey="sales" fill="#EEF0F4" stroke="none" />
|
|
<Bar dataKey="revenue" fill="#5BA4C7" radius={[2, 2, 0, 0]} />
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className={cn(CARD, 'p-5 flex flex-col')}>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<CardTitle>Order sources</CardTitle>
|
|
<Link
|
|
href="/orders"
|
|
className="w-8 h-8 crm-radius-control border border-border text-muted-foreground hover:bg-muted flex items-center justify-center"
|
|
aria-label="View orders"
|
|
>
|
|
<ArrowRight className="w-3.5 h-3.5" />
|
|
</Link>
|
|
</div>
|
|
<div className="flex-1 flex flex-col items-center justify-center py-3">
|
|
{donutSlices.length === 0 ? (
|
|
<p className="text-[13px] text-muted-foreground">No orders yet</p>
|
|
) : (
|
|
<StatusDonut slices={donutSlices} />
|
|
)}
|
|
</div>
|
|
<div className="divide-y divide-border">
|
|
{donutSlices.map((slice) => (
|
|
<div key={slice.label} className="flex items-center justify-between py-2.5">
|
|
<span className="inline-flex items-center gap-2 text-[13px] text-foreground">
|
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: slice.color }} />
|
|
{slice.label}
|
|
</span>
|
|
<span className="text-[13px] font-bold text-foreground">{slice.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
|
<div className={cn(CARD, 'p-4')}>
|
|
<div className="flex items-start justify-between">
|
|
<p className="text-[13px] text-muted-foreground">Revenue</p>
|
|
<span className="w-10 h-10 rounded-full bg-primary text-white flex items-center justify-center">
|
|
<TrendingUp className="w-4 h-4" />
|
|
</span>
|
|
</div>
|
|
<p className="text-[22px] font-bold text-foreground mt-2 tracking-tight">
|
|
{formatInr(displayStats.revenue_all_time)}
|
|
</p>
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<DeltaPill value={revenueDelta} />
|
|
<span className="text-[12px] text-muted-foreground">From last week</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={cn(CARD, 'p-4')}>
|
|
<div className="flex items-start justify-between">
|
|
<p className="text-[13px] text-muted-foreground">Active orders</p>
|
|
<span className="w-10 h-10 rounded-full bg-[#3B82F6] text-white flex items-center justify-center">
|
|
<Handshake className="w-4 h-4" />
|
|
</span>
|
|
</div>
|
|
<p className="text-[22px] font-bold text-foreground mt-2 tracking-tight">{totalOrders}</p>
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<DeltaPill value={ordersDelta} />
|
|
<span className="text-[12px] text-muted-foreground">From last week</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={cn(CARD, 'p-4')}>
|
|
<div className="flex items-start justify-between">
|
|
<p className="text-[13px] text-muted-foreground">Fulfillment rate</p>
|
|
<span className="w-10 h-10 rounded-full bg-[#C026D3] text-white flex items-center justify-center">
|
|
<Filter className="w-4 h-4" />
|
|
</span>
|
|
</div>
|
|
<p className="text-[22px] font-bold text-foreground mt-2 tracking-tight">
|
|
{conversion.toFixed(1)}%
|
|
</p>
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<DeltaPill value={conversionDelta} />
|
|
<span className="text-[12px] text-muted-foreground">Delivered vs total</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={cn(CARD, 'p-4')}>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="text-[13px] text-muted-foreground">Total customers</p>
|
|
<p className="text-[22px] font-bold text-foreground mt-2 tracking-tight">
|
|
{displayStats.total_users}
|
|
</p>
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<DeltaPill value={customersDelta} />
|
|
</div>
|
|
<div className="mt-3 flex items-center">
|
|
{customerAvatars.map((order, index) => (
|
|
<span
|
|
key={order.order_no}
|
|
className="w-7 h-7 rounded-full bg-primary text-white text-[10px] font-bold flex items-center justify-center border-2 border-card"
|
|
style={{ marginLeft: index === 0 ? 0 : -8 }}
|
|
>
|
|
{initials(order.customer_name)}
|
|
</span>
|
|
))}
|
|
<span className="ml-2 text-[11px] font-semibold text-muted-foreground">
|
|
+{Math.max(displayStats.total_users - customerAvatars.length, 0)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-end gap-[3px] h-12 shrink-0 pt-1">
|
|
{spark.map((value, index) => (
|
|
<span
|
|
key={index}
|
|
className="w-[5px] rounded-full bg-primary/70"
|
|
style={{ height: `${Math.max(18, (value / sparkMax) * 100)}%` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
|
<div className={cn(CARD, 'p-5 flex flex-col')}>
|
|
<div className="flex items-center justify-between gap-2 mb-4">
|
|
<CardTitle>Top products</CardTitle>
|
|
<GhostSelect label="Last 30 Days" />
|
|
</div>
|
|
<div className="flex-1 space-y-1">
|
|
{(displayStats.top_products ?? []).length === 0 ? (
|
|
<p className="text-[13px] text-muted-foreground text-center py-8">No sales data yet</p>
|
|
) : (
|
|
(displayStats.top_products ?? []).slice(0, 5).map((product, index) => (
|
|
<div key={product.name} className="flex items-center gap-3 py-2.5">
|
|
<span
|
|
className="w-9 h-9 rounded-full text-white text-[11px] font-bold flex items-center justify-center shrink-0"
|
|
style={{ backgroundColor: PRODUCT_TONES[index % PRODUCT_TONES.length] }}
|
|
>
|
|
{initials(product.name)}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[13px] font-semibold text-foreground truncate">{product.name}</p>
|
|
<p className="text-[12px] text-muted-foreground">{product.total_sold} units sold</p>
|
|
</div>
|
|
<span className="text-[13px] font-bold text-foreground shrink-0">
|
|
{formatInr(product.total_revenue)}
|
|
</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
<Link
|
|
href="/products"
|
|
className="mt-3 inline-flex items-center gap-1 text-[13px] font-medium text-muted-foreground hover:text-foreground"
|
|
>
|
|
View All <ArrowRight className="w-3.5 h-3.5" />
|
|
</Link>
|
|
</div>
|
|
|
|
<div className={cn(CARD, 'p-5 flex flex-col')}>
|
|
<div className="flex items-center justify-between gap-2 mb-4">
|
|
<CardTitle>Pipeline statistics</CardTitle>
|
|
<GhostSelect label="Weekly" />
|
|
</div>
|
|
<div className="grid grid-cols-4 gap-2 mb-4">
|
|
{pipelineRows.map((row) => (
|
|
<div key={row.key} className="min-w-0">
|
|
<p className="text-[11px] text-muted-foreground truncate">{row.label}</p>
|
|
<p className="text-[13px] font-bold text-foreground mt-0.5 truncate">
|
|
{formatCompact(row.value)}
|
|
</p>
|
|
<p className="text-[11px] text-muted-foreground">{row.count} orders</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="flex items-end gap-3 h-[120px]">
|
|
{pipelineRows.map((row) => (
|
|
<div key={row.key} className="flex-1 flex flex-col items-center justify-end h-full gap-2">
|
|
<div
|
|
className="w-full max-w-[42px] rounded-t-[5px]"
|
|
style={{
|
|
height: `${Math.max(18, (row.count / maxPipeline) * 100)}%`,
|
|
backgroundColor: row.color,
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-4 pt-3 border-t border-border flex items-center justify-between gap-3">
|
|
<p className="text-[13px] text-muted-foreground">
|
|
Stock value{' '}
|
|
<span className="font-bold text-foreground">
|
|
{(displayStats.total_stock_units ?? 0).toLocaleString()} units
|
|
</span>
|
|
</p>
|
|
<div className="flex items-end gap-[3px] h-8">
|
|
{spark.slice(-10).map((value, index) => (
|
|
<span
|
|
key={index}
|
|
className="w-[6px] rounded-full bg-primary"
|
|
style={{ height: `${Math.max(20, (value / sparkMax) * 100)}%`, opacity: 0.45 + (index / 20) }}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={cn(CARD, 'p-5 flex flex-col')}>
|
|
<div className="flex items-center justify-between gap-2 mb-4">
|
|
<CardTitle>Orders overview</CardTitle>
|
|
<Link
|
|
href="/orders"
|
|
className="w-8 h-8 crm-radius-control border border-border text-muted-foreground hover:bg-muted flex items-center justify-center"
|
|
aria-label="View orders"
|
|
>
|
|
<ArrowRight className="w-3.5 h-3.5" />
|
|
</Link>
|
|
</div>
|
|
<div className="h-2.5 rounded-full overflow-hidden flex">
|
|
{overviewRows.map((row) => (
|
|
<span
|
|
key={row.key}
|
|
style={{
|
|
width: `${(row.count / overviewTotal) * 100}%`,
|
|
backgroundColor: row.color,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
<p className="text-[28px] font-bold text-foreground mt-3 leading-none">{totalOrders}</p>
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<DeltaPill value={12.5} />
|
|
<span className="text-[12px] text-muted-foreground">compared to last week</span>
|
|
</div>
|
|
<div className="mt-4 space-y-3 flex-1">
|
|
{overviewRows.map((row) => (
|
|
<div key={row.key} className="flex items-center justify-between gap-2">
|
|
<span className="inline-flex items-center gap-2 text-[13px] text-foreground">
|
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: row.color }} />
|
|
{row.label}
|
|
</span>
|
|
<span className="text-[13px] font-bold text-foreground">{row.count} orders</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-4 bg-muted/60 crm-radius-control px-3 py-2.5 flex items-center justify-between gap-2">
|
|
<p className="text-[13px] text-muted-foreground">
|
|
Orders delivered <span className="font-bold text-foreground">{delivered}</span>
|
|
</p>
|
|
<span className="inline-flex items-center">
|
|
{customerAvatars.slice(0, 4).map((order, index) => (
|
|
<span
|
|
key={order.order_no}
|
|
className="w-6 h-6 rounded-full bg-card border border-border text-[9px] font-bold flex items-center justify-center text-foreground"
|
|
style={{ marginLeft: index === 0 ? 0 : -6 }}
|
|
>
|
|
{initials(order.customer_name)}
|
|
</span>
|
|
))}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={CARD}>
|
|
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
|
<CardTitle>Recent orders</CardTitle>
|
|
<Link
|
|
href="/orders"
|
|
className="inline-flex items-center gap-1 h-8 px-3 crm-radius-control border border-border text-[13px] font-medium text-foreground hover:bg-muted"
|
|
>
|
|
View All <ArrowRight className="w-3.5 h-3.5" />
|
|
</Link>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
{(displayStats.recent_orders ?? []).length === 0 ? (
|
|
<p className="text-[13px] text-muted-foreground text-center py-10">No orders yet</p>
|
|
) : (
|
|
<table className="crm-data-table min-w-[680px]">
|
|
<thead>
|
|
<tr className="border-y border-border">
|
|
<th className="text-left font-medium text-muted-foreground px-5 py-2.5">Order</th>
|
|
<th className="text-left font-medium text-muted-foreground px-5 py-2.5">Stage</th>
|
|
<th className="text-left font-medium text-muted-foreground px-5 py-2.5">Value</th>
|
|
<th className="text-left font-medium text-muted-foreground px-5 py-2.5">Payment</th>
|
|
<th className="text-left font-medium text-muted-foreground px-5 py-2.5">Customer</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(displayStats.recent_orders ?? []).map((order) => (
|
|
<tr key={order.order_no} className="border-b border-border last:border-0">
|
|
<td className="px-5 py-3 font-semibold text-foreground whitespace-nowrap">
|
|
{order.order_no}
|
|
</td>
|
|
<td className="px-5 py-3 text-muted-foreground capitalize">{order.status}</td>
|
|
<td className="px-5 py-3 text-muted-foreground whitespace-nowrap">
|
|
{formatInr(order.amount)}
|
|
</td>
|
|
<td className="px-5 py-3">
|
|
<span
|
|
className={cn(
|
|
'inline-flex items-center h-6 px-2 crm-radius-badge text-[11px] font-semibold border capitalize',
|
|
PAYMENT_TAG[order.payment_status] ?? 'border-border text-muted-foreground'
|
|
)}
|
|
>
|
|
{order.payment_status || '—'}
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-3">
|
|
<span className="inline-flex items-center gap-2 min-w-0">
|
|
<span className="w-7 h-7 rounded-full bg-primary/10 text-primary text-[10px] font-bold flex items-center justify-center shrink-0">
|
|
{initials(order.customer_name)}
|
|
</span>
|
|
<span className="text-[13px] text-foreground truncate">{order.customer_name}</span>
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|