Files
bakery-ia/frontend/src/pages/app/operations/pos/POSPage.tsx

1050 lines
44 KiB
TypeScript
Raw Normal View History

2025-09-21 22:56:55 +02:00
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';
2025-08-28 10:41:04 +02:00
import { PageHeader } from '../../../../components/layout';
2025-09-26 07:46:25 +02:00
import { LoadingSpinner } from '../../../../components/ui';
2025-09-21 22:56:55 +02:00
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';
2025-10-30 21:08:07 +01:00
import { showToast } from '../../../../utils/toast';
import { usePOSConfigurationData, usePOSConfigurationManager, usePOSTransactions, usePOSTransactionsDashboard, usePOSTransaction } from '../../../../api/hooks/pos';
2025-10-21 19:50:07 +02:00
import { POSConfiguration } from '../../../../api/types/pos';
2025-09-24 22:22:01 +02:00
import { posService } from '../../../../api/services/pos';
2025-10-21 19:50:07 +02:00
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';
2025-09-21 22:56:55 +02:00
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
category: string;
stock: number;
}
2025-08-28 10:41:04 +02:00
// 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>
)}
</>
);
};
2025-08-28 10:41:04 +02:00
const POSPage: React.FC = () => {
2025-09-21 22:56:55 +02:00
const [cart, setCart] = useState<CartItem[]>([]);
2025-08-28 10:41:04 +02:00
const [selectedCategory, setSelectedCategory] = useState('all');
2025-10-21 19:50:07 +02:00
const [showStats, setShowStats] = useState(false);
2025-09-24 22:22:01 +02:00
// POS Configuration State
2025-10-21 19:50:07 +02:00
const [showPosConfigModal, setShowPosConfigModal] = useState(false);
2025-09-24 22:22:01 +02:00
const [selectedPosConfig, setSelectedPosConfig] = useState<POSConfiguration | null>(null);
2025-10-21 19:50:07 +02:00
const [posConfigMode, setPosConfigMode] = useState<'create' | 'edit'>('create');
2025-09-24 22:22:01 +02:00
const [testingConnection, setTestingConnection] = useState<string | null>(null);
2025-08-28 10:41:04 +02:00
2025-09-21 22:56:55 +02:00
const tenantId = useTenantId();
2025-10-30 21:08:07 +01:00
2025-09-24 22:22:01 +02:00
// POS Configuration hooks
const posData = usePOSConfigurationData(tenantId);
const posManager = usePOSConfigurationManager(tenantId);
2025-08-28 10:41:04 +02:00
// 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]);
2025-09-21 22:56:55 +02:00
// Fetch finished products from API
const {
data: ingredientsData,
isLoading: productsLoading,
error: productsError
} = useIngredients(tenantId, {
2025-10-21 19:50:07 +02:00
category: undefined,
2025-09-21 22:56:55 +02:00
search: undefined
});
// Filter for finished products and convert to POS format
const products = useMemo(() => {
2025-09-21 22:56:55 +02:00
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',
2025-09-21 22:56:55 +02:00
stock: Number(ingredient.current_stock) || 0,
ingredient: ingredient
}))
2025-10-21 19:50:07 +02:00
.filter(product => product.stock > 0);
2025-09-21 22:56:55 +02:00
}, [ingredientsData]);
2025-10-21 19:50:07 +02:00
// Generate categories from actual product data with bakery colors
2025-09-21 22:56:55 +02:00
const categories = useMemo(() => {
const categoryMap = new Map();
2025-10-21 19:50:07 +02:00
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: '📦'
};
2025-09-21 22:56:55 +02:00
products.forEach(product => {
if (!categoryMap.has(product.category)) {
const categoryName = product.category.charAt(0).toUpperCase() + product.category.slice(1);
2025-10-21 19:50:07 +02:00
categoryMap.set(product.category, {
id: product.category,
name: categoryName,
color: categoryColors[product.category] || categoryColors.default,
icon: categoryIcons[product.category] || categoryIcons.default
});
2025-09-21 22:56:55 +02:00
}
});
return Array.from(categoryMap.values());
}, [products]);
2025-08-28 10:41:04 +02:00
2025-10-21 19:50:07 +02:00
// Load POS configurations function for refetching after updates
const loadPosConfigurations = () => {
console.log('POS configurations updated, consider implementing refetch if needed');
};
2025-09-24 22:22:01 +02:00
2025-09-21 22:56:55 +02:00
const filteredProducts = useMemo(() => {
return products.filter(product =>
selectedCategory === 'all' || product.category === selectedCategory
);
}, [products, selectedCategory]);
2025-08-28 10:41:04 +02:00
2025-09-24 22:22:01 +02:00
// POS Configuration Handlers
const handleAddPosConfiguration = () => {
2025-10-21 19:50:07 +02:00
setSelectedPosConfig(null);
setPosConfigMode('create');
setShowPosConfigModal(true);
2025-09-24 22:22:01 +02:00
};
const handleEditPosConfiguration = (config: POSConfiguration) => {
setSelectedPosConfig(config);
2025-10-21 19:50:07 +02:00
setPosConfigMode('edit');
setShowPosConfigModal(true);
2025-09-24 22:22:01 +02:00
};
2025-10-21 19:50:07 +02:00
const handlePosConfigSuccess = () => {
loadPosConfigurations();
setShowPosConfigModal(false);
2025-09-24 22:22:01 +02:00
};
const handleTestPosConnection = async (configId: string) => {
try {
setTestingConnection(configId);
const response = await posService.testPOSConnection({
tenant_id: tenantId,
config_id: configId,
});
if (response.success) {
2025-10-30 21:08:07 +01:00
showToast.success('Conexión exitosa');
2025-09-24 22:22:01 +02:00
} else {
2025-10-30 21:08:07 +01:00
showToast.error(`Error en la conexión: ${response.message || 'Error desconocido'}`);
2025-09-24 22:22:01 +02:00
}
} catch (error) {
2025-10-30 21:08:07 +01:00
showToast.error('Error al probar la conexión');
2025-09-24 22:22:01 +02:00
} 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,
});
2025-10-30 21:08:07 +01:00
showToast.success('Configuración eliminada correctamente');
2025-09-24 22:22:01 +02:00
loadPosConfigurations();
} catch (error) {
2025-10-30 21:08:07 +01:00
showToast.error('Error al eliminar la configuración');
2025-09-24 22:22:01 +02:00
}
};
2025-08-28 10:41:04 +02:00
const addToCart = (product: typeof products[0]) => {
setCart(prevCart => {
const existingItem = prevCart.find(item => item.id === product.id);
if (existingItem) {
2025-09-21 22:56:55 +02:00
if (existingItem.quantity >= product.stock) {
2025-10-21 19:50:07 +02:00
return prevCart;
2025-09-21 22:56:55 +02:00
}
2025-08-28 10:41:04 +02:00
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,
2025-09-21 22:56:55 +02:00
stock: product.stock
2025-08-28 10:41:04 +02:00
}];
}
});
};
const updateQuantity = (id: string, quantity: number) => {
if (quantity <= 0) {
setCart(prevCart => prevCart.filter(item => item.id !== id));
} else {
setCart(prevCart =>
2025-09-21 22:56:55 +02:00
prevCart.map(item => {
if (item.id === id) {
const maxQuantity = Math.min(quantity, item.stock);
return { ...item, quantity: maxQuantity };
}
return item;
})
2025-08-28 10:41:04 +02:00
);
}
};
const clearCart = () => {
setCart([]);
};
const subtotal = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
2025-10-21 19:50:07 +02:00
const taxRate = 0.21;
2025-08-28 10:41:04 +02:00
const tax = subtotal * taxRate;
const total = subtotal + tax;
2025-10-21 19:50:07 +02:00
const processPayment = (paymentData: any) => {
2025-08-28 10:41:04 +02:00
if (cart.length === 0) return;
2025-09-21 22:56:55 +02:00
2025-08-28 10:41:04 +02:00
console.log('Processing payment:', {
cart,
2025-10-21 19:50:07 +02:00
...paymentData,
2025-08-28 10:41:04 +02:00
total,
});
setCart([]);
2025-10-30 21:08:07 +01:00
showToast.success('Venta procesada exitosamente');
2025-08-28 10:41:04 +02:00
};
2025-09-21 22:56:55 +02:00
// 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>
);
}
2025-09-24 22:22:01 +02:00
2025-08-28 10:41:04 +02:00
return (
2025-09-21 22:56:55 +02:00
<div className="space-y-6">
2025-08-28 10:41:04 +02:00
<PageHeader
title="Punto de Venta"
2025-09-21 22:56:55 +02:00
description="Sistema de ventas para productos terminados"
/>
2025-09-24 22:22:01 +02:00
{/* POS Mode Toggle */}
<Card className="p-6">
2025-10-21 19:50:07 +02:00
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
2025-09-24 22:22:01 +02:00
<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' ? (
<>
2025-10-21 19:50:07 +02:00
{/* 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>
2025-08-28 10:41:04 +02:00
2025-10-21 19:50:07 +02:00
{/* 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)}
/>
);
})}
2025-08-28 10:41:04 +02:00
</div>
2025-10-21 19:50:07 +02:00
{/* 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>
2025-08-28 10:41:04 +02:00
</div>
)}
2025-10-21 19:50:07 +02:00
</div>
2025-08-28 10:41:04 +02:00
2025-10-21 19:50:07 +02:00
{/* 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}
/>
2025-08-28 10:41:04 +02:00
</div>
2025-10-21 19:50:07 +02:00
</div>
2025-09-24 22:22:01 +02:00
</>
) : (
2025-10-21 19:50:07 +02:00
/* Automatic POS Integration Section with StatusCard */
2025-09-24 22:22:01 +02:00
<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 ? (
2025-10-21 19:50:07 +02:00
<div className="text-center py-12">
2025-09-24 22:22:01 +02:00
<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);
2025-10-21 19:50:07 +02:00
const isConnected = config.is_connected && config.is_active;
2025-09-24 22:22:01 +02:00
2025-10-21 19:50:07 +02:00
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,
},
]}
/>
2025-09-24 22:22:01 +02:00
);
})}
</div>
)}
</Card>
{/* Transactions Section - Only show if there are configurations */}
{posData.configurations.length > 0 && (
<TransactionsSection tenantId={tenantId} />
)}
2025-09-24 22:22:01 +02:00
</div>
)}
2025-10-21 19:50:07 +02:00
{/* POS Configuration Modal */}
<CreatePOSConfigModal
isOpen={showPosConfigModal}
onClose={() => setShowPosConfigModal(false)}
tenantId={tenantId}
onSuccess={handlePosConfigSuccess}
existingConfig={selectedPosConfig}
mode={posConfigMode}
/>
2025-08-28 10:41:04 +02:00
</div>
);
};
2025-10-21 19:50:07 +02:00
export default POSPage;