2025-09-21 22:56:55 +02:00
|
|
|
|
import React, { useState, useMemo } from 'react';
|
2025-10-23 07:44:54 +02:00
|
|
|
|
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';
|
2025-10-23 07:44:54 +02:00
|
|
|
|
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
|
|
|
|
|
2025-10-23 07:44:54 +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
|
|
|
|
|
2025-10-23 07:44:54 +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
|
2025-10-23 07:44:54 +02:00
|
|
|
|
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,
|
2025-10-23 07:44:54 +02:00
|
|
|
|
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>
|
2025-10-23 07:44:54 +02:00
|
|
|
|
|
|
|
|
|
|
{/* 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;
|