Files
SIG-CM/frontend/counter-panel/src/pages/UniversalPosPage.tsx

381 lines
16 KiB
TypeScript
Raw Normal View History

2026-01-07 17:52:10 -03:00
import { useState, useEffect } from 'react';
import { useCartStore } from '../store/cartStore';
import { productService } from '../services/productService';
import type { Product } from '../types/Product';
import ProductSearch from '../components/POS/ProductSearch';
import { Trash2, ShoppingCart, CreditCard, User, Box, Layers } from 'lucide-react';
import { useToast } from '../context/use-toast';
import PaymentModal, { type Payment } from '../components/PaymentModal';
import { orderService } from '../services/orderService';
import type { CreateOrderRequest } from '../types/Order';
import AdEditorModal from '../components/POS/AdEditorModal';
import BundleConfiguratorModal, { type ComponentConfig } from '../components/POS/BundleConfiguratorModal';
2026-02-21 19:23:17 -03:00
import ClientCreateModal from '../components/POS/ClientCreateModal';
import ClientSearchModal from '../components/POS/ClientSearchModal';
import { AnimatePresence } from 'framer-motion';
2026-01-07 17:52:10 -03:00
export default function UniversalPosPage() {
const { showToast } = useToast();
const { items, addItem, removeItem, clearCart, getTotal, clientId, clientName, setClient, sellerId, setSeller } = useCartStore();
const [catalog, setCatalog] = useState<Product[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [showPayment, setShowPayment] = useState(false);
// Estados de Modales
const [showAdEditor, setShowAdEditor] = useState(false);
const [selectedAdProduct, setSelectedAdProduct] = useState<Product | null>(null);
const [showBundleConfigurator, setShowBundleConfigurator] = useState(false);
const [selectedBundle, setSelectedBundle] = useState<Product | null>(null);
2026-02-21 19:23:17 -03:00
const [showCreateClient, setShowCreateClient] = useState(false);
const [showClientSearch, setShowClientSearch] = useState(false);
2026-01-07 17:52:10 -03:00
// Estado de carga para agregar combos (puede tardar un poco en traer los hijos)
const [addingProduct, setAddingProduct] = useState(false);
useEffect(() => {
productService.getAll().then(setCatalog).catch(console.error);
const userStr = localStorage.getItem('user');
if (userStr) {
try {
const user = JSON.parse(userStr);
if (user.id) setSeller(user.id);
} catch { /* ... */ }
}
}, [setSeller]);
// Manejador de Teclado Global del POS
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// F10: Cobrar
if (e.key === 'F10') {
e.preventDefault();
handleCheckout();
}
// F7: Cambiar Cliente (Antes F9)
if (e.key === 'F7') {
e.preventDefault();
handleChangeClient();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [items, clientId]); // Dependencias para que handleCheckout tenga el estado fresco
const handleProductSelect = async (product: Product) => {
setAddingProduct(true);
try {
// 1. AVISOS CLASIFICADOS
if (product.typeCode === 'CLASSIFIED_AD') {
if (!clientId) setClient(1005, "Consumidor Final (Default)");
setSelectedAdProduct(product);
setShowAdEditor(true);
return;
}
// 2. COMBOS (BUNDLES) - Lógica de Visualización y Configuración
2026-01-07 17:52:10 -03:00
if (product.typeCode === 'BUNDLE') {
if (!clientId) setClient(1005, "Consumidor Final (Default)");
setSelectedBundle(product);
setShowBundleConfigurator(true);
2026-01-07 17:52:10 -03:00
return;
}
// 3. PRODUCTO ESTÁNDAR
addItem(product, 1);
showToast(`${product.name} agregado`, 'success');
} catch (error) {
console.error(error);
showToast("Error al agregar producto", "error");
} finally {
setAddingProduct(false);
}
};
const handleAdConfirmed = (listingId: number, price: number, description: string) => {
if (selectedAdProduct) {
addItem(
{ ...selectedAdProduct, basePrice: price },
1,
{
relatedEntity: { id: listingId, type: 'Listing', extraInfo: description }
}
);
showToast('Aviso agregado al carrito', 'success');
}
};
const handleBundleConfirmed = (configs: ComponentConfig[]) => {
if (selectedBundle) {
const subItemsNames = configs.map(c =>
`${c.product.name} ${c.isConfigured ? '✓' : ''}`
);
addItem(selectedBundle, 1, {
subItems: subItemsNames,
componentsData: configs
});
showToast(`${selectedBundle.name} configurado y agregado`, 'success');
setShowBundleConfigurator(false);
}
};
2026-01-07 17:52:10 -03:00
const handleCheckout = () => {
if (items.length === 0) return showToast("El carrito está vacío", "error");
if (!clientId) setClient(1005, "Consumidor Final");
setShowPayment(true);
};
2026-02-21 19:23:17 -03:00
const handleChangeClient = () => {
setShowClientSearch(true);
};
// Callback cuando seleccionan del buscador
const handleClientSelected = (client: { id: number; name: string }) => {
setClient(client.id, client.name);
// showClientSearch se cierra automáticamente por el componente, o lo forzamos aquí si es necesario
// El componente ClientSearchModal llama a onClose internamente después de onSelect
};
// Callback cuando crean uno nuevo
const handleClientCreated = (client: { id: number; name: string }) => {
setClient(client.id, client.name);
setShowCreateClient(false);
};
// Función puente: Del Buscador -> Al Creador
const switchToCreate = () => {
setShowClientSearch(false);
setTimeout(() => setShowCreateClient(true), 100); // Pequeño delay para transición suave
};
2026-01-07 17:52:10 -03:00
const finalizeOrder = async (_payments: Payment[], isCreditSale: boolean) => {
setIsProcessing(true);
try {
const isDirectPayment = !isCreditSale;
const payload: CreateOrderRequest = {
clientId: clientId || 1005,
sellerId: sellerId || 2,
isDirectPayment: isDirectPayment,
notes: "Venta de Mostrador (Universal POS)",
items: items.flatMap(i => {
if (i.componentsData && i.componentsData.length > 0) {
// Expandir el combo en sus partes para el backend
// El precio se prorratea según el total del combo
const bundleTotal = i.subTotal;
const sumAllocations = i.componentsData.reduce((acc, c) => acc + (c.allocationAmount || 0), 0);
const ratio = sumAllocations > 0 ? (bundleTotal / sumAllocations) : 1;
return i.componentsData.map(c => ({
productId: c.productId,
quantity: 1,
unitPrice: (c.allocationAmount || 0) * ratio,
relatedEntityId: c.listingId,
relatedEntityType: c.listingId ? 'Listing' : undefined
}));
}
return [{
productId: i.productId,
quantity: i.quantity,
unitPrice: i.unitPrice,
relatedEntityId: i.relatedEntityId,
relatedEntityType: i.relatedEntityType
}];
})
2026-01-07 17:52:10 -03:00
};
const result = await orderService.createOrder(payload);
showToast(`Orden ${result.orderNumber} generada con éxito`, 'success');
clearCart();
setShowPayment(false);
} catch (error: any) {
console.error(error);
const msg = error.response?.data?.message || error.message || "Error al procesar la venta";
showToast(msg, "error");
} finally {
setIsProcessing(false);
}
};
return (
<div className={`h-full flex gap-6 p-6 ${addingProduct ? 'cursor-wait opacity-80' : ''}`}>
{/* SECCIÓN IZQUIERDA */}
<div className="flex-[2] flex flex-col gap-6 min-w-0">
<div className="bg-white p-6 rounded-[2rem] shadow-sm border border-slate-200">
<h2 className="text-xl font-black text-slate-800 uppercase tracking-tight mb-4 flex items-center gap-2">
<Box className="text-blue-600" /> Nueva Venta
</h2>
<ProductSearch products={catalog} onSelect={handleProductSelect} />
</div>
<div className="flex-1 bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-6 overflow-y-auto custom-scrollbar">
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest mb-4">Accesos Rápidos</h3>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{catalog.filter(p => p.typeCode === 'PHYSICAL' || p.typeCode === 'BUNDLE').slice(0, 9).map(p => (
<button
key={p.id}
onClick={() => handleProductSelect(p)}
className="bg-white p-4 rounded-xl shadow-sm border border-slate-200 hover:border-blue-400 hover:shadow-md transition-all text-left group flex flex-col justify-between h-24 relative overflow-hidden"
>
{p.typeCode === 'BUNDLE' && (
<div className="absolute top-0 right-0 bg-purple-100 text-purple-600 p-1 rounded-bl-lg">
<Layers size={12} />
</div>
)}
<div className="text-xs font-bold text-slate-700 group-hover:text-blue-700 line-clamp-2 leading-tight pr-4">
{p.name}
</div>
<div>
<div className="text-[10px] font-black text-slate-400 mt-1 uppercase">{p.typeCode === 'BUNDLE' ? 'Combo' : 'Producto'}</div>
<div className="text-sm font-black text-slate-900">$ {p.basePrice.toLocaleString()}</div>
</div>
</button>
))}
</div>
</div>
</div>
{/* SECCIÓN DERECHA: CARRITO */}
<div className="flex-1 bg-white rounded-[2rem] shadow-xl border border-slate-200 flex flex-col overflow-hidden min-w-[340px]">
{/* Header Carrito */}
<div className="p-6 bg-slate-900 text-white border-b border-slate-800 shrink-0">
<div className="flex justify-between items-center mb-1">
<span className="text-[10px] font-black uppercase tracking-widest text-blue-400">Orden Actual</span>
<ShoppingCart size={18} className="text-slate-400" />
</div>
<div className="text-3xl font-mono font-black tracking-tight truncate">
$ {getTotal().toLocaleString()}
</div>
</div>
{/* Lista de Items */}
<div className="flex-1 overflow-y-auto p-4 space-y-2 custom-scrollbar">
{items.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-slate-300 gap-2">
<ShoppingCart size={48} className="opacity-20" />
<span className="text-xs font-bold uppercase tracking-widest">Carrito Vacío</span>
</div>
) : (
items.map(item => (
<div key={item.tempId} className="flex flex-col bg-slate-50 rounded-xl border border-slate-100 group hover:border-blue-200 transition-colors overflow-hidden">
{/* Cabecera del Item */}
<div className="flex justify-between items-center p-3">
<div className="flex-1 min-w-0 pr-2">
<div className="text-xs font-bold text-slate-800 truncate" title={item.productName}>
{item.productName}
</div>
<div className="text-[10px] font-mono text-slate-500">
{item.quantity} x ${item.unitPrice.toLocaleString()}
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="font-mono font-black text-sm text-slate-900">${item.subTotal.toLocaleString()}</span>
<button onClick={() => removeItem(item.tempId)} className="text-rose-300 hover:text-rose-500 transition-colors p-1 hover:bg-rose-50 rounded">
<Trash2 size={14} />
</button>
</div>
</div>
{/* VISUALIZACIÓN DE COMPONENTES DEL COMBO */}
{item.subItems && item.subItems.length > 0 && (
<div className="bg-purple-50/50 px-3 py-2 border-t border-slate-100">
<div className="text-[9px] font-black text-purple-400 uppercase tracking-widest mb-1 flex items-center gap-1">
<Layers size={10} /> Incluye:
</div>
<ul className="space-y-0.5">
{item.subItems.map((sub, idx) => (
<li key={idx} className="text-[10px] text-slate-600 pl-3 relative flex items-center gap-1">
<div className="w-1 h-1 bg-purple-300 rounded-full"></div>
{sub}
</li>
))}
</ul>
</div>
)}
</div>
))
)}
</div>
{/* Cliente y Acciones */}
<div className="p-6 border-t border-slate-100 bg-slate-50 space-y-4 shrink-0">
<div
onClick={handleChangeClient}
className="flex items-center justify-between p-3 bg-white rounded-xl border border-slate-200 cursor-pointer hover:border-blue-300 transition-colors group"
>
<div className="flex items-center gap-3 min-w-0">
<div className="p-2 bg-blue-50 text-blue-600 rounded-lg group-hover:bg-blue-100 transition-colors"><User size={16} /></div>
<div className="min-w-0">
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Cliente</div>
<div className="text-xs font-bold text-slate-800 truncate">{clientName || "Consumidor Final"}</div>
</div>
</div>
<div className="text-[10px] font-bold text-blue-600 uppercase whitespace-nowrap">F7 Cambiar</div>
</div>
<button
onClick={handleCheckout}
disabled={items.length === 0 || isProcessing}
className="w-full py-4 bg-emerald-600 text-white rounded-xl font-black uppercase text-xs tracking-widest shadow-lg shadow-emerald-200 hover:bg-emerald-700 transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-95"
>
<CreditCard size={16} /> {isProcessing ? 'Procesando...' : 'Cobrar Orden (F10)'}
</button>
</div>
</div>
{/* --- MODALES --- */}
{showPayment && (
<PaymentModal
totalAmount={getTotal()}
clientId={clientId}
onConfirm={finalizeOrder}
onCancel={() => setShowPayment(false)}
/>
)}
{showAdEditor && (
<AdEditorModal
isOpen={showAdEditor}
onClose={() => setShowAdEditor(false)}
onConfirm={handleAdConfirmed}
clientId={clientId || 1005}
productId={selectedAdProduct?.id || 0}
2026-01-07 17:52:10 -03:00
/>
)}
2026-02-21 19:23:17 -03:00
{showBundleConfigurator && selectedBundle && (
<BundleConfiguratorModal
bundle={selectedBundle}
clientId={clientId || 1005}
onClose={() => setShowBundleConfigurator(false)}
onConfirm={handleBundleConfirmed}
/>
)}
2026-02-21 19:23:17 -03:00
{/* MODAL DE BÚSQUEDA DE CLIENTE (F7) */}
<AnimatePresence>
{showClientSearch && (
<ClientSearchModal
onClose={() => setShowClientSearch(false)}
onSelect={handleClientSelected}
onCreateNew={switchToCreate}
/>
)}
</AnimatePresence>
{/* MODAL DE ALTA RÁPIDA */}
<AnimatePresence>
{showCreateClient && (
<ClientCreateModal
onClose={() => setShowCreateClient(false)}
onSuccess={handleClientCreated}
/>
)}
</AnimatePresence>
2026-01-07 17:52:10 -03:00
</div>
);
}