1050 lines
44 KiB
TypeScript
1050 lines
44 KiB
TypeScript
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;
|