Feat: Configuración y Administración de Tipos
This commit is contained in:
@@ -61,7 +61,20 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId, pr
|
||||
api.get('/operations'),
|
||||
productService.getById(productId)
|
||||
]);
|
||||
setFlatCategories(processCategories(catRes.data));
|
||||
const allCategories = processCategories(catRes.data);
|
||||
let filteredCategories = allCategories;
|
||||
|
||||
if (prodData.categoryId) {
|
||||
const rootCategory = allCategories.find(c => c.id === prodData.categoryId);
|
||||
if (rootCategory) {
|
||||
// Filtrar para mostrar solo la categoría raíz y sus descendientes
|
||||
filteredCategories = allCategories.filter(c =>
|
||||
c.id === rootCategory.id || c.path.startsWith(rootCategory.path + ' > ')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setFlatCategories(filteredCategories);
|
||||
setOperations(opRes.data);
|
||||
setProduct(prodData);
|
||||
|
||||
@@ -71,6 +84,12 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId, pr
|
||||
} else {
|
||||
setDays(3);
|
||||
}
|
||||
|
||||
// Si hay una sola opción elegible, seleccionarla automáticamente
|
||||
const selectable = filteredCategories.filter(c => c.isSelectable);
|
||||
if (selectable.length === 1) {
|
||||
setCategoryId(selectable[0].id.toString());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error cargando configuración", e);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, CheckCircle2, AlertCircle, Settings2, ArrowRight } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import type { Product } from '../../types/Product';
|
||||
import { productService } from '../../services/productService';
|
||||
import clsx from 'clsx';
|
||||
import AdEditorModal from './AdEditorModal';
|
||||
|
||||
interface Props {
|
||||
bundle: Product;
|
||||
clientId: number;
|
||||
onClose: () => void;
|
||||
onConfirm: (componentsData: ComponentConfig[]) => void;
|
||||
}
|
||||
|
||||
export interface ComponentConfig {
|
||||
productId: number;
|
||||
listingId?: number;
|
||||
description?: string;
|
||||
price?: number;
|
||||
isConfigured: boolean;
|
||||
product: Product;
|
||||
allocationAmount?: number;
|
||||
}
|
||||
|
||||
export default function BundleConfiguratorModal({ bundle, clientId, onClose, onConfirm }: Props) {
|
||||
const [components, setComponents] = useState<ComponentConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Estado para el editor de avisos interno
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
productService.getBundleComponents(bundle.id).then(data => {
|
||||
const configs = data.map(c => ({
|
||||
productId: c.childProductId,
|
||||
isConfigured: !(c.childProduct?.requiresText || c.childProduct?.hasDuration),
|
||||
product: c.childProduct as Product,
|
||||
allocationAmount: c.fixedAllocationAmount || c.childProduct?.basePrice || 0
|
||||
}));
|
||||
setComponents(configs);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [bundle.id]);
|
||||
|
||||
const handleConfigure = (index: number) => {
|
||||
setEditingIndex(index);
|
||||
};
|
||||
|
||||
const handleAdConfirmed = (listingId: number, price: number, description: string) => {
|
||||
if (editingIndex !== null) {
|
||||
const newComponents = [...components];
|
||||
newComponents[editingIndex] = {
|
||||
...newComponents[editingIndex],
|
||||
listingId,
|
||||
price,
|
||||
allocationAmount: price, // Usamos el precio calculado para el prorrateo
|
||||
description,
|
||||
isConfigured: true
|
||||
};
|
||||
setComponents(newComponents);
|
||||
setEditingIndex(null);
|
||||
}
|
||||
};
|
||||
|
||||
const allDone = components.every(c => c.isConfigured);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-950/60 backdrop-blur-md z-[200] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
className="bg-white w-full max-w-2xl rounded-[2.5rem] shadow-2xl overflow-hidden border border-slate-200 flex flex-col max-h-[85vh]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-8 bg-slate-50 border-b border-slate-100 flex justify-between items-center relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-12 bg-purple-500/5 rounded-full -mr-16 -mt-16"></div>
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="p-2 bg-purple-100 text-purple-600 rounded-xl shadow-sm">
|
||||
<Settings2 size={20} />
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-slate-800 uppercase tracking-tight">Configurar Combo</h3>
|
||||
</div>
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">{bundle.name}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-white rounded-xl transition-all text-slate-400 hover:text-slate-600 shadow-sm relative z-10"><X /></button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-8 overflow-y-auto custom-scrollbar flex-1 space-y-4">
|
||||
{loading ? (
|
||||
<div className="py-20 text-center animate-pulse font-black text-slate-300 uppercase tracking-widest">
|
||||
Identificando componentes...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{components.map((c, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={clsx(
|
||||
"p-5 rounded-3xl border-2 transition-all flex items-center justify-between group",
|
||||
c.isConfigured
|
||||
? "border-emerald-100 bg-emerald-50/30"
|
||||
: "border-slate-100 bg-white hover:border-blue-200"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={clsx(
|
||||
"w-12 h-12 rounded-2xl flex items-center justify-center shadow-sm transition-transform group-hover:scale-110",
|
||||
c.isConfigured ? "bg-emerald-500 text-white" : "bg-slate-100 text-slate-400"
|
||||
)}>
|
||||
{c.isConfigured ? <CheckCircle2 size={24} /> : <AlertCircle size={24} />}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-black text-slate-800 leading-tight uppercase tracking-tight">{c.product.name}</h4>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">
|
||||
{c.isConfigured ? '✓ Configuración Completa' : '⚠ Requiere Datos Técnicos'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!c.isConfigured ? (
|
||||
<button
|
||||
onClick={() => handleConfigure(idx)}
|
||||
className="px-5 py-2.5 bg-blue-600 text-white rounded-xl font-black text-[10px] uppercase tracking-widest shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all active:scale-95"
|
||||
>
|
||||
Configurar
|
||||
</button>
|
||||
) : (
|
||||
(c.product.requiresText || c.product.hasDuration) && (
|
||||
<button
|
||||
onClick={() => handleConfigure(idx)}
|
||||
className="text-emerald-600 font-black text-[10px] uppercase tracking-widest hover:underline"
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-8 bg-slate-50 border-t border-slate-100 flex justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Progreso</span>
|
||||
<div className="flex gap-1 mt-1.5">
|
||||
{components.map((c, i) => (
|
||||
<div key={i} className={clsx("h-1.5 w-6 rounded-full transition-all duration-500", c.isConfigured ? "bg-emerald-500" : "bg-slate-200")}></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={!allDone}
|
||||
onClick={() => onConfirm(components)}
|
||||
className={clsx(
|
||||
"px-8 py-4 rounded-2xl font-black text-xs uppercase tracking-[0.15em] transition-all flex items-center gap-3 shadow-xl active:scale-95",
|
||||
allDone
|
||||
? "bg-slate-900 text-white shadow-slate-300 hover:bg-black"
|
||||
: "bg-slate-200 text-slate-400 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
Confirmar Combo <ArrowRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Editor Interno para los componentes */}
|
||||
<AnimatePresence>
|
||||
{editingIndex !== null && (
|
||||
<AdEditorModal
|
||||
isOpen={true}
|
||||
onClose={() => setEditingIndex(null)}
|
||||
onConfirm={handleAdConfirmed}
|
||||
clientId={clientId}
|
||||
productId={components[editingIndex].productId}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
@@ -24,6 +25,8 @@ export default function UniversalPosPage() {
|
||||
// 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);
|
||||
|
||||
@@ -70,16 +73,11 @@ export default function UniversalPosPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. COMBOS (BUNDLES) - Lógica de Visualización
|
||||
// 2. COMBOS (BUNDLES) - Lógica de Visualización y Configuració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');
|
||||
if (!clientId) setClient(1005, "Consumidor Final (Default)");
|
||||
setSelectedBundle(product);
|
||||
setShowBundleConfigurator(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,6 +106,22 @@ export default function UniversalPosPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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");
|
||||
@@ -146,12 +160,31 @@ export default function UniversalPosPage() {
|
||||
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
|
||||
}))
|
||||
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);
|
||||
@@ -314,6 +347,15 @@ export default function UniversalPosPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showBundleConfigurator && selectedBundle && (
|
||||
<BundleConfiguratorModal
|
||||
bundle={selectedBundle}
|
||||
clientId={clientId || 1005}
|
||||
onClose={() => setShowBundleConfigurator(false)}
|
||||
onConfirm={handleBundleConfirmed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* MODAL DE BÚSQUEDA DE CLIENTE (F7) */}
|
||||
<AnimatePresence>
|
||||
{showClientSearch && (
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { create } from 'zustand';
|
||||
import type { OrderItemDto } from '../types/Order';
|
||||
import type { Product } from '../types/Product';
|
||||
import type { ComponentConfig } from '../components/POS/BundleConfiguratorModal';
|
||||
|
||||
interface CartItem extends OrderItemDto {
|
||||
tempId: string;
|
||||
productName: string;
|
||||
unitPrice: number;
|
||||
subTotal: number;
|
||||
// Lista de nombres de componentes para mostrar en el ticket/pantalla
|
||||
subItems?: string[];
|
||||
// Datos de configuración de cada componente del combo
|
||||
componentsData?: ComponentConfig[];
|
||||
}
|
||||
|
||||
interface CartState {
|
||||
@@ -22,7 +24,8 @@ interface CartState {
|
||||
quantity: number,
|
||||
options?: {
|
||||
relatedEntity?: { id: number, type: string, extraInfo?: string },
|
||||
subItems?: string[]
|
||||
subItems?: string[],
|
||||
componentsData?: ComponentConfig[]
|
||||
}
|
||||
) => void;
|
||||
|
||||
@@ -41,10 +44,10 @@ export const useCartStore = create<CartState>((set, get) => ({
|
||||
|
||||
addItem: (product, quantity, options) => {
|
||||
const currentItems = get().items;
|
||||
const { relatedEntity, subItems } = options || {};
|
||||
const { relatedEntity, subItems, componentsData } = options || {};
|
||||
|
||||
// Si tiene entidad relacionada (Aviso) o SubItems (Combo), no agrupamos
|
||||
const isComplexItem = !!relatedEntity || (subItems && subItems.length > 0);
|
||||
// Si tiene entidad relacionada (Aviso) o SubItems (Combo) o Configuración interna, no agrupamos
|
||||
const isComplexItem = !!relatedEntity || (subItems && subItems.length > 0) || (componentsData && componentsData.length > 0);
|
||||
|
||||
const existingIndex = !isComplexItem
|
||||
? currentItems.findIndex(i => i.productId === product.id && !i.relatedEntityId)
|
||||
@@ -67,7 +70,8 @@ export const useCartStore = create<CartState>((set, get) => ({
|
||||
subTotal: product.basePrice * quantity,
|
||||
relatedEntityId: relatedEntity?.id,
|
||||
relatedEntityType: relatedEntity?.type,
|
||||
subItems: subItems // Guardamos la lista visual
|
||||
subItems: subItems,
|
||||
componentsData: componentsData
|
||||
};
|
||||
set({ items: [...currentItems, newItem] });
|
||||
}
|
||||
|
||||
@@ -13,9 +13,12 @@ export interface Product {
|
||||
currency: string;
|
||||
isActive: boolean;
|
||||
|
||||
// Campos extendidos para UI (Joins)
|
||||
companyName?: string;
|
||||
// Propiedades de Tipo de Producto (Joins)
|
||||
requiresText: boolean;
|
||||
hasDuration: boolean;
|
||||
requiresCategory: boolean;
|
||||
typeCode?: string; // 'BUNDLE', 'PHYSICAL', etc.
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
export interface ProductBundleComponent {
|
||||
|
||||
Reference in New Issue
Block a user