249 lines
7.6 KiB
TypeScript
249 lines
7.6 KiB
TypeScript
'use client';
|
|
import { useState, useEffect } from 'react';
|
|
import { api } from '@/lib/api-client';
|
|
|
|
interface Stats {
|
|
ordersToday: number;
|
|
revenueTodayCents: number;
|
|
revenueTodayFormatted: string;
|
|
ordersByState: Record<string, number>;
|
|
outOfStockVariants: number;
|
|
totalActiveProducts: number;
|
|
newCustomersThisMonth: number;
|
|
generatedAt: string;
|
|
}
|
|
|
|
function formatCents(cents: number): string {
|
|
return `€${(cents / 100).toFixed(2)}`;
|
|
}
|
|
|
|
const STATE_LABELS: Record<string, string> = {
|
|
PENDING: 'Pendientes',
|
|
AWAITING_PAYMENT: 'Esperando pago',
|
|
PAID: 'Pagados',
|
|
PROCESSING: 'Procesando',
|
|
SHIPPED: 'Enviados',
|
|
DELIVERED: 'Entregados',
|
|
CANCELLED: 'Cancelados',
|
|
REFUNDED: 'Reembolsados',
|
|
PARTIALLY_REFUNDED: 'Reembolso parcial',
|
|
};
|
|
|
|
const STATE_COLORS: Record<string, string> = {
|
|
PENDING: 'bg-amber-100 text-amber-700',
|
|
AWAITING_PAYMENT: 'bg-orange-100 text-orange-700',
|
|
PAID: 'bg-green-100 text-green-700',
|
|
PROCESSING: 'bg-blue-100 text-blue-700',
|
|
SHIPPED: 'bg-indigo-100 text-indigo-700',
|
|
DELIVERED: 'bg-emerald-100 text-emerald-700',
|
|
CANCELLED: 'bg-gray-100 text-gray-600',
|
|
REFUNDED: 'bg-red-100 text-red-700',
|
|
PARTIALLY_REFUNDED: 'bg-pink-100 text-pink-700',
|
|
};
|
|
|
|
function KPICard({
|
|
label,
|
|
value,
|
|
sub,
|
|
icon,
|
|
trend,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
sub?: string;
|
|
icon: string;
|
|
trend?: 'up' | 'down' | 'neutral';
|
|
}) {
|
|
return (
|
|
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-500">{label}</p>
|
|
<p className="text-3xl font-bold text-gray-900 mt-1">{value}</p>
|
|
{sub && <p className="text-xs text-gray-400 mt-1">{sub}</p>}
|
|
</div>
|
|
<div className="text-3xl">{icon}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OrderStateBar({ state, count, total }: { state: string; count: number; total: number }) {
|
|
const pct = total > 0 ? (count / total) * 100 : 0;
|
|
const label = STATE_LABELS[state] ?? state;
|
|
const color = STATE_COLORS[state] ?? 'bg-gray-100 text-gray-700';
|
|
|
|
return (
|
|
<div className="flex items-center gap-3 py-2">
|
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium min-w-[100px] ${color}`}>
|
|
{label}
|
|
</span>
|
|
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-[#2D6A4F] rounded-full transition-all"
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-sm font-semibold text-gray-700 min-w-[32px] text-right">{count}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function DashboardPage() {
|
|
const [stats, setStats] = useState<Stats | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
|
|
useEffect(() => {
|
|
api
|
|
.get<Stats>('/api/admin/stats')
|
|
.then(setStats)
|
|
.catch(() => setError('No se pudieron cargar las estadísticas'))
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="p-8 space-y-6">
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{[...Array(4)].map((_, i) => (
|
|
<div key={i} className="bg-white border border-gray-200 rounded-xl p-5 animate-pulse">
|
|
<div className="h-4 bg-gray-200 rounded w-1/2 mb-3" />
|
|
<div className="h-8 bg-gray-200 rounded w-3/4" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="bg-white border border-gray-200 rounded-xl p-6 animate-pulse">
|
|
<div className="h-5 bg-gray-200 rounded w-1/4 mb-4" />
|
|
<div className="space-y-3">
|
|
{[...Array(5)].map((_, i) => (
|
|
<div key={i} className="h-8 bg-gray-100 rounded" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !stats) {
|
|
return (
|
|
<div className="p-8">
|
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
|
|
{error ?? 'Error desconocido'}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const totalOrders = Object.values(stats.ordersByState).reduce((a, b) => a + b, 0);
|
|
const ordersByStateSorted = Object.entries(stats.ordersByState).sort(
|
|
([, a], [, b]) => b - a,
|
|
);
|
|
|
|
return (
|
|
<div className="p-8 space-y-6">
|
|
{/* KPI Cards */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<KPICard
|
|
label="Pedidos hoy"
|
|
value={String(stats.ordersToday)}
|
|
sub="Órdenes del día"
|
|
icon="📦"
|
|
/>
|
|
<KPICard
|
|
label="Ingresos hoy"
|
|
value={formatCents(stats.revenueTodayCents)}
|
|
sub="Revenue del día"
|
|
icon="💶"
|
|
/>
|
|
<KPICard
|
|
label="Productos activos"
|
|
value={String(stats.totalActiveProducts)}
|
|
sub="En el catálogo"
|
|
icon="🌿"
|
|
/>
|
|
<KPICard
|
|
label="Sin stock"
|
|
value={String(stats.outOfStockVariants)}
|
|
sub="Variantes agotadas"
|
|
icon="⚠️"
|
|
/>
|
|
</div>
|
|
|
|
{/* Secondary KPIs */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<KPICard
|
|
label="Clientes nuevos"
|
|
value={String(stats.newCustomersThisMonth)}
|
|
sub="Este mes"
|
|
icon="👥"
|
|
/>
|
|
<KPICard
|
|
label="Total pedidos"
|
|
value={String(totalOrders)}
|
|
sub="En el sistema"
|
|
icon="📋"
|
|
/>
|
|
<KPICard
|
|
label="Alertas"
|
|
value={
|
|
stats.outOfStockVariants > 0
|
|
? `${stats.outOfStockVariants} sin stock`
|
|
: 'Sin alertas'
|
|
}
|
|
sub={stats.outOfStockVariants > 0 ? 'Revisar inventario' : 'Todo OK'}
|
|
icon={stats.outOfStockVariants > 0 ? '🔴' : '✅'}
|
|
/>
|
|
</div>
|
|
|
|
{/* Orders by state */}
|
|
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-base font-semibold text-gray-900">Pedidos por estado</h2>
|
|
<span className="text-sm text-gray-500">{totalOrders} total</span>
|
|
</div>
|
|
|
|
{totalOrders === 0 ? (
|
|
<div className="py-8 text-center text-gray-400 text-sm">
|
|
<p className="text-3xl mb-2">📋</p>
|
|
<p>No hay pedidos en el sistema</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{ordersByStateSorted.map(([state, count]) => (
|
|
<OrderStateBar
|
|
key={state}
|
|
state={state}
|
|
count={count}
|
|
total={totalOrders}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Quick actions */}
|
|
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
|
<h2 className="text-base font-semibold text-gray-900 mb-4">Acciones rápidas</h2>
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
|
{[
|
|
{ href: '/products/new', label: '+ Nuevo producto', icon: '🌿' },
|
|
{ href: '/orders', label: 'Ver pedidos', icon: '📦' },
|
|
{ href: '/inventory', label: 'Revisar stock', icon: '📊' },
|
|
{ href: '/customers', label: 'Clientes', icon: '👥' },
|
|
].map(({ href, label, icon }) => (
|
|
<a
|
|
key={href}
|
|
href={href}
|
|
className="flex items-center gap-2 px-4 py-3 border border-gray-200 rounded-xl hover:bg-gray-50 hover:border-[#2D6A4F] transition-colors text-sm font-medium text-gray-700"
|
|
>
|
|
<span>{icon}</span>
|
|
<span>{label}</span>
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|