- Add settingsApi in lib/api.ts with get, update, getWhatsAppStatus, testWhatsApp and getSubscription endpoints - Rewrite Settings.tsx to use React Query for data fetching - Implement useQuery for loading settings and subscription data - Implement useMutation for saving settings changes - Add form state management with controlled inputs - Add loading states, error handling and success notifications - Add individual save buttons per section plus global save - Add WhatsApp connection test functionality - Display subscription details with token usage Also includes exports API and export button components added by linter. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
291 lines
8.9 KiB
TypeScript
291 lines
8.9 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
|
import {
|
|
TrendingUp,
|
|
ShoppingCart,
|
|
Users,
|
|
CreditCard,
|
|
Package,
|
|
AlertCircle,
|
|
Loader2,
|
|
} from 'lucide-react';
|
|
import { dashboardApi, ordersApi, inventoryApi, exportsApi, downloadBlob } from '../lib/api';
|
|
import { ExportButton } from '../components/ExportButton';
|
|
|
|
interface DashboardStats {
|
|
salesToday: number;
|
|
ordersCount: number;
|
|
customersCount: number;
|
|
pendingFiado: number;
|
|
salesChange: string;
|
|
ordersChange: string;
|
|
customersChange: string;
|
|
fiadoChange: string;
|
|
}
|
|
|
|
interface Order {
|
|
id: string;
|
|
customer: { name: string } | null;
|
|
total: number;
|
|
status: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
interface LowStockProduct {
|
|
id: string;
|
|
name: string;
|
|
stock: number;
|
|
minStock: number;
|
|
}
|
|
|
|
const statusColors: Record<string, string> = {
|
|
pending: 'bg-yellow-100 text-yellow-800',
|
|
preparing: 'bg-blue-100 text-blue-800',
|
|
ready: 'bg-green-100 text-green-800',
|
|
completed: 'bg-gray-100 text-gray-800',
|
|
};
|
|
|
|
const statusLabels: Record<string, string> = {
|
|
pending: 'Pendiente',
|
|
preparing: 'Preparando',
|
|
ready: 'Listo',
|
|
completed: 'Completado',
|
|
};
|
|
|
|
function formatCurrency(amount: number): string {
|
|
return new Intl.NumberFormat('es-MX', {
|
|
style: 'currency',
|
|
currency: 'MXN',
|
|
}).format(amount);
|
|
}
|
|
|
|
function LoadingSpinner() {
|
|
return (
|
|
<div className="flex items-center justify-center p-8">
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary-600" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ErrorMessage({ message }: { message: string }) {
|
|
return (
|
|
<div className="flex items-center justify-center p-8 text-red-600">
|
|
<AlertCircle className="h-5 w-5 mr-2" />
|
|
<span>{message}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Dashboard() {
|
|
// Fetch dashboard stats
|
|
const {
|
|
data: statsData,
|
|
isLoading: statsLoading,
|
|
error: statsError,
|
|
} = useQuery({
|
|
queryKey: ['dashboard-stats'],
|
|
queryFn: async () => {
|
|
const response = await dashboardApi.getStats();
|
|
return response.data as DashboardStats;
|
|
},
|
|
});
|
|
|
|
// Fetch recent orders
|
|
const {
|
|
data: ordersData,
|
|
isLoading: ordersLoading,
|
|
error: ordersError,
|
|
} = useQuery({
|
|
queryKey: ['recent-orders'],
|
|
queryFn: async () => {
|
|
const response = await ordersApi.getAll({ status: undefined });
|
|
return (response.data as Order[]).slice(0, 5);
|
|
},
|
|
});
|
|
|
|
// Fetch low stock products
|
|
const {
|
|
data: lowStockData,
|
|
isLoading: lowStockLoading,
|
|
error: lowStockError,
|
|
} = useQuery({
|
|
queryKey: ['low-stock'],
|
|
queryFn: async () => {
|
|
const response = await inventoryApi.getLowStock();
|
|
return response.data as LowStockProduct[];
|
|
},
|
|
});
|
|
|
|
// Build stats array from API data
|
|
const stats = statsData
|
|
? [
|
|
{
|
|
name: 'Ventas Hoy',
|
|
value: formatCurrency(statsData.salesToday),
|
|
change: statsData.salesChange,
|
|
icon: TrendingUp,
|
|
color: 'green',
|
|
},
|
|
{
|
|
name: 'Pedidos',
|
|
value: String(statsData.ordersCount),
|
|
change: statsData.ordersChange,
|
|
icon: ShoppingCart,
|
|
color: 'blue',
|
|
},
|
|
{
|
|
name: 'Clientes',
|
|
value: String(statsData.customersCount),
|
|
change: statsData.customersChange,
|
|
icon: Users,
|
|
color: 'purple',
|
|
},
|
|
{
|
|
name: 'Fiados Pendientes',
|
|
value: formatCurrency(statsData.pendingFiado),
|
|
change: statsData.fiadoChange,
|
|
icon: CreditCard,
|
|
color: 'orange',
|
|
},
|
|
]
|
|
: [];
|
|
|
|
// Get today's date for export filename
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
|
const handleExportSalesPdf = async () => {
|
|
const response = await exportsApi.sales('pdf', { startDate: today, endDate: today });
|
|
downloadBlob(response.data, `ventas-${today}.pdf`);
|
|
};
|
|
|
|
const handleExportSalesExcel = async () => {
|
|
const response = await exportsApi.sales('xlsx', { startDate: today, endDate: today });
|
|
downloadBlob(response.data, `ventas-${today}.xlsx`);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
|
<p className="text-gray-500">Bienvenido a MiChangarrito</p>
|
|
</div>
|
|
<ExportButton
|
|
onExportPdf={handleExportSalesPdf}
|
|
onExportExcel={handleExportSalesExcel}
|
|
disabled={statsLoading}
|
|
/>
|
|
</div>
|
|
|
|
{/* Stats Grid */}
|
|
{statsLoading ? (
|
|
<LoadingSpinner />
|
|
) : statsError ? (
|
|
<ErrorMessage message="Error al cargar estadisticas" />
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
{stats.map((stat) => (
|
|
<div key={stat.name} className="card">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-500">{stat.name}</p>
|
|
<p className="text-2xl font-bold">{stat.value}</p>
|
|
<p className={`text-sm ${
|
|
stat.change.startsWith('+') ? 'text-green-600' : 'text-red-600'
|
|
}`}>
|
|
{stat.change}
|
|
</p>
|
|
</div>
|
|
<div className={`p-3 rounded-full bg-${stat.color}-100`}>
|
|
<stat.icon className={`h-6 w-6 text-${stat.color}-600`} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
{/* Recent Orders */}
|
|
<div className="card">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
|
<ShoppingCart className="h-5 w-5" />
|
|
Pedidos Recientes
|
|
</h2>
|
|
<a href="/orders" className="text-sm text-primary-600 hover:underline">
|
|
Ver todos
|
|
</a>
|
|
</div>
|
|
{ordersLoading ? (
|
|
<LoadingSpinner />
|
|
) : ordersError ? (
|
|
<ErrorMessage message="Error al cargar pedidos" />
|
|
) : ordersData && ordersData.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{ordersData.map((order) => (
|
|
<div
|
|
key={order.id}
|
|
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
|
>
|
|
<div>
|
|
<p className="font-medium">{order.id.slice(0, 8).toUpperCase()}</p>
|
|
<p className="text-sm text-gray-500">
|
|
{order.customer?.name || 'Cliente General'}
|
|
</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="font-medium">{formatCurrency(order.total)}</p>
|
|
<span className={`inline-block px-2 py-1 text-xs rounded-full ${statusColors[order.status] || 'bg-gray-100 text-gray-800'}`}>
|
|
{statusLabels[order.status] || order.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-center text-gray-500 py-4">No hay pedidos recientes</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Low Stock Alert */}
|
|
<div className="card">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
|
<AlertCircle className="h-5 w-5 text-orange-500" />
|
|
Stock Bajo
|
|
</h2>
|
|
<a href="/inventory" className="text-sm text-primary-600 hover:underline">
|
|
Ver inventario
|
|
</a>
|
|
</div>
|
|
{lowStockLoading ? (
|
|
<LoadingSpinner />
|
|
) : lowStockError ? (
|
|
<ErrorMessage message="Error al cargar inventario" />
|
|
) : lowStockData && lowStockData.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{lowStockData.map((product) => (
|
|
<div
|
|
key={product.id}
|
|
className="flex items-center justify-between p-3 bg-orange-50 rounded-lg"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<Package className="h-5 w-5 text-orange-500" />
|
|
<p className="font-medium">{product.name}</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="font-bold text-orange-600">{product.stock} unidades</p>
|
|
<p className="text-xs text-gray-500">Min: {product.minStock}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-center text-gray-500 py-4">No hay productos con stock bajo</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|