Feat ERP 2
This commit is contained in:
302
frontend/counter-panel/src/pages/UniversalPosPage.tsx
Normal file
302
frontend/counter-panel/src/pages/UniversalPosPage.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
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';
|
||||
// Importamos el componente de búsqueda de clientes para el modal (Asumiremos que existe o usamos un simple prompt por ahora para no extender demasiado, idealmente ClientSearchModal)
|
||||
// import ClientSearchModal from '../components/POS/ClientSearchModal';
|
||||
|
||||
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);
|
||||
|
||||
// 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 handleChangeClient = () => {
|
||||
// Aquí abriríamos el ClientSearchModal.
|
||||
// Para no bloquear, simulamos un cambio rápido o un prompt simple si no hay modal aún.
|
||||
// En producción: setShowClientModal(true);
|
||||
const id = prompt("Ingrese ID de Cliente (Simulación F7):", "1003");
|
||||
if (id) {
|
||||
// Buscar nombre real en API...
|
||||
setClient(parseInt(id), "Cliente #" + id);
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
if (product.typeCode === 'BUNDLE') {
|
||||
// Traemos los componentes para mostrarlos en el ticket
|
||||
const components = await productService.getBundleComponents(product.id);
|
||||
const subItemsNames = components.map(c =>
|
||||
`${c.quantity}x ${c.childProduct?.name || 'Item'}`
|
||||
);
|
||||
|
||||
addItem(product, 1, { subItems: subItemsNames });
|
||||
showToast(`Combo agregado con ${components.length} ítems`, 'success');
|
||||
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 handleCheckout = () => {
|
||||
if (items.length === 0) return showToast("El carrito está vacío", "error");
|
||||
if (!clientId) setClient(1005, "Consumidor Final");
|
||||
setShowPayment(true);
|
||||
};
|
||||
|
||||
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.map(i => ({
|
||||
productId: i.productId,
|
||||
quantity: i.quantity,
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user