Feat: Configuración y Administración de Tipos

This commit is contained in:
2026-02-25 18:11:52 -03:00
parent a8b8229b41
commit b4fa74ad9b
25 changed files with 941 additions and 107 deletions

View File

@@ -1,13 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin-panel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin-panel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -14,6 +14,7 @@ import AuditTimeline from './pages/Audit/AuditTimeline';
import ClientManager from './pages/Clients/ClientManager';
import CouponsPage from './pages/Coupons/CouponsPage';
import ProductManager from './pages/Products/ProductManager';
import ProductTypeManager from './pages/Products/ProductTypeManager';
import CompanyManager from './pages/Companies/CompanyManager';
import CreditManager from './pages/Finance/CreditManager';
import CalendarManager from './pages/Companies/CalendarManager';
@@ -34,6 +35,7 @@ function App() {
<Route path="/users" element={<UserManager />} />
<Route path="/diagram" element={<DiagramPage />} />
<Route path="/products" element={<ProductManager />} />
<Route path="/product-types" element={<ProductTypeManager />} />
<Route path="/pricing" element={<PricingManager />} />
<Route path="/promotions" element={<PromotionsManager />} />
<Route path="/coupons" element={<CouponsPage />} />

View File

@@ -1,8 +1,9 @@
import { useState, useEffect } from 'react';
import type { Product, ProductBundleComponent } from '../../types/Product';
import type { Product, ProductBundleComponent, ProductType } from '../../types/Product';
import type { Company } from '../../types/Company';
import type { Category } from '../../types/Category';
import { productService } from '../../services/productService';
import { productTypeService } from '../../services/productTypeService';
import { X, Save, Layers, Plus, Trash2, AlertCircle, Package } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
@@ -14,22 +15,13 @@ interface Props {
onClose: (refresh?: boolean) => void;
}
const PRODUCT_TYPES = [
{ id: 1, code: 'CLASSIFIED_AD', name: 'Aviso Clasificado' },
{ id: 2, code: 'GRAPHIC_AD', name: 'Publicidad Gráfica' },
{ id: 3, code: 'RADIO_AD', name: 'Publicidad Radial' },
{ id: 4, code: 'PHYSICAL', name: 'Producto Físico' },
{ id: 5, code: 'SERVICE', name: 'Servicio' },
{ id: 6, code: 'BUNDLE', name: 'Paquete Promocional (Combo)' },
];
export default function ProductModal({ product, companies, categories, allProducts, onClose }: Props) {
const [formData, setFormData] = useState<Partial<Product>>({
name: '',
description: '',
companyId: 0,
categoryId: undefined,
productTypeId: 4, // Default Physical
productTypeId: 0,
basePrice: 0,
priceDurationDays: 1,
taxRate: 21,
@@ -37,35 +29,48 @@ export default function ProductModal({ product, companies, categories, allProduc
isActive: true
});
const [productTypes, setProductTypes] = useState<ProductType[]>([]);
const [isBundle, setIsBundle] = useState(false);
const [bundleComponents, setBundleComponents] = useState<ProductBundleComponent[]>([]);
const [newComponentId, setNewComponentId] = useState<number>(0);
const [loading, setLoading] = useState(false);
const [hasDuration, setHasDuration] = useState(false);
const [hasDurationUI, setHasDurationUI] = useState(false);
useEffect(() => {
productTypeService.getAll().then(setProductTypes).catch(console.error);
}, []);
useEffect(() => {
if (product) {
setFormData(product);
setHasDuration(product.priceDurationDays > 1);
const type = PRODUCT_TYPES.find(t => t.id === product.productTypeId);
if (type?.code === 'BUNDLE') {
setIsBundle(true);
loadBundleComponents(product.id);
setHasDurationUI(product.priceDurationDays > 1);
if (productTypes.length > 0) {
const type = productTypes.find(t => t.id === product.productTypeId);
if (type?.isBundle) {
setIsBundle(true);
loadBundleComponents(product.id);
}
}
} else if (productTypes.length > 0 && formData.productTypeId === 0) {
// Default to first type if creating new
setFormData(f => ({ ...f, productTypeId: productTypes[0].id }));
}
}, [product]);
}, [product, productTypes]);
useEffect(() => {
const type = PRODUCT_TYPES.find(t => t.id === formData.productTypeId);
setIsBundle(type?.code === 'BUNDLE');
const type = productTypes.find(t => t.id === formData.productTypeId);
if (!type) return;
// Si es producto físico o combo, forzamos duración 1 (sin periodo)
if (formData.productTypeId === 4 || formData.productTypeId === 6) {
setIsBundle(type.isBundle);
// Si el tipo de producto NO soporta duración, forzamos duración 1
if (!type.hasDuration) {
if (formData.priceDurationDays !== 1) {
setFormData(f => ({ ...f, priceDurationDays: 1 }));
setHasDurationUI(false);
}
}
}, [formData.productTypeId]);
}, [formData.productTypeId, productTypes]);
const loadBundleComponents = async (productId: number) => {
try {
@@ -150,7 +155,7 @@ export default function ProductModal({ product, companies, categories, allProduc
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Tipo de Producto</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{PRODUCT_TYPES.map(type => (
{productTypes.map(type => (
<div
key={type.id}
onClick={() => setFormData({ ...formData, productTypeId: type.id })}
@@ -198,27 +203,27 @@ export default function ProductModal({ product, companies, categories, allProduc
value={formData.basePrice} onChange={e => setFormData({ ...formData, basePrice: parseFloat(e.target.value) })} />
</div>
{formData.productTypeId !== 4 && formData.productTypeId !== 6 && (
{productTypes.find(t => t.id === formData.productTypeId)?.hasDuration && (
<div className="col-span-2 space-y-3">
<div
onClick={() => {
const newValue = !hasDuration;
setHasDuration(newValue);
const newValue = !hasDurationUI;
setHasDurationUI(newValue);
if (!newValue) setFormData({ ...formData, priceDurationDays: 1 });
}}
className={`flex items-center justify-between p-3 rounded-xl border-2 cursor-pointer transition-all ${hasDuration ? 'border-blue-500 bg-blue-50' : 'border-slate-100 bg-slate-50 opacity-60'
className={`flex items-center justify-between p-3 rounded-xl border-2 cursor-pointer transition-all ${hasDurationUI ? 'border-blue-500 bg-blue-50' : 'border-slate-100 bg-slate-50 opacity-60'
}`}
>
<div className="flex flex-col">
<span className="text-[10px] font-black text-slate-500 uppercase tracking-widest leading-none">Precio por Duración</span>
<span className="text-[9px] text-slate-400 font-bold mt-1">Habilitar si el precio base cubre varios días</span>
</div>
<div className={`w-10 h-5 rounded-full relative transition-colors ${hasDuration ? 'bg-blue-600' : 'bg-slate-300'}`}>
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${hasDuration ? 'right-1' : 'left-1'}`} />
<div className={`w-10 h-5 rounded-full relative transition-colors ${hasDurationUI ? 'bg-blue-600' : 'bg-slate-300'}`}>
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${hasDurationUI ? 'right-1' : 'left-1'}`} />
</div>
</div>
{hasDuration && (
{hasDurationUI && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="space-y-1.5 pl-2 border-l-2 border-blue-200 ml-4">
<label className="text-[10px] font-black text-blue-500 uppercase tracking-widest ml-1">Cantidad de Días</label>
<div className="relative">

View File

@@ -0,0 +1,124 @@
import { useState, useEffect } from 'react';
import type { ProductType } from '../../types/Product';
import { productTypeService } from '../../services/productTypeService';
import { X, Save, Settings2 } from 'lucide-react';
import { motion } from 'framer-motion';
interface Props {
type: ProductType | null;
onClose: (refresh?: boolean) => void;
}
export default function ProductTypeModal({ type, onClose }: Props) {
const [formData, setFormData] = useState<Partial<ProductType>>({
code: '',
name: '',
description: '',
icon: 'Package',
hasDuration: false,
requiresText: false,
requiresCategory: false,
isBundle: false,
isActive: true
});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (type) setFormData(type);
}, [type]);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
if (type) await productTypeService.update(type.id, formData);
else await productTypeService.create(formData);
onClose(true);
} catch (error) {
alert("Error al guardar tipo de producto");
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 bg-slate-950/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }}
className="bg-white w-full max-w-xl rounded-[2rem] shadow-2xl overflow-hidden border border-slate-100 flex flex-col max-h-[90vh]"
>
<div className="p-6 bg-slate-50 border-b border-slate-100 flex justify-between items-center">
<h3 className="text-lg font-black text-slate-800 uppercase tracking-tight">
{type ? `Configurar: ${type.name}` : 'Nuevo Tipo de Producto'}
</h3>
<button onClick={() => onClose()} className="p-2 hover:bg-white rounded-xl transition-colors text-slate-400"><X /></button>
</div>
<form onSubmit={handleSave} className="p-8 overflow-y-auto custom-scrollbar space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Código Único (Backend)</label>
<input required type="text" className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-mono font-bold text-sm"
placeholder="EJ: AV_CLASIFICADO"
disabled={!!type}
value={formData.code} onChange={e => setFormData({ ...formData, code: e.target.value.toUpperCase() })} />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Nombre Display</label>
<input required type="text" className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-bold text-sm"
placeholder="EJ: Aviso Clasificado"
value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} />
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Descripción</label>
<textarea className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-bold text-sm resize-none h-20"
value={formData.description} onChange={e => setFormData({ ...formData, description: e.target.value })} />
</div>
<div className="bg-blue-50/50 p-6 rounded-2xl border border-blue-100 space-y-4">
<h4 className="flex items-center gap-2 text-[10px] font-black text-blue-600 uppercase tracking-widest mb-2">
<Settings2 size={14} /> Parámetros de Comportamiento
</h4>
<div className="grid grid-cols-1 gap-3">
{[
{ key: 'hasDuration', label: '¿Soporta Duración?', desc: 'Habilita el parámetro de selección de días/módulos.' },
{ key: 'requiresText', label: '¿Requiere Texto?', desc: 'Habilita el editor de texto para composición de avisos.' },
{ key: 'requiresCategory', label: '¿Obligatorio asignar Rubro?', desc: 'El producto debe estar vinculado a un rubro del árbol.' },
{ key: 'isBundle', label: '¿Es un Combo?', desc: 'Habilita la gestión de componentes hijos para este producto.' }
].map(opt => (
<div
key={opt.key}
onClick={() => setFormData({ ...formData, [opt.key]: !formData[opt.key as keyof ProductType] })}
className={`flex items-center justify-between p-3 rounded-xl border-2 cursor-pointer transition-all ${formData[opt.key as keyof ProductType] ? 'border-blue-600 bg-white' : 'border-slate-100 bg-slate-50/50 grayscale opacity-60'
}`}
>
<div>
<p className="text-xs font-black text-slate-700 uppercase">{opt.label}</p>
<p className="text-[10px] text-slate-400 font-bold">{opt.desc}</p>
</div>
<div className={`w-10 h-5 rounded-full relative transition-colors ${formData[opt.key as keyof ProductType] ? 'bg-blue-600' : 'bg-slate-300'}`}>
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${formData[opt.key as keyof ProductType] ? 'right-1' : 'left-1'}`} />
</div>
</div>
))}
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button type="button" onClick={() => onClose()} className="px-6 py-3 rounded-xl font-bold text-slate-500 hover:bg-slate-50 transition-all text-xs uppercase tracking-wider">Cancelar</button>
<button
type="submit" disabled={loading}
className="bg-blue-600 text-white px-8 py-3 rounded-xl font-black text-xs uppercase tracking-widest shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all flex items-center gap-2"
>
<Save size={16} /> {loading ? 'Guardando...' : 'Guardar Configuración'}
</button>
</div>
</form>
</motion.div>
</div>
);
}

View File

@@ -59,6 +59,7 @@ export default function ProtectedLayout() {
title: "Catálogo & Precios",
items: [
{ label: 'Productos & Tarifas', href: '/products', icon: <Box size={18} />, roles: ['Admin'] },
{ label: 'Configuración de Tipos', href: '/product-types', icon: <Settings size={18} />, roles: ['Admin'] },
{ label: 'Reglas por Rubro', href: '/pricing', icon: <DollarSign size={18} />, roles: ['Admin'] },
{ label: 'Promociones', href: '/promotions', icon: <Tag size={18} />, roles: ['Admin'] },
{ label: 'Cupones de Descuento', href: '/coupons', icon: <Tag size={18} />, roles: ['Admin'] },

View File

@@ -0,0 +1,139 @@
import { useState, useEffect } from 'react';
import { Settings, Plus, Search, Edit, Trash2, Code, Layers, Type, Image, Mic, Package, LifeBuoy } from 'lucide-react';
import type { ProductType } from '../../types/Product';
import { productTypeService } from '../../services/productTypeService';
import ProductTypeModal from '../../components/Products/ProductTypeModal';
import clsx from 'clsx';
const ICON_MAP: Record<string, any> = {
'Type': Type,
'Image': Image,
'Mic': Mic,
'Package': Package,
'LifeBuoy': LifeBuoy,
'Layers': Layers
};
export default function ProductTypeManager() {
const [types, setTypes] = useState<ProductType[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingType, setEditingType] = useState<ProductType | null>(null);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
const data = await productTypeService.getAll();
setTypes(data);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setEditingType(null);
setIsModalOpen(true);
};
const handleEdit = (t: ProductType) => {
setEditingType(t);
setIsModalOpen(true);
};
const handleDelete = async (id: number) => {
if (!confirm('¿Eliminar este tipo de producto? Solo es posible si no tiene productos asociados.')) return;
try {
await productTypeService.delete(id);
loadData();
} catch (e: any) {
alert(e.response?.data || 'Error al eliminar');
}
};
const filtered = types.filter(t =>
t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.code.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="max-w-6xl mx-auto space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-black text-slate-800 uppercase tracking-tight flex items-center gap-2">
<Settings className="text-blue-600" /> Tipos de Producto
</h2>
<p className="text-sm text-slate-500 font-medium">Define el comportamiento y parámetros de las categorías de venta.</p>
</div>
<button
onClick={handleCreate}
className="bg-blue-600 text-white px-5 py-2.5 rounded-xl font-black text-xs uppercase tracking-widest shadow-lg shadow-blue-500/20 hover:bg-blue-700 transition-all flex items-center gap-2"
>
<Plus size={16} /> Nuevo Tipo
</button>
</div>
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
placeholder="Buscar por Nombre o Código..."
className="w-full pl-10 pr-4 py-2 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 transition-all font-bold text-sm text-slate-700"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{loading ? (
<div className="col-span-full py-20 text-center text-slate-400 font-bold animate-pulse">Cargando configuraciones...</div>
) : filtered.map(t => {
const IconComp = ICON_MAP[t.icon || 'Package'] || Package;
return (
<div key={t.id} className={clsx("bg-white p-6 rounded-[1.5rem] border-2 transition-all hover:shadow-lg group flex flex-col justify-between", t.isActive ? "border-slate-100" : "border-slate-100 opacity-60 grayscale")}>
<div>
<div className="flex justify-between items-start mb-4">
<div className="p-3 bg-slate-50 rounded-2xl text-slate-600 group-hover:bg-blue-50 group-hover:text-blue-600 transition-colors">
<IconComp size={24} />
</div>
<div className="flex gap-1">
<button onClick={() => handleEdit(t)} className="p-2 text-slate-400 hover:text-blue-600 hover:bg-slate-50 rounded-lg transition-all"><Edit size={16} /></button>
<button onClick={() => handleDelete(t.id)} className="p-2 text-slate-400 hover:text-rose-600 hover:bg-slate-50 rounded-lg transition-all"><Trash2 size={16} /></button>
</div>
</div>
<h3 className="text-lg font-black text-slate-800 leading-tight mb-1 uppercase tracking-tight">{t.name}</h3>
<div className="flex items-center gap-1.5 mb-3">
<Code size={12} className="text-slate-400" />
<span className="text-[10px] font-mono font-bold text-slate-400">{t.code}</span>
</div>
<p className="text-xs text-slate-500 font-medium mb-4 line-clamp-2">{t.description}</p>
</div>
<div className="space-y-1.5 border-t border-slate-50 pt-4">
<div className="flex flex-wrap gap-2">
{t.hasDuration && <span className="px-2 py-0.5 bg-blue-50 text-blue-600 rounded text-[9px] font-black uppercase tracking-widest border border-blue-100">Días</span>}
{t.requiresText && <span className="px-2 py-0.5 bg-indigo-50 text-indigo-600 rounded text-[9px] font-black uppercase tracking-widest border border-indigo-100">Texto</span>}
{t.requiresCategory && <span className="px-2 py-0.5 bg-amber-50 text-amber-600 rounded text-[9px] font-black uppercase tracking-widest border border-amber-100">Rubro</span>}
{t.isBundle && <span className="px-2 py-0.5 bg-purple-50 text-purple-600 rounded text-[9px] font-black uppercase tracking-widest border border-purple-100">Combo</span>}
</div>
</div>
</div>
);
})}
</div>
{isModalOpen && (
<ProductTypeModal type={editingType} onClose={(refresh) => { setIsModalOpen(false); if (refresh) loadData(); }} />
)}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import api from './api';
import type { ProductType } from '../types/Product';
export const productTypeService = {
getAll: async (): Promise<ProductType[]> => {
const res = await api.get('/producttypes');
return res.data;
},
getById: async (id: number): Promise<ProductType> => {
const res = await api.get(`/producttypes/${id}`);
return res.data;
},
create: async (data: Partial<ProductType>): Promise<ProductType> => {
const res = await api.post('/producttypes', data);
return res.data;
},
update: async (id: number, data: Partial<ProductType>): Promise<void> => {
await api.put(`/producttypes/${id}`, data);
},
delete: async (id: number): Promise<void> => {
await api.delete(`/producttypes/${id}`);
}
};

View File

@@ -27,4 +27,19 @@ export interface ProductBundleComponent {
// Datos del hijo para visualización
childProduct?: Product;
}
export interface ProductType {
id: number;
code: string;
name: string;
description?: string;
icon?: string;
// Configuración dinámica
hasDuration: boolean;
requiresText: boolean;
requiresCategory: boolean;
isBundle: boolean;
isActive: boolean;
}

View File

@@ -1,13 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>counter-panel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>counter-panel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -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);
}

View File

@@ -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>
);
}

View File

@@ -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 && (

View File

@@ -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] });
}

View File

@@ -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 {