381 lines
16 KiB
TypeScript
381 lines
16 KiB
TypeScript
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';
|
|
import ClientCreateModal from '../components/POS/ClientCreateModal';
|
|
import ClientSearchModal from '../components/POS/ClientSearchModal';
|
|
import { AnimatePresence } from 'framer-motion';
|
|
|
|
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);
|
|
const [showCreateClient, setShowCreateClient] = useState(false);
|
|
const [showClientSearch, setShowClientSearch] = useState(false);
|
|
|
|
// 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
|
|
if (product.typeCode === 'BUNDLE') {
|
|
if (!clientId) setClient(1005, "Consumidor Final (Default)");
|
|
setSelectedBundle(product);
|
|
setShowBundleConfigurator(true);
|
|
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);
|
|
}
|
|
};
|
|
|
|
const handleCheckout = () => {
|
|
if (items.length === 0) return showToast("El carrito está vacío", "error");
|
|
if (!clientId) setClient(1005, "Consumidor Final");
|
|
setShowPayment(true);
|
|
};
|
|
|
|
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
|
|
};
|
|
|
|
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
|
|
}];
|
|
})
|
|
};
|
|
|
|
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}
|
|
/>
|
|
)}
|
|
|
|
{showBundleConfigurator && selectedBundle && (
|
|
<BundleConfiguratorModal
|
|
bundle={selectedBundle}
|
|
clientId={clientId || 1005}
|
|
onClose={() => setShowBundleConfigurator(false)}
|
|
onConfirm={handleBundleConfirmed}
|
|
/>
|
|
)}
|
|
|
|
{/* 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>
|
|
</div>
|
|
);
|
|
} |