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>

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') {
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);
}
}
}, [product]);
} else if (productTypes.length > 0 && formData.productTypeId === 0) {
// Default to first type if creating new
setFormData(f => ({ ...f, productTypeId: productTypes[0].id }));
}
}, [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

@@ -28,3 +28,18 @@ 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>

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 => ({
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 {

View File

@@ -0,0 +1,64 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
namespace SIGCM.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(Roles = "Admin")]
public class ProductTypesController : ControllerBase
{
private readonly IProductTypeRepository _repo;
public ProductTypesController(IProductTypeRepository repo)
{
_repo = repo;
}
[HttpGet]
[AllowAnonymous] // Permitir que el frontal cargue los tipos para formularios
public async Task<IActionResult> GetAll()
{
var types = await _repo.GetAllAsync();
return Ok(types);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var type = await _repo.GetByIdAsync(id);
if (type == null) return NotFound();
return Ok(type);
}
[HttpPost]
public async Task<IActionResult> Create(ProductType productType)
{
var id = await _repo.CreateAsync(productType);
return CreatedAtAction(nameof(GetById), new { id }, productType);
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, ProductType productType)
{
if (id != productType.Id) return BadRequest();
await _repo.UpdateAsync(productType);
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
try
{
await _repo.DeleteAsync(id);
return NoContent();
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
}

View File

@@ -19,9 +19,8 @@ public class OrderItemDto
public int ProductId { get; set; }
public decimal Quantity { get; set; }
// Opcional: Si el vendedor aplica un descuento manual unitario
// (Por ahora usaremos el precio base del producto)
// public decimal? ManualUnitPrice { get; set; }
// Opcional: Precio unitario manual (se usa en Combos prorrateados o descuentos)
public decimal? UnitPrice { get; set; }
// Para vincular con un aviso específico creado previamente
public int? RelatedEntityId { get; set; }

View File

@@ -21,4 +21,7 @@ public class Product
// Propiedades auxiliares para Joins
public string? CompanyName { get; set; }
public string? TypeCode { get; set; } // 'CLASSIFIED_AD', 'PHYSICAL', etc.
public bool RequiresText { get; set; }
public bool HasDuration { get; set; }
public bool RequiresCategory { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace SIGCM.Domain.Entities;
public class ProductType
{
public int Id { get; set; }
public required string Code { get; set; } // 'CLASSIFIED_AD', 'PHYSICAL', etc.
public required string Name { get; set; }
public string? Description { get; set; }
public string? Icon { get; set; } // Nombre del icono para la UI (lucide)
// Configuración dinámica
public bool HasDuration { get; set; } // Si permite/requiere cantidad de días
public bool RequiresText { get; set; } // Si requiere redacción de texto (ej: avisos)
public bool RequiresCategory { get; set; } // Si debe estar vinculado a un rubro
public bool IsBundle { get; set; } // Si es un combo de otros productos
public bool IsActive { get; set; } = true;
}

View File

@@ -0,0 +1,12 @@
using SIGCM.Domain.Entities;
namespace SIGCM.Domain.Interfaces;
public interface IProductTypeRepository
{
Task<IEnumerable<ProductType>> GetAllAsync();
Task<ProductType?> GetByIdAsync(int id);
Task<int> CreateAsync(ProductType productType);
Task UpdateAsync(ProductType productType);
Task DeleteAsync(int id);
}

View File

@@ -42,6 +42,22 @@ BEGIN
);
END
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ProductTypes')
BEGIN
CREATE TABLE ProductTypes (
Id INT IDENTITY(1,1) PRIMARY KEY,
Code NVARCHAR(50) NOT NULL UNIQUE,
Name NVARCHAR(100) NOT NULL,
Description NVARCHAR(255) NULL,
Icon NVARCHAR(50) NULL,
HasDuration BIT NOT NULL DEFAULT 0,
RequiresText BIT NOT NULL DEFAULT 0,
RequiresCategory BIT NOT NULL DEFAULT 0,
IsBundle BIT NOT NULL DEFAULT 0,
IsActive BIT NOT NULL DEFAULT 1
);
END
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Operations')
BEGIN
CREATE TABLE Operations (
@@ -115,6 +131,64 @@ BEGIN
FOREIGN KEY (ListingId) REFERENCES Listings(Id) ON DELETE CASCADE
);
END
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Companies')
BEGIN
CREATE TABLE Companies (
Id INT IDENTITY(1,1) PRIMARY KEY,
Name NVARCHAR(100) NOT NULL,
TaxId NVARCHAR(20) NULL,
IsActive BIT DEFAULT 1
);
END
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Products')
BEGIN
CREATE TABLE Products (
Id INT IDENTITY(1,1) PRIMARY KEY,
CompanyId INT NOT NULL,
ProductTypeId INT NOT NULL,
CategoryId INT NULL,
Name NVARCHAR(200) NOT NULL,
Description NVARCHAR(MAX) NULL,
SKU NVARCHAR(50) NULL,
ExternalId NVARCHAR(100) NULL,
BasePrice DECIMAL(18,2) NOT NULL DEFAULT 0,
PriceDurationDays INT NOT NULL DEFAULT 1,
TaxRate DECIMAL(18,2) NOT NULL DEFAULT 21,
Currency NVARCHAR(3) DEFAULT 'ARS',
IsActive BIT DEFAULT 1,
FOREIGN KEY (CompanyId) REFERENCES Companies(Id),
FOREIGN KEY (ProductTypeId) REFERENCES ProductTypes(Id),
FOREIGN KEY (CategoryId) REFERENCES Categories(Id)
);
END
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ProductPrices')
BEGIN
CREATE TABLE ProductPrices (
Id INT IDENTITY(1,1) PRIMARY KEY,
ProductId INT NOT NULL,
Price DECIMAL(18,2) NOT NULL,
ValidFrom DATETIME2 DEFAULT GETUTCDATE(),
ValidTo DATETIME2 NULL,
CreatedByUserId INT NULL,
FOREIGN KEY (ProductId) REFERENCES Products(Id) ON DELETE CASCADE
);
END
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ProductBundles')
BEGIN
CREATE TABLE ProductBundles (
Id INT IDENTITY(1,1) PRIMARY KEY,
ParentProductId INT NOT NULL,
ChildProductId INT NOT NULL,
Quantity INT DEFAULT 1,
FixedAllocationAmount DECIMAL(18,2) NULL,
FOREIGN KEY (ParentProductId) REFERENCES Products(Id),
FOREIGN KEY (ChildProductId) REFERENCES Products(Id)
);
END
";
// Ejecutar creación de tablas base
await connection.ExecuteAsync(schemaSql);
@@ -134,6 +208,22 @@ END
ALTER TABLE Products ADD PriceDurationDays INT NOT NULL DEFAULT 1;
END
-- ProductTypes Columns (Migration for existing sparse table)
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'Description' AND Object_ID = Object_ID(N'ProductTypes'))
ALTER TABLE ProductTypes ADD Description NVARCHAR(255) NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'Icon' AND Object_ID = Object_ID(N'ProductTypes'))
ALTER TABLE ProductTypes ADD Icon NVARCHAR(50) NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'HasDuration' AND Object_ID = Object_ID(N'ProductTypes'))
ALTER TABLE ProductTypes ADD HasDuration BIT NOT NULL DEFAULT 0;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'RequiresText' AND Object_ID = Object_ID(N'ProductTypes'))
ALTER TABLE ProductTypes ADD RequiresText BIT NOT NULL DEFAULT 0;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'RequiresCategory' AND Object_ID = Object_ID(N'ProductTypes'))
ALTER TABLE ProductTypes ADD RequiresCategory BIT NOT NULL DEFAULT 0;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'IsBundle' AND Object_ID = Object_ID(N'ProductTypes'))
ALTER TABLE ProductTypes ADD IsBundle BIT NOT NULL DEFAULT 0;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'IsActive' AND Object_ID = Object_ID(N'ProductTypes'))
ALTER TABLE ProductTypes ADD IsActive BIT NOT NULL DEFAULT 1;
-- CategoryPricing Columns
IF EXISTS(SELECT * FROM sys.columns WHERE Name = N'BasePrice' AND Object_ID = Object_ID(N'CategoryPricing'))
BEGIN
@@ -354,6 +444,23 @@ END
";
await connection.ExecuteAsync(upgradeClientsSql);
// --- SEED DE TIPOS DE PRODUCTO ---
var ptCount = await connection.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM ProductTypes");
if (ptCount == 0)
{
var seedTypesSql = @"
INSERT INTO ProductTypes (Code, Name, Description, Icon, HasDuration, RequiresText, RequiresCategory, IsBundle)
VALUES
('CLASSIFIED_AD', 'Aviso Clasificado', 'Anuncio de texto en secciones categorizadas', 'Type', 1, 1, 1, 0),
('GRAPHIC_AD', 'Publicidad Gráfica', 'Anuncios visuales por módulos', 'Image', 1, 0, 1, 0),
('RADIO_AD', 'Publicidad Radial', 'Pauta comercial en radio', 'Mic', 1, 0, 1, 0),
('PHYSICAL', 'Producto Físico', 'Venta de bienes materiales', 'Package', 0, 0, 0, 0),
('SERVICE', 'Servicio', 'Prestación de servicios profesionales', 'LifeBuoy', 1, 0, 0, 0),
('BUNDLE', 'Paquete Promocional (Combo)', 'Agrupación de múltiples productos', 'Layers', 0, 0, 0, 1)";
await connection.ExecuteAsync(seedTypesSql);
}
// --- SEED DE DATOS (Usuario Admin) ---
var adminCount = await connection.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM Users WHERE Username = 'admin'");

View File

@@ -42,7 +42,8 @@ public static class DependencyInjection
services.AddScoped<IClientProfileRepository, ClientProfileRepository>();
services.AddScoped<ICompanyRepository, CompanyRepository>();
services.AddScoped<IReportRepository, ReportRepository>();
services.AddScoped<ICalendarRepository, CalendarRepository>();;
services.AddScoped<ICalendarRepository, CalendarRepository>();
services.AddScoped<IProductTypeRepository, ProductTypeRepository>();
// Registro de MercadoPagoService configurado desde IConfiguration (appsettings o env vars)
services.AddScoped<MercadoPagoService>(sp =>

View File

@@ -15,7 +15,8 @@ public class ProductRepository : IProductRepository
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT p.*, c.Name as CompanyName, pt.Code as TypeCode
SELECT p.*, c.Name as CompanyName, pt.Code as TypeCode,
pt.RequiresText, pt.HasDuration, pt.RequiresCategory
FROM Products p
JOIN Companies c ON p.CompanyId = c.Id
JOIN ProductTypes pt ON p.ProductTypeId = pt.Id
@@ -27,7 +28,8 @@ public class ProductRepository : IProductRepository
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT p.*, c.Name as CompanyName, pt.Code as TypeCode
SELECT p.*, c.Name as CompanyName, pt.Code as TypeCode,
pt.RequiresText, pt.HasDuration, pt.RequiresCategory
FROM Products p
JOIN Companies c ON p.CompanyId = c.Id
JOIN ProductTypes pt ON p.ProductTypeId = pt.Id
@@ -59,7 +61,8 @@ public class ProductRepository : IProductRepository
FROM Categories c
INNER JOIN CategoryAncestors ca ON c.Id = ca.ParentId
)
SELECT p.*, comp.Name as CompanyName, pt.Code as TypeCode
SELECT p.*, comp.Name as CompanyName, pt.Code as TypeCode,
pt.RequiresText, pt.HasDuration, pt.RequiresCategory
FROM Products p
JOIN Companies comp ON p.CompanyId = comp.Id
JOIN ProductTypes pt ON p.ProductTypeId = pt.Id
@@ -99,9 +102,10 @@ public class ProductRepository : IProductRepository
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT pb.*, p.*
SELECT pb.*, p.*, pt.Code as TypeCode, pt.RequiresText, pt.HasDuration, pt.RequiresCategory
FROM ProductBundles pb
JOIN Products p ON pb.ChildProductId = p.Id
JOIN ProductTypes pt ON p.ProductTypeId = pt.Id
WHERE pb.ParentProductId = @ParentProductId";
// Usamos Dapper Multi-Mapping para llenar el objeto ChildProduct

View File

@@ -0,0 +1,60 @@
using Dapper;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Data;
namespace SIGCM.Infrastructure.Repositories;
public class ProductTypeRepository : IProductTypeRepository
{
private readonly IDbConnectionFactory _db;
public ProductTypeRepository(IDbConnectionFactory db) => _db = db;
public async Task<IEnumerable<ProductType>> GetAllAsync()
{
using var conn = _db.CreateConnection();
return await conn.QueryAsync<ProductType>("SELECT * FROM ProductTypes WHERE IsActive = 1");
}
public async Task<ProductType?> GetByIdAsync(int id)
{
using var conn = _db.CreateConnection();
return await conn.QuerySingleOrDefaultAsync<ProductType>(
"SELECT * FROM ProductTypes WHERE Id = @Id", new { Id = id });
}
public async Task<int> CreateAsync(ProductType productType)
{
using var conn = _db.CreateConnection();
var sql = @"
INSERT INTO ProductTypes (Code, Name, Description, Icon, HasDuration, RequiresText, RequiresCategory, IsBundle, IsActive)
VALUES (@Code, @Name, @Description, @Icon, @HasDuration, @RequiresText, @RequiresCategory, @IsBundle, @IsActive);
SELECT CAST(SCOPE_IDENTITY() as int);";
return await conn.QuerySingleAsync<int>(sql, productType);
}
public async Task UpdateAsync(ProductType productType)
{
using var conn = _db.CreateConnection();
var sql = @"
UPDATE ProductTypes
SET Code = @Code, Name = @Name, Description = @Description, Icon = @Icon,
HasDuration = @HasDuration, RequiresText = @RequiresText,
RequiresCategory = @RequiresCategory, IsBundle = @IsBundle, IsActive = @IsActive
WHERE Id = @Id";
await conn.ExecuteAsync(sql, productType);
}
public async Task DeleteAsync(int id)
{
using var conn = _db.CreateConnection();
// Verificar si hay productos que usen este tipo antes de borrar
var count = await conn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM Products WHERE ProductTypeId = @Id", new { Id = id });
if (count > 0) throw new InvalidOperationException("No se puede eliminar el tipo porque tiene productos asociados.");
await conn.ExecuteAsync("DELETE FROM ProductTypes WHERE Id = @Id", new { Id = id });
}
}

View File

@@ -85,9 +85,8 @@ public class OrderService : IOrderService
}
}
// B. OBTENER PRECIO VIGENTE (Histórico)
// Usamos la fecha actual para determinar el precio.
decimal currentUnitPrice = await _productRepo.GetCurrentPriceAsync(product.Id, DateTime.UtcNow);
// B. OBTENER PRECIO VIGENTE (Histórico o Manual)
decimal currentUnitPrice = itemDto.UnitPrice ?? await _productRepo.GetCurrentPriceAsync(product.Id, DateTime.UtcNow);
// C. LÓGICA DE COMBOS VS SIMPLE
if (product.TypeCode == "BUNDLE")

View File

@@ -25,9 +25,10 @@ public class PricingService
// Si ProductId = 0, el llamador no seleccionó un producto (ej: FastEntryPage legacy).
// En ese caso fallamos graciosamente con precio base 0.
decimal productBasePrice = 0;
Product? product = null;
if (request.ProductId > 0)
{
var product = await _productRepo.GetByIdAsync(request.ProductId);
product = await _productRepo.GetByIdAsync(request.ProductId);
if (product == null) return new CalculatePriceResponse
{
TotalPrice = 0,
@@ -37,8 +38,8 @@ public class PricingService
decimal retrievedPrice = await _productRepo.GetCurrentPriceAsync(request.ProductId, request.StartDate == default ? DateTime.UtcNow : request.StartDate);
// Si el precio es por X días (ej: 30), el costo diario es Precio / X
int duration = product.PriceDurationDays > 0 ? product.PriceDurationDays : 1;
productBasePrice = retrievedPrice / duration;
int prodDuration = product.PriceDurationDays > 0 ? product.PriceDurationDays : 1;
productBasePrice = retrievedPrice / prodDuration;
}
// 1. Obtener Reglas
@@ -67,20 +68,16 @@ public class PricingService
int realWordCount = words.Length;
// 3. Costo Base y Excedente (productBasePrice ya viene resuelto desde sección 0)
decimal currentCost = productBasePrice; // Precio base del Producto seleccionado
int calcDuration = product?.PriceDurationDays > 0 ? product.PriceDurationDays : 1;
decimal currentCost = productBasePrice; // Precio base del Producto seleccionado (ya dividido por duration si aplica)
// ¿Cuántas palabras extra cobramos?
// Nota: Los caracteres especiales se cobran aparte según tu requerimiento,
// o suman al conteo de palabras. Aquí implemento: Se cobran APARTE.
int extraWords = Math.Max(0, realWordCount - pricing.BaseWordCount);
decimal extraWordCost = 0;
if (pricing.WordRanges != null && pricing.WordRanges.Any() && extraWords > 0)
{
// Buscamos el rango aplicable para la cantidad TOTAL de palabras extra
// El ToCount = 0 se interpreta como "sin límite superior"
var applicableRange = pricing.WordRanges.FirstOrDefault(r =>
extraWords >= r.FromCount && (extraWords <= r.ToCount || r.ToCount == 0));
@@ -90,7 +87,6 @@ public class PricingService
}
else
{
// Fallback al precio base si no se define rango que cubra la cantidad
extraWordCost = extraWords * pricing.ExtraWordPrice;
}
}
@@ -99,19 +95,22 @@ public class PricingService
extraWordCost = extraWords * pricing.ExtraWordPrice;
}
decimal specialCharCost = specialCharCount * pricing.SpecialCharPrice;
// Dividimos los costos extra por la duración para convertirlos en tasa diaria
// si el precio base del producto también es por un periodo > 1 día.
decimal dailyExtraWordCost = extraWordCost / calcDuration;
decimal dailySpecialCharCost = (specialCharCount * pricing.SpecialCharPrice) / calcDuration;
currentCost += extraWordCost + specialCharCost;
currentCost += dailyExtraWordCost + dailySpecialCharCost;
// 4. Estilos (Negrita / Recuadro / Destacado)
if (request.IsBold) currentCost += pricing.BoldSurcharge;
if (request.IsFrame) currentCost += pricing.FrameSurcharge;
if (request.IsBold) currentCost += pricing.BoldSurcharge / calcDuration;
if (request.IsFrame) currentCost += pricing.FrameSurcharge / calcDuration;
// Costo Destacado (Hardcoded por ahora o agregar a regla)
// Costo Destacado
decimal featuredSurcharge = 0;
if (request.IsFeatured)
{
featuredSurcharge = 500m; // Valor ejemplo por día
featuredSurcharge = 500m; // Se asume 500 por día si no se especifica lo contrario
currentCost += featuredSurcharge;
}
@@ -180,12 +179,14 @@ public class PricingService
{
TotalPrice = Math.Max(0, totalBeforeDiscount - totalDiscount),
BaseCost = productBasePrice * request.Days,
ExtraCost = (extraWordCost + specialCharCost) * request.Days,
Surcharges = ((request.IsBold ? pricing.BoldSurcharge : 0) + (request.IsFrame ? pricing.FrameSurcharge : 0) + (request.IsFeatured ? featuredSurcharge : 0)) * request.Days,
ExtraCost = (dailyExtraWordCost + dailySpecialCharCost) * request.Days,
Surcharges = ((request.IsBold ? pricing.BoldSurcharge / calcDuration : 0) +
(request.IsFrame ? pricing.FrameSurcharge / calcDuration : 0) +
(request.IsFeatured ? featuredSurcharge : 0)) * request.Days,
Discount = totalDiscount,
WordCount = realWordCount,
SpecialCharCount = specialCharCount,
Details = $"Tarifa Diaria: ${currentCost} x {request.Days} días. (Extras diarios: ${extraWordCost + specialCharCost}). {string.Join(", ", appliedPromos)}",
Details = $"Tarifa Diaria: ${currentCost:N2} x {request.Days} días. (Extras diarios: ${dailyExtraWordCost + dailySpecialCharCost:N2}). {string.Join(", ", appliedPromos)}",
AppliedPromotion = string.Join(", ", appliedPromos)
};
}