ADD new frontend
This commit is contained in:
1272
frontend/src/components/domain/sales/CustomerInfo.tsx
Normal file
1272
frontend/src/components/domain/sales/CustomerInfo.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1272
frontend/src/components/domain/sales/CustomerInfo.tsx.backup
Normal file
1272
frontend/src/components/domain/sales/CustomerInfo.tsx.backup
Normal file
File diff suppressed because it is too large
Load Diff
1374
frontend/src/components/domain/sales/OrderForm.tsx
Normal file
1374
frontend/src/components/domain/sales/OrderForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1374
frontend/src/components/domain/sales/OrderForm.tsx.backup
Normal file
1374
frontend/src/components/domain/sales/OrderForm.tsx.backup
Normal file
File diff suppressed because it is too large
Load Diff
879
frontend/src/components/domain/sales/OrdersTable.tsx
Normal file
879
frontend/src/components/domain/sales/OrdersTable.tsx
Normal 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;
|
||||
879
frontend/src/components/domain/sales/OrdersTable.tsx.backup
Normal file
879
frontend/src/components/domain/sales/OrdersTable.tsx.backup
Normal 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;
|
||||
869
frontend/src/components/domain/sales/SalesChart.tsx
Normal file
869
frontend/src/components/domain/sales/SalesChart.tsx
Normal 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;
|
||||
22
frontend/src/components/domain/sales/index.ts
Normal file
22
frontend/src/components/domain/sales/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user