ADD new frontend

This commit is contained in:
Urtzi Alfaro
2025-08-28 10:41:04 +02:00
parent 9c247a5f99
commit 0fd273cfce
492 changed files with 114979 additions and 1632 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,879 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import {
Table,
Button,
Badge,
Input,
Select,
Card,
Modal,
Tooltip
} from '../../ui';
import {
SalesRecord,
SalesChannel,
PaymentMethod,
SalesSortField,
SortOrder
} from '../../../types/sales.types';
import { salesService } from '../../../services/api/sales.service';
import { useSales } from '../../../hooks/api/useSales';
// Extended interface for orders
interface Order extends SalesRecord {
customer_name: string;
customer_phone?: string;
delivery_address?: string;
order_notes?: string;
items_count: number;
status: OrderStatus;
}
enum OrderStatus {
PENDIENTE = 'pendiente',
CONFIRMADO = 'confirmado',
EN_PREPARACION = 'en_preparacion',
LISTO = 'listo',
ENTREGADO = 'entregado',
CANCELADO = 'cancelado'
}
interface OrdersTableProps {
tenantId?: string;
showActions?: boolean;
onOrderSelect?: (order: Order) => void;
onOrderUpdate?: (orderId: string, updates: Partial<Order>) => void;
initialFilters?: OrderFilters;
}
interface OrderFilters {
status?: OrderStatus;
customer_name?: string;
date_from?: string;
date_to?: string;
min_total?: number;
max_total?: number;
sales_channel?: SalesChannel;
payment_method?: PaymentMethod;
}
interface BulkActions {
selectedOrders: string[];
action: 'change_status' | 'print_receipts' | 'export' | 'delete';
newStatus?: OrderStatus;
}
const StatusColors = {
[OrderStatus.PENDIENTE]: 'yellow',
[OrderStatus.CONFIRMADO]: 'blue',
[OrderStatus.EN_PREPARACION]: 'orange',
[OrderStatus.LISTO]: 'green',
[OrderStatus.ENTREGADO]: 'emerald',
[OrderStatus.CANCELADO]: 'red'
} as const;
const StatusLabels = {
[OrderStatus.PENDIENTE]: 'Pendiente',
[OrderStatus.CONFIRMADO]: 'Confirmado',
[OrderStatus.EN_PREPARACION]: 'En Preparación',
[OrderStatus.LISTO]: 'Listo para Entrega',
[OrderStatus.ENTREGADO]: 'Entregado',
[OrderStatus.CANCELADO]: 'Cancelado'
} as const;
const ChannelLabels = {
[SalesChannel.STORE_FRONT]: 'Tienda',
[SalesChannel.ONLINE]: 'Online',
[SalesChannel.PHONE_ORDER]: 'Teléfono',
[SalesChannel.DELIVERY]: 'Delivery',
[SalesChannel.CATERING]: 'Catering',
[SalesChannel.WHOLESALE]: 'Mayorista',
[SalesChannel.FARMERS_MARKET]: 'Mercado',
[SalesChannel.THIRD_PARTY]: 'Terceros'
} as const;
const PaymentLabels = {
[PaymentMethod.CASH]: 'Efectivo',
[PaymentMethod.CREDIT_CARD]: 'Tarjeta Crédito',
[PaymentMethod.DEBIT_CARD]: 'Tarjeta Débito',
[PaymentMethod.DIGITAL_WALLET]: 'Wallet Digital',
[PaymentMethod.BANK_TRANSFER]: 'Transferencia',
[PaymentMethod.CHECK]: 'Cheque',
[PaymentMethod.STORE_CREDIT]: 'Crédito Tienda'
} as const;
export const OrdersTable: React.FC<OrdersTableProps> = ({
tenantId,
showActions = true,
onOrderSelect,
onOrderUpdate,
initialFilters = {}
}) => {
// State
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Filters and sorting
const [filters, setFilters] = useState<OrderFilters>(initialFilters);
const [sortBy, setSortBy] = useState<SalesSortField>(SalesSortField.DATE);
const [sortOrder, setSortOrder] = useState<SortOrder>(SortOrder.DESC);
const [searchTerm, setSearchTerm] = useState('');
// Selection and bulk actions
const [selectedOrders, setSelectedOrders] = useState<string[]>([]);
const [showBulkActions, setShowBulkActions] = useState(false);
const [bulkActionModal, setBulkActionModal] = useState(false);
const [bulkAction, setBulkAction] = useState<BulkActions['action'] | null>(null);
const [newBulkStatus, setNewBulkStatus] = useState<OrderStatus | null>(null);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [totalPages, setTotalPages] = useState(1);
const [totalOrders, setTotalOrders] = useState(0);
// Order details modal
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);
const [showOrderDetails, setShowOrderDetails] = useState(false);
// Sales hook
const { fetchSalesData } = useSales();
// Load orders
const loadOrders = useCallback(async () => {
setLoading(true);
setError(null);
try {
const queryParams = {
page: currentPage,
size: pageSize,
sort_by: sortBy,
sort_order: sortOrder,
...filters,
search: searchTerm || undefined,
};
// Simulate API call with mock data transformation
const response = await salesService.getSales(queryParams);
if (response.success && response.data) {
// Transform sales records to orders (in real app this would come from orders API)
const transformedOrders: Order[] = response.data.items.map((sale: any, index: number) => ({
...sale,
customer_name: `Cliente ${index + 1}`,
customer_phone: `+34 ${Math.floor(Math.random() * 900000000 + 100000000)}`,
delivery_address: sale.sales_channel === SalesChannel.DELIVERY
? `Calle Ejemplo ${Math.floor(Math.random() * 100)}, Madrid`
: undefined,
order_notes: Math.random() > 0.7 ? 'Sin gluten' : undefined,
items_count: Math.floor(Math.random() * 5) + 1,
status: Object.values(OrderStatus)[Math.floor(Math.random() * Object.values(OrderStatus).length)],
}));
setOrders(transformedOrders);
setTotalOrders(response.data.total);
setTotalPages(response.data.pages);
} else {
setError(response.error || 'Error al cargar pedidos');
}
} catch (err) {
setError('Error de conexión al servidor');
console.error('Error loading orders:', err);
} finally {
setLoading(false);
}
}, [currentPage, pageSize, sortBy, sortOrder, filters, searchTerm]);
// Effects
useEffect(() => {
loadOrders();
}, [loadOrders]);
useEffect(() => {
setCurrentPage(1);
}, [filters, searchTerm]);
// Filtered and sorted orders
const filteredOrders = useMemo(() => {
let filtered = [...orders];
// Apply search filter
if (searchTerm) {
const searchLower = searchTerm.toLowerCase();
filtered = filtered.filter(order =>
order.customer_name.toLowerCase().includes(searchLower) ||
order.id.toLowerCase().includes(searchLower) ||
order.product_name.toLowerCase().includes(searchLower)
);
}
return filtered;
}, [orders, searchTerm]);
// Table columns
const columns = [
{
key: 'select',
title: (
<input
type="checkbox"
checked={selectedOrders.length === filteredOrders.length && filteredOrders.length > 0}
onChange={(e) => {
if (e.target.checked) {
setSelectedOrders(filteredOrders.map(order => order.id));
} else {
setSelectedOrders([]);
}
}}
className="rounded border-[var(--border-secondary)]"
/>
),
render: (order: Order) => (
<input
type="checkbox"
checked={selectedOrders.includes(order.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedOrders(prev => [...prev, order.id]);
} else {
setSelectedOrders(prev => prev.filter(id => id !== order.id));
}
}}
className="rounded border-[var(--border-secondary)]"
/>
),
},
{
key: 'id',
title: 'Nº Pedido',
sortable: true,
render: (order: Order) => (
<button
onClick={() => {
setSelectedOrder(order);
setShowOrderDetails(true);
}}
className="text-[var(--color-info)] hover:text-[var(--color-info)] font-medium"
>
#{order.id.slice(-8)}
</button>
),
},
{
key: 'customer_name',
title: 'Cliente',
sortable: true,
render: (order: Order) => (
<div>
<div className="font-medium">{order.customer_name}</div>
{order.customer_phone && (
<div className="text-sm text-[var(--text-tertiary)]">{order.customer_phone}</div>
)}
</div>
),
},
{
key: 'products',
title: 'Productos',
render: (order: Order) => (
<div>
<div className="font-medium">{order.product_name}</div>
<div className="text-sm text-[var(--text-tertiary)]">
{order.items_count} {order.items_count === 1 ? 'artículo' : 'artículos'}
</div>
</div>
),
},
{
key: 'total_revenue',
title: 'Total',
sortable: true,
render: (order: Order) => (
<div className="text-right">
<div className="font-semibold">{order.total_revenue.toFixed(2)}</div>
{order.discount_applied > 0 && (
<div className="text-sm text-[var(--color-success)]">
-{order.discount_applied.toFixed(2)}
</div>
)}
</div>
),
},
{
key: 'status',
title: 'Estado',
sortable: true,
render: (order: Order) => (
<Badge color={StatusColors[order.status]} variant="soft">
{StatusLabels[order.status]}
</Badge>
),
},
{
key: 'sales_channel',
title: 'Canal',
render: (order: Order) => (
<span className="text-sm text-[var(--text-secondary)]">
{ChannelLabels[order.sales_channel]}
</span>
),
},
{
key: 'date',
title: 'Fecha',
sortable: true,
render: (order: Order) => (
<div>
<div>{new Date(order.date).toLocaleDateString('es-ES')}</div>
<div className="text-sm text-[var(--text-tertiary)]">
{new Date(order.created_at).toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit'
})}
</div>
</div>
),
},
];
if (showActions) {
columns.push({
key: 'actions',
title: 'Acciones',
render: (order: Order) => (
<div className="flex space-x-2">
<Tooltip content="Ver detalles">
<Button
size="sm"
variant="outline"
onClick={() => {
setSelectedOrder(order);
setShowOrderDetails(true);
}}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</Button>
</Tooltip>
<Tooltip content="Cambiar estado">
<Select
size="sm"
value={order.status}
onChange={(value) => handleStatusChange(order.id, value as OrderStatus)}
options={Object.values(OrderStatus).map(status => ({
value: status,
label: StatusLabels[status]
}))}
/>
</Tooltip>
{order.status !== OrderStatus.CANCELADO && (
<Tooltip content="Imprimir recibo">
<Button
size="sm"
variant="outline"
onClick={() => handlePrintReceipt(order.id)}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
</Button>
</Tooltip>
)}
</div>
),
});
}
// Handlers
const handleStatusChange = async (orderId: string, newStatus: OrderStatus) => {
try {
// Update locally first
setOrders(prev => prev.map(order =>
order.id === orderId ? { ...order, status: newStatus } : order
));
// Call API
onOrderUpdate?.(orderId, { status: newStatus });
// In real app, make API call here
} catch (error) {
console.error('Error updating order status:', error);
// Revert on error
loadOrders();
}
};
const handlePrintReceipt = (orderId: string) => {
// In real app, generate and print receipt
console.log('Printing receipt for order:', orderId);
window.print();
};
const handleBulkAction = async () => {
if (!bulkAction || selectedOrders.length === 0) return;
try {
switch (bulkAction) {
case 'change_status':
if (newBulkStatus) {
setOrders(prev => prev.map(order =>
selectedOrders.includes(order.id)
? { ...order, status: newBulkStatus }
: order
));
}
break;
case 'print_receipts':
selectedOrders.forEach(handlePrintReceipt);
break;
case 'export':
handleExportOrders(selectedOrders);
break;
case 'delete':
setOrders(prev => prev.filter(order => !selectedOrders.includes(order.id)));
break;
}
setSelectedOrders([]);
setBulkActionModal(false);
setBulkAction(null);
setNewBulkStatus(null);
} catch (error) {
console.error('Error performing bulk action:', error);
}
};
const handleExportOrders = async (orderIds?: string[]) => {
try {
const exportData = orders
.filter(order => !orderIds || orderIds.includes(order.id))
.map(order => ({
'Nº Pedido': order.id,
'Cliente': order.customer_name,
'Teléfono': order.customer_phone,
'Productos': order.product_name,
'Cantidad': order.quantity_sold,
'Total': order.total_revenue,
'Estado': StatusLabels[order.status],
'Canal': ChannelLabels[order.sales_channel],
'Fecha': new Date(order.date).toLocaleDateString('es-ES'),
}));
const csv = [
Object.keys(exportData[0]).join(','),
...exportData.map(row => Object.values(row).join(','))
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pedidos_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error exporting orders:', error);
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-[var(--text-primary)]">Gestión de Pedidos</h2>
<p className="text-[var(--text-secondary)]">
{totalOrders} pedidos encontrados
</p>
</div>
<div className="flex items-center space-x-3">
<Button onClick={() => handleExportOrders()} variant="outline" size="sm">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Exportar
</Button>
<Button onClick={() => loadOrders()} size="sm">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Actualizar
</Button>
</div>
</div>
{/* Filters */}
<Card className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Input
label="Buscar pedidos"
placeholder="Cliente, nº pedido o producto..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<Select
label="Estado"
value={filters.status || ''}
onChange={(value) => setFilters(prev => ({
...prev,
status: value ? value as OrderStatus : undefined
}))}
options={[
{ value: '', label: 'Todos los estados' },
...Object.values(OrderStatus).map(status => ({
value: status,
label: StatusLabels[status]
}))
]}
/>
<Select
label="Canal de venta"
value={filters.sales_channel || ''}
onChange={(value) => setFilters(prev => ({
...prev,
sales_channel: value ? value as SalesChannel : undefined
}))}
options={[
{ value: '', label: 'Todos los canales' },
...Object.values(SalesChannel).map(channel => ({
value: channel,
label: ChannelLabels[channel]
}))
]}
/>
<div className="flex space-x-2">
<Input
label="Total mínimo"
type="number"
step="0.01"
value={filters.min_total || ''}
onChange={(e) => setFilters(prev => ({
...prev,
min_total: e.target.value ? parseFloat(e.target.value) : undefined
}))}
placeholder="€0.00"
/>
<Input
label="Total máximo"
type="number"
step="0.01"
value={filters.max_total || ''}
onChange={(e) => setFilters(prev => ({
...prev,
max_total: e.target.value ? parseFloat(e.target.value) : undefined
}))}
placeholder="€999.99"
/>
</div>
</div>
<div className="flex justify-between items-center mt-4 pt-4 border-t border-[var(--border-primary)]">
<div className="text-sm text-[var(--text-secondary)]">
Mostrando {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, totalOrders)} de {totalOrders}
</div>
<Select
value={pageSize.toString()}
onChange={(value) => setPageSize(parseInt(value))}
options={[
{ value: '10', label: '10 por página' },
{ value: '20', label: '20 por página' },
{ value: '50', label: '50 por página' },
{ value: '100', label: '100 por página' }
]}
/>
</div>
</Card>
{/* Bulk Actions */}
{selectedOrders.length > 0 && (
<Card className="p-4">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">
{selectedOrders.length} pedidos seleccionados
</span>
<div className="flex space-x-2">
<Button
size="sm"
variant="outline"
onClick={() => {
setBulkAction('change_status');
setBulkActionModal(true);
}}
>
Cambiar Estado
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setBulkAction('print_receipts');
handleBulkAction();
}}
>
Imprimir Recibos
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleExportOrders(selectedOrders)}
>
Exportar
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setSelectedOrders([])}
>
Limpiar Selección
</Button>
</div>
</div>
</Card>
)}
{/* Table */}
<Card>
<div className="overflow-x-auto">
<Table
columns={columns}
data={filteredOrders}
loading={loading}
emptyMessage="No se encontraron pedidos"
/>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-4 border-t border-[var(--border-primary)]">
<Button
variant="outline"
size="sm"
disabled={currentPage === 1}
onClick={() => setCurrentPage(prev => prev - 1)}
>
Anterior
</Button>
<div className="flex space-x-2">
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(page =>
page === 1 ||
page === totalPages ||
Math.abs(page - currentPage) <= 2
)
.map((page, index, array) => (
<React.Fragment key={page}>
{index > 0 && array[index - 1] !== page - 1 && (
<span className="px-3 py-1 text-[var(--text-tertiary)]">...</span>
)}
<Button
size="sm"
variant={currentPage === page ? 'primary' : 'outline'}
onClick={() => setCurrentPage(page)}
>
{page}
</Button>
</React.Fragment>
))}
</div>
<Button
variant="outline"
size="sm"
disabled={currentPage === totalPages}
onClick={() => setCurrentPage(prev => prev + 1)}
>
Siguiente
</Button>
</div>
)}
</Card>
{/* Order Details Modal */}
<Modal
isOpen={showOrderDetails}
onClose={() => setShowOrderDetails(false)}
title={`Pedido #${selectedOrder?.id.slice(-8)}`}
size="lg"
>
{selectedOrder && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<h4 className="font-medium text-[var(--text-primary)] mb-3">Información del Cliente</h4>
<div className="space-y-2">
<div><span className="text-[var(--text-secondary)]">Nombre:</span> {selectedOrder.customer_name}</div>
{selectedOrder.customer_phone && (
<div><span className="text-[var(--text-secondary)]">Teléfono:</span> {selectedOrder.customer_phone}</div>
)}
{selectedOrder.delivery_address && (
<div><span className="text-[var(--text-secondary)]">Dirección:</span> {selectedOrder.delivery_address}</div>
)}
</div>
</div>
<div>
<h4 className="font-medium text-[var(--text-primary)] mb-3">Detalles del Pedido</h4>
<div className="space-y-2">
<div><span className="text-[var(--text-secondary)]">Estado:</span>
<Badge color={StatusColors[selectedOrder.status]} variant="soft" className="ml-2">
{StatusLabels[selectedOrder.status]}
</Badge>
</div>
<div><span className="text-[var(--text-secondary)]">Canal:</span> {ChannelLabels[selectedOrder.sales_channel]}</div>
<div><span className="text-[var(--text-secondary)]">Fecha:</span> {new Date(selectedOrder.date).toLocaleDateString('es-ES')}</div>
{selectedOrder.payment_method && (
<div><span className="text-[var(--text-secondary)]">Pago:</span> {PaymentLabels[selectedOrder.payment_method]}</div>
)}
</div>
</div>
</div>
<div>
<h4 className="font-medium text-[var(--text-primary)] mb-3">Productos</h4>
<div className="border rounded-lg p-4">
<div className="flex justify-between items-center">
<div>
<div className="font-medium">{selectedOrder.product_name}</div>
{selectedOrder.category && (
<div className="text-sm text-[var(--text-secondary)]">{selectedOrder.category}</div>
)}
</div>
<div className="text-right">
<div className="font-medium">{selectedOrder.unit_price.toFixed(2)} × {selectedOrder.quantity_sold}</div>
<div className="text-sm text-[var(--text-secondary)]">{selectedOrder.total_revenue.toFixed(2)}</div>
</div>
</div>
</div>
</div>
{selectedOrder.order_notes && (
<div>
<h4 className="font-medium text-[var(--text-primary)] mb-3">Notas del Pedido</h4>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm">
{selectedOrder.order_notes}
</div>
</div>
)}
<div className="border-t pt-4">
<div className="flex justify-between items-center text-lg font-semibold">
<span>Total del Pedido:</span>
<span>{selectedOrder.total_revenue.toFixed(2)}</span>
</div>
{selectedOrder.discount_applied > 0 && (
<div className="flex justify-between items-center text-sm text-[var(--color-success)]">
<span>Descuento aplicado:</span>
<span>-{selectedOrder.discount_applied.toFixed(2)}</span>
</div>
)}
{selectedOrder.tax_amount > 0 && (
<div className="flex justify-between items-center text-sm text-[var(--text-secondary)]">
<span>IVA incluido:</span>
<span>{selectedOrder.tax_amount.toFixed(2)}</span>
</div>
)}
</div>
<div className="flex justify-end space-x-3">
<Button
variant="outline"
onClick={() => handlePrintReceipt(selectedOrder.id)}
>
Imprimir Recibo
</Button>
<Button
onClick={() => {
onOrderSelect?.(selectedOrder);
setShowOrderDetails(false);
}}
>
Editar Pedido
</Button>
</div>
</div>
)}
</Modal>
{/* Bulk Action Modal */}
<Modal
isOpen={bulkActionModal}
onClose={() => setBulkActionModal(false)}
title="Acción masiva"
>
<div className="space-y-4">
<p>
Vas a realizar una acción sobre {selectedOrders.length} pedidos seleccionados.
</p>
{bulkAction === 'change_status' && (
<Select
label="Nuevo estado"
value={newBulkStatus || ''}
onChange={(value) => setNewBulkStatus(value as OrderStatus)}
options={Object.values(OrderStatus).map(status => ({
value: status,
label: StatusLabels[status]
}))}
required
/>
)}
<div className="flex justify-end space-x-3">
<Button variant="outline" onClick={() => setBulkActionModal(false)}>
Cancelar
</Button>
<Button
onClick={handleBulkAction}
disabled={bulkAction === 'change_status' && !newBulkStatus}
>
Confirmar
</Button>
</div>
</div>
</Modal>
{/* Error Display */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex">
<svg className="w-5 h-5 text-red-400 mr-2 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<div>
<h3 className="text-sm font-medium text-[var(--color-error)]">Error</h3>
<p className="text-sm text-[var(--color-error)] mt-1">{error}</p>
<Button
size="sm"
variant="outline"
onClick={() => setError(null)}
className="mt-2"
>
Cerrar
</Button>
</div>
</div>
</div>
)}
</div>
);
};
export default OrdersTable;

View File

@@ -0,0 +1,879 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import {
Table,
Button,
Badge,
Input,
Select,
Card,
Modal,
Tooltip
} from '../../ui';
import {
SalesRecord,
SalesChannel,
PaymentMethod,
SalesSortField,
SortOrder
} from '../../../types/sales.types';
import { salesService } from '../../../services/api/sales.service';
import { useSales } from '../../../hooks/api/useSales';
// Extended interface for orders
interface Order extends SalesRecord {
customer_name: string;
customer_phone?: string;
delivery_address?: string;
order_notes?: string;
items_count: number;
status: OrderStatus;
}
enum OrderStatus {
PENDIENTE = 'pendiente',
CONFIRMADO = 'confirmado',
EN_PREPARACION = 'en_preparacion',
LISTO = 'listo',
ENTREGADO = 'entregado',
CANCELADO = 'cancelado'
}
interface OrdersTableProps {
tenantId?: string;
showActions?: boolean;
onOrderSelect?: (order: Order) => void;
onOrderUpdate?: (orderId: string, updates: Partial<Order>) => void;
initialFilters?: OrderFilters;
}
interface OrderFilters {
status?: OrderStatus;
customer_name?: string;
date_from?: string;
date_to?: string;
min_total?: number;
max_total?: number;
sales_channel?: SalesChannel;
payment_method?: PaymentMethod;
}
interface BulkActions {
selectedOrders: string[];
action: 'change_status' | 'print_receipts' | 'export' | 'delete';
newStatus?: OrderStatus;
}
const StatusColors = {
[OrderStatus.PENDIENTE]: 'yellow',
[OrderStatus.CONFIRMADO]: 'blue',
[OrderStatus.EN_PREPARACION]: 'orange',
[OrderStatus.LISTO]: 'green',
[OrderStatus.ENTREGADO]: 'emerald',
[OrderStatus.CANCELADO]: 'red'
} as const;
const StatusLabels = {
[OrderStatus.PENDIENTE]: 'Pendiente',
[OrderStatus.CONFIRMADO]: 'Confirmado',
[OrderStatus.EN_PREPARACION]: 'En Preparación',
[OrderStatus.LISTO]: 'Listo para Entrega',
[OrderStatus.ENTREGADO]: 'Entregado',
[OrderStatus.CANCELADO]: 'Cancelado'
} as const;
const ChannelLabels = {
[SalesChannel.STORE_FRONT]: 'Tienda',
[SalesChannel.ONLINE]: 'Online',
[SalesChannel.PHONE_ORDER]: 'Teléfono',
[SalesChannel.DELIVERY]: 'Delivery',
[SalesChannel.CATERING]: 'Catering',
[SalesChannel.WHOLESALE]: 'Mayorista',
[SalesChannel.FARMERS_MARKET]: 'Mercado',
[SalesChannel.THIRD_PARTY]: 'Terceros'
} as const;
const PaymentLabels = {
[PaymentMethod.CASH]: 'Efectivo',
[PaymentMethod.CREDIT_CARD]: 'Tarjeta Crédito',
[PaymentMethod.DEBIT_CARD]: 'Tarjeta Débito',
[PaymentMethod.DIGITAL_WALLET]: 'Wallet Digital',
[PaymentMethod.BANK_TRANSFER]: 'Transferencia',
[PaymentMethod.CHECK]: 'Cheque',
[PaymentMethod.STORE_CREDIT]: 'Crédito Tienda'
} as const;
export const OrdersTable: React.FC<OrdersTableProps> = ({
tenantId,
showActions = true,
onOrderSelect,
onOrderUpdate,
initialFilters = {}
}) => {
// State
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Filters and sorting
const [filters, setFilters] = useState<OrderFilters>(initialFilters);
const [sortBy, setSortBy] = useState<SalesSortField>(SalesSortField.DATE);
const [sortOrder, setSortOrder] = useState<SortOrder>(SortOrder.DESC);
const [searchTerm, setSearchTerm] = useState('');
// Selection and bulk actions
const [selectedOrders, setSelectedOrders] = useState<string[]>([]);
const [showBulkActions, setShowBulkActions] = useState(false);
const [bulkActionModal, setBulkActionModal] = useState(false);
const [bulkAction, setBulkAction] = useState<BulkActions['action'] | null>(null);
const [newBulkStatus, setNewBulkStatus] = useState<OrderStatus | null>(null);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [totalPages, setTotalPages] = useState(1);
const [totalOrders, setTotalOrders] = useState(0);
// Order details modal
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);
const [showOrderDetails, setShowOrderDetails] = useState(false);
// Sales hook
const { fetchSalesData } = useSales();
// Load orders
const loadOrders = useCallback(async () => {
setLoading(true);
setError(null);
try {
const queryParams = {
page: currentPage,
size: pageSize,
sort_by: sortBy,
sort_order: sortOrder,
...filters,
search: searchTerm || undefined,
};
// Simulate API call with mock data transformation
const response = await salesService.getSales(queryParams);
if (response.success && response.data) {
// Transform sales records to orders (in real app this would come from orders API)
const transformedOrders: Order[] = response.data.items.map((sale: any, index: number) => ({
...sale,
customer_name: `Cliente ${index + 1}`,
customer_phone: `+34 ${Math.floor(Math.random() * 900000000 + 100000000)}`,
delivery_address: sale.sales_channel === SalesChannel.DELIVERY
? `Calle Ejemplo ${Math.floor(Math.random() * 100)}, Madrid`
: undefined,
order_notes: Math.random() > 0.7 ? 'Sin gluten' : undefined,
items_count: Math.floor(Math.random() * 5) + 1,
status: Object.values(OrderStatus)[Math.floor(Math.random() * Object.values(OrderStatus).length)],
}));
setOrders(transformedOrders);
setTotalOrders(response.data.total);
setTotalPages(response.data.pages);
} else {
setError(response.error || 'Error al cargar pedidos');
}
} catch (err) {
setError('Error de conexión al servidor');
console.error('Error loading orders:', err);
} finally {
setLoading(false);
}
}, [currentPage, pageSize, sortBy, sortOrder, filters, searchTerm]);
// Effects
useEffect(() => {
loadOrders();
}, [loadOrders]);
useEffect(() => {
setCurrentPage(1);
}, [filters, searchTerm]);
// Filtered and sorted orders
const filteredOrders = useMemo(() => {
let filtered = [...orders];
// Apply search filter
if (searchTerm) {
const searchLower = searchTerm.toLowerCase();
filtered = filtered.filter(order =>
order.customer_name.toLowerCase().includes(searchLower) ||
order.id.toLowerCase().includes(searchLower) ||
order.product_name.toLowerCase().includes(searchLower)
);
}
return filtered;
}, [orders, searchTerm]);
// Table columns
const columns = [
{
key: 'select',
title: (
<input
type="checkbox"
checked={selectedOrders.length === filteredOrders.length && filteredOrders.length > 0}
onChange={(e) => {
if (e.target.checked) {
setSelectedOrders(filteredOrders.map(order => order.id));
} else {
setSelectedOrders([]);
}
}}
className="rounded border-gray-300"
/>
),
render: (order: Order) => (
<input
type="checkbox"
checked={selectedOrders.includes(order.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedOrders(prev => [...prev, order.id]);
} else {
setSelectedOrders(prev => prev.filter(id => id !== order.id));
}
}}
className="rounded border-gray-300"
/>
),
},
{
key: 'id',
title: 'Nº Pedido',
sortable: true,
render: (order: Order) => (
<button
onClick={() => {
setSelectedOrder(order);
setShowOrderDetails(true);
}}
className="text-blue-600 hover:text-blue-800 font-medium"
>
#{order.id.slice(-8)}
</button>
),
},
{
key: 'customer_name',
title: 'Cliente',
sortable: true,
render: (order: Order) => (
<div>
<div className="font-medium">{order.customer_name}</div>
{order.customer_phone && (
<div className="text-sm text-gray-500">{order.customer_phone}</div>
)}
</div>
),
},
{
key: 'products',
title: 'Productos',
render: (order: Order) => (
<div>
<div className="font-medium">{order.product_name}</div>
<div className="text-sm text-gray-500">
{order.items_count} {order.items_count === 1 ? 'artículo' : 'artículos'}
</div>
</div>
),
},
{
key: 'total_revenue',
title: 'Total',
sortable: true,
render: (order: Order) => (
<div className="text-right">
<div className="font-semibold">€{order.total_revenue.toFixed(2)}</div>
{order.discount_applied > 0 && (
<div className="text-sm text-green-600">
-€{order.discount_applied.toFixed(2)}
</div>
)}
</div>
),
},
{
key: 'status',
title: 'Estado',
sortable: true,
render: (order: Order) => (
<Badge color={StatusColors[order.status]} variant="soft">
{StatusLabels[order.status]}
</Badge>
),
},
{
key: 'sales_channel',
title: 'Canal',
render: (order: Order) => (
<span className="text-sm text-gray-600">
{ChannelLabels[order.sales_channel]}
</span>
),
},
{
key: 'date',
title: 'Fecha',
sortable: true,
render: (order: Order) => (
<div>
<div>{new Date(order.date).toLocaleDateString('es-ES')}</div>
<div className="text-sm text-gray-500">
{new Date(order.created_at).toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit'
})}
</div>
</div>
),
},
];
if (showActions) {
columns.push({
key: 'actions',
title: 'Acciones',
render: (order: Order) => (
<div className="flex space-x-2">
<Tooltip content="Ver detalles">
<Button
size="sm"
variant="outline"
onClick={() => {
setSelectedOrder(order);
setShowOrderDetails(true);
}}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</Button>
</Tooltip>
<Tooltip content="Cambiar estado">
<Select
size="sm"
value={order.status}
onChange={(value) => handleStatusChange(order.id, value as OrderStatus)}
options={Object.values(OrderStatus).map(status => ({
value: status,
label: StatusLabels[status]
}))}
/>
</Tooltip>
{order.status !== OrderStatus.CANCELADO && (
<Tooltip content="Imprimir recibo">
<Button
size="sm"
variant="outline"
onClick={() => handlePrintReceipt(order.id)}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
</Button>
</Tooltip>
)}
</div>
),
});
}
// Handlers
const handleStatusChange = async (orderId: string, newStatus: OrderStatus) => {
try {
// Update locally first
setOrders(prev => prev.map(order =>
order.id === orderId ? { ...order, status: newStatus } : order
));
// Call API
onOrderUpdate?.(orderId, { status: newStatus });
// In real app, make API call here
} catch (error) {
console.error('Error updating order status:', error);
// Revert on error
loadOrders();
}
};
const handlePrintReceipt = (orderId: string) => {
// In real app, generate and print receipt
console.log('Printing receipt for order:', orderId);
window.print();
};
const handleBulkAction = async () => {
if (!bulkAction || selectedOrders.length === 0) return;
try {
switch (bulkAction) {
case 'change_status':
if (newBulkStatus) {
setOrders(prev => prev.map(order =>
selectedOrders.includes(order.id)
? { ...order, status: newBulkStatus }
: order
));
}
break;
case 'print_receipts':
selectedOrders.forEach(handlePrintReceipt);
break;
case 'export':
handleExportOrders(selectedOrders);
break;
case 'delete':
setOrders(prev => prev.filter(order => !selectedOrders.includes(order.id)));
break;
}
setSelectedOrders([]);
setBulkActionModal(false);
setBulkAction(null);
setNewBulkStatus(null);
} catch (error) {
console.error('Error performing bulk action:', error);
}
};
const handleExportOrders = async (orderIds?: string[]) => {
try {
const exportData = orders
.filter(order => !orderIds || orderIds.includes(order.id))
.map(order => ({
'Nº Pedido': order.id,
'Cliente': order.customer_name,
'Teléfono': order.customer_phone,
'Productos': order.product_name,
'Cantidad': order.quantity_sold,
'Total': order.total_revenue,
'Estado': StatusLabels[order.status],
'Canal': ChannelLabels[order.sales_channel],
'Fecha': new Date(order.date).toLocaleDateString('es-ES'),
}));
const csv = [
Object.keys(exportData[0]).join(','),
...exportData.map(row => Object.values(row).join(','))
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pedidos_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error exporting orders:', error);
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900">Gestión de Pedidos</h2>
<p className="text-gray-600">
{totalOrders} pedidos encontrados
</p>
</div>
<div className="flex items-center space-x-3">
<Button onClick={() => handleExportOrders()} variant="outline" size="sm">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Exportar
</Button>
<Button onClick={() => loadOrders()} size="sm">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Actualizar
</Button>
</div>
</div>
{/* Filters */}
<Card className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Input
label="Buscar pedidos"
placeholder="Cliente, nº pedido o producto..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<Select
label="Estado"
value={filters.status || ''}
onChange={(value) => setFilters(prev => ({
...prev,
status: value ? value as OrderStatus : undefined
}))}
options={[
{ value: '', label: 'Todos los estados' },
...Object.values(OrderStatus).map(status => ({
value: status,
label: StatusLabels[status]
}))
]}
/>
<Select
label="Canal de venta"
value={filters.sales_channel || ''}
onChange={(value) => setFilters(prev => ({
...prev,
sales_channel: value ? value as SalesChannel : undefined
}))}
options={[
{ value: '', label: 'Todos los canales' },
...Object.values(SalesChannel).map(channel => ({
value: channel,
label: ChannelLabels[channel]
}))
]}
/>
<div className="flex space-x-2">
<Input
label="Total mínimo"
type="number"
step="0.01"
value={filters.min_total || ''}
onChange={(e) => setFilters(prev => ({
...prev,
min_total: e.target.value ? parseFloat(e.target.value) : undefined
}))}
placeholder="€0.00"
/>
<Input
label="Total máximo"
type="number"
step="0.01"
value={filters.max_total || ''}
onChange={(e) => setFilters(prev => ({
...prev,
max_total: e.target.value ? parseFloat(e.target.value) : undefined
}))}
placeholder="€999.99"
/>
</div>
</div>
<div className="flex justify-between items-center mt-4 pt-4 border-t border-gray-200">
<div className="text-sm text-gray-600">
Mostrando {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, totalOrders)} de {totalOrders}
</div>
<Select
value={pageSize.toString()}
onChange={(value) => setPageSize(parseInt(value))}
options={[
{ value: '10', label: '10 por página' },
{ value: '20', label: '20 por página' },
{ value: '50', label: '50 por página' },
{ value: '100', label: '100 por página' }
]}
/>
</div>
</Card>
{/* Bulk Actions */}
{selectedOrders.length > 0 && (
<Card className="p-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">
{selectedOrders.length} pedidos seleccionados
</span>
<div className="flex space-x-2">
<Button
size="sm"
variant="outline"
onClick={() => {
setBulkAction('change_status');
setBulkActionModal(true);
}}
>
Cambiar Estado
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setBulkAction('print_receipts');
handleBulkAction();
}}
>
Imprimir Recibos
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleExportOrders(selectedOrders)}
>
Exportar
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setSelectedOrders([])}
>
Limpiar Selección
</Button>
</div>
</div>
</Card>
)}
{/* Table */}
<Card>
<div className="overflow-x-auto">
<Table
columns={columns}
data={filteredOrders}
loading={loading}
emptyMessage="No se encontraron pedidos"
/>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
<Button
variant="outline"
size="sm"
disabled={currentPage === 1}
onClick={() => setCurrentPage(prev => prev - 1)}
>
Anterior
</Button>
<div className="flex space-x-2">
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(page =>
page === 1 ||
page === totalPages ||
Math.abs(page - currentPage) <= 2
)
.map((page, index, array) => (
<React.Fragment key={page}>
{index > 0 && array[index - 1] !== page - 1 && (
<span className="px-3 py-1 text-gray-400">...</span>
)}
<Button
size="sm"
variant={currentPage === page ? 'primary' : 'outline'}
onClick={() => setCurrentPage(page)}
>
{page}
</Button>
</React.Fragment>
))}
</div>
<Button
variant="outline"
size="sm"
disabled={currentPage === totalPages}
onClick={() => setCurrentPage(prev => prev + 1)}
>
Siguiente
</Button>
</div>
)}
</Card>
{/* Order Details Modal */}
<Modal
isOpen={showOrderDetails}
onClose={() => setShowOrderDetails(false)}
title={`Pedido #${selectedOrder?.id.slice(-8)}`}
size="lg"
>
{selectedOrder && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<h4 className="font-medium text-gray-900 mb-3">Información del Cliente</h4>
<div className="space-y-2">
<div><span className="text-gray-600">Nombre:</span> {selectedOrder.customer_name}</div>
{selectedOrder.customer_phone && (
<div><span className="text-gray-600">Teléfono:</span> {selectedOrder.customer_phone}</div>
)}
{selectedOrder.delivery_address && (
<div><span className="text-gray-600">Dirección:</span> {selectedOrder.delivery_address}</div>
)}
</div>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-3">Detalles del Pedido</h4>
<div className="space-y-2">
<div><span className="text-gray-600">Estado:</span>
<Badge color={StatusColors[selectedOrder.status]} variant="soft" className="ml-2">
{StatusLabels[selectedOrder.status]}
</Badge>
</div>
<div><span className="text-gray-600">Canal:</span> {ChannelLabels[selectedOrder.sales_channel]}</div>
<div><span className="text-gray-600">Fecha:</span> {new Date(selectedOrder.date).toLocaleDateString('es-ES')}</div>
{selectedOrder.payment_method && (
<div><span className="text-gray-600">Pago:</span> {PaymentLabels[selectedOrder.payment_method]}</div>
)}
</div>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-3">Productos</h4>
<div className="border rounded-lg p-4">
<div className="flex justify-between items-center">
<div>
<div className="font-medium">{selectedOrder.product_name}</div>
{selectedOrder.category && (
<div className="text-sm text-gray-600">{selectedOrder.category}</div>
)}
</div>
<div className="text-right">
<div className="font-medium">€{selectedOrder.unit_price.toFixed(2)} × {selectedOrder.quantity_sold}</div>
<div className="text-sm text-gray-600">€{selectedOrder.total_revenue.toFixed(2)}</div>
</div>
</div>
</div>
</div>
{selectedOrder.order_notes && (
<div>
<h4 className="font-medium text-gray-900 mb-3">Notas del Pedido</h4>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm">
{selectedOrder.order_notes}
</div>
</div>
)}
<div className="border-t pt-4">
<div className="flex justify-between items-center text-lg font-semibold">
<span>Total del Pedido:</span>
<span>€{selectedOrder.total_revenue.toFixed(2)}</span>
</div>
{selectedOrder.discount_applied > 0 && (
<div className="flex justify-between items-center text-sm text-green-600">
<span>Descuento aplicado:</span>
<span>-€{selectedOrder.discount_applied.toFixed(2)}</span>
</div>
)}
{selectedOrder.tax_amount > 0 && (
<div className="flex justify-between items-center text-sm text-gray-600">
<span>IVA incluido:</span>
<span>€{selectedOrder.tax_amount.toFixed(2)}</span>
</div>
)}
</div>
<div className="flex justify-end space-x-3">
<Button
variant="outline"
onClick={() => handlePrintReceipt(selectedOrder.id)}
>
Imprimir Recibo
</Button>
<Button
onClick={() => {
onOrderSelect?.(selectedOrder);
setShowOrderDetails(false);
}}
>
Editar Pedido
</Button>
</div>
</div>
)}
</Modal>
{/* Bulk Action Modal */}
<Modal
isOpen={bulkActionModal}
onClose={() => setBulkActionModal(false)}
title="Acción masiva"
>
<div className="space-y-4">
<p>
Vas a realizar una acción sobre {selectedOrders.length} pedidos seleccionados.
</p>
{bulkAction === 'change_status' && (
<Select
label="Nuevo estado"
value={newBulkStatus || ''}
onChange={(value) => setNewBulkStatus(value as OrderStatus)}
options={Object.values(OrderStatus).map(status => ({
value: status,
label: StatusLabels[status]
}))}
required
/>
)}
<div className="flex justify-end space-x-3">
<Button variant="outline" onClick={() => setBulkActionModal(false)}>
Cancelar
</Button>
<Button
onClick={handleBulkAction}
disabled={bulkAction === 'change_status' && !newBulkStatus}
>
Confirmar
</Button>
</div>
</div>
</Modal>
{/* Error Display */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex">
<svg className="w-5 h-5 text-red-400 mr-2 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<div>
<h3 className="text-sm font-medium text-red-800">Error</h3>
<p className="text-sm text-red-700 mt-1">{error}</p>
<Button
size="sm"
variant="outline"
onClick={() => setError(null)}
className="mt-2"
>
Cerrar
</Button>
</div>
</div>
</div>
)}
</div>
);
};
export default OrdersTable;

View File

@@ -0,0 +1,869 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Card,
Select,
Button,
Badge,
Tooltip
} from '../../ui';
import {
SalesAnalytics,
DailyTrend,
ProductPerformance,
PeriodType
} from '../../../types/sales.types';
import { salesService } from '../../../services/api/sales.service';
import { useSales } from '../../../hooks/api/useSales';
interface SalesChartProps {
tenantId?: string;
initialPeriod?: PeriodType;
showExport?: boolean;
className?: string;
}
interface ChartData {
labels: string[];
datasets: ChartDataset[];
}
interface ChartDataset {
label: string;
data: number[];
backgroundColor?: string;
borderColor?: string;
borderWidth?: number;
fill?: boolean;
tension?: number;
}
enum ChartType {
LINE = 'line',
BAR = 'bar',
PIE = 'pie',
AREA = 'area'
}
enum MetricType {
REVENUE = 'revenue',
ORDERS = 'orders',
AVERAGE_ORDER = 'average_order',
TOP_PRODUCTS = 'top_products',
CHANNELS = 'channels',
HOURLY = 'hourly'
}
const ChartTypeLabels = {
[ChartType.LINE]: 'Líneas',
[ChartType.BAR]: 'Barras',
[ChartType.PIE]: 'Circular',
[ChartType.AREA]: 'Área'
};
const MetricLabels = {
[MetricType.REVENUE]: 'Ingresos',
[MetricType.ORDERS]: 'Pedidos',
[MetricType.AVERAGE_ORDER]: 'Ticket Promedio',
[MetricType.TOP_PRODUCTS]: 'Productos Top',
[MetricType.CHANNELS]: 'Canales de Venta',
[MetricType.HOURLY]: 'Patrones Horarios'
};
const PeriodLabels = {
[PeriodType.DAILY]: 'Diario',
[PeriodType.WEEKLY]: 'Semanal',
[PeriodType.MONTHLY]: 'Mensual',
[PeriodType.QUARTERLY]: 'Trimestral',
[PeriodType.YEARLY]: 'Anual'
};
const Colors = {
primary: '#3B82F6',
secondary: '#10B981',
tertiary: '#F59E0B',
quaternary: '#EF4444',
background: '#F3F4F6',
text: '#1F2937',
grid: '#E5E7EB'
};
export const SalesChart: React.FC<SalesChartProps> = ({
tenantId,
initialPeriod = PeriodType.MONTHLY,
showExport = true,
className = ''
}) => {
// State
const [analytics, setAnalytics] = useState<SalesAnalytics | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Chart configuration
const [chartType, setChartType] = useState<ChartType>(ChartType.LINE);
const [selectedMetric, setSelectedMetric] = useState<MetricType>(MetricType.REVENUE);
const [timePeriod, setTimePeriod] = useState<PeriodType>(initialPeriod);
const [dateRange, setDateRange] = useState({
start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
end: new Date().toISOString().split('T')[0]
});
// Comparison
const [showComparison, setShowComparison] = useState(false);
const [comparisonPeriod, setComparisonPeriod] = useState({
start: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
end: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
});
// Export options
const [showExportModal, setShowExportModal] = useState(false);
// Sales hook
const { fetchAnalytics } = useSales();
// Load analytics data
useEffect(() => {
loadAnalytics();
}, [dateRange, timePeriod]);
const loadAnalytics = async () => {
setLoading(true);
setError(null);
try {
const response = await salesService.getSalesAnalytics({
start_date: dateRange.start,
end_date: dateRange.end,
granularity: timePeriod === PeriodType.DAILY ? 'daily' :
timePeriod === PeriodType.WEEKLY ? 'weekly' : 'monthly'
});
if (response.success && response.data) {
// Transform the API data to our analytics format
const mockAnalytics: SalesAnalytics = {
overview: {
total_revenue: response.data.daily_sales?.reduce((sum, day) => sum + day.revenue, 0) || 0,
total_quantity: response.data.daily_sales?.reduce((sum, day) => sum + day.quantity, 0) || 0,
total_orders: response.data.daily_sales?.reduce((sum, day) => sum + day.orders, 0) || 0,
average_order_value: 0,
gross_profit: 0,
profit_margin: 0,
discount_percentage: 0,
tax_percentage: 21,
best_selling_products: response.data.product_performance || [],
revenue_by_channel: []
},
daily_trends: response.data.daily_sales?.map(day => ({
date: day.date,
revenue: day.revenue,
quantity: day.quantity,
orders: day.orders,
average_order_value: day.revenue / day.orders || 0,
new_customers: Math.floor(Math.random() * 10),
day_type: 'weekday' as const
})) || [],
hourly_patterns: response.data.hourly_patterns?.map((pattern, index) => ({
hour: index + 8, // Start from 8 AM
average_sales: pattern.average_sales,
peak_day: pattern.peak_day as any,
orders_count: Math.floor(pattern.average_sales / 15),
revenue_percentage: (pattern.average_sales / 1000) * 100,
staff_recommendation: Math.ceil(pattern.average_sales / 200)
})) || [],
product_performance: response.data.product_performance || [],
customer_segments: [],
weather_impact: response.data.weather_impact || [],
seasonal_patterns: [],
forecast: []
};
setAnalytics(mockAnalytics);
} else {
setError(response.error || 'Error al cargar datos de analítica');
}
} catch (err) {
setError('Error de conexión al servidor');
console.error('Error loading analytics:', err);
} finally {
setLoading(false);
}
};
// Chart data preparation
const chartData = useMemo((): ChartData => {
if (!analytics) return { labels: [], datasets: [] };
switch (selectedMetric) {
case MetricType.REVENUE:
return {
labels: analytics.daily_trends.map(trend =>
new Date(trend.date).toLocaleDateString('es-ES', {
month: 'short',
day: 'numeric'
})
),
datasets: [
{
label: 'Ingresos (€)',
data: analytics.daily_trends.map(trend => trend.revenue),
backgroundColor: chartType === ChartType.PIE ?
generateColors(analytics.daily_trends.length) : Colors.primary,
borderColor: Colors.primary,
borderWidth: 2,
fill: chartType === ChartType.AREA,
tension: 0.4
}
]
};
case MetricType.ORDERS:
return {
labels: analytics.daily_trends.map(trend =>
new Date(trend.date).toLocaleDateString('es-ES', {
month: 'short',
day: 'numeric'
})
),
datasets: [
{
label: 'Número de Pedidos',
data: analytics.daily_trends.map(trend => trend.orders),
backgroundColor: chartType === ChartType.PIE ?
generateColors(analytics.daily_trends.length) : Colors.secondary,
borderColor: Colors.secondary,
borderWidth: 2,
fill: chartType === ChartType.AREA,
tension: 0.4
}
]
};
case MetricType.AVERAGE_ORDER:
return {
labels: analytics.daily_trends.map(trend =>
new Date(trend.date).toLocaleDateString('es-ES', {
month: 'short',
day: 'numeric'
})
),
datasets: [
{
label: 'Ticket Promedio (€)',
data: analytics.daily_trends.map(trend => trend.average_order_value),
backgroundColor: chartType === ChartType.PIE ?
generateColors(analytics.daily_trends.length) : Colors.tertiary,
borderColor: Colors.tertiary,
borderWidth: 2,
fill: chartType === ChartType.AREA,
tension: 0.4
}
]
};
case MetricType.TOP_PRODUCTS:
const topProducts = analytics.product_performance.slice(0, 10);
return {
labels: topProducts.map(product => product.product_name),
datasets: [
{
label: 'Ingresos por Producto (€)',
data: topProducts.map(product => product.total_revenue),
backgroundColor: generateColors(topProducts.length),
borderColor: Colors.primary,
borderWidth: 1
}
]
};
case MetricType.HOURLY:
return {
labels: analytics.hourly_patterns.map(pattern => `${pattern.hour}:00`),
datasets: [
{
label: 'Ventas Promedio por Hora (€)',
data: analytics.hourly_patterns.map(pattern => pattern.average_sales),
backgroundColor: Colors.secondary,
borderColor: Colors.secondary,
borderWidth: 2,
fill: chartType === ChartType.AREA,
tension: 0.4
}
]
};
default:
return { labels: [], datasets: [] };
}
}, [analytics, selectedMetric, chartType]);
// Generate colors for pie charts
const generateColors = (count: number): string[] => {
const baseColors = [
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
'#06B6D4', '#84CC16', '#F97316', '#EC4899', '#6366F1'
];
const colors: string[] = [];
for (let i = 0; i < count; i++) {
colors.push(baseColors[i % baseColors.length]);
}
return colors;
};
// Chart component (simplified SVG implementation)
const renderChart = () => {
if (!chartData.labels.length) {
return (
<div className="flex items-center justify-center h-64 text-[var(--text-tertiary)]">
No hay datos para mostrar
</div>
);
}
const maxValue = Math.max(...chartData.datasets.flatMap(dataset => dataset.data));
const minValue = Math.min(...chartData.datasets.flatMap(dataset => dataset.data));
const range = maxValue - minValue || 1;
const chartWidth = 800;
const chartHeight = 400;
const padding = 60;
const chartArea = {
width: chartWidth - padding * 2,
height: chartHeight - padding * 2
};
const xStep = chartArea.width / (chartData.labels.length - 1 || 1);
const yScale = chartArea.height / range;
if (chartType === ChartType.PIE && selectedMetric === MetricType.TOP_PRODUCTS) {
// Pie chart implementation
const total = chartData.datasets[0].data.reduce((sum, value) => sum + value, 0);
let currentAngle = -90;
const centerX = chartWidth / 2;
const centerY = chartHeight / 2;
const radius = Math.min(chartArea.width, chartArea.height) / 3;
return (
<div className="relative">
<svg width={chartWidth} height={chartHeight} className="overflow-visible">
{chartData.datasets[0].data.map((value, index) => {
const percentage = (value / total) * 100;
const angle = (value / total) * 360;
const startAngle = currentAngle;
const endAngle = currentAngle + angle;
currentAngle += angle;
const startX = centerX + radius * Math.cos((startAngle * Math.PI) / 180);
const startY = centerY + radius * Math.sin((startAngle * Math.PI) / 180);
const endX = centerX + radius * Math.cos((endAngle * Math.PI) / 180);
const endY = centerY + radius * Math.sin((endAngle * Math.PI) / 180);
const largeArcFlag = angle > 180 ? 1 : 0;
const pathData = [
'M', centerX, centerY,
'L', startX, startY,
'A', radius, radius, 0, largeArcFlag, 1, endX, endY,
'Z'
].join(' ');
return (
<g key={index}>
<path
d={pathData}
fill={(chartData.datasets[0].backgroundColor as string[])[index]}
stroke="white"
strokeWidth={2}
/>
{percentage > 5 && (
<text
x={centerX + (radius * 0.7) * Math.cos(((startAngle + endAngle) / 2 * Math.PI) / 180)}
y={centerY + (radius * 0.7) * Math.sin(((startAngle + endAngle) / 2 * Math.PI) / 180)}
textAnchor="middle"
fontSize="12"
fill="white"
fontWeight="bold"
>
{percentage.toFixed(1)}%
</text>
)}
</g>
);
})}
</svg>
{/* Legend */}
<div className="mt-4 flex flex-wrap gap-4 justify-center">
{chartData.labels.map((label, index) => (
<div key={index} className="flex items-center">
<div
className="w-3 h-3 rounded mr-2"
style={{ backgroundColor: (chartData.datasets[0].backgroundColor as string[])[index] }}
/>
<span className="text-sm text-[var(--text-secondary)] truncate max-w-24">{label}</span>
</div>
))}
</div>
</div>
);
}
// Line/Bar/Area chart implementation
return (
<svg width={chartWidth} height={chartHeight} className="bg-white">
{/* Grid lines */}
{[0, 0.25, 0.5, 0.75, 1].map(ratio => {
const y = padding + chartArea.height * ratio;
return (
<g key={ratio}>
<line
x1={padding}
y1={y}
x2={chartWidth - padding}
y2={y}
stroke={Colors.grid}
strokeWidth={1}
strokeDasharray="2,2"
/>
<text
x={padding - 10}
y={y + 4}
textAnchor="end"
fontSize="12"
fill={Colors.text}
>
{(minValue + range * (1 - ratio)).toLocaleString('es-ES', { maximumFractionDigits: 0 })}
</text>
</g>
);
})}
{/* X-axis labels */}
{chartData.labels.map((label, index) => {
const x = padding + index * xStep;
return (
<text
key={index}
x={x}
y={chartHeight - padding + 20}
textAnchor="middle"
fontSize="12"
fill={Colors.text}
>
{label}
</text>
);
})}
{/* Data visualization */}
{chartData.datasets.map((dataset, datasetIndex) => {
const points = dataset.data.map((value, index) => ({
x: padding + index * xStep,
y: padding + chartArea.height - ((value - minValue) * yScale)
}));
if (chartType === ChartType.BAR) {
return (
<g key={datasetIndex}>
{points.map((point, index) => (
<rect
key={index}
x={point.x - 15}
y={point.y}
width={30}
height={padding + chartArea.height - point.y}
fill={dataset.backgroundColor as string}
rx={2}
/>
))}
</g>
);
}
// Line chart
const pathData = points.map((point, index) =>
`${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`
).join(' ');
return (
<g key={datasetIndex}>
{/* Area fill */}
{chartType === ChartType.AREA && (
<path
d={`${pathData} L ${points[points.length - 1].x} ${padding + chartArea.height} L ${points[0].x} ${padding + chartArea.height} Z`}
fill={dataset.backgroundColor as string}
fillOpacity={0.3}
/>
)}
{/* Line */}
<path
d={pathData}
fill="none"
stroke={dataset.borderColor}
strokeWidth={dataset.borderWidth}
/>
{/* Data points */}
{points.map((point, index) => (
<circle
key={index}
cx={point.x}
cy={point.y}
r={4}
fill={dataset.borderColor}
stroke="white"
strokeWidth={2}
>
<title>
{chartData.labels[index]}: {dataset.data[index].toLocaleString('es-ES', { minimumFractionDigits: 2 })}
</title>
</circle>
))}
</g>
);
})}
</svg>
);
};
// Export functionality
const handleExport = async (format: 'png' | 'pdf' | 'csv') => {
try {
switch (format) {
case 'png':
// Convert SVG to PNG
const svg = document.querySelector('svg');
if (svg) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
const svgBlob = new Blob([svg.outerHTML], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
img.onload = () => {
canvas.width = 800;
canvas.height = 400;
ctx?.drawImage(img, 0, 0);
canvas.toBlob(blob => {
if (blob) {
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = `grafico_ventas_${selectedMetric}_${new Date().toISOString().split('T')[0]}.png`;
a.click();
URL.revokeObjectURL(downloadUrl);
}
});
URL.revokeObjectURL(url);
};
img.src = url;
}
break;
case 'csv':
const csvData = [
['Período', ...chartData.datasets.map(d => d.label)],
...chartData.labels.map((label, index) => [
label,
...chartData.datasets.map(dataset => dataset.data[index].toFixed(2))
])
];
const csvContent = csvData.map(row => row.join(',')).join('\n');
const csvBlob = new Blob([csvContent], { type: 'text/csv' });
const csvUrl = URL.createObjectURL(csvBlob);
const csvLink = document.createElement('a');
csvLink.href = csvUrl;
csvLink.download = `datos_ventas_${selectedMetric}_${new Date().toISOString().split('T')[0]}.csv`;
csvLink.click();
URL.revokeObjectURL(csvUrl);
break;
case 'pdf':
// In a real implementation, use a library like jsPDF
console.log('PDF export would be implemented with jsPDF library');
break;
}
setShowExportModal(false);
} catch (error) {
console.error('Error exporting chart:', error);
}
};
// Summary statistics
const summaryStats = useMemo(() => {
if (!analytics) return null;
const currentPeriodTotal = analytics.daily_trends.reduce((sum, trend) => sum + trend.revenue, 0);
const currentPeriodOrders = analytics.daily_trends.reduce((sum, trend) => sum + trend.orders, 0);
const avgOrderValue = currentPeriodTotal / currentPeriodOrders || 0;
// Calculate growth (mock data for comparison)
const growthRate = Math.random() * 20 - 10; // -10% to +10%
return {
totalRevenue: currentPeriodTotal,
totalOrders: currentPeriodOrders,
avgOrderValue,
growthRate,
topProduct: analytics.product_performance[0]?.product_name || 'N/A'
};
}, [analytics]);
return (
<div className={`space-y-6 ${className}`}>
{/* Header Controls */}
<Card className="p-6">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-[var(--text-primary)]">Análisis de Ventas</h2>
<p className="text-[var(--text-secondary)]">
Período: {new Date(dateRange.start).toLocaleDateString('es-ES')} - {new Date(dateRange.end).toLocaleDateString('es-ES')}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<Select
value={selectedMetric}
onChange={(value) => setSelectedMetric(value as MetricType)}
options={Object.values(MetricType).map(metric => ({
value: metric,
label: MetricLabels[metric]
}))}
/>
<Select
value={chartType}
onChange={(value) => setChartType(value as ChartType)}
options={Object.values(ChartType).map(type => ({
value: type,
label: ChartTypeLabels[type]
}))}
/>
<Select
value={timePeriod}
onChange={(value) => setTimePeriod(value as PeriodType)}
options={Object.values(PeriodType).filter(period =>
[PeriodType.DAILY, PeriodType.WEEKLY, PeriodType.MONTHLY].includes(period)
).map(period => ({
value: period,
label: PeriodLabels[period]
}))}
/>
{showExport && (
<Button
variant="outline"
onClick={() => setShowExportModal(true)}
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Exportar
</Button>
)}
</div>
</div>
{/* Date Range Selector */}
<div className="flex flex-wrap items-center gap-4 mt-4 pt-4 border-t border-[var(--border-primary)]">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">Desde:</label>
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange(prev => ({ ...prev, start: e.target.value }))}
className="px-3 py-1 border border-[var(--border-secondary)] rounded-md text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">Hasta:</label>
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange(prev => ({ ...prev, end: e.target.value }))}
className="px-3 py-1 border border-[var(--border-secondary)] rounded-md text-sm"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowComparison(!showComparison)}
>
{showComparison ? 'Ocultar' : 'Mostrar'} Comparación
</Button>
</div>
</Card>
{/* Summary Statistics */}
{summaryStats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Ingresos Totales</p>
<p className="text-2xl font-bold text-[var(--text-primary)]">
{summaryStats.totalRevenue.toLocaleString('es-ES', { minimumFractionDigits: 2 })}
</p>
</div>
<div className={`flex items-center ${summaryStats.growthRate >= 0 ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d={summaryStats.growthRate >= 0
? "M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z"
: "M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V5a1 1 0 012 0v7.586l2.293-2.293a1 1 0 011.414 0z"
} clipRule="evenodd" />
</svg>
<span className="text-sm font-medium">{Math.abs(summaryStats.growthRate).toFixed(1)}%</span>
</div>
</div>
</Card>
<Card className="p-4">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Pedidos Totales</p>
<p className="text-2xl font-bold text-[var(--text-primary)]">{summaryStats.totalOrders.toLocaleString('es-ES')}</p>
</div>
</Card>
<Card className="p-4">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Ticket Promedio</p>
<p className="text-2xl font-bold text-[var(--text-primary)]">
{summaryStats.avgOrderValue.toLocaleString('es-ES', { minimumFractionDigits: 2 })}
</p>
</div>
</Card>
<Card className="p-4">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Producto Top</p>
<p className="text-lg font-bold text-[var(--text-primary)] truncate">{summaryStats.topProduct}</p>
</div>
</Card>
</div>
)}
{/* Main Chart */}
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{MetricLabels[selectedMetric]}
</h3>
<div className="flex items-center space-x-2">
<span className="text-sm text-[var(--text-secondary)]">Tipo:</span>
<Badge variant="soft" color="blue">
{ChartTypeLabels[chartType]}
</Badge>
</div>
</div>
<div className="relative">
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-2 text-[var(--text-secondary)]">Cargando datos...</span>
</div>
) : error ? (
<div className="flex items-center justify-center h-64 text-[var(--color-error)]">
<svg className="w-6 h-6 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
{error}
</div>
) : (
<div className="overflow-x-auto">
{renderChart()}
</div>
)}
</div>
</Card>
{/* Comparison Period */}
{showComparison && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Comparación de Períodos</h3>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">Período comparación desde:</label>
<input
type="date"
value={comparisonPeriod.start}
onChange={(e) => setComparisonPeriod(prev => ({ ...prev, start: e.target.value }))}
className="px-3 py-1 border border-[var(--border-secondary)] rounded-md text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">hasta:</label>
<input
type="date"
value={comparisonPeriod.end}
onChange={(e) => setComparisonPeriod(prev => ({ ...prev, end: e.target.value }))}
className="px-3 py-1 border border-[var(--border-secondary)] rounded-md text-sm"
/>
</div>
</div>
<div className="h-64 bg-[var(--bg-secondary)] rounded-lg flex items-center justify-center">
<p className="text-[var(--text-secondary)]">Gráfico de comparación se implementaría aquí</p>
</div>
</Card>
)}
{/* Export Modal */}
{showExportModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Exportar Gráfico</h3>
<div className="space-y-3">
<Button
variant="outline"
className="w-full justify-start"
onClick={() => handleExport('png')}
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Exportar como PNG
</Button>
<Button
variant="outline"
className="w-full justify-start"
onClick={() => handleExport('pdf')}
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Exportar como PDF
</Button>
<Button
variant="outline"
className="w-full justify-start"
onClick={() => handleExport('csv')}
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Exportar Datos (CSV)
</Button>
</div>
<div className="flex justify-end space-x-3 mt-6">
<Button variant="outline" onClick={() => setShowExportModal(false)}>
Cancelar
</Button>
</div>
</div>
</div>
)}
</div>
);
};
export default SalesChart;

View File

@@ -0,0 +1,22 @@
// Sales Domain Components
export { default as OrdersTable } from './OrdersTable';
export { default as SalesChart } from './SalesChart';
export { default as CustomerInfo } from './CustomerInfo';
export { default as OrderForm } from './OrderForm';
// Export component types for TypeScript
export type { OrdersTableProps } from './OrdersTable';
export type { SalesChartProps } from './SalesChart';
export type { CustomerInfoProps } from './CustomerInfo';
export type { OrderFormProps } from './OrderForm';
// Re-export related types from the sales types file
export type {
SalesRecord,
SalesAnalytics,
ProductPerformance,
SalesChannel,
PaymentMethod,
SalesSortField,
SortOrder
} from '../../../types/sales.types';