Feat: Configuración y Administración de Tipos
This commit is contained in:
@@ -1,13 +1,15 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
<head>
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>admin-panel</title>
|
<title>admin-panel</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
<body>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<div id="root"></div>
|
||||||
</body>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -14,6 +14,7 @@ import AuditTimeline from './pages/Audit/AuditTimeline';
|
|||||||
import ClientManager from './pages/Clients/ClientManager';
|
import ClientManager from './pages/Clients/ClientManager';
|
||||||
import CouponsPage from './pages/Coupons/CouponsPage';
|
import CouponsPage from './pages/Coupons/CouponsPage';
|
||||||
import ProductManager from './pages/Products/ProductManager';
|
import ProductManager from './pages/Products/ProductManager';
|
||||||
|
import ProductTypeManager from './pages/Products/ProductTypeManager';
|
||||||
import CompanyManager from './pages/Companies/CompanyManager';
|
import CompanyManager from './pages/Companies/CompanyManager';
|
||||||
import CreditManager from './pages/Finance/CreditManager';
|
import CreditManager from './pages/Finance/CreditManager';
|
||||||
import CalendarManager from './pages/Companies/CalendarManager';
|
import CalendarManager from './pages/Companies/CalendarManager';
|
||||||
@@ -34,6 +35,7 @@ function App() {
|
|||||||
<Route path="/users" element={<UserManager />} />
|
<Route path="/users" element={<UserManager />} />
|
||||||
<Route path="/diagram" element={<DiagramPage />} />
|
<Route path="/diagram" element={<DiagramPage />} />
|
||||||
<Route path="/products" element={<ProductManager />} />
|
<Route path="/products" element={<ProductManager />} />
|
||||||
|
<Route path="/product-types" element={<ProductTypeManager />} />
|
||||||
<Route path="/pricing" element={<PricingManager />} />
|
<Route path="/pricing" element={<PricingManager />} />
|
||||||
<Route path="/promotions" element={<PromotionsManager />} />
|
<Route path="/promotions" element={<PromotionsManager />} />
|
||||||
<Route path="/coupons" element={<CouponsPage />} />
|
<Route path="/coupons" element={<CouponsPage />} />
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
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 { Company } from '../../types/Company';
|
||||||
import type { Category } from '../../types/Category';
|
import type { Category } from '../../types/Category';
|
||||||
import { productService } from '../../services/productService';
|
import { productService } from '../../services/productService';
|
||||||
|
import { productTypeService } from '../../services/productTypeService';
|
||||||
import { X, Save, Layers, Plus, Trash2, AlertCircle, Package } from 'lucide-react';
|
import { X, Save, Layers, Plus, Trash2, AlertCircle, Package } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
@@ -14,22 +15,13 @@ interface Props {
|
|||||||
onClose: (refresh?: boolean) => void;
|
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) {
|
export default function ProductModal({ product, companies, categories, allProducts, onClose }: Props) {
|
||||||
const [formData, setFormData] = useState<Partial<Product>>({
|
const [formData, setFormData] = useState<Partial<Product>>({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
companyId: 0,
|
companyId: 0,
|
||||||
categoryId: undefined,
|
categoryId: undefined,
|
||||||
productTypeId: 4, // Default Physical
|
productTypeId: 0,
|
||||||
basePrice: 0,
|
basePrice: 0,
|
||||||
priceDurationDays: 1,
|
priceDurationDays: 1,
|
||||||
taxRate: 21,
|
taxRate: 21,
|
||||||
@@ -37,35 +29,48 @@ export default function ProductModal({ product, companies, categories, allProduc
|
|||||||
isActive: true
|
isActive: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [productTypes, setProductTypes] = useState<ProductType[]>([]);
|
||||||
const [isBundle, setIsBundle] = useState(false);
|
const [isBundle, setIsBundle] = useState(false);
|
||||||
const [bundleComponents, setBundleComponents] = useState<ProductBundleComponent[]>([]);
|
const [bundleComponents, setBundleComponents] = useState<ProductBundleComponent[]>([]);
|
||||||
const [newComponentId, setNewComponentId] = useState<number>(0);
|
const [newComponentId, setNewComponentId] = useState<number>(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [hasDuration, setHasDuration] = useState(false);
|
const [hasDurationUI, setHasDurationUI] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
productTypeService.getAll().then(setProductTypes).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (product) {
|
if (product) {
|
||||||
setFormData(product);
|
setFormData(product);
|
||||||
setHasDuration(product.priceDurationDays > 1);
|
setHasDurationUI(product.priceDurationDays > 1);
|
||||||
const type = PRODUCT_TYPES.find(t => t.id === product.productTypeId);
|
if (productTypes.length > 0) {
|
||||||
if (type?.code === 'BUNDLE') {
|
const type = productTypes.find(t => t.id === product.productTypeId);
|
||||||
setIsBundle(true);
|
if (type?.isBundle) {
|
||||||
loadBundleComponents(product.id);
|
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(() => {
|
useEffect(() => {
|
||||||
const type = PRODUCT_TYPES.find(t => t.id === formData.productTypeId);
|
const type = productTypes.find(t => t.id === formData.productTypeId);
|
||||||
setIsBundle(type?.code === 'BUNDLE');
|
if (!type) return;
|
||||||
|
|
||||||
// Si es producto físico o combo, forzamos duración 1 (sin periodo)
|
setIsBundle(type.isBundle);
|
||||||
if (formData.productTypeId === 4 || formData.productTypeId === 6) {
|
|
||||||
|
// Si el tipo de producto NO soporta duración, forzamos duración 1
|
||||||
|
if (!type.hasDuration) {
|
||||||
if (formData.priceDurationDays !== 1) {
|
if (formData.priceDurationDays !== 1) {
|
||||||
setFormData(f => ({ ...f, priceDurationDays: 1 }));
|
setFormData(f => ({ ...f, priceDurationDays: 1 }));
|
||||||
|
setHasDurationUI(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [formData.productTypeId]);
|
}, [formData.productTypeId, productTypes]);
|
||||||
|
|
||||||
const loadBundleComponents = async (productId: number) => {
|
const loadBundleComponents = async (productId: number) => {
|
||||||
try {
|
try {
|
||||||
@@ -150,7 +155,7 @@ export default function ProductModal({ product, companies, categories, allProduc
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Tipo de Producto</label>
|
<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">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
{PRODUCT_TYPES.map(type => (
|
{productTypes.map(type => (
|
||||||
<div
|
<div
|
||||||
key={type.id}
|
key={type.id}
|
||||||
onClick={() => setFormData({ ...formData, productTypeId: 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) })} />
|
value={formData.basePrice} onChange={e => setFormData({ ...formData, basePrice: parseFloat(e.target.value) })} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formData.productTypeId !== 4 && formData.productTypeId !== 6 && (
|
{productTypes.find(t => t.id === formData.productTypeId)?.hasDuration && (
|
||||||
<div className="col-span-2 space-y-3">
|
<div className="col-span-2 space-y-3">
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newValue = !hasDuration;
|
const newValue = !hasDurationUI;
|
||||||
setHasDuration(newValue);
|
setHasDurationUI(newValue);
|
||||||
if (!newValue) setFormData({ ...formData, priceDurationDays: 1 });
|
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">
|
<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-[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>
|
<span className="text-[9px] text-slate-400 font-bold mt-1">Habilitar si el precio base cubre varios días</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`w-10 h-5 rounded-full relative transition-colors ${hasDuration ? 'bg-blue-600' : 'bg-slate-300'}`}>
|
<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 ${hasDuration ? 'right-1' : 'left-1'}`} />
|
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${hasDurationUI ? 'right-1' : 'left-1'}`} />
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<label className="text-[10px] font-black text-blue-500 uppercase tracking-widest ml-1">Cantidad de Días</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ export default function ProtectedLayout() {
|
|||||||
title: "Catálogo & Precios",
|
title: "Catálogo & Precios",
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Productos & Tarifas', href: '/products', icon: <Box size={18} />, roles: ['Admin'] },
|
{ 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: 'Reglas por Rubro', href: '/pricing', icon: <DollarSign size={18} />, roles: ['Admin'] },
|
||||||
{ label: 'Promociones', href: '/promotions', icon: <Tag 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'] },
|
{ label: 'Cupones de Descuento', href: '/coupons', icon: <Tag size={18} />, roles: ['Admin'] },
|
||||||
|
|||||||
139
frontend/admin-panel/src/pages/Products/ProductTypeManager.tsx
Normal file
139
frontend/admin-panel/src/pages/Products/ProductTypeManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/admin-panel/src/services/productTypeService.ts
Normal file
23
frontend/admin-panel/src/services/productTypeService.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -28,3 +28,18 @@ export interface ProductBundleComponent {
|
|||||||
// Datos del hijo para visualización
|
// Datos del hijo para visualización
|
||||||
childProduct?: Product;
|
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;
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
<head>
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>counter-panel</title>
|
<title>counter-panel</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
<body>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<div id="root"></div>
|
||||||
</body>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -61,7 +61,20 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId, pr
|
|||||||
api.get('/operations'),
|
api.get('/operations'),
|
||||||
productService.getById(productId)
|
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);
|
setOperations(opRes.data);
|
||||||
setProduct(prodData);
|
setProduct(prodData);
|
||||||
|
|
||||||
@@ -71,6 +84,12 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId, pr
|
|||||||
} else {
|
} else {
|
||||||
setDays(3);
|
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) {
|
} catch (e) {
|
||||||
console.error("Error cargando configuración", 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 { orderService } from '../services/orderService';
|
||||||
import type { CreateOrderRequest } from '../types/Order';
|
import type { CreateOrderRequest } from '../types/Order';
|
||||||
import AdEditorModal from '../components/POS/AdEditorModal';
|
import AdEditorModal from '../components/POS/AdEditorModal';
|
||||||
|
import BundleConfiguratorModal, { type ComponentConfig } from '../components/POS/BundleConfiguratorModal';
|
||||||
import ClientCreateModal from '../components/POS/ClientCreateModal';
|
import ClientCreateModal from '../components/POS/ClientCreateModal';
|
||||||
import ClientSearchModal from '../components/POS/ClientSearchModal';
|
import ClientSearchModal from '../components/POS/ClientSearchModal';
|
||||||
import { AnimatePresence } from 'framer-motion';
|
import { AnimatePresence } from 'framer-motion';
|
||||||
@@ -24,6 +25,8 @@ export default function UniversalPosPage() {
|
|||||||
// Estados de Modales
|
// Estados de Modales
|
||||||
const [showAdEditor, setShowAdEditor] = useState(false);
|
const [showAdEditor, setShowAdEditor] = useState(false);
|
||||||
const [selectedAdProduct, setSelectedAdProduct] = useState<Product | null>(null);
|
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 [showCreateClient, setShowCreateClient] = useState(false);
|
||||||
const [showClientSearch, setShowClientSearch] = useState(false);
|
const [showClientSearch, setShowClientSearch] = useState(false);
|
||||||
|
|
||||||
@@ -70,16 +73,11 @@ export default function UniversalPosPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. COMBOS (BUNDLES) - Lógica de Visualización
|
// 2. COMBOS (BUNDLES) - Lógica de Visualización y Configuración
|
||||||
if (product.typeCode === 'BUNDLE') {
|
if (product.typeCode === 'BUNDLE') {
|
||||||
// Traemos los componentes para mostrarlos en el ticket
|
if (!clientId) setClient(1005, "Consumidor Final (Default)");
|
||||||
const components = await productService.getBundleComponents(product.id);
|
setSelectedBundle(product);
|
||||||
const subItemsNames = components.map(c =>
|
setShowBundleConfigurator(true);
|
||||||
`${c.quantity}x ${c.childProduct?.name || 'Item'}`
|
|
||||||
);
|
|
||||||
|
|
||||||
addItem(product, 1, { subItems: subItemsNames });
|
|
||||||
showToast(`Combo agregado con ${components.length} ítems`, 'success');
|
|
||||||
return;
|
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 = () => {
|
const handleCheckout = () => {
|
||||||
if (items.length === 0) return showToast("El carrito está vacío", "error");
|
if (items.length === 0) return showToast("El carrito está vacío", "error");
|
||||||
if (!clientId) setClient(1005, "Consumidor Final");
|
if (!clientId) setClient(1005, "Consumidor Final");
|
||||||
@@ -146,12 +160,31 @@ export default function UniversalPosPage() {
|
|||||||
sellerId: sellerId || 2,
|
sellerId: sellerId || 2,
|
||||||
isDirectPayment: isDirectPayment,
|
isDirectPayment: isDirectPayment,
|
||||||
notes: "Venta de Mostrador (Universal POS)",
|
notes: "Venta de Mostrador (Universal POS)",
|
||||||
items: items.map(i => ({
|
items: items.flatMap(i => {
|
||||||
productId: i.productId,
|
if (i.componentsData && i.componentsData.length > 0) {
|
||||||
quantity: i.quantity,
|
// Expandir el combo en sus partes para el backend
|
||||||
relatedEntityId: i.relatedEntityId,
|
// El precio se prorratea según el total del combo
|
||||||
relatedEntityType: i.relatedEntityType
|
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);
|
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) */}
|
{/* MODAL DE BÚSQUEDA DE CLIENTE (F7) */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showClientSearch && (
|
{showClientSearch && (
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { OrderItemDto } from '../types/Order';
|
import type { OrderItemDto } from '../types/Order';
|
||||||
import type { Product } from '../types/Product';
|
import type { Product } from '../types/Product';
|
||||||
|
import type { ComponentConfig } from '../components/POS/BundleConfiguratorModal';
|
||||||
|
|
||||||
interface CartItem extends OrderItemDto {
|
interface CartItem extends OrderItemDto {
|
||||||
tempId: string;
|
tempId: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
subTotal: number;
|
subTotal: number;
|
||||||
// Lista de nombres de componentes para mostrar en el ticket/pantalla
|
|
||||||
subItems?: string[];
|
subItems?: string[];
|
||||||
|
// Datos de configuración de cada componente del combo
|
||||||
|
componentsData?: ComponentConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CartState {
|
interface CartState {
|
||||||
@@ -22,7 +24,8 @@ interface CartState {
|
|||||||
quantity: number,
|
quantity: number,
|
||||||
options?: {
|
options?: {
|
||||||
relatedEntity?: { id: number, type: string, extraInfo?: string },
|
relatedEntity?: { id: number, type: string, extraInfo?: string },
|
||||||
subItems?: string[]
|
subItems?: string[],
|
||||||
|
componentsData?: ComponentConfig[]
|
||||||
}
|
}
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
@@ -41,10 +44,10 @@ export const useCartStore = create<CartState>((set, get) => ({
|
|||||||
|
|
||||||
addItem: (product, quantity, options) => {
|
addItem: (product, quantity, options) => {
|
||||||
const currentItems = get().items;
|
const currentItems = get().items;
|
||||||
const { relatedEntity, subItems } = options || {};
|
const { relatedEntity, subItems, componentsData } = options || {};
|
||||||
|
|
||||||
// Si tiene entidad relacionada (Aviso) o SubItems (Combo), no agrupamos
|
// Si tiene entidad relacionada (Aviso) o SubItems (Combo) o Configuración interna, no agrupamos
|
||||||
const isComplexItem = !!relatedEntity || (subItems && subItems.length > 0);
|
const isComplexItem = !!relatedEntity || (subItems && subItems.length > 0) || (componentsData && componentsData.length > 0);
|
||||||
|
|
||||||
const existingIndex = !isComplexItem
|
const existingIndex = !isComplexItem
|
||||||
? currentItems.findIndex(i => i.productId === product.id && !i.relatedEntityId)
|
? currentItems.findIndex(i => i.productId === product.id && !i.relatedEntityId)
|
||||||
@@ -67,7 +70,8 @@ export const useCartStore = create<CartState>((set, get) => ({
|
|||||||
subTotal: product.basePrice * quantity,
|
subTotal: product.basePrice * quantity,
|
||||||
relatedEntityId: relatedEntity?.id,
|
relatedEntityId: relatedEntity?.id,
|
||||||
relatedEntityType: relatedEntity?.type,
|
relatedEntityType: relatedEntity?.type,
|
||||||
subItems: subItems // Guardamos la lista visual
|
subItems: subItems,
|
||||||
|
componentsData: componentsData
|
||||||
};
|
};
|
||||||
set({ items: [...currentItems, newItem] });
|
set({ items: [...currentItems, newItem] });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ export interface Product {
|
|||||||
currency: string;
|
currency: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
|
||||||
// Campos extendidos para UI (Joins)
|
// Propiedades de Tipo de Producto (Joins)
|
||||||
companyName?: string;
|
requiresText: boolean;
|
||||||
|
hasDuration: boolean;
|
||||||
|
requiresCategory: boolean;
|
||||||
typeCode?: string; // 'BUNDLE', 'PHYSICAL', etc.
|
typeCode?: string; // 'BUNDLE', 'PHYSICAL', etc.
|
||||||
|
companyName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductBundleComponent {
|
export interface ProductBundleComponent {
|
||||||
|
|||||||
64
src/SIGCM.API/Controllers/ProductTypesController.cs
Normal file
64
src/SIGCM.API/Controllers/ProductTypesController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,9 +19,8 @@ public class OrderItemDto
|
|||||||
public int ProductId { get; set; }
|
public int ProductId { get; set; }
|
||||||
public decimal Quantity { get; set; }
|
public decimal Quantity { get; set; }
|
||||||
|
|
||||||
// Opcional: Si el vendedor aplica un descuento manual unitario
|
// Opcional: Precio unitario manual (se usa en Combos prorrateados o descuentos)
|
||||||
// (Por ahora usaremos el precio base del producto)
|
public decimal? UnitPrice { get; set; }
|
||||||
// public decimal? ManualUnitPrice { get; set; }
|
|
||||||
|
|
||||||
// Para vincular con un aviso específico creado previamente
|
// Para vincular con un aviso específico creado previamente
|
||||||
public int? RelatedEntityId { get; set; }
|
public int? RelatedEntityId { get; set; }
|
||||||
|
|||||||
@@ -21,4 +21,7 @@ public class Product
|
|||||||
// Propiedades auxiliares para Joins
|
// Propiedades auxiliares para Joins
|
||||||
public string? CompanyName { get; set; }
|
public string? CompanyName { get; set; }
|
||||||
public string? TypeCode { get; set; } // 'CLASSIFIED_AD', 'PHYSICAL', etc.
|
public string? TypeCode { get; set; } // 'CLASSIFIED_AD', 'PHYSICAL', etc.
|
||||||
|
public bool RequiresText { get; set; }
|
||||||
|
public bool HasDuration { get; set; }
|
||||||
|
public bool RequiresCategory { get; set; }
|
||||||
}
|
}
|
||||||
17
src/SIGCM.Domain/Entities/ProductType.cs
Normal file
17
src/SIGCM.Domain/Entities/ProductType.cs
Normal 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;
|
||||||
|
}
|
||||||
12
src/SIGCM.Domain/Interfaces/IProductTypeRepository.cs
Normal file
12
src/SIGCM.Domain/Interfaces/IProductTypeRepository.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -42,6 +42,22 @@ BEGIN
|
|||||||
);
|
);
|
||||||
END
|
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')
|
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Operations')
|
||||||
BEGIN
|
BEGIN
|
||||||
CREATE TABLE Operations (
|
CREATE TABLE Operations (
|
||||||
@@ -115,6 +131,64 @@ BEGIN
|
|||||||
FOREIGN KEY (ListingId) REFERENCES Listings(Id) ON DELETE CASCADE
|
FOREIGN KEY (ListingId) REFERENCES Listings(Id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
END
|
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
|
// Ejecutar creación de tablas base
|
||||||
await connection.ExecuteAsync(schemaSql);
|
await connection.ExecuteAsync(schemaSql);
|
||||||
@@ -134,6 +208,22 @@ END
|
|||||||
ALTER TABLE Products ADD PriceDurationDays INT NOT NULL DEFAULT 1;
|
ALTER TABLE Products ADD PriceDurationDays INT NOT NULL DEFAULT 1;
|
||||||
END
|
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
|
-- CategoryPricing Columns
|
||||||
IF EXISTS(SELECT * FROM sys.columns WHERE Name = N'BasePrice' AND Object_ID = Object_ID(N'CategoryPricing'))
|
IF EXISTS(SELECT * FROM sys.columns WHERE Name = N'BasePrice' AND Object_ID = Object_ID(N'CategoryPricing'))
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -354,6 +444,23 @@ END
|
|||||||
";
|
";
|
||||||
await connection.ExecuteAsync(upgradeClientsSql);
|
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) ---
|
// --- SEED DE DATOS (Usuario Admin) ---
|
||||||
var adminCount = await connection.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM Users WHERE Username = 'admin'");
|
var adminCount = await connection.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM Users WHERE Username = 'admin'");
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IClientProfileRepository, ClientProfileRepository>();
|
services.AddScoped<IClientProfileRepository, ClientProfileRepository>();
|
||||||
services.AddScoped<ICompanyRepository, CompanyRepository>();
|
services.AddScoped<ICompanyRepository, CompanyRepository>();
|
||||||
services.AddScoped<IReportRepository, ReportRepository>();
|
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)
|
// Registro de MercadoPagoService configurado desde IConfiguration (appsettings o env vars)
|
||||||
services.AddScoped<MercadoPagoService>(sp =>
|
services.AddScoped<MercadoPagoService>(sp =>
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ public class ProductRepository : IProductRepository
|
|||||||
{
|
{
|
||||||
using var conn = _db.CreateConnection();
|
using var conn = _db.CreateConnection();
|
||||||
var sql = @"
|
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
|
FROM Products p
|
||||||
JOIN Companies c ON p.CompanyId = c.Id
|
JOIN Companies c ON p.CompanyId = c.Id
|
||||||
JOIN ProductTypes pt ON p.ProductTypeId = pt.Id
|
JOIN ProductTypes pt ON p.ProductTypeId = pt.Id
|
||||||
@@ -27,7 +28,8 @@ public class ProductRepository : IProductRepository
|
|||||||
{
|
{
|
||||||
using var conn = _db.CreateConnection();
|
using var conn = _db.CreateConnection();
|
||||||
var sql = @"
|
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
|
FROM Products p
|
||||||
JOIN Companies c ON p.CompanyId = c.Id
|
JOIN Companies c ON p.CompanyId = c.Id
|
||||||
JOIN ProductTypes pt ON p.ProductTypeId = pt.Id
|
JOIN ProductTypes pt ON p.ProductTypeId = pt.Id
|
||||||
@@ -59,7 +61,8 @@ public class ProductRepository : IProductRepository
|
|||||||
FROM Categories c
|
FROM Categories c
|
||||||
INNER JOIN CategoryAncestors ca ON c.Id = ca.ParentId
|
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
|
FROM Products p
|
||||||
JOIN Companies comp ON p.CompanyId = comp.Id
|
JOIN Companies comp ON p.CompanyId = comp.Id
|
||||||
JOIN ProductTypes pt ON p.ProductTypeId = pt.Id
|
JOIN ProductTypes pt ON p.ProductTypeId = pt.Id
|
||||||
@@ -99,9 +102,10 @@ public class ProductRepository : IProductRepository
|
|||||||
{
|
{
|
||||||
using var conn = _db.CreateConnection();
|
using var conn = _db.CreateConnection();
|
||||||
var sql = @"
|
var sql = @"
|
||||||
SELECT pb.*, p.*
|
SELECT pb.*, p.*, pt.Code as TypeCode, pt.RequiresText, pt.HasDuration, pt.RequiresCategory
|
||||||
FROM ProductBundles pb
|
FROM ProductBundles pb
|
||||||
JOIN Products p ON pb.ChildProductId = p.Id
|
JOIN Products p ON pb.ChildProductId = p.Id
|
||||||
|
JOIN ProductTypes pt ON p.ProductTypeId = pt.Id
|
||||||
WHERE pb.ParentProductId = @ParentProductId";
|
WHERE pb.ParentProductId = @ParentProductId";
|
||||||
|
|
||||||
// Usamos Dapper Multi-Mapping para llenar el objeto ChildProduct
|
// Usamos Dapper Multi-Mapping para llenar el objeto ChildProduct
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,9 +85,8 @@ public class OrderService : IOrderService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// B. OBTENER PRECIO VIGENTE (Histórico)
|
// B. OBTENER PRECIO VIGENTE (Histórico o Manual)
|
||||||
// Usamos la fecha actual para determinar el precio.
|
decimal currentUnitPrice = itemDto.UnitPrice ?? await _productRepo.GetCurrentPriceAsync(product.Id, DateTime.UtcNow);
|
||||||
decimal currentUnitPrice = await _productRepo.GetCurrentPriceAsync(product.Id, DateTime.UtcNow);
|
|
||||||
|
|
||||||
// C. LÓGICA DE COMBOS VS SIMPLE
|
// C. LÓGICA DE COMBOS VS SIMPLE
|
||||||
if (product.TypeCode == "BUNDLE")
|
if (product.TypeCode == "BUNDLE")
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ public class PricingService
|
|||||||
// Si ProductId = 0, el llamador no seleccionó un producto (ej: FastEntryPage legacy).
|
// Si ProductId = 0, el llamador no seleccionó un producto (ej: FastEntryPage legacy).
|
||||||
// En ese caso fallamos graciosamente con precio base 0.
|
// En ese caso fallamos graciosamente con precio base 0.
|
||||||
decimal productBasePrice = 0;
|
decimal productBasePrice = 0;
|
||||||
|
Product? product = null;
|
||||||
if (request.ProductId > 0)
|
if (request.ProductId > 0)
|
||||||
{
|
{
|
||||||
var product = await _productRepo.GetByIdAsync(request.ProductId);
|
product = await _productRepo.GetByIdAsync(request.ProductId);
|
||||||
if (product == null) return new CalculatePriceResponse
|
if (product == null) return new CalculatePriceResponse
|
||||||
{
|
{
|
||||||
TotalPrice = 0,
|
TotalPrice = 0,
|
||||||
@@ -37,8 +38,8 @@ public class PricingService
|
|||||||
decimal retrievedPrice = await _productRepo.GetCurrentPriceAsync(request.ProductId, request.StartDate == default ? DateTime.UtcNow : request.StartDate);
|
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
|
// Si el precio es por X días (ej: 30), el costo diario es Precio / X
|
||||||
int duration = product.PriceDurationDays > 0 ? product.PriceDurationDays : 1;
|
int prodDuration = product.PriceDurationDays > 0 ? product.PriceDurationDays : 1;
|
||||||
productBasePrice = retrievedPrice / duration;
|
productBasePrice = retrievedPrice / prodDuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Obtener Reglas
|
// 1. Obtener Reglas
|
||||||
@@ -67,20 +68,16 @@ public class PricingService
|
|||||||
int realWordCount = words.Length;
|
int realWordCount = words.Length;
|
||||||
|
|
||||||
// 3. Costo Base y Excedente (productBasePrice ya viene resuelto desde sección 0)
|
// 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?
|
// ¿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);
|
int extraWords = Math.Max(0, realWordCount - pricing.BaseWordCount);
|
||||||
decimal extraWordCost = 0;
|
decimal extraWordCost = 0;
|
||||||
|
|
||||||
if (pricing.WordRanges != null && pricing.WordRanges.Any() && extraWords > 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 =>
|
var applicableRange = pricing.WordRanges.FirstOrDefault(r =>
|
||||||
extraWords >= r.FromCount && (extraWords <= r.ToCount || r.ToCount == 0));
|
extraWords >= r.FromCount && (extraWords <= r.ToCount || r.ToCount == 0));
|
||||||
|
|
||||||
@@ -90,7 +87,6 @@ public class PricingService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Fallback al precio base si no se define rango que cubra la cantidad
|
|
||||||
extraWordCost = extraWords * pricing.ExtraWordPrice;
|
extraWordCost = extraWords * pricing.ExtraWordPrice;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,19 +95,22 @@ public class PricingService
|
|||||||
extraWordCost = extraWords * pricing.ExtraWordPrice;
|
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)
|
// 4. Estilos (Negrita / Recuadro / Destacado)
|
||||||
if (request.IsBold) currentCost += pricing.BoldSurcharge;
|
if (request.IsBold) currentCost += pricing.BoldSurcharge / calcDuration;
|
||||||
if (request.IsFrame) currentCost += pricing.FrameSurcharge;
|
if (request.IsFrame) currentCost += pricing.FrameSurcharge / calcDuration;
|
||||||
|
|
||||||
// Costo Destacado (Hardcoded por ahora o agregar a regla)
|
// Costo Destacado
|
||||||
decimal featuredSurcharge = 0;
|
decimal featuredSurcharge = 0;
|
||||||
if (request.IsFeatured)
|
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;
|
currentCost += featuredSurcharge;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,12 +179,14 @@ public class PricingService
|
|||||||
{
|
{
|
||||||
TotalPrice = Math.Max(0, totalBeforeDiscount - totalDiscount),
|
TotalPrice = Math.Max(0, totalBeforeDiscount - totalDiscount),
|
||||||
BaseCost = productBasePrice * request.Days,
|
BaseCost = productBasePrice * request.Days,
|
||||||
ExtraCost = (extraWordCost + specialCharCost) * request.Days,
|
ExtraCost = (dailyExtraWordCost + dailySpecialCharCost) * request.Days,
|
||||||
Surcharges = ((request.IsBold ? pricing.BoldSurcharge : 0) + (request.IsFrame ? pricing.FrameSurcharge : 0) + (request.IsFeatured ? featuredSurcharge : 0)) * request.Days,
|
Surcharges = ((request.IsBold ? pricing.BoldSurcharge / calcDuration : 0) +
|
||||||
|
(request.IsFrame ? pricing.FrameSurcharge / calcDuration : 0) +
|
||||||
|
(request.IsFeatured ? featuredSurcharge : 0)) * request.Days,
|
||||||
Discount = totalDiscount,
|
Discount = totalDiscount,
|
||||||
WordCount = realWordCount,
|
WordCount = realWordCount,
|
||||||
SpecialCharCount = specialCharCount,
|
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)
|
AppliedPromotion = string.Join(", ", appliedPromos)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user