Improve the frontend and repository layer

This commit is contained in:
Urtzi Alfaro
2025-10-23 07:44:54 +02:00
parent 8d30172483
commit 07c33fa578
112 changed files with 14726 additions and 2733 deletions

View File

@@ -1,6 +1,6 @@
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, ChevronDown, ChevronUp } from 'lucide-react';
import { Button, Card, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
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';
@@ -8,7 +8,7 @@ import { useIngredients } from '../../../../api/hooks/inventory';
import { useTenantId } from '../../../../hooks/useTenantId';
import { ProductType, ProductCategory, IngredientResponse } from '../../../../api/types/inventory';
import { useToast } from '../../../../hooks/ui/useToast';
import { usePOSConfigurationData, usePOSConfigurationManager } from '../../../../api/hooks/pos';
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';
@@ -28,11 +28,515 @@ interface CartItem {
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 [posMode, setPosMode] = useState<'manual' | 'automatic'>('manual');
const [showPOSConfig, setShowPOSConfig] = useState(false);
const [showStats, setShowStats] = useState(false);
// POS Configuration State
@@ -48,6 +552,19 @@ const POSPage: React.FC = () => {
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,
@@ -59,7 +576,7 @@ const POSPage: React.FC = () => {
});
// Filter for finished products and convert to POS format
const products = useMemo(() => {
const products = useMemo(() => {
if (!ingredientsData) return [];
return ingredientsData
@@ -68,7 +585,7 @@ const POSPage: React.FC = () => {
id: ingredient.id,
name: ingredient.name,
price: Number(ingredient.average_cost) || 0,
category: ingredient.category.toLowerCase(),
category: ingredient.category?.toLowerCase() || 'uncategorized',
stock: Number(ingredient.current_stock) || 0,
ingredient: ingredient
}))
@@ -248,64 +765,6 @@ const POSPage: React.FC = () => {
addToast('Venta procesada exitosamente', { type: 'success' });
};
// Calculate stats for the POS dashboard
const posStats = useMemo(() => {
const totalProducts = products.length;
const totalStock = products.reduce((sum, product) => sum + product.stock, 0);
const cartValue = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
const cartItems = cart.reduce((sum, item) => sum + item.quantity, 0);
const lowStockProducts = products.filter(product => product.stock <= 5).length;
const avgProductPrice = totalProducts > 0 ? products.reduce((sum, product) => sum + product.price, 0) / totalProducts : 0;
return {
totalProducts,
totalStock,
cartValue,
cartItems,
lowStockProducts,
avgProductPrice
};
}, [products, cart]);
const stats = [
{
title: 'Productos Disponibles',
value: posStats.totalProducts,
variant: 'default' as const,
icon: Package,
},
{
title: 'Stock Total',
value: posStats.totalStock,
variant: 'info' as const,
icon: Package,
},
{
title: 'Artículos en Carrito',
value: posStats.cartItems,
variant: 'success' as const,
icon: ShoppingCart,
},
{
title: 'Valor del Carrito',
value: formatters.currency(posStats.cartValue),
variant: 'success' as const,
icon: Euro,
},
{
title: 'Stock Bajo',
value: posStats.lowStockProducts,
variant: 'warning' as const,
icon: Clock,
},
{
title: 'Precio Promedio',
value: formatters.currency(posStats.avgProductPrice),
variant: 'info' as const,
icon: TrendingUp,
},
];
// Loading and error states
if (productsLoading || !tenantId) {
return (
@@ -371,47 +830,12 @@ const POSPage: React.FC = () => {
Automático
</span>
</div>
{posMode === 'automatic' && (
<Button
variant="outline"
onClick={() => setShowPOSConfig(!showPOSConfig)}
className="flex items-center gap-2"
>
<Settings className="w-4 h-4" />
Configurar POS
</Button>
)}
</div>
</div>
</Card>
{posMode === 'manual' ? (
<>
{/* Collapsible Stats Grid */}
<Card className="p-4">
<button
onClick={() => setShowStats(!showStats)}
className="w-full flex items-center justify-between text-left"
>
<div className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-[var(--color-primary)]" />
<span className="font-semibold text-[var(--text-primary)]">
Estadísticas del POS
</span>
</div>
{showStats ? (
<ChevronUp className="w-5 h-5 text-[var(--text-tertiary)]" />
) : (
<ChevronDown className="w-5 h-5 text-[var(--text-tertiary)]" />
)}
</button>
{showStats && (
<div className="mt-4 pt-4 border-t border-[var(--border-primary)]">
<StatsGrid stats={stats} columns={3} />
</div>
)}
</Card>
{/* Main 2-Column Layout */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column: Products (2/3 width on desktop) */}
@@ -601,6 +1025,11 @@ const POSPage: React.FC = () => {
</div>
)}
</Card>
{/* Transactions Section - Only show if there are configurations */}
{posData.configurations.length > 0 && (
<TransactionsSection tenantId={tenantId} />
)}
</div>
)}