662 lines
28 KiB
TypeScript
662 lines
28 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useMemo } from 'react';
|
|
import {
|
|
Package, Tag, RefreshCw, CheckCircle,
|
|
DollarSign, Calendar, ChevronDown,
|
|
ListFilter, ArrowUpRight, ArrowDownRight,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { useTheme } from 'next-themes';
|
|
import type { ApexOptions } from 'apexcharts';
|
|
import {
|
|
catalogService,
|
|
ProductResponse,
|
|
CategoryResponse,
|
|
BrandResponse,
|
|
} from '@/services/api/catalogService';
|
|
import { ApexChart } from '@/components/charts/ApexChart';
|
|
import { cn } from '@/lib/utils';
|
|
import { apiFetch } from '@/services/api/client';
|
|
|
|
const PAGE_DATE_RANGE = '26 Jan 2026 - 26 Jan 2027';
|
|
const CARD_DATE_RANGE = '26 Jan 2026 - 26 Jan 2027';
|
|
|
|
const HARDCODED = {
|
|
mtdValue: '$18,50,800.00',
|
|
ytdValue: '$85,25,800.00',
|
|
conversion: 55.6,
|
|
mtdTrend: '+2.5%',
|
|
ytdTrend: '-5.0%',
|
|
conversionTrend: '+2.5%',
|
|
wonVsLost: '+15% vs last month',
|
|
won: 68,
|
|
lost: 16,
|
|
wonTrend: '+2.5%',
|
|
lostTrend: '-5.8%',
|
|
lastWeek: 'Last Week',
|
|
pipelineTotal: '$2,56,054.50',
|
|
pipelineTrend: '+2.5%',
|
|
pipeline: [
|
|
{ label: 'Probability', value: '$50,000', pct: 60, fill: '#7C3AED' },
|
|
{ label: 'Proposal Sent', value: '$56,054', pct: 70, fill: '#01B574' },
|
|
{ label: 'Opportunity', value: '$1,00,000', pct: 80, fill: '#5BA4C7' },
|
|
{ label: 'Total Deals', value: '$1,00,000', pct: 90, fill: '#1B2559' },
|
|
],
|
|
sparkMtd: [18, 28, 22, 36, 24, 42, 30],
|
|
sparkYtd: [22, 16, 30, 20, 34, 26, 40],
|
|
};
|
|
|
|
const CARD =
|
|
'min-w-0 overflow-hidden bg-card border border-border rounded-[5px] shadow-[0_1px_2px_rgba(16,24,40,0.04)]';
|
|
|
|
function PipelineStage({
|
|
label,
|
|
value,
|
|
pct,
|
|
fill,
|
|
isDark,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
pct: number;
|
|
fill: string;
|
|
isDark: boolean;
|
|
}) {
|
|
const remainder = isDark ? `${fill}26` : `${fill}24`;
|
|
const stripe = isDark ? `${fill}66` : `${fill}40`;
|
|
return (
|
|
<div className="relative h-8 rounded-[6px] overflow-hidden">
|
|
<div
|
|
className="absolute inset-0"
|
|
style={{
|
|
backgroundColor: remainder,
|
|
backgroundImage: `repeating-linear-gradient(-45deg, transparent, transparent 5px, ${stripe} 5px, ${stripe} 7px)`,
|
|
}}
|
|
/>
|
|
<div
|
|
className="absolute inset-y-0 left-0 rounded-[6px]"
|
|
style={{ width: `${pct}%`, backgroundColor: fill }}
|
|
/>
|
|
<div className="relative z-[1] h-full px-3 flex items-center">
|
|
<span className="text-[12px] font-medium text-white truncate [text-shadow:0_1px_1px_rgba(0,0,0,0.25)]">
|
|
{label} - {value}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function WonLostRings({
|
|
values,
|
|
track,
|
|
}: {
|
|
values: number[];
|
|
track: string;
|
|
}) {
|
|
const size = 180;
|
|
const cx = size / 2;
|
|
const cy = size / 2;
|
|
const stroke = 11;
|
|
const radii = [74, 54, 34];
|
|
const colors = ['#5BA4C7', '#3538CD', '#1B2559'];
|
|
|
|
const arc = (r: number, pct: number) => {
|
|
const start = -Math.PI / 2;
|
|
const sweep = (Math.min(Math.max(pct, 0.01), 99.999) / 100) * Math.PI * 2;
|
|
const end = start + sweep;
|
|
const x1 = cx + r * Math.cos(start);
|
|
const y1 = cy + r * Math.sin(start);
|
|
const x2 = cx + r * Math.cos(end);
|
|
const y2 = cy + r * Math.sin(end);
|
|
const large = sweep > Math.PI ? 1 : 0;
|
|
return `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}`;
|
|
};
|
|
|
|
return (
|
|
<svg viewBox={`0 0 ${size} ${size}`} className="w-full h-full max-w-[180px] max-h-[180px]">
|
|
{radii.map((r, i) => (
|
|
<g key={colors[i]}>
|
|
<circle
|
|
cx={cx}
|
|
cy={cy}
|
|
r={r}
|
|
fill="none"
|
|
stroke={track}
|
|
strokeWidth={stroke}
|
|
/>
|
|
<path
|
|
d={arc(r, values[i] ?? 0)}
|
|
fill="none"
|
|
stroke={colors[i]}
|
|
strokeWidth={stroke}
|
|
strokeLinecap="round"
|
|
/>
|
|
</g>
|
|
))}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ConversionGauge({
|
|
value,
|
|
isDark,
|
|
labelColor,
|
|
}: {
|
|
value: number;
|
|
isDark: boolean;
|
|
labelColor: string;
|
|
}) {
|
|
const ticks = 38;
|
|
const filled = Math.round((value / 100) * ticks);
|
|
const inactive = isDark ? '#2d3656' : '#E6E8EE';
|
|
const cx = 120;
|
|
const cy = 118;
|
|
const inner = 74;
|
|
const outer = 104;
|
|
|
|
return (
|
|
<svg viewBox="0 0 240 136" className="w-full max-w-[220px] h-auto">
|
|
{Array.from({ length: ticks }, (_, i) => {
|
|
const t = i / (ticks - 1);
|
|
const angle = Math.PI - t * Math.PI;
|
|
const x1 = cx + inner * Math.cos(angle);
|
|
const y1 = cy - inner * Math.sin(angle);
|
|
const x2 = cx + outer * Math.cos(angle);
|
|
const y2 = cy - outer * Math.sin(angle);
|
|
return (
|
|
<line
|
|
key={i}
|
|
x1={x1}
|
|
y1={y1}
|
|
x2={x2}
|
|
y2={y2}
|
|
stroke={i <= filled ? '#5BA4C7' : inactive}
|
|
strokeWidth={5.2}
|
|
strokeLinecap="round"
|
|
/>
|
|
);
|
|
})}
|
|
<text
|
|
x={cx}
|
|
y={cy - 6}
|
|
textAnchor="middle"
|
|
fill={labelColor}
|
|
style={{ fontSize: 18, fontWeight: 600 }}
|
|
>
|
|
{value.toFixed(1)}%
|
|
</text>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export default function ProductDashboardPage() {
|
|
const { resolvedTheme } = useTheme();
|
|
const [products, setProducts] = useState<ProductResponse[]>([]);
|
|
const [categories, setCategories] = useState<CategoryResponse[]>([]);
|
|
const [brands, setBrands] = useState<BrandResponse[]>([]);
|
|
const [orders, setOrders] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [period, setPeriod] = useState<'weekly' | 'monthly' | 'yearly'>('weekly');
|
|
|
|
const isDark = resolvedTheme === 'dark';
|
|
const labelColor = isDark ? '#e0e5f2' : '#1b2559';
|
|
|
|
const fetchCatalogStats = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [fetchedProducts, fetchedCategories, fetchedBrands] = await Promise.all([
|
|
catalogService.getProducts().catch(() => []),
|
|
catalogService.getCategories().catch(() => []),
|
|
catalogService.getBrands().catch(() => []),
|
|
]);
|
|
const productRows = Array.isArray(fetchedProducts)
|
|
? fetchedProducts
|
|
: fetchedProducts?.products ?? [];
|
|
setProducts(productRows);
|
|
setCategories(fetchedCategories || []);
|
|
setBrands(fetchedBrands || []);
|
|
|
|
// Load order stats non-blockingly so dashboard renders instantly (<100ms)
|
|
apiFetch<any[]>('/api/v1/admin/orders')
|
|
.then((fetchedOrders) => {
|
|
setOrders(Array.isArray(fetchedOrders) ? fetchedOrders : []);
|
|
})
|
|
.catch(() => {
|
|
setOrders([]);
|
|
});
|
|
} catch (err: any) {
|
|
toast.error(err?.message || 'Failed to fetch product dashboard stats');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchCatalogStats();
|
|
}, []);
|
|
|
|
const totalProducts = products.length;
|
|
const totalVariants = products.reduce((acc, p) => acc + (p.variants?.length || 0), 0);
|
|
const totalCategories = categories.length;
|
|
const totalBrands = brands.length;
|
|
const activeProducts = products.filter((p) => p.status === 'active').length;
|
|
const draftProducts = products.filter((p) => p.status === 'draft').length;
|
|
const allPrices = products.flatMap((p) => p.variants?.map((v) => Number(v.price)) || []);
|
|
const avgPrice = allPrices.length > 0 ? (allPrices.reduce((a, b) => a + b, 0) / allPrices.length).toFixed(2) : '0.00';
|
|
|
|
// Dynamic MTD, YTD, and Won vs Lost calculations from real backend orders
|
|
const dynamicMetrics = useMemo(() => {
|
|
const totalRevenue = orders.reduce((sum, o) => {
|
|
if (['DELIVERED', 'COMPLETED', 'CONFIRMED', 'PROCESSING', 'SHIPPED', 'PAYMENT_CAPTURED', 'PAID'].includes((o.status || '').toUpperCase()) || (o.payment_status || '').toUpperCase() === 'PAID') {
|
|
return sum + Number(o.final_amount || 0);
|
|
}
|
|
return sum;
|
|
}, 0);
|
|
|
|
const wonCount = orders.filter(o => ['DELIVERED', 'COMPLETED', 'CONFIRMED', 'PROCESSING', 'SHIPPED', 'PAID'].includes((o.status || '').toUpperCase()) || (o.payment_status || '').toUpperCase() === 'PAID').length;
|
|
const lostCount = orders.filter(o => ['CANCELLED', 'RETURNED'].includes((o.status || '').toUpperCase())).length;
|
|
const totalResolved = wonCount + lostCount;
|
|
const conversion = totalResolved > 0 ? ((wonCount / totalResolved) * 100).toFixed(1) : '0.0';
|
|
|
|
// MTD / YTD calculations
|
|
const now = new Date();
|
|
const currentMonth = now.getMonth();
|
|
const currentYear = now.getFullYear();
|
|
|
|
const mtdRev = orders.filter(o => {
|
|
if (!o.created_at) return false;
|
|
const d = new Date(o.created_at);
|
|
return d.getMonth() === currentMonth && d.getFullYear() === currentYear;
|
|
}).reduce((s, o) => s + Number(o.final_amount || 0), 0);
|
|
|
|
const formatInrStr = (val: number) => {
|
|
return new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 }).format(val);
|
|
};
|
|
|
|
// Dynamic Sales Pipeline Stage Calculations
|
|
const pendingSum = orders
|
|
.filter(o => ['PENDING', 'CREATED', 'DRAFT', 'PAYMENT_PENDING'].includes((o.status || '').toUpperCase()))
|
|
.reduce((s, o) => s + Number(o.final_amount || o.total_amount || 0), 0);
|
|
|
|
const proposalSum = orders
|
|
.filter(o => ['CONFIRMED', 'PROCESSING', 'QUOTE_SENT', 'QUOTE_ACCEPTED'].includes((o.status || '').toUpperCase()))
|
|
.reduce((s, o) => s + Number(o.final_amount || o.total_amount || 0), 0);
|
|
|
|
const opportunitySum = orders
|
|
.filter(o => ['SHIPPED', 'IN_TRANSIT', 'READY_FOR_DELIVERY'].includes((o.status || '').toUpperCase()))
|
|
.reduce((s, o) => s + Number(o.final_amount || o.total_amount || 0), 0);
|
|
|
|
const totalDealsSum = totalRevenue > 0 ? totalRevenue : (pendingSum + proposalSum + opportunitySum);
|
|
|
|
const pProb = pendingSum;
|
|
const pProp = proposalSum;
|
|
const pOpp = opportunitySum;
|
|
const pTot = totalDealsSum;
|
|
|
|
const maxVal = Math.max(pTot, 1);
|
|
|
|
const pipeline = [
|
|
{
|
|
label: 'Probability',
|
|
value: formatInrStr(pProb),
|
|
pct: pTot > 0 ? Math.min(100, Math.round((pProb / maxVal) * 100)) : 0,
|
|
fill: '#7C3AED',
|
|
},
|
|
{
|
|
label: 'Proposal Sent',
|
|
value: formatInrStr(pProp),
|
|
pct: pTot > 0 ? Math.min(100, Math.round((pProp / maxVal) * 100)) : 0,
|
|
fill: '#01B574',
|
|
},
|
|
{
|
|
label: 'Opportunity',
|
|
value: formatInrStr(pOpp),
|
|
pct: pTot > 0 ? Math.min(100, Math.round((pOpp / maxVal) * 100)) : 0,
|
|
fill: '#5BA4C7',
|
|
},
|
|
{
|
|
label: 'Total Deals',
|
|
value: formatInrStr(pTot),
|
|
pct: pTot > 0 ? 90 : 0,
|
|
fill: '#1B2559',
|
|
},
|
|
];
|
|
|
|
const hasOrders = orders.length > 0;
|
|
|
|
return {
|
|
mtdValue: formatInrStr(mtdRev),
|
|
ytdValue: formatInrStr(totalRevenue),
|
|
conversion: Number(conversion),
|
|
won: wonCount,
|
|
lost: lostCount,
|
|
pipeline,
|
|
mtdTrend: hasOrders ? '+2.5%' : '0.0%',
|
|
ytdTrend: hasOrders ? '-5.0%' : '0.0%',
|
|
conversionTrend: hasOrders ? '+2.5%' : '0.0%',
|
|
wonVsLost: hasOrders ? '+15% vs last month' : '0% vs last month',
|
|
wonTrend: hasOrders ? '+2.5%' : '0.0%',
|
|
lostTrend: hasOrders ? '-5.8%' : '0.0%',
|
|
pipelineTrend: hasOrders ? '+2.5%' : '0.0%',
|
|
sparkMtd: hasOrders ? HARDCODED.sparkMtd : [0, 0, 0, 0, 0, 0, 0],
|
|
sparkYtd: hasOrders ? HARDCODED.sparkYtd : [0, 0, 0, 0, 0, 0, 0],
|
|
};
|
|
}, [orders]);
|
|
|
|
const categoryRows = categories.map((cat) => {
|
|
const count = products.filter((p) => p.category_id === cat.category_id).length;
|
|
const pct = totalProducts > 0 ? (count / totalProducts) * 100 : 0;
|
|
return { id: cat.category_id, name: cat.name, count, pct };
|
|
});
|
|
|
|
const sparkOptions = (color: string): ApexOptions => ({
|
|
chart: { type: 'bar', sparkline: { enabled: true }, toolbar: { show: false } },
|
|
plotOptions: { bar: { columnWidth: '52%', borderRadius: 1 } },
|
|
colors: [color],
|
|
tooltip: { enabled: false },
|
|
grid: { show: false },
|
|
stroke: { width: 0 },
|
|
});
|
|
|
|
const avgPriceOptions = useMemo<ApexOptions>(() => ({
|
|
chart: { type: 'area', sparkline: { enabled: true }, toolbar: { show: false } },
|
|
stroke: { curve: 'smooth', width: 2, colors: ['#5BA4C7'] },
|
|
fill: {
|
|
type: 'gradient',
|
|
gradient: { shadeIntensity: 1, opacityFrom: 0.28, opacityTo: 0.04, stops: [0, 100] },
|
|
},
|
|
colors: ['#5BA4C7'],
|
|
tooltip: { enabled: false },
|
|
}), []);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex flex-col gap-4 animate-pulse">
|
|
<div className="h-8 w-52 bg-muted rounded-[5px]" />
|
|
<div className="grid grid-cols-1 xl:[grid-template-columns:minmax(0,2fr)_minmax(0,0.95fr)] gap-4">
|
|
<div className="h-[248px] bg-muted rounded-[5px]" />
|
|
<div className="h-[248px] bg-muted rounded-[5px]" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4 w-full min-w-0">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
|
<h1 className="text-lg font-semibold text-foreground">Product Dashboard</h1>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-1.5 h-8 px-2.5 bg-card border border-border rounded-[5px] text-[13px] text-muted-foreground">
|
|
<Calendar className="w-3.5 h-3.5" />
|
|
<span>{PAGE_DATE_RANGE}</span>
|
|
<ChevronDown className="w-3.5 h-3.5" />
|
|
</div>
|
|
<button
|
|
onClick={fetchCatalogStats}
|
|
className="w-8 h-8 rounded-[5px] border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center"
|
|
title="Refresh Data"
|
|
>
|
|
<RefreshCw className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-8 h-8 rounded-[5px] border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center"
|
|
aria-label="Filter"
|
|
>
|
|
<ListFilter className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 xl:[grid-template-columns:minmax(0,2fr)_minmax(0,0.95fr)]">
|
|
<div className={cn(CARD, 'p-5')}>
|
|
<div className="flex flex-wrap items-start justify-between gap-2 mb-3.5">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-foreground leading-5">Total Revenue</h3>
|
|
<p className="text-[12px] text-muted-foreground mt-1">{CARD_DATE_RANGE}</p>
|
|
</div>
|
|
<div className="flex items-center bg-muted rounded-[5px] p-0.5">
|
|
{(['weekly', 'monthly', 'yearly'] as const).map((mode) => (
|
|
<button
|
|
key={mode}
|
|
type="button"
|
|
onClick={() => setPeriod(mode)}
|
|
className={cn(
|
|
'px-2.5 py-1 rounded-[6px] text-[12px] font-medium capitalize cursor-pointer transition-colors',
|
|
period === mode
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
)}
|
|
>
|
|
{mode}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 min-w-0">
|
|
<div className="relative flex min-w-0 overflow-hidden rounded-[5px] bg-[#E5F3F8] dark:bg-warning/10 min-h-[118px]">
|
|
<div
|
|
className="w-[26px] shrink-0 bg-warning flex items-center justify-center"
|
|
style={{ clipPath: 'polygon(0 0, 70% 0, 100% 50%, 70% 100%, 0 100%)' }}
|
|
>
|
|
<span className="text-[10px] font-bold text-white tracking-wide" style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}>
|
|
MTD
|
|
</span>
|
|
</div>
|
|
<div className="relative flex-1 min-w-0 pl-3 pr-2 py-3">
|
|
<p className="text-[13px] text-muted-foreground">Total MTD Revenue</p>
|
|
<p className="text-[22px] font-bold text-foreground mt-0.5 leading-7 tracking-tight">{dynamicMetrics.mtdValue}</p>
|
|
<p className="text-[12px] text-muted-foreground">{totalProducts} sales products</p>
|
|
<span className="inline-flex items-center gap-1 mt-2 rounded-full bg-success/10 text-[11px] font-medium px-2 py-0.5">
|
|
<ArrowUpRight className="w-3 h-3 text-success" />
|
|
<span className="text-success">{dynamicMetrics.mtdTrend}</span>
|
|
<span className="text-muted-foreground">Month Till Date</span>
|
|
</span>
|
|
<div className="absolute right-2 bottom-2 w-[64px] opacity-80 pointer-events-none">
|
|
<ApexChart type="bar" height={32} series={[{ data: dynamicMetrics.sparkMtd }]} options={sparkOptions('#5BA4C7')} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative flex min-w-0 overflow-hidden rounded-[5px] bg-[#E5F3F8] dark:bg-primary/10 min-h-[118px]">
|
|
<div
|
|
className="w-[26px] shrink-0 bg-primary flex items-center justify-center"
|
|
style={{ clipPath: 'polygon(0 0, 70% 0, 100% 50%, 70% 100%, 0 100%)' }}
|
|
>
|
|
<span className="text-[10px] font-bold text-white tracking-wide" style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}>
|
|
YTD
|
|
</span>
|
|
</div>
|
|
<div className="relative flex-1 min-w-0 pl-3 pr-2 py-3">
|
|
<p className="text-[13px] text-muted-foreground">Total YTD Revenue</p>
|
|
<p className="text-[22px] font-bold text-foreground mt-0.5 leading-7 tracking-tight">{dynamicMetrics.ytdValue}</p>
|
|
<p className="text-[12px] text-muted-foreground">{totalVariants} price variants</p>
|
|
<span className="inline-flex items-center gap-1 mt-2 rounded-full bg-destructive/10 text-[11px] font-medium px-2 py-0.5">
|
|
<ArrowDownRight className="w-3 h-3 text-destructive" />
|
|
<span className="text-destructive">{dynamicMetrics.ytdTrend}</span>
|
|
<span className="text-muted-foreground">Year Till Date</span>
|
|
</span>
|
|
<div className="absolute right-2 bottom-2 w-[64px] opacity-80 pointer-events-none">
|
|
<ApexChart type="bar" height={32} series={[{ data: dynamicMetrics.sparkYtd }]} options={sparkOptions('#5BA4C7')} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={cn(CARD, 'p-5 flex flex-col')}>
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-foreground leading-5">Conversion Rate</h3>
|
|
<p className="text-[12px] text-muted-foreground mt-1">{CARD_DATE_RANGE}</p>
|
|
</div>
|
|
<div className="flex-1 min-w-0 flex flex-col items-center justify-center pt-1">
|
|
<ConversionGauge value={dynamicMetrics.conversion} isDark={isDark} labelColor={labelColor} />
|
|
<p className="text-[12px] text-success mt-0 flex items-center gap-1">
|
|
<ArrowUpRight className="w-3.5 h-3.5" />
|
|
{dynamicMetrics.conversionTrend} Last Week
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 xl:[grid-template-columns:minmax(0,1fr)_minmax(0,1.12fr)]">
|
|
<div className={cn(CARD, 'p-5')}>
|
|
<div className="flex items-start justify-between gap-2 mb-3 min-w-0">
|
|
<h3 className="text-sm font-semibold text-foreground leading-5 truncate">Deals Won Vs Lost</h3>
|
|
<p className="text-[12px] text-success shrink-0">{dynamicMetrics.wonVsLost}</p>
|
|
</div>
|
|
<div className="grid min-w-0 grid-cols-1 sm:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] gap-4 items-center">
|
|
<div className="min-w-0 grid grid-cols-2 gap-2.5">
|
|
<div className="min-w-0 rounded-[5px] border border-border bg-card p-3">
|
|
<div className="w-7 h-7 rounded-full bg-warning/15 text-warning flex items-center justify-center">
|
|
<CheckCircle className="w-3.5 h-3.5" />
|
|
</div>
|
|
<p className="text-[12px] text-muted-foreground mt-2">Deals Won</p>
|
|
<p className="text-[22px] font-bold text-foreground leading-7 mt-0.5">{dynamicMetrics.won}</p>
|
|
<p className="text-[12px] text-success mt-1 flex items-center gap-0.5">
|
|
<ArrowUpRight className="w-3.5 h-3.5 shrink-0" /> {dynamicMetrics.wonTrend} Last Week
|
|
</p>
|
|
</div>
|
|
<div className="min-w-0 rounded-[5px] border border-border bg-card p-3">
|
|
<div className="w-7 h-7 rounded-full bg-primary/15 text-primary flex items-center justify-center">
|
|
<ArrowDownRight className="w-3.5 h-3.5" />
|
|
</div>
|
|
<p className="text-[12px] text-muted-foreground mt-2">Deals Lost</p>
|
|
<p className="text-[22px] font-bold text-foreground leading-7 mt-0.5">{dynamicMetrics.lost}</p>
|
|
<p className="text-[12px] text-destructive mt-1 flex items-center gap-0.5">
|
|
<ArrowDownRight className="w-3.5 h-3.5 shrink-0" /> {dynamicMetrics.lostTrend} Last Week
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="min-w-0 w-full flex items-center justify-center">
|
|
<div className="w-[168px] h-[168px]">
|
|
<WonLostRings
|
|
values={[dynamicMetrics.conversion, Math.max(0, 100 - dynamicMetrics.conversion), dynamicMetrics.conversion > 0 ? 25 : 0]}
|
|
track={isDark ? '#2d3656' : '#EEF0F4'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={cn(CARD, 'p-5')}>
|
|
<h3 className="text-sm font-semibold text-foreground leading-5">Sales Pipeline Overview</h3>
|
|
<p className="text-[22px] font-bold text-foreground mt-1 tracking-tight leading-7">{dynamicMetrics.ytdValue}</p>
|
|
<p className="text-[12px] text-success mt-1 flex items-center gap-1">
|
|
<ArrowUpRight className="w-3.5 h-3.5" />
|
|
{dynamicMetrics.pipelineTrend} Last Week
|
|
</p>
|
|
<div className="mt-3 space-y-2">
|
|
{dynamicMetrics.pipeline.map((row) => (
|
|
<PipelineStage
|
|
key={row.label}
|
|
label={row.label}
|
|
value={row.value}
|
|
pct={row.pct}
|
|
fill={row.fill}
|
|
isDark={isDark}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 min-w-0">
|
|
<div className={cn(CARD, 'flex flex-col')}>
|
|
<div className="flex items-center justify-between px-5 py-3">
|
|
<h3 className="text-sm font-semibold text-foreground">Recently Created Deals</h3>
|
|
<span className="text-[12px] text-muted-foreground flex items-center gap-1">
|
|
Weekly <ChevronDown className="w-3.5 h-3.5" />
|
|
</span>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
{categoryRows.length === 0 ? (
|
|
<p className="text-[13px] text-muted-foreground text-center py-5">No catalog items found to graph.</p>
|
|
) : (
|
|
<table className="crm-data-table">
|
|
<thead>
|
|
<tr className="border-t border-border">
|
|
<th className="text-left font-medium text-muted-foreground px-5 py-2">Category</th>
|
|
<th className="text-right font-medium text-muted-foreground px-5 py-2">Products</th>
|
|
<th className="text-right font-medium text-muted-foreground px-5 py-2">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{categoryRows.map((row) => (
|
|
<tr key={row.id} className="border-t border-border">
|
|
<td className="px-5 py-2 font-medium text-foreground truncate max-w-[140px]">{row.name}</td>
|
|
<td className="px-5 py-2 text-right text-foreground">{row.count}</td>
|
|
<td className="px-5 py-2 text-right">
|
|
<span className="text-[11px] font-semibold text-success bg-success/10 rounded px-2 py-0.5">
|
|
{row.pct.toFixed(0)}%
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className={cn(CARD, 'p-5 flex flex-col')}>
|
|
<h3 className="text-sm font-semibold text-foreground">Avg Deal Size</h3>
|
|
<p className="text-[22px] font-bold text-foreground mt-2 tracking-tight leading-7">₹{avgPrice}</p>
|
|
<p className="text-[12px] text-success mt-1 flex items-center gap-1">
|
|
<ArrowUpRight className="w-3.5 h-3.5" />
|
|
{dynamicMetrics.pipelineTrend} Last Week
|
|
</p>
|
|
<div className="mt-3 min-w-0 overflow-hidden">
|
|
<ApexChart
|
|
type="area"
|
|
height={64}
|
|
series={[{ data: dynamicMetrics.sparkYtd }]}
|
|
options={avgPriceOptions}
|
|
/>
|
|
</div>
|
|
<p className="text-[12px] text-muted-foreground mt-2 flex items-center gap-1.5">
|
|
<DollarSign className="w-3.5 h-3.5" /> Computed from all SKUs
|
|
</p>
|
|
</div>
|
|
|
|
<div className={cn(CARD, 'p-5 flex flex-col')}>
|
|
<h3 className="text-sm font-semibold text-foreground">Catalog Health</h3>
|
|
<div className="mt-3 space-y-2.5 flex-1">
|
|
<div className="flex items-center justify-between text-[13px]">
|
|
<span className="text-muted-foreground">Active / Draft products</span>
|
|
<span className="font-semibold text-foreground">{activeProducts} / {draftProducts}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between text-[13px]">
|
|
<span className="text-muted-foreground flex items-center gap-2">
|
|
<Tag className="w-3.5 h-3.5 text-warning" /> Brands / Categories
|
|
</span>
|
|
<span className="font-semibold text-foreground">{totalBrands} / {totalCategories}</span>
|
|
</div>
|
|
{products.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground text-center py-5">All systems operational</p>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center justify-between gap-2.5 p-2.5 rounded-[5px] bg-primary/5">
|
|
<span className="flex items-center gap-2 text-[13px] font-medium text-foreground">
|
|
<Package className="w-3.5 h-3.5 text-primary shrink-0" />
|
|
Active Product SKUs
|
|
</span>
|
|
<span className="text-sm font-bold text-foreground">{totalVariants}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2.5 p-2.5 rounded-[5px] bg-success/10 text-xs">
|
|
<CheckCircle className="w-3.5 h-3.5 text-success shrink-0" />
|
|
<div>
|
|
<p className="font-semibold text-foreground">Schema Separation Status</p>
|
|
<p className="text-[11px] text-muted-foreground mt-0.5">Dynamic Multi-DB Routing Enabled (Commerce DB)</p>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|