Files
bakery-ia/frontend/src/pages/app/operations/pos/POSPage.tsx
2025-10-30 21:08:07 +01:00

1050 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useMemo } from 'react';
import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt, Package, Euro, TrendingUp, Clock, ToggleLeft, ToggleRight, Settings, Zap, Wifi, WifiOff, AlertCircle, CheckCircle, Loader, Trash2, X, ChevronRight, ChevronLeft } from 'lucide-react';
import { Button, Card, StatusCard, getStatusColor, Badge } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { LoadingSpinner } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { useIngredients } from '../../../../api/hooks/inventory';
import { useTenantId } from '../../../../hooks/useTenantId';
import { ProductType, ProductCategory, IngredientResponse } from '../../../../api/types/inventory';
import { showToast } from '../../../../utils/toast';
import { usePOSConfigurationData, usePOSConfigurationManager, usePOSTransactions, usePOSTransactionsDashboard, usePOSTransaction } from '../../../../api/hooks/pos';
import { POSConfiguration } from '../../../../api/types/pos';
import { posService } from '../../../../api/services/pos';
import { bakeryColors } from '../../../../styles/colors';
// Import new POS components
import { POSProductCard } from '../../../../components/domain/pos/POSProductCard';
import { POSCart } from '../../../../components/domain/pos/POSCart';
import { POSPayment } from '../../../../components/domain/pos/POSPayment';
import { CreatePOSConfigModal } from '../../../../components/domain/pos/CreatePOSConfigModal';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
category: string;
stock: number;
}
// Transactions Section Component
const TransactionsSection: React.FC<{ tenantId: string }> = ({ tenantId }) => {
const [page, setPage] = useState(0);
const [selectedTransactionId, setSelectedTransactionId] = useState<string | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const limit = 10;
// Fetch transactions
const { data: transactionsData, isLoading: transactionsLoading } = usePOSTransactions({
tenant_id: tenantId,
limit,
offset: page * limit,
});
// Fetch dashboard summary
const { data: dashboardData, isLoading: dashboardLoading } = usePOSTransactionsDashboard({
tenant_id: tenantId,
});
// Fetch selected transaction details
const { data: selectedTransaction, isLoading: detailLoading } = usePOSTransaction(
{
tenant_id: tenantId,
transaction_id: selectedTransactionId || '',
},
{
enabled: !!selectedTransactionId,
}
);
const handleViewDetails = (transactionId: string) => {
setSelectedTransactionId(transactionId);
setShowDetailModal(true);
};
const handleCloseDetail = () => {
setShowDetailModal(false);
setSelectedTransactionId(null);
};
if (transactionsLoading || dashboardLoading) {
return (
<Card className="p-6">
<div className="flex items-center justify-center h-32">
<LoadingSpinner text="Cargando transacciones..." />
</div>
</Card>
);
}
const transactions = transactionsData?.transactions || [];
const summary = transactionsData?.summary;
const dashboard = dashboardData;
return (
<>
{/* Dashboard Stats */}
{dashboard && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4 flex items-center">
<Receipt className="w-5 h-5 mr-2 text-blue-500" />
Resumen de Transacciones
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="text-sm text-[var(--text-secondary)] mb-1">Hoy</div>
<div className="text-2xl font-bold text-[var(--text-primary)]">{dashboard.total_transactions_today}</div>
<div className="text-sm text-[var(--text-tertiary)] mt-1">
{formatters.currency(dashboard.revenue_today)}
</div>
</div>
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="text-sm text-[var(--text-secondary)] mb-1">Esta Semana</div>
<div className="text-2xl font-bold text-[var(--text-primary)]">{dashboard.total_transactions_this_week}</div>
<div className="text-sm text-[var(--text-tertiary)] mt-1">
{formatters.currency(dashboard.revenue_this_week)}
</div>
</div>
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="text-sm text-[var(--text-secondary)] mb-1">Este Mes</div>
<div className="text-2xl font-bold text-[var(--text-primary)]">{dashboard.total_transactions_this_month}</div>
<div className="text-sm text-[var(--text-tertiary)] mt-1">
{formatters.currency(dashboard.revenue_this_month)}
</div>
</div>
</div>
</Card>
)}
{/* Transactions List */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-[var(--text-primary)] flex items-center">
<Receipt className="w-5 h-5 mr-2 text-green-500" />
Transacciones Recientes
</h3>
{summary && (
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
<div className="flex items-center gap-1">
<CheckCircle className="w-4 h-4 text-green-500" />
<span>{summary.sync_status.synced} sincronizadas</span>
</div>
<div className="flex items-center gap-1">
<Clock className="w-4 h-4 text-yellow-500" />
<span>{summary.sync_status.pending} pendientes</span>
</div>
{summary.sync_status.failed > 0 && (
<div className="flex items-center gap-1">
<AlertCircle className="w-4 h-4 text-red-500" />
<span>{summary.sync_status.failed} fallidas</span>
</div>
)}
</div>
)}
</div>
{transactions.length === 0 ? (
<div className="text-center py-12">
<Receipt className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4 opacity-30" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No hay transacciones
</h3>
<p className="text-[var(--text-secondary)]">
Las transacciones sincronizadas desde tus sistemas POS aparecerán aquí
</p>
</div>
) : (
<>
{/* Desktop Table View - Hidden on mobile */}
<div className="hidden md:block overflow-x-auto">
<table className="w-full">
<thead className="bg-[var(--bg-secondary)]">
<tr>
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--text-primary)]">ID Transacción</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--text-primary)]">Fecha</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--text-primary)]">Total</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--text-primary)]">Método Pago</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--text-primary)]">Estado</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--text-primary)]">Sync</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--text-primary)]">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-primary)]">
{transactions.map((transaction) => (
<tr key={transaction.id} className="hover:bg-[var(--bg-secondary)] transition-colors">
<td className="px-4 py-3 text-sm text-[var(--text-primary)] font-mono">
{transaction.external_transaction_id}
</td>
<td className="px-4 py-3 text-sm text-[var(--text-secondary)]">
{new Date(transaction.transaction_date).toLocaleString('es-ES', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</td>
<td className="px-4 py-3 text-sm font-semibold text-[var(--text-primary)]">
{formatters.currency(transaction.total_amount)}
</td>
<td className="px-4 py-3 text-sm text-[var(--text-secondary)] capitalize">
{transaction.payment_method || 'N/A'}
</td>
<td className="px-4 py-3">
<Badge
variant={
transaction.status === 'completed' ? 'success' :
transaction.status === 'pending' ? 'warning' :
'error'
}
size="sm"
>
{transaction.status}
</Badge>
</td>
<td className="px-4 py-3">
{transaction.is_synced_to_sales ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<Clock className="w-5 h-5 text-yellow-500" />
)}
</td>
<td className="px-4 py-3">
<button
onClick={() => handleViewDetails(transaction.id)}
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
>
Ver detalles
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile Card View - Hidden on desktop */}
<div className="md:hidden space-y-4">
{transactions.map((transaction) => (
<div
key={transaction.id}
className="bg-[var(--bg-secondary)] rounded-lg p-4 border border-[var(--border-primary)] hover:border-[var(--border-secondary)] transition-colors cursor-pointer"
onClick={() => handleViewDetails(transaction.id)}
>
{/* Header Row */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="text-xs font-mono text-[var(--text-tertiary)] mb-1">
{transaction.external_transaction_id}
</div>
<div className="text-sm text-[var(--text-secondary)]">
{new Date(transaction.transaction_date).toLocaleString('es-ES', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
<div className="flex items-center gap-2">
{transaction.is_synced_to_sales ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<Clock className="w-5 h-5 text-yellow-500" />
)}
<Badge
variant={
transaction.status === 'completed' ? 'success' :
transaction.status === 'pending' ? 'warning' :
'error'
}
size="sm"
>
{transaction.status}
</Badge>
</div>
</div>
{/* Amount and Payment */}
<div className="flex items-center justify-between">
<div>
<div className="text-2xl font-bold text-[var(--text-primary)]">
{formatters.currency(transaction.total_amount)}
</div>
<div className="text-sm text-[var(--text-secondary)] capitalize mt-1">
{transaction.payment_method || 'N/A'}
</div>
</div>
<ChevronRight className="w-5 h-5 text-[var(--text-tertiary)]" />
</div>
{/* Items Count */}
{transaction.items && transaction.items.length > 0 && (
<div className="mt-3 pt-3 border-t border-[var(--border-primary)] text-xs text-[var(--text-secondary)]">
{transaction.items.length} {transaction.items.length === 1 ? 'artículo' : 'artículos'}
</div>
)}
</div>
))}
</div>
{/* Pagination */}
{transactionsData && (transactionsData.has_more || page > 0) && (
<div className="mt-6 flex items-center justify-between">
<Button
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
variant="secondary"
className="flex items-center gap-2"
>
<ChevronLeft className="w-4 h-4" />
<span className="hidden sm:inline">Anterior</span>
</Button>
<span className="text-sm text-[var(--text-secondary)]">
Página {page + 1}
</span>
<Button
onClick={() => setPage(page + 1)}
disabled={!transactionsData.has_more}
variant="secondary"
className="flex items-center gap-2"
>
<span className="hidden sm:inline">Siguiente</span>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
</>
)}
</Card>
{/* Transaction Detail Modal */}
{showDetailModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div className="bg-[var(--bg-primary)] rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
{/* Modal Header */}
<div className="sticky top-0 bg-[var(--bg-primary)] border-b border-[var(--border-primary)] px-6 py-4 flex items-center justify-between">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
Detalles de Transacción
</h2>
<button
onClick={handleCloseDetail}
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Modal Content */}
<div className="p-6">
{detailLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingSpinner text="Cargando detalles..." />
</div>
) : selectedTransaction ? (
<div className="space-y-6">
{/* Transaction Header */}
<div className="bg-[var(--bg-secondary)] rounded-lg p-4">
<div className="flex items-start justify-between mb-4">
<div>
<div className="text-sm text-[var(--text-secondary)] mb-1">ID Transacción</div>
<div className="font-mono text-lg text-[var(--text-primary)]">
{selectedTransaction.external_transaction_id}
</div>
</div>
<Badge
variant={
selectedTransaction.status === 'completed' ? 'success' :
selectedTransaction.status === 'pending' ? 'warning' :
'error'
}
size="md"
>
{selectedTransaction.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-[var(--text-secondary)] mb-1">Fecha</div>
<div className="text-sm text-[var(--text-primary)]">
{new Date(selectedTransaction.transaction_date).toLocaleString('es-ES', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
<div>
<div className="text-sm text-[var(--text-secondary)] mb-1">Sistema POS</div>
<div className="text-sm text-[var(--text-primary)] capitalize">
{selectedTransaction.pos_system}
</div>
</div>
</div>
</div>
{/* Payment Information */}
<div>
<h3 className="text-sm font-semibold text-[var(--text-primary)] mb-3">Información de Pago</h3>
<div className="bg-[var(--bg-secondary)] rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Método de pago</span>
<span className="text-sm font-medium text-[var(--text-primary)] capitalize">
{selectedTransaction.payment_method || 'N/A'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Subtotal</span>
<span className="text-sm text-[var(--text-primary)]">
{formatters.currency(selectedTransaction.subtotal)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Impuestos</span>
<span className="text-sm text-[var(--text-primary)]">
{formatters.currency(selectedTransaction.tax_amount)}
</span>
</div>
{selectedTransaction.discount_amount && parseFloat(String(selectedTransaction.discount_amount)) > 0 && (
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Descuento</span>
<span className="text-sm text-green-600">
-{formatters.currency(selectedTransaction.discount_amount)}
</span>
</div>
)}
{selectedTransaction.tip_amount && parseFloat(String(selectedTransaction.tip_amount)) > 0 && (
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Propina</span>
<span className="text-sm text-[var(--text-primary)]">
{formatters.currency(selectedTransaction.tip_amount)}
</span>
</div>
)}
<div className="pt-3 border-t border-[var(--border-primary)] flex items-center justify-between">
<span className="font-semibold text-[var(--text-primary)]">Total</span>
<span className="text-xl font-bold text-[var(--text-primary)]">
{formatters.currency(selectedTransaction.total_amount)}
</span>
</div>
</div>
</div>
{/* Transaction Items */}
{selectedTransaction.items && selectedTransaction.items.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-[var(--text-primary)] mb-3">
Artículos ({selectedTransaction.items.length})
</h3>
<div className="space-y-2">
{selectedTransaction.items.map((item) => (
<div
key={item.id}
className="bg-[var(--bg-secondary)] rounded-lg p-4 flex items-center justify-between"
>
<div className="flex-1">
<div className="font-medium text-[var(--text-primary)]">
{item.product_name}
</div>
{item.sku && (
<div className="text-xs text-[var(--text-tertiary)] font-mono mt-1">
SKU: {item.sku}
</div>
)}
<div className="text-sm text-[var(--text-secondary)] mt-1">
{item.quantity} × {formatters.currency(item.unit_price)}
</div>
</div>
<div className="text-right">
<div className="font-semibold text-[var(--text-primary)]">
{formatters.currency(item.total_price)}
</div>
{item.is_synced_to_sales ? (
<div className="text-xs text-green-600 mt-1 flex items-center justify-end gap-1">
<CheckCircle className="w-3 h-3" />
Sincronizado
</div>
) : (
<div className="text-xs text-yellow-600 mt-1 flex items-center justify-end gap-1">
<Clock className="w-3 h-3" />
Pendiente
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Sync Status */}
<div className="bg-[var(--bg-secondary)] rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
{selectedTransaction.is_synced_to_sales ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<Clock className="w-5 h-5 text-yellow-500" />
)}
<span className="font-medium text-[var(--text-primary)]">
Estado de Sincronización
</span>
</div>
<div className="text-sm text-[var(--text-secondary)]">
{selectedTransaction.is_synced_to_sales ? (
<>
Sincronizado exitosamente
{selectedTransaction.sync_completed_at && (
<span className="block mt-1">
{new Date(selectedTransaction.sync_completed_at).toLocaleString('es-ES')}
</span>
)}
</>
) : (
'Pendiente de sincronización con sistema de ventas'
)}
</div>
{selectedTransaction.sync_error && (
<div className="mt-2 text-sm text-red-600">
Error: {selectedTransaction.sync_error}
</div>
)}
</div>
</div>
) : (
<div className="text-center py-12 text-[var(--text-secondary)]">
No se encontraron detalles de la transacción
</div>
)}
</div>
{/* Modal Footer */}
<div className="sticky bottom-0 bg-[var(--bg-primary)] border-t border-[var(--border-primary)] px-6 py-4">
<Button onClick={handleCloseDetail} variant="secondary" className="w-full sm:w-auto">
Cerrar
</Button>
</div>
</div>
</div>
)}
</>
);
};
const POSPage: React.FC = () => {
const [cart, setCart] = useState<CartItem[]>([]);
const [selectedCategory, setSelectedCategory] = useState('all');
const [showStats, setShowStats] = useState(false);
// POS Configuration State
const [showPosConfigModal, setShowPosConfigModal] = useState(false);
const [selectedPosConfig, setSelectedPosConfig] = useState<POSConfiguration | null>(null);
const [posConfigMode, setPosConfigMode] = useState<'create' | 'edit'>('create');
const [testingConnection, setTestingConnection] = useState<string | null>(null);
const tenantId = useTenantId();
// POS Configuration hooks
const posData = usePOSConfigurationData(tenantId);
const posManager = usePOSConfigurationManager(tenantId);
// Set initial POS mode based on whether there are configured integrations
// Default to 'automatic' if POS configurations exist, otherwise 'manual'
const [posMode, setPosMode] = useState<'manual' | 'automatic'>(() => {
return posData.configurations.length > 0 ? 'automatic' : 'manual';
});
// Update posMode when configurations change (e.g., when first config is added)
React.useEffect(() => {
if (!posData.isLoading && posData.configurations.length > 0 && posMode === 'manual') {
setPosMode('automatic');
}
}, [posData.configurations.length, posData.isLoading]);
// Fetch finished products from API
const {
data: ingredientsData,
isLoading: productsLoading,
error: productsError
} = useIngredients(tenantId, {
category: undefined,
search: undefined
});
// Filter for finished products and convert to POS format
const products = useMemo(() => {
if (!ingredientsData) return [];
return ingredientsData
.filter(ingredient => ingredient.product_type === ProductType.FINISHED_PRODUCT)
.map(ingredient => ({
id: ingredient.id,
name: ingredient.name,
price: Number(ingredient.average_cost) || 0,
category: ingredient.category?.toLowerCase() || 'uncategorized',
stock: Number(ingredient.current_stock) || 0,
ingredient: ingredient
}))
.filter(product => product.stock > 0);
}, [ingredientsData]);
// Generate categories from actual product data with bakery colors
const categories = useMemo(() => {
const categoryMap = new Map();
categoryMap.set('all', {
id: 'all',
name: 'Todos',
color: bakeryColors.flour,
icon: '🛍️'
});
// Define category colors from bakery palette
const categoryColors: Record<string, string> = {
bread: bakeryColors.sourdough,
pastry: bakeryColors.brioche,
cake: bakeryColors.strawberry,
cookie: bakeryColors.caramel,
beverage: bakeryColors.espresso,
default: bakeryColors.wheat
};
const categoryIcons: Record<string, string> = {
bread: '🍞',
pastry: '🥐',
cake: '🎂',
cookie: '🍪',
beverage: '☕',
default: '📦'
};
products.forEach(product => {
if (!categoryMap.has(product.category)) {
const categoryName = product.category.charAt(0).toUpperCase() + product.category.slice(1);
categoryMap.set(product.category, {
id: product.category,
name: categoryName,
color: categoryColors[product.category] || categoryColors.default,
icon: categoryIcons[product.category] || categoryIcons.default
});
}
});
return Array.from(categoryMap.values());
}, [products]);
// Load POS configurations function for refetching after updates
const loadPosConfigurations = () => {
console.log('POS configurations updated, consider implementing refetch if needed');
};
const filteredProducts = useMemo(() => {
return products.filter(product =>
selectedCategory === 'all' || product.category === selectedCategory
);
}, [products, selectedCategory]);
// POS Configuration Handlers
const handleAddPosConfiguration = () => {
setSelectedPosConfig(null);
setPosConfigMode('create');
setShowPosConfigModal(true);
};
const handleEditPosConfiguration = (config: POSConfiguration) => {
setSelectedPosConfig(config);
setPosConfigMode('edit');
setShowPosConfigModal(true);
};
const handlePosConfigSuccess = () => {
loadPosConfigurations();
setShowPosConfigModal(false);
};
const handleTestPosConnection = async (configId: string) => {
try {
setTestingConnection(configId);
const response = await posService.testPOSConnection({
tenant_id: tenantId,
config_id: configId,
});
if (response.success) {
showToast.success('Conexión exitosa');
} else {
showToast.error(`Error en la conexión: ${response.message || 'Error desconocido'}`);
}
} catch (error) {
showToast.error('Error al probar la conexión');
} finally {
setTestingConnection(null);
}
};
const handleDeletePosConfiguration = async (configId: string) => {
if (!window.confirm('¿Estás seguro de que deseas eliminar esta configuración?')) {
return;
}
try {
await posService.deletePOSConfiguration({
tenant_id: tenantId,
config_id: configId,
});
showToast.success('Configuración eliminada correctamente');
loadPosConfigurations();
} catch (error) {
showToast.error('Error al eliminar la configuración');
}
};
const addToCart = (product: typeof products[0]) => {
setCart(prevCart => {
const existingItem = prevCart.find(item => item.id === product.id);
if (existingItem) {
if (existingItem.quantity >= product.stock) {
return prevCart;
}
return prevCart.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
} else {
return [...prevCart, {
id: product.id,
name: product.name,
price: product.price,
quantity: 1,
category: product.category,
stock: product.stock
}];
}
});
};
const updateQuantity = (id: string, quantity: number) => {
if (quantity <= 0) {
setCart(prevCart => prevCart.filter(item => item.id !== id));
} else {
setCart(prevCart =>
prevCart.map(item => {
if (item.id === id) {
const maxQuantity = Math.min(quantity, item.stock);
return { ...item, quantity: maxQuantity };
}
return item;
})
);
}
};
const clearCart = () => {
setCart([]);
};
const subtotal = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
const taxRate = 0.21;
const tax = subtotal * taxRate;
const total = subtotal + tax;
const processPayment = (paymentData: any) => {
if (cart.length === 0) return;
console.log('Processing payment:', {
cart,
...paymentData,
total,
});
setCart([]);
showToast.success('Venta procesada exitosamente');
};
// Loading and error states
if (productsLoading || !tenantId) {
return (
<div className="flex items-center justify-center min-h-64">
<LoadingSpinner text="Cargando productos..." />
</div>
);
}
if (productsError) {
return (
<div className="text-center py-12">
<Package className="mx-auto h-12 w-12 text-red-500 mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
Error al cargar productos
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{productsError.message || 'Ha ocurrido un error inesperado'}
</p>
<Button onClick={() => window.location.reload()}>
Reintentar
</Button>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title="Punto de Venta"
description="Sistema de ventas para productos terminados"
/>
{/* POS Mode Toggle */}
<Card className="p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">Modo de Operación</h3>
<p className="text-sm text-[var(--text-secondary)]">
{posMode === 'manual'
? 'Sistema POS manual interno - gestiona las ventas directamente desde la aplicación'
: 'Integración automática con sistemas POS externos - sincroniza datos automáticamente'
}
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<span className={`text-sm ${posMode === 'manual' ? 'text-[var(--color-primary)] font-medium' : 'text-[var(--text-tertiary)]'}`}>
Manual
</span>
<button
onClick={() => setPosMode(posMode === 'manual' ? 'automatic' : 'manual')}
className="relative inline-flex items-center"
>
{posMode === 'manual' ? (
<ToggleLeft className="w-8 h-8 text-[var(--text-tertiary)] hover:text-[var(--color-primary)] transition-colors" />
) : (
<ToggleRight className="w-8 h-8 text-[var(--color-primary)] hover:text-[var(--color-primary-dark)] transition-colors" />
)}
</button>
<span className={`text-sm ${posMode === 'automatic' ? 'text-[var(--color-primary)] font-medium' : 'text-[var(--text-tertiary)]'}`}>
Automático
</span>
</div>
</div>
</div>
</Card>
{posMode === 'manual' ? (
<>
{/* Main 2-Column Layout */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column: Products (2/3 width on desktop) */}
<div className="lg:col-span-2 space-y-6">
{/* Category Pills with Bakery Colors */}
<Card className="p-4">
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-thin">
{categories.map(category => (
<button
key={category.id}
onClick={() => setSelectedCategory(category.id)}
className={`
flex items-center gap-2 px-4 py-3 rounded-xl font-semibold text-sm whitespace-nowrap
transition-all duration-200 hover:scale-105 active:scale-95
${selectedCategory === category.id
? 'shadow-lg ring-2'
: 'shadow hover:shadow-md'
}
`}
style={{
backgroundColor: selectedCategory === category.id
? category.color
: 'var(--bg-secondary)',
color: selectedCategory === category.id
? '#ffffff'
: 'var(--text-primary)',
borderColor: category.color,
...(selectedCategory === category.id && {
ringColor: category.color,
}),
}}
>
<span className="text-lg">{category.icon}</span>
<span>{category.name}</span>
</button>
))}
</div>
</Card>
{/* Products Grid - Large Touch-Friendly Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredProducts.map(product => {
const cartItem = cart.find(item => item.id === product.id);
const cartQuantity = cartItem?.quantity || 0;
return (
<POSProductCard
key={product.id}
id={product.id}
name={product.name}
price={product.price}
category={product.category}
stock={product.stock}
cartQuantity={cartQuantity}
onAddToCart={() => addToCart(product)}
/>
);
})}
</div>
{/* Empty State */}
{filteredProducts.length === 0 && (
<div className="text-center py-12">
<Package className="mx-auto h-16 w-16 text-[var(--text-tertiary)] mb-4 opacity-30" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No hay productos disponibles
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{selectedCategory === 'all'
? 'No hay productos en stock en este momento'
: `No hay productos en la categoría "${categories.find(c => c.id === selectedCategory)?.name}"`
}
</p>
</div>
)}
</div>
{/* Right Column: Cart & Payment (1/3 width on desktop) */}
<div className="lg:sticky lg:top-6 lg:self-start space-y-6">
{/* Cart Component */}
<Card className="p-6 min-h-[400px]">
<POSCart
cart={cart}
onUpdateQuantity={updateQuantity}
onClearCart={clearCart}
taxRate={taxRate}
/>
</Card>
{/* Payment Component */}
<POSPayment
total={total}
onProcessPayment={processPayment}
disabled={cart.length === 0}
/>
</div>
</div>
</>
) : (
/* Automatic POS Integration Section with StatusCard */
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)] flex items-center">
<Zap className="w-5 h-5 mr-2 text-yellow-500" />
Sistemas POS Integrados
</h3>
<p className="text-sm text-[var(--text-secondary)]">
Gestiona tus sistemas POS externos y configuraciones de integración
</p>
</div>
<Button
onClick={handleAddPosConfiguration}
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Agregar Sistema POS
</Button>
</div>
{posData.isLoading ? (
<div className="flex items-center justify-center h-32">
<Loader className="w-8 h-8 animate-spin" />
</div>
) : posData.configurations.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Zap className="w-8 h-8 text-gray-400" />
</div>
<h3 className="text-lg font-medium mb-2">No hay sistemas POS configurados</h3>
<p className="text-gray-500 mb-4">
Configura tu primer sistema POS para comenzar a sincronizar datos de ventas.
</p>
<Button onClick={handleAddPosConfiguration}>
Agregar Sistema POS
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{posData.configurations.map(config => {
const provider = posData.supportedSystems.find(p => p.id === config.pos_system);
const isConnected = config.is_connected && config.is_active;
return (
<StatusCard
key={config.id}
id={config.id}
statusIndicator={{
color: isConnected ? getStatusColor('completed') : getStatusColor('cancelled'),
text: isConnected ? 'Conectado' : 'Desconectado',
icon: isConnected ? CheckCircle : WifiOff,
isCritical: !isConnected,
}}
title={config.provider_name}
subtitle={provider?.name || config.pos_system}
primaryValue={config.last_sync_at
? new Date(config.last_sync_at).toLocaleString('es-ES', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
: 'Sin sincronizar'
}
primaryValueLabel="Última sincronización"
actions={[
{
label: 'Probar',
icon: Wifi,
onClick: () => handleTestPosConnection(config.id),
priority: 'secondary' as const,
disabled: testingConnection === config.id,
},
{
label: 'Editar',
icon: Settings,
onClick: () => handleEditPosConfiguration(config),
priority: 'secondary' as const,
},
{
label: 'Eliminar',
icon: Trash2,
onClick: () => handleDeletePosConfiguration(config.id),
priority: 'tertiary' as const,
destructive: true,
},
]}
/>
);
})}
</div>
)}
</Card>
{/* Transactions Section - Only show if there are configurations */}
{posData.configurations.length > 0 && (
<TransactionsSection tenantId={tenantId} />
)}
</div>
)}
{/* POS Configuration Modal */}
<CreatePOSConfigModal
isOpen={showPosConfigModal}
onClose={() => setShowPosConfigModal(false)}
tenantId={tenantId}
onSuccess={handlePosConfigSuccess}
existingConfig={selectedPosConfig}
mode={posConfigMode}
/>
</div>
);
};
export default POSPage;