feat(ADM-018): completed feature
This commit is contained in:
248
project/apps/admin/src/app/(dashboard)/page.tsx
Normal file
248
project/apps/admin/src/app/(dashboard)/page.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
'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>('/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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user