Compare commits

..

10 Commits

Author SHA1 Message Date
b4fa74ad9b Feat: Configuración y Administración de Tipos 2026-02-25 18:11:52 -03:00
a8b8229b41 Implementación de duración de precio por producto y mejoras en cálculo de tarifas
Detalles:
- Se añadió PriceDurationDays a la entidad Product.
- Actualización de base de datos y repositorios para soportar la nueva propiedad.
- Ajuste en PricingService para calcular costos diarios basados en la duración del producto.
- Mejora en UI: el selector de días en ventas se adapta a la duración base del producto.
- Duración del precio ahora es opcional mediante un interruptor en el modal de producto.
- Corrección en DeleteAsync usando SQL dinámico para mayor seguridad.
- Cálculo de precio instantáneo al seleccionar el rubro sin requerir texto previo.
2026-02-21 21:17:50 -03:00
f3638195a6 feat: Implementación de eliminación de productos con validación de asociaciones 2026-02-21 20:41:41 -03:00
b8f1ed8a68 feat: Implementación de rangos de precio por palabra con validación de continuidad 2026-02-21 20:28:50 -03:00
da99fd5843 fix: GetByCategoryIdAsync incluye productos de categorías ancestras (CTE recursivo) 2026-02-21 20:19:21 -03:00
a9ad545fbb ux: menú lateral más descriptivo y PricingManager muestra productos del rubro con precios 2026-02-21 20:10:40 -03:00
e21028ee9f feat: selector de producto por rubro en FastEntryPage y Mostrador Universal 2026-02-21 19:59:19 -03:00
16f84237fb fix: PricingService tolera ProductId=0 para compatibilidad con FastEntryPage 2026-02-21 19:55:08 -03:00
6d1eb908a0 Refactor product pricing: catalog owns base price, pricing manager owns rules 2026-02-21 19:52:25 -03:00
841cc961b5 Feat ERP 3 2026-02-21 19:23:17 -03:00
48 changed files with 2561 additions and 251 deletions

2
.gitignore vendored
View File

@@ -38,7 +38,7 @@ yarn-error.log*
# Configuración de desarrollo que puede contener secretos. # Configuración de desarrollo que puede contener secretos.
# Es mejor usar "User Secrets" en desarrollo para las claves. # Es mejor usar "User Secrets" en desarrollo para las claves.
appsettings.Development.json #appsettings.Development.json
# Archivos de publicación de Visual Studio # Archivos de publicación de Visual Studio
[Pp]roperties/[Pp]ublish[Pp]rofiles/ [Pp]roperties/[Pp]ublish[Pp]rofiles/

View File

@@ -1,13 +1,15 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin-panel</title> <title>admin-panel</title>
</head> </head>
<body>
<body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -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 />} />

View File

@@ -1,8 +1,10 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import type { Company } from '../../types/Company'; import type { Company } from '../../types/Company';
import { companyService } from '../../services/companyService'; import { companyService } from '../../services/companyService';
import { X, Save, Building2, FileText, MapPin, Link } from 'lucide-react'; import { X, Save, Building2, MapPin, Link } from 'lucide-react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import CuitInput from '../Shared/CuitInput';
import clsx from 'clsx';
interface Props { interface Props {
company: Company | null; company: Company | null;
@@ -11,6 +13,7 @@ interface Props {
export default function CompanyModal({ company, onClose }: Props) { export default function CompanyModal({ company, onClose }: Props) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isCuitValid, setIsCuitValid] = useState(true);
const [formData, setFormData] = useState<Partial<Company>>({ const [formData, setFormData] = useState<Partial<Company>>({
name: '', name: '',
taxId: '', taxId: '',
@@ -66,11 +69,15 @@ export default function CompanyModal({ company, onClose }: Props) {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1"> <CuitInput
<FileText size={12} /> CUIT / Tax ID label="CUIT / Tax ID"
</label> value={formData.taxId || ''}
<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" required
value={formData.taxId} onChange={e => setFormData({ ...formData, taxId: e.target.value })} /> onChange={(val, valid) => {
setFormData({ ...formData, taxId: val });
setIsCuitValid(valid);
}}
/>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1"> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1">
@@ -103,8 +110,15 @@ export default function CompanyModal({ company, onClose }: Props) {
<div className="pt-4"> <div className="pt-4">
<button <button
type="submit" disabled={loading} type="submit"
className="w-full bg-blue-600 text-white py-4 rounded-xl font-black text-xs uppercase tracking-widest shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all flex items-center justify-center gap-2" // Bloquear si está cargando O si el CUIT es inválido (y tiene algo escrito)
disabled={loading || !isCuitValid || !formData.taxId}
className={clsx(
"w-full py-4 rounded-xl font-black text-xs uppercase tracking-widest shadow-lg transition-all flex items-center justify-center gap-2",
loading || !isCuitValid
? "bg-slate-300 text-slate-500 cursor-not-allowed shadow-none"
: "bg-blue-600 text-white shadow-blue-200 hover:bg-blue-700"
)}
> >
<Save size={16} /> {loading ? 'Guardando...' : 'Guardar Empresa'} <Save size={16} /> {loading ? 'Guardando...' : 'Guardar Empresa'}
</button> </button>

View File

@@ -1,58 +1,76 @@
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 { 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';
interface Props { interface Props {
product: Product | null; product: Product | null;
companies: Company[]; companies: Company[];
categories: Category[];
allProducts: Product[]; allProducts: Product[];
onClose: (refresh?: boolean) => void; onClose: (refresh?: boolean) => void;
} }
const PRODUCT_TYPES = [ export default function ProductModal({ product, companies, categories, allProducts, onClose }: Props) {
{ 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, allProducts, onClose }: Props) {
const [formData, setFormData] = useState<Partial<Product>>({ const [formData, setFormData] = useState<Partial<Product>>({
name: '', name: '',
description: '', description: '',
companyId: 0, companyId: 0,
productTypeId: 4, // Default Physical categoryId: undefined,
productTypeId: 0,
basePrice: 0, basePrice: 0,
priceDurationDays: 1,
taxRate: 21, taxRate: 21,
sku: '', sku: '',
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 [hasDurationUI, setHasDurationUI] = useState(false);
useEffect(() => {
productTypeService.getAll().then(setProductTypes).catch(console.error);
}, []);
useEffect(() => { useEffect(() => {
if (product) { if (product) {
setFormData(product); setFormData(product);
const type = PRODUCT_TYPES.find(t => t.id === product.productTypeId); setHasDurationUI(product.priceDurationDays > 1);
if (type?.code === 'BUNDLE') { if (productTypes.length > 0) {
const type = productTypes.find(t => t.id === product.productTypeId);
if (type?.isBundle) {
setIsBundle(true); setIsBundle(true);
loadBundleComponents(product.id); 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(() => { 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;
}, [formData.productTypeId]);
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, productTypes]);
const loadBundleComponents = async (productId: number) => { const loadBundleComponents = async (productId: number) => {
try { try {
@@ -137,7 +155,7 @@ export default function ProductModal({ product, companies, allProducts, onClose
<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 })}
@@ -169,12 +187,55 @@ export default function ProductModal({ product, companies, allProducts, onClose
</select> </select>
</div> </div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Rubro Asociado</label>
<select 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 appearance-none"
value={formData.categoryId || 0} onChange={e => setFormData({ ...formData, categoryId: Number(e.target.value) || undefined })}
>
<option value="0">Ninguno (Producto General)</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Precio Base ($)</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Precio Base ($)</label>
<input required type="number" step="0.01" className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-black text-sm" <input required type="number" step="0.01" className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-black text-sm"
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>
{productTypes.find(t => t.id === formData.productTypeId)?.hasDuration && (
<div className="col-span-2 space-y-3">
<div
onClick={() => {
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 ${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 ${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>
{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">
<input required type="number" min="2" className="w-full p-3 bg-blue-50/50 border-2 border-blue-100 rounded-xl outline-none focus:border-blue-500 font-black text-sm text-blue-600"
value={formData.priceDurationDays} onChange={e => setFormData({ ...formData, priceDurationDays: Math.max(2, parseInt(e.target.value) || 2) })} />
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] font-black text-blue-300 uppercase">Días</span>
</div>
</motion.div>
)}
</div>
)}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Alicuota IVA (%)</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Alicuota IVA (%)</label>
<select 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 appearance-none" <select 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 appearance-none"

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

@@ -0,0 +1,98 @@
import { useState, useEffect, type ChangeEvent } from 'react';
import { isValidCuit, formatCuit } from '../../utils/cuitValidator';
import { ShieldCheck, ShieldAlert } from 'lucide-react';
import clsx from 'clsx';
interface CuitInputProps {
value: string;
onChange: (value: string, isValid: boolean) => void;
required?: boolean;
placeholder?: string;
className?: string;
label?: string; // Opcional, para renderizar el label dentro del componente
}
export default function CuitInput({
value,
onChange,
required = false,
placeholder = "20-12345678-9",
className,
label
}: CuitInputProps) {
const [isValid, setIsValid] = useState(true);
const [isTouched, setIsTouched] = useState(false);
// Validar al montar o cuando cambia el value externamente
useEffect(() => {
if (value) {
setIsValid(isValidCuit(value));
} else {
setIsValid(!required); // Si está vacío y no es requerido, es válido
}
}, [value, required]);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const rawValue = e.target.value;
const formatted = formatCuit(rawValue);
// Solo permitir hasta 13 caracteres (11 dígitos + 2 guiones)
if (formatted.length > 13) return;
const valid = isValidCuit(formatted);
setIsTouched(true);
setIsValid(valid);
// Propagamos el cambio al padre
onChange(formatted, valid);
};
const handleBlur = () => {
setIsTouched(true);
};
const showError = isTouched && !isValid && value.length > 0;
const showSuccess = isValid && value.length === 13;
return (
<div className={className}>
{label && (
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-1.5 flex items-center gap-1">
{label}
{required && <span className="text-rose-500">*</span>}
</label>
)}
<div className="relative">
<input
type="text"
value={value}
onChange={handleChange}
onBlur={handleBlur}
placeholder={placeholder}
required={required}
className={clsx(
"w-full p-3 bg-slate-50 border-2 rounded-xl outline-none font-mono font-bold text-sm transition-all pr-10",
showError
? "border-rose-300 text-rose-700 focus:border-rose-500 bg-rose-50"
: showSuccess
? "border-emerald-300 text-emerald-700 focus:border-emerald-500 bg-emerald-50/30"
: "border-slate-100 text-slate-700 focus:border-blue-500"
)}
/>
{/* Icono de estado absoluto a la derecha */}
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
{showError && <ShieldAlert size={18} className="text-rose-500 animate-pulse" />}
{showSuccess && <ShieldCheck size={18} className="text-emerald-500" />}
</div>
</div>
{showError && (
<p className="text-[10px] font-bold text-rose-500 mt-1 ml-1 uppercase tracking-tight">
CUIT inválido (Verifique dígito verificador)
</p>
)}
</div>
);
}

View File

@@ -49,20 +49,21 @@ export default function ProtectedLayout() {
{ {
title: "Gestión Diaria", title: "Gestión Diaria",
items: [ items: [
{ label: 'Dashboard', href: '/', icon: <LayoutDashboard size={18} />, roles: ['Admin', 'Cajero'] }, { label: 'Inicio', href: '/', icon: <LayoutDashboard size={18} />, roles: ['Admin', 'Cajero'] },
{ label: 'Moderación', href: '/moderation', icon: <Eye size={18} />, roles: ['Admin', 'Moderador'], badge: unreadCount }, { label: 'Moderación de Avisos', href: '/moderation', icon: <Eye size={18} />, roles: ['Admin', 'Moderador'], badge: unreadCount },
{ label: 'Explorador', href: '/listings', icon: <Search size={18} />, roles: ['Admin', 'Cajero', 'Moderador'] }, { label: 'Buscar Publicaciones', href: '/listings', icon: <Search size={18} />, roles: ['Admin', 'Cajero', 'Moderador'] },
{ label: 'Clientes', href: '/clients', icon: <ClientIcon size={18} />, roles: ['Admin', 'Cajero'] }, { label: 'Clientes', href: '/clients', icon: <ClientIcon size={18} />, roles: ['Admin', 'Cajero'] },
] ]
}, },
{ {
title: "Comercial & Catálogo", title: "Catálogo & Precios",
items: [ items: [
{ label: 'Productos', href: '/products', icon: <Box size={18} />, roles: ['Admin'] }, { label: 'Productos & Tarifas', href: '/products', icon: <Box size={18} />, roles: ['Admin'] },
{ label: 'Tarifas', href: '/pricing', icon: <DollarSign 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: 'Promociones', href: '/promotions', icon: <Tag size={18} />, roles: ['Admin'] },
{ label: 'Cupones', href: '/coupons', icon: <Tag size={18} />, roles: ['Admin'] }, { label: 'Cupones de Descuento', href: '/coupons', icon: <Tag size={18} />, roles: ['Admin'] },
{ label: 'Categorías', href: '/categories', icon: <FolderTree size={18} />, roles: ['Admin'] }, { label: 'Rubros & Categorías', href: '/categories', icon: <FolderTree size={18} />, roles: ['Admin'] },
] ]
}, },
{ {
@@ -70,16 +71,16 @@ export default function ProtectedLayout() {
items: [ items: [
{ label: 'Empresas', href: '/companies', icon: <Building2 size={18} />, roles: ['Admin'] }, { label: 'Empresas', href: '/companies', icon: <Building2 size={18} />, roles: ['Admin'] },
{ label: 'Riesgo Crediticio', href: '/finance/credit', icon: <ShieldAlert size={18} />, roles: ['Admin', 'Gerente'] }, { label: 'Riesgo Crediticio', href: '/finance/credit', icon: <ShieldAlert size={18} />, roles: ['Admin', 'Gerente'] },
{ label: 'Liquidación', href: '/reports/settlement', icon: <ArrowRightLeft size={18} />, roles: ['Admin', 'Contador'] }, { label: 'Liquidación de Cuentas', href: '/reports/settlement', icon: <ArrowRightLeft size={18} />, roles: ['Admin', 'Contador'] },
] ]
}, },
{ {
title: "Operaciones & Sistema", title: "Sistemas & Administración",
items: [ items: [
{ label: 'Diagramación', href: '/diagram', icon: <FileText size={18} />, roles: ['Admin', 'Diagramador'] }, { label: 'Diagramación de Página', href: '/diagram', icon: <FileText size={18} />, roles: ['Admin', 'Diagramador'] },
{ label: 'Calendario', href: '/companies/calendar', icon: <Calendar size={18} />, roles: ['Admin'] }, { label: 'Calendario de Ediciones', href: '/companies/calendar', icon: <Calendar size={18} />, roles: ['Admin'] },
{ label: 'Usuarios', href: '/users', icon: <Users size={18} />, roles: ['Admin'] }, { label: 'Gestión de Usuarios', href: '/users', icon: <Users size={18} />, roles: ['Admin'] },
{ label: 'Auditoría', href: '/audit', icon: <History size={18} />, roles: ['Admin'] }, { label: 'Registro de Auditoría', href: '/audit', icon: <History size={18} />, roles: ['Admin'] },
] ]
} }
]; ];

View File

@@ -1,27 +1,54 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import api from '../../services/api'; import api from '../../services/api';
import { Save, DollarSign, FileText, Type, AlertCircle } from 'lucide-react'; import { Save, DollarSign, FileText, Package, ExternalLink, ArrowRight, Plus, Trash2, Info, AlertCircle } from 'lucide-react';
import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils'; import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils';
interface PricingConfig { // Datos mínimos de un Producto del Catálogo
interface ProductSummary {
id: number;
name: string;
typeCode: string;
basePrice: number; basePrice: number;
}
interface WordRange {
id: number;
fromCount: number;
toCount: number;
pricePerWord: number;
}
interface PricingConfig {
id: number;
categoryId: number;
baseWordCount: number; baseWordCount: number;
extraWordPrice: number; extraWordPrice: number;
specialChars: string; specialChars: string;
specialCharPrice: number; specialCharPrice: number;
boldSurcharge: number; boldSurcharge: number;
frameSurcharge: number; frameSurcharge: number;
wordRanges: WordRange[];
} }
// Configuración por defecto // Configuración por defecto
const defaultConfig: PricingConfig = { const defaultConfig: PricingConfig = {
basePrice: 0, baseWordCount: 15, extraWordPrice: 0, id: 0,
specialChars: '!', specialCharPrice: 0, boldSurcharge: 0, frameSurcharge: 0 categoryId: 0,
baseWordCount: 15,
extraWordPrice: 0,
specialChars: '!',
specialCharPrice: 0,
boldSurcharge: 0,
frameSurcharge: 0,
wordRanges: []
}; };
export default function PricingManager() { export default function PricingManager() {
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]); const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
const [selectedCat, setSelectedCat] = useState<number | null>(null); const [selectedCat, setSelectedCat] = useState<number | null>(null);
const [categoryProducts, setCategoryProducts] = useState<ProductSummary[]>([]);
const [loadingProducts, setLoadingProducts] = useState(false);
const [config, setConfig] = useState<PricingConfig>(defaultConfig); const [config, setConfig] = useState<PricingConfig>(defaultConfig);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -36,12 +63,21 @@ export default function PricingManager() {
useEffect(() => { useEffect(() => {
if (selectedCat) { if (selectedCat) {
// Cargar config existente // Cargar config existente del rubro
api.get(`/pricing/${selectedCat}`) api.get(`/pricing/${selectedCat}`)
.then(res => { .then(res => {
if (res.data) setConfig(res.data); if (res.data) setConfig(res.data);
else setConfig(defaultConfig); // Reset si es nuevo else setConfig(defaultConfig);
}); });
// Cargar productos vinculados a este rubro
setLoadingProducts(true);
api.get(`/products/by-category/${selectedCat}`)
.then(res => setCategoryProducts(res.data || []))
.catch(() => setCategoryProducts([]))
.finally(() => setLoadingProducts(false));
} else {
setCategoryProducts([]);
} }
}, [selectedCat]); }, [selectedCat]);
@@ -65,7 +101,7 @@ export default function PricingManager() {
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
<h2 className="text-2xl font-bold mb-6 flex items-center gap-2 text-gray-800"> <h2 className="text-2xl font-bold mb-6 flex items-center gap-2 text-gray-800">
<DollarSign className="text-green-600" /> <DollarSign className="text-green-600" />
Gestor de Tarifas y Reglas Reglas de Tarifación por Rubro
</h2> </h2>
{/* SELECTOR DE RUBRO */} {/* SELECTOR DE RUBRO */}
@@ -82,7 +118,7 @@ export default function PricingManager() {
<option <option
key={cat.id} key={cat.id}
value={cat.id} value={cat.id}
disabled={!cat.isSelectable} // Bloqueamos padres para forzar config en hojas disabled={!cat.isSelectable}
className={cat.isSelectable ? "text-gray-900 font-medium" : "text-gray-400 font-bold bg-gray-50"} className={cat.isSelectable ? "text-gray-900 font-medium" : "text-gray-400 font-bold bg-gray-50"}
> >
{'\u00A0\u00A0'.repeat(cat.level)} {'\u00A0\u00A0'.repeat(cat.level)}
@@ -90,7 +126,6 @@ export default function PricingManager() {
</option> </option>
))} ))}
</select> </select>
{/* Flecha custom para estilo */}
<div className="absolute right-4 top-3.5 pointer-events-none text-gray-500"></div> <div className="absolute right-4 top-3.5 pointer-events-none text-gray-500"></div>
</div> </div>
@@ -106,50 +141,196 @@ export default function PricingManager() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* TARIFA BASE */} {/* TARJETA: PRECIO BASE - muestra productos vinculados */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200"> <div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h3 className="font-bold text-lg mb-4 pb-2 border-b border-gray-100 flex items-center gap-2 text-gray-800"> <h3 className="font-bold text-lg mb-1 flex items-center gap-2 text-gray-800">
<FileText size={20} className="text-blue-500" /> Tarifa Base (Texto) <Package size={20} className="text-blue-500" /> Precio Base del Rubro
</h3> </h3>
<p className="text-xs text-gray-400 mb-4">
El precio mínimo por aviso se define en cada Producto del Catálogo vinculado a este rubro.
</p>
{loadingProducts ? (
<div className="text-xs text-slate-400 animate-pulse py-3">Cargando productos...</div>
) : categoryProducts.length === 0 ? (
<div className="flex flex-col gap-3">
<div className="flex items-start gap-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<span className="text-amber-500 text-xl leading-none"></span>
<div>
<p className="text-xs font-bold text-amber-700">Sin productos vinculados</p>
<p className="text-xs text-amber-600 mt-0.5">
Este rubro no tiene productos en el Catálogo. El precio base será $0 al calcular tarifas.
</p>
</div>
</div>
<Link
to="/products"
className="inline-flex items-center gap-2 text-xs font-bold text-blue-600 hover:text-blue-800 transition-colors bg-blue-50 px-3 py-2 rounded-lg border border-blue-200"
>
<ExternalLink size={12} /> Ir al Catálogo de Productos para agregar uno
</Link>
</div>
) : (
<div className="space-y-2">
{categoryProducts.map(prod => (
<div key={prod.id} className="flex items-center justify-between py-2.5 px-3 bg-slate-50 rounded-lg border border-slate-100 hover:border-blue-200 transition-colors">
<div className="flex items-center gap-2">
<span className="text-[9px] font-black uppercase text-slate-400 bg-slate-200 px-1.5 py-0.5 rounded">{prod.typeCode}</span>
<span className="text-sm font-bold text-slate-700">{prod.name}</span>
</div>
<span className="font-mono font-black text-blue-600 text-sm">${prod.basePrice.toLocaleString()}</span>
</div>
))}
<Link
to="/products"
className="mt-2 inline-flex items-center gap-1.5 text-[11px] font-bold text-blue-500 hover:text-blue-700 transition-colors"
>
<ArrowRight size={11} /> Administrar precios en el Catálogo de Productos
</Link>
</div>
)}
</div>
{/* TARJETA: REGLAS POR PALABRAS */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 lg:col-span-2">
<div className="flex justify-between items-center mb-4 pb-2 border-b border-gray-100">
<h3 className="font-bold text-lg flex items-center gap-2 text-gray-800">
<FileText size={20} className="text-blue-500" /> Tarifación por Cantidad de Palabras
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* CONFIGURACIÓN BÁSICA */}
<div className="space-y-4"> <div className="space-y-4">
<h4 className="text-sm font-bold text-gray-700 flex items-center gap-2">
<Info size={14} className="text-blue-400" /> Configuración Base
</h4>
<div> <div>
<label className="block text-sm font-medium text-gray-600 mb-1">Precio Mínimo ($)</label> <label className="block text-sm font-medium text-gray-600 mb-1">Palabras Incluidas en Base</label>
<div className="relative">
<span className="absolute left-3 top-2 text-gray-500">$</span>
<input type="number" className="pl-6 border p-2 rounded w-full focus:ring-2 focus:ring-blue-500 outline-none"
value={config.basePrice} onChange={e => setConfig({ ...config, basePrice: parseFloat(e.target.value) })} />
</div>
<p className="text-xs text-gray-400 mt-1">Costo por el aviso básico por día.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-600 mb-1">Palabras Incluidas</label>
<input type="number" className="border p-2 rounded w-full focus:ring-2 focus:ring-blue-500 outline-none" <input type="number" className="border p-2 rounded w-full focus:ring-2 focus:ring-blue-500 outline-none"
value={config.baseWordCount} onChange={e => setConfig({ ...config, baseWordCount: parseInt(e.target.value) })} /> value={config.baseWordCount} onChange={e => setConfig({ ...config, baseWordCount: parseInt(e.target.value) })} />
<p className="text-[10px] text-gray-400 mt-1">Cantidad de palabras ya cubiertas por el Precio Base del Producto.</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-600 mb-1">Costo Palabra Extra ($)</label> <label className="block text-sm font-medium text-gray-600 mb-1">Precio x Palabra (Fallback)</label>
<input type="number" className="border p-2 rounded w-full focus:ring-2 focus:ring-blue-500 outline-none" <input type="number" className="border p-2 rounded w-full focus:ring-2 focus:ring-blue-500 outline-none"
value={config.extraWordPrice} onChange={e => setConfig({ ...config, extraWordPrice: parseFloat(e.target.value) })} /> value={config.extraWordPrice} onChange={e => setConfig({ ...config, extraWordPrice: parseFloat(e.target.value) })} />
<p className="text-[10px] text-gray-400 mt-1">Se usa si no se definen rangos o si el total supera los rangos definidos.</p>
</div> </div>
</div> </div>
{/* EDITOR DE RANGOS */}
<div className="md:col-span-2 space-y-4">
<div className="flex justify-between items-center">
<h4 className="text-sm font-bold text-gray-700">Escalones de Precio (Palabras Extra)</h4>
<button
type="button"
onClick={() => {
const newRanges = [...config.wordRanges];
const lastRange = newRanges[newRanges.length - 1];
const from = lastRange ? lastRange.toCount + 1 : 1;
newRanges.push({ id: 0, fromCount: from, toCount: from + 4, pricePerWord: 0 });
setConfig({ ...config, wordRanges: newRanges });
}}
className="text-xs font-bold bg-blue-50 text-blue-600 px-3 py-1.5 rounded-lg hover:bg-blue-100 transition-colors flex items-center gap-1.5"
>
<Plus size={14} /> AGREGAR RANGO
</button>
</div>
{config.wordRanges.length === 0 ? (
<div className="bg-slate-50 border border-dashed border-slate-200 rounded-xl p-8 text-center">
<p className="text-xs text-slate-400 font-medium">No hay rangos definidos. Se cobrará siempre el precio de fallback por cada palabra extra.</p>
</div>
) : (
<div className="space-y-2">
{config.wordRanges.map((range, idx) => (
<div key={idx} className="flex items-center gap-3 bg-white p-2 border rounded-xl shadow-sm animate-fade-in group">
<div className="flex-1 grid grid-cols-4 gap-4 items-center px-2">
<div className="text-center">
<span className="text-[10px] uppercase font-black text-slate-300 block">DESDE</span>
<span className="font-mono font-bold text-slate-600">{range.fromCount}</span>
</div>
<div>
<span className="text-[10px] uppercase font-black text-slate-300 block">HASTA</span>
<input
type="number"
className="w-full border-b-2 border-slate-100 focus:border-blue-400 outline-none text-sm font-bold p-1 text-center"
value={range.toCount}
onChange={(e) => {
const val = parseInt(e.target.value);
const newRanges = [...config.wordRanges];
newRanges[idx].toCount = val;
// Actualizar automáticamente el "Desde" del siguiente rango para mantener continuidad
for (let i = idx + 1; i < newRanges.length; i++) {
newRanges[i].fromCount = newRanges[i - 1].toCount + 1;
}
setConfig({ ...config, wordRanges: newRanges });
}}
/>
</div>
<div className="col-span-2">
<span className="text-[10px] uppercase font-black text-slate-300 block">PRECIO X PALABRA</span>
<div className="flex items-center gap-2">
<span className="text-slate-400 font-bold">$</span>
<input
type="number"
step="0.01"
className="w-full border-b-2 border-slate-100 focus:border-green-400 outline-none text-sm font-black p-1 text-green-600"
value={range.pricePerWord}
onChange={(e) => {
const newRanges = [...config.wordRanges];
newRanges[idx].pricePerWord = parseFloat(e.target.value);
setConfig({ ...config, wordRanges: newRanges });
}}
/>
</div>
</div> </div>
</div> </div>
{/* CONTENIDO ESPECIAL */} <button
type="button"
onClick={() => {
const newRanges = config.wordRanges.filter((_, i) => i !== idx);
// Re-calcular continuidades
if (newRanges.length > 0) {
newRanges[0].fromCount = 1;
for (let i = 1; i < newRanges.length; i++) {
newRanges[i].fromCount = newRanges[i - 1].toCount + 1;
}
}
setConfig({ ...config, wordRanges: newRanges });
}}
className="p-2 text-rose-300 hover:text-rose-500 transition-colors opacity-0 group-hover:opacity-100"
>
<Trash2 size={18} />
</button>
</div>
))}
<p className="text-[10px] text-amber-500 font-bold flex items-center gap-1 mt-3 px-1">
<AlertCircle size={10} /> TIP: Usa un 'Hasta' muy alto (ej: 9999) en el último rango para cubrir todas las palabras extra restantes.
</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* TARJETA: CARACTERES ESPECIALES */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200"> <div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h3 className="font-bold text-lg mb-4 pb-2 border-b border-gray-100 flex items-center gap-2 text-gray-800"> <h3 className="font-bold text-lg mb-4 pb-2 border-b border-gray-100 flex items-center gap-2 text-gray-800">
<AlertCircle size={20} className="text-orange-500" /> Caracteres Especiales <DollarSign size={20} className="text-orange-500" /> Caracteres Especiales
</h3> </h3>
<div className="space-y-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label className="block text-sm font-medium text-gray-600 mb-1">Caracteres a cobrar (ej: !$%)</label> <label className="block text-sm font-medium text-gray-600 mb-1">Caracteres a cobrar (ej: !$%)</label>
<input type="text" className="border p-2 rounded w-full font-mono tracking-widest focus:ring-2 focus:ring-orange-500 outline-none" <input type="text" className="border p-2 rounded w-full font-mono tracking-widest focus:ring-2 focus:ring-orange-500 outline-none"
value={config.specialChars} onChange={e => setConfig({ ...config, specialChars: e.target.value })} /> value={config.specialChars} onChange={e => setConfig({ ...config, specialChars: e.target.value })} />
<p className="text-xs text-gray-400 mt-1">Cada uno de estos símbolos se cobrará aparte.</p> <p className="text-xs text-gray-400 mt-1">Cada uno de estos símbolos se cobrará aparte del precio por palabra.</p>
</div> </div>
<div> <div>
@@ -163,10 +344,10 @@ export default function PricingManager() {
</div> </div>
</div> </div>
{/* ESTILOS VISUALES */} {/* TARJETA: ESTILOS VISUALES */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 lg:col-span-2"> <div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h3 className="font-bold text-lg mb-4 pb-2 border-b border-gray-100 flex items-center gap-2 text-gray-800"> <h3 className="font-bold text-lg mb-4 pb-2 border-b border-gray-100 flex items-center gap-2 text-gray-800">
<Type size={20} className="text-purple-500" /> Estilos Visuales (Recargos) <Save size={20} className="text-purple-500" /> Estilos Visuales (Recargos)
</h3> </h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
@@ -190,8 +371,6 @@ export default function PricingManager() {
</div> </div>
</div> </div>
</div>
{/* BARRA DE ACCIÓN FLOTANTE */} {/* BARRA DE ACCIÓN FLOTANTE */}
<div className="sticky bottom-4 bg-gray-900 text-white p-4 rounded-lg shadow-lg flex justify-between items-center z-10"> <div className="sticky bottom-4 bg-gray-900 text-white p-4 rounded-lg shadow-lg flex justify-between items-center z-10">
<div className="text-sm"> <div className="text-sm">

View File

@@ -1,15 +1,18 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Plus, Search, Edit, Box, Layers } from 'lucide-react'; import { Plus, Search, Edit, Box, Layers, Trash2 } from 'lucide-react';
import { productService } from '../../../../counter-panel/src/services/productService'; import { productService } from '../../../../counter-panel/src/services/productService';
import { companyService } from '../../../../counter-panel/src/services/companyService'; import { companyService } from '../../../../counter-panel/src/services/companyService';
import { categoryService } from '../../services/categoryService';
import type { Product } from '../../../../counter-panel/src/types/Product'; import type { Product } from '../../../../counter-panel/src/types/Product';
import type { Company } from '../../../../counter-panel/src/types/Company'; import type { Company } from '../../../../counter-panel/src/types/Company';
import type { Category } from '../../types/Category';
import ProductModal from '../../components/Products/ProductModal'; import ProductModal from '../../components/Products/ProductModal';
import clsx from 'clsx'; import clsx from 'clsx';
export default function ProductManager() { export default function ProductManager() {
const [products, setProducts] = useState<Product[]>([]); const [products, setProducts] = useState<Product[]>([]);
const [companies, setCompanies] = useState<Company[]>([]); const [companies, setCompanies] = useState<Company[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@@ -24,12 +27,14 @@ export default function ProductManager() {
const loadData = async () => { const loadData = async () => {
setLoading(true); setLoading(true);
try { try {
const [prodRes, compRes] = await Promise.all([ const [prodRes, compRes, catRes] = await Promise.all([
productService.getAll(), productService.getAll(),
companyService.getAll() companyService.getAll(),
categoryService.getAll()
]); ]);
setProducts(prodRes); setProducts(prodRes);
setCompanies(compRes); setCompanies(compRes);
setCategories(catRes);
} catch (error) { } catch (error) {
console.error("Error cargando catálogo", error); console.error("Error cargando catálogo", error);
} finally { } finally {
@@ -52,6 +57,21 @@ export default function ProductManager() {
if (shouldRefresh) loadData(); if (shouldRefresh) loadData();
}; };
const handleDelete = async (product: Product) => {
if (!window.confirm(`¿Está seguro que desea eliminar el producto "${product.name}"? Esta acción no se puede deshacer.`)) {
return;
}
try {
await productService.delete(product.id);
loadData();
} catch (error: any) {
console.error("Error eliminando producto", error);
const msg = error.response?.data?.message || "Ocurrió un error al intentar eliminar el producto.";
alert(msg);
}
};
const filteredProducts = products.filter(p => const filteredProducts = products.filter(p =>
p.name.toLowerCase().includes(searchTerm.toLowerCase()) || p.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
p.sku?.toLowerCase().includes(searchTerm.toLowerCase()) p.sku?.toLowerCase().includes(searchTerm.toLowerCase())
@@ -133,12 +153,22 @@ export default function ProductManager() {
<span className={clsx("w-2 h-2 rounded-full inline-block", p.isActive ? "bg-emerald-500" : "bg-rose-500")}></span> <span className={clsx("w-2 h-2 rounded-full inline-block", p.isActive ? "bg-emerald-500" : "bg-rose-500")}></span>
</td> </td>
<td className="p-5 text-right"> <td className="p-5 text-right">
<div className="flex justify-end gap-1">
<button <button
onClick={() => handleEdit(p)} onClick={() => handleEdit(p)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Editar"
> >
<Edit size={18} /> <Edit size={18} />
</button> </button>
<button
onClick={() => handleDelete(p)}
className="p-2 text-rose-600 hover:bg-rose-50 rounded-lg transition-colors"
title="Eliminar"
>
<Trash2 size={18} />
</button>
</div>
</td> </td>
</tr> </tr>
))} ))}
@@ -150,6 +180,7 @@ export default function ProductManager() {
<ProductModal <ProductModal
product={editingProduct} product={editingProduct}
companies={companies} companies={companies}
categories={categories}
allProducts={products} // Pasamos todos los productos para poder armar combos allProducts={products} // Pasamos todos los productos para poder armar combos
onClose={handleModalClose} onClose={handleModalClose}
/> />

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

@@ -2,11 +2,13 @@ export interface Product {
id: number; id: number;
companyId: number; companyId: number;
productTypeId: number; // 1:Classified, 2:Graphic, 3:Radio, 4:Physical, 5:Service, 6:Bundle productTypeId: number; // 1:Classified, 2:Graphic, 3:Radio, 4:Physical, 5:Service, 6:Bundle
categoryId?: number; // Para relacionarlo a un rubro
name: string; name: string;
description?: string; description?: string;
sku?: string; sku?: string;
externalId?: string; externalId?: string;
basePrice: number; basePrice: number;
priceDurationDays: number;
taxRate: number; taxRate: number;
currency: string; currency: string;
isActive: boolean; isActive: boolean;
@@ -26,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;
}

View File

@@ -0,0 +1,43 @@
/**
* Valida un CUIT/CUIL argentino aplicando el algoritmo de Módulo 11.
* Soporta formatos con o sin guiones (20-12345678-9 o 20123456789).
*/
export const isValidCuit = (cuit: string): boolean => {
if (!cuit) return false;
// 1. Limpiar el input: dejar solo números
const cleanCuit = cuit.replace(/[^0-9]/g, '');
// 2. Verificar longitud exacta (11 dígitos)
if (cleanCuit.length !== 11) return false;
// 3. Convertir a array de números
const digits = cleanCuit.split('').map(Number);
// 4. Algoritmo de verificación (Módulo 11)
const multipliers = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
let total = 0;
for (let i = 0; i < 10; i++) {
total += digits[i] * multipliers[i];
}
const remainder = total % 11;
const calculatedVerifier = remainder === 0 ? 0 : remainder === 1 ? 9 : 11 - remainder;
const actualVerifier = digits[10];
// Caso especial: Cuando el resto es 1, el resultado suele ser 9 en algunos casos específicos
// o se considera inválido y se debe cambiar el prefijo (Hombres/Mujeres),
// pero para validación estricta matemática:
return calculatedVerifier === actualVerifier;
};
/**
* Formatea un string de números al formato XX-XXXXXXXX-X
*/
export const formatCuit = (cuit: string): string => {
const nums = cuit.replace(/[^0-9]/g, '');
if (nums.length <= 2) return nums;
if (nums.length <= 10) return `${nums.slice(0, 2)}-${nums.slice(2)}`;
return `${nums.slice(0, 2)}-${nums.slice(2, 10)}-${nums.slice(10, 11)}`;
};

View File

@@ -1,13 +1,15 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>counter-panel</title> <title>counter-panel</title>
</head> </head>
<body>
<body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -4,12 +4,15 @@ import { useDebounce } from '../../hooks/useDebounce';
import api from '../../services/api'; import api from '../../services/api';
import clsx from 'clsx'; import clsx from 'clsx';
import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils'; import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils';
import { productService } from '../../services/productService';
import type { Product } from '../../types/Product';
interface AdEditorModalProps { interface AdEditorModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onConfirm: (listingId: number, price: number, description: string) => void; onConfirm: (listingId: number, price: number, description: string) => void;
clientId: number | null; // El aviso se vinculará a este cliente clientId: number | null; // El aviso se vinculará a este cliente
productId: number; // Necesario para el precio base
} }
interface PricingResult { interface PricingResult {
@@ -18,9 +21,10 @@ interface PricingResult {
details: string; details: string;
} }
export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId }: AdEditorModalProps) { export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId, productId }: AdEditorModalProps) {
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]); const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
const [operations, setOperations] = useState<any[]>([]); const [operations, setOperations] = useState<any[]>([]);
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [calculating, setCalculating] = useState(false); const [calculating, setCalculating] = useState(false);
@@ -52,9 +56,40 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId }:
const loadData = async () => { const loadData = async () => {
try { try {
const [catRes, opRes] = await Promise.all([api.get('/categories'), api.get('/operations')]); const [catRes, opRes, prodData] = await Promise.all([
setFlatCategories(processCategories(catRes.data)); api.get('/categories'),
api.get('/operations'),
productService.getById(productId)
]);
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);
// Ajustar días iniciales según la duración del producto
if (prodData.priceDurationDays > 1) {
setDays(prodData.priceDurationDays);
} 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) { } catch (e) {
console.error("Error cargando configuración", e); console.error("Error cargando configuración", e);
} }
@@ -65,13 +100,17 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId }:
// Calculadora de Precio en Tiempo Real // Calculadora de Precio en Tiempo Real
useEffect(() => { useEffect(() => {
if (!categoryId || !text) return; if (!categoryId) {
setPricing({ totalPrice: 0, wordCount: 0, details: '' });
return;
}
const calculate = async () => { const calculate = async () => {
setCalculating(true); setCalculating(true);
try { try {
const res = await api.post('/pricing/calculate', { const res = await api.post('/pricing/calculate', {
categoryId: parseInt(categoryId), categoryId: parseInt(categoryId),
productId: productId,
text: debouncedText, text: debouncedText,
days: days, days: days,
isBold: styles.isBold, isBold: styles.isBold,
@@ -86,7 +125,7 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId }:
} }
}; };
calculate(); calculate();
}, [debouncedText, categoryId, days, styles, startDate]); }, [debouncedText, categoryId, days, styles, startDate, productId]);
const handleSave = async () => { const handleSave = async () => {
if (!categoryId || !operationId || !text) return alert("Complete los campos obligatorios"); if (!categoryId || !operationId || !text) return alert("Complete los campos obligatorios");
@@ -258,18 +297,42 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId }:
/> />
</div> </div>
</div> </div>
{product?.productTypeId !== 4 && product?.productTypeId !== 6 && (
<div className="col-span-1"> <div className="col-span-1">
<label className="text-[10px] font-black text-blue-400 uppercase tracking-widest ml-1 mb-1 block">Cantidad Días</label> <label className="text-[10px] font-black text-blue-400 uppercase tracking-widest ml-1 mb-1 block">
{product && product.priceDurationDays > 1 ? `Cant. de Módulos (${product.priceDurationDays}d)` : 'Cantidad Días'}
</label>
<div className="flex items-center bg-white border border-blue-200 rounded-xl overflow-hidden h-[38px]"> <div className="flex items-center bg-white border border-blue-200 rounded-xl overflow-hidden h-[38px]">
<button onClick={() => setDays(Math.max(1, days - 1))} className="px-3 hover:bg-blue-50 text-blue-400 transition-colors">-</button> <button
onClick={() => setDays(Math.max(product?.priceDurationDays || 1, days - (product?.priceDurationDays || 1)))}
className="px-3 hover:bg-blue-50 text-blue-400 transition-colors"
>
-
</button>
<input <input
type="number" type="number"
className="w-full text-center font-black text-slate-700 text-sm outline-none" className="w-full text-center font-black text-slate-700 text-sm outline-none"
value={days} onChange={e => setDays(parseInt(e.target.value) || 1)} value={days}
onChange={e => {
const val = parseInt(e.target.value) || 0;
const step = product?.priceDurationDays || 1;
// Redondear al múltiplo más cercano si es necesario, o simplemente dejarlo
setDays(Math.max(step, val));
}}
step={product?.priceDurationDays || 1}
/> />
<button onClick={() => setDays(days + 1)} className="px-3 hover:bg-blue-50 text-blue-400 transition-colors">+</button> <button
onClick={() => setDays(days + (product?.priceDurationDays || 1))}
className="px-3 hover:bg-blue-50 text-blue-400 transition-colors"
>
+
</button>
</div> </div>
{product && product.priceDurationDays > 1 && (
<p className="text-[9px] text-blue-400 font-bold mt-1 ml-1 uppercase">Mínimo: {product.priceDurationDays} días</p>
)}
</div> </div>
)}
<div className="col-span-1 flex flex-col justify-center items-end"> <div className="col-span-1 flex flex-col justify-center items-end">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-0.5">Costo Estimado</span> <span className="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-0.5">Costo Estimado</span>
<div className="text-2xl font-mono font-black text-slate-900"> <div className="text-2xl font-mono font-black text-slate-900">

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

@@ -0,0 +1,186 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { X, Save, User, MapPin, Mail, Phone, CreditCard, FileText } from 'lucide-react';
import CuitInput from '../Shared/CuitInput';
import { clientService, type CreateClientRequest } from '../../services/clientService';
import { useToast } from '../../context/use-toast';
import clsx from 'clsx';
interface Props {
onClose: () => void;
onSuccess: (client: { id: number; name: string; dniOrCuit: string }) => void;
initialCuit?: string; // Por si el cajero buscó un CUIT y no existía, precargarlo
}
export default function ClientCreateModal({ onClose, onSuccess, initialCuit = '' }: Props) {
const { showToast } = useToast();
const [loading, setLoading] = useState(false);
// Estado del formulario
const [formData, setFormData] = useState<CreateClientRequest>({
name: '',
dniOrCuit: initialCuit,
email: '',
phone: '',
address: '',
taxType: 'Consumidor Final'
});
const [isCuitValid, setIsCuitValid] = useState(!!initialCuit);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!isCuitValid) return showToast("El CUIT ingresado no es válido", "error");
setLoading(true);
try {
const newClient = await clientService.create(formData);
showToast("Cliente creado exitosamente", "success");
onSuccess(newClient);
onClose();
} catch (error: any) {
console.error(error);
const msg = error.response?.data || "Error al crear cliente";
showToast(msg, "error");
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[400] 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-2xl rounded-[2rem] shadow-2xl overflow-hidden border border-slate-200 flex flex-col"
>
{/* Header */}
<div className="p-6 bg-slate-50 border-b border-slate-100 flex justify-between items-center">
<div>
<h3 className="text-xl font-black text-slate-800 uppercase tracking-tight flex items-center gap-2">
<User className="text-blue-600" /> Alta Rápida de Cliente
</h3>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-8">Registro Express para Facturación</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-white rounded-xl transition-colors text-slate-400"><X /></button>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* CUIT - Componente Reutilizable */}
<div className="md:col-span-1">
<CuitInput
label="DNI / CUIT"
value={formData.dniOrCuit}
required
onChange={(val, valid) => {
setFormData({ ...formData, dniOrCuit: val });
setIsCuitValid(valid);
}}
/>
</div>
{/* Condición Fiscal */}
<div className="md:col-span-1 space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1">
<FileText size={12} /> Condición Fiscal
</label>
<select
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 text-slate-700 appearance-none"
value={formData.taxType}
onChange={e => setFormData({ ...formData, taxType: e.target.value })}
>
<option value="Consumidor Final">Consumidor Final</option>
<option value="IVA Responsable Inscripto">Responsable Inscripto</option>
<option value="Monotributista">Monotributista</option>
<option value="IVA Exento">IVA Exento</option>
<option value="No Alcanzado">No Alcanzado</option>
</select>
</div>
{/* Razón Social */}
<div className="md:col-span-2 space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1">
<CreditCard size={12} /> Razón Social / Nombre Completo <span className="text-rose-500">*</span>
</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-black text-slate-800 text-lg uppercase"
placeholder="Ej: EMPRESA S.A. o JUAN PEREZ"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
/>
</div>
{/* Dirección */}
<div className="md:col-span-2 space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1">
<MapPin size={12} /> Dirección de Facturación
</label>
<input
type="text"
className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-medium text-sm text-slate-700"
placeholder="Calle, Altura, Localidad"
value={formData.address || ''}
onChange={e => setFormData({ ...formData, address: e.target.value })}
/>
</div>
{/* Email */}
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1">
<Mail size={12} /> Correo Electrónico
</label>
<input
type="email"
className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-medium text-sm text-slate-700"
placeholder="contacto@cliente.com"
value={formData.email || ''}
onChange={e => setFormData({ ...formData, email: e.target.value })}
/>
</div>
{/* Teléfono */}
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1">
<Phone size={12} /> Teléfono
</label>
<input
type="text"
className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-medium text-sm text-slate-700"
placeholder="Cod. Area + Número"
value={formData.phone || ''}
onChange={e => setFormData({ ...formData, phone: e.target.value })}
/>
</div>
</div>
{/* Footer Actions */}
<div className="pt-4 flex gap-4 border-t border-slate-100">
<button
type="button"
onClick={onClose}
className="flex-1 py-4 bg-white border-2 border-slate-200 text-slate-500 font-black uppercase text-xs tracking-widest rounded-xl hover:bg-slate-50 transition-all"
>
Cancelar
</button>
<button
type="submit"
disabled={loading || !isCuitValid || !formData.name}
className={clsx(
"flex-[2] py-4 rounded-xl font-black uppercase text-xs tracking-widest shadow-lg transition-all flex items-center justify-center gap-2",
loading || !isCuitValid || !formData.name
? "bg-slate-300 text-white cursor-not-allowed shadow-none"
: "bg-blue-600 text-white shadow-blue-200 hover:bg-blue-700"
)}
>
<Save size={16} /> {loading ? 'Registrando...' : 'Crear Cliente'}
</button>
</div>
</form>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,178 @@
import { useState, useEffect, useRef } from 'react';
import { Search, User, Plus, X, ArrowRight, CreditCard, ShieldAlert } from 'lucide-react';
import { clientService } from '../../services/clientService';
import { useDebounce } from '../../hooks/useDebounce';
import { motion } from 'framer-motion';
import clsx from 'clsx';
interface ClientSearchResult {
id: number;
name: string;
dniOrCuit: string;
taxType: string;
isCreditBlocked?: boolean; // Si el backend lo devuelve en la búsqueda
}
interface Props {
onClose: () => void;
onSelect: (client: { id: number; name: string }) => void;
onCreateNew: () => void; // Callback para switchear al modal de creación
}
export default function ClientSearchModal({ onClose, onSelect, onCreateNew }: Props) {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
const [results, setResults] = useState<ClientSearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
// Carga inicial y búsqueda
useEffect(() => {
setLoading(true);
clientService.getAll(debouncedQuery)
.then((data: any) => {
setResults(data);
setSelectedIndex(0); // Reset selección al buscar
})
.catch(console.error)
.finally(() => setLoading(false));
}, [debouncedQuery]);
// Manejo de Teclado
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(prev => (prev + 1) % results.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => (prev - 1 + results.length) % results.length);
} else if (e.key === 'Enter') {
e.preventDefault();
if (results.length > 0) {
handleSelect(results[selectedIndex]);
}
} else if (e.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [results, selectedIndex]);
const handleSelect = (client: ClientSearchResult) => {
onSelect({ id: client.id, name: client.name });
onClose();
};
return (
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[300] flex items-start justify-center pt-20 p-4">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="bg-white w-full max-w-2xl rounded-[2rem] shadow-2xl overflow-hidden border border-slate-200 flex flex-col max-h-[80vh]"
>
{/* Header con Buscador */}
<div className="p-6 border-b border-slate-100 bg-slate-50">
<div className="flex justify-between items-center mb-4">
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-2">
<User className="text-blue-600" size={18} /> Buscar Cliente
</h3>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600"><X size={20} /></button>
</div>
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
<input
ref={inputRef}
autoFocus
type="text"
placeholder="Escriba Nombre, Razón Social, DNI o CUIT..."
className="w-full pl-12 pr-4 py-4 bg-white border-2 border-slate-200 rounded-xl outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 font-bold text-lg text-slate-800 placeholder:text-slate-300 transition-all"
value={query}
onChange={e => setQuery(e.target.value)}
/>
{loading && (
<div className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
)}
</div>
</div>
{/* Lista de Resultados */}
<div className="flex-1 overflow-y-auto p-2 custom-scrollbar bg-white min-h-[300px]">
{results.length === 0 && !loading ? (
<div className="h-full flex flex-col items-center justify-center text-slate-400 gap-3">
<Search size={48} className="opacity-20" />
<p className="text-xs font-bold uppercase tracking-widest">No se encontraron clientes</p>
<button
onClick={onCreateNew}
className="mt-2 bg-blue-600 text-white px-6 py-2 rounded-xl font-black text-xs uppercase tracking-widest hover:bg-blue-700 transition-all flex items-center gap-2"
>
<Plus size={16} /> Crear Nuevo Cliente
</button>
</div>
) : (
<div className="space-y-1">
{results.map((client, idx) => (
<div
key={client.id}
onClick={() => handleSelect(client)}
className={clsx(
"p-4 rounded-xl cursor-pointer transition-all flex justify-between items-center group",
idx === selectedIndex
? "bg-blue-600 text-white shadow-md transform scale-[1.01]"
: "hover:bg-slate-50 text-slate-700"
)}
>
<div className="flex items-center gap-4">
<div className={clsx(
"w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm",
idx === selectedIndex ? "bg-white/20 text-white" : "bg-slate-100 text-slate-500"
)}>
{client.name.charAt(0).toUpperCase()}
</div>
<div>
<div className="font-black uppercase text-sm leading-tight">{client.name}</div>
<div className={clsx("text-[10px] font-mono mt-1 flex items-center gap-2", idx === selectedIndex ? "text-blue-100" : "text-slate-400")}>
<CreditCard size={10} /> {client.dniOrCuit}
<span className="opacity-50"></span>
<span>{client.taxType || 'Consumidor Final'}</span>
</div>
</div>
</div>
{/* Indicador visual si está seleccionado o bloqueado */}
<div className="flex items-center gap-3">
{client.isCreditBlocked && (
<div className={clsx("px-2 py-1 rounded text-[8px] font-black uppercase flex items-center gap-1", idx === selectedIndex ? "bg-rose-500/20 text-white" : "bg-rose-100 text-rose-600")}>
<ShieldAlert size={10} /> Bloqueado
</div>
)}
<ArrowRight size={18} className={clsx("transition-transform", idx === selectedIndex ? "opacity-100 translate-x-0" : "opacity-0 -translate-x-2")} />
</div>
</div>
))}
</div>
)}
</div>
{/* Footer con Ayuda */}
<div className="p-3 bg-slate-50 border-t border-slate-100 flex justify-between items-center text-[9px] font-bold text-slate-400 uppercase tracking-wider px-6">
<div className="flex gap-4">
<span> Navegar</span>
<span>ENTER Seleccionar</span>
</div>
<button
onClick={onCreateNew}
className="flex items-center gap-1 hover:text-blue-600 transition-colors"
>
<Plus size={12} /> Crear Nuevo Cliente
</button>
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { useState, useEffect, type ChangeEvent } from 'react';
import { isValidCuit, formatCuit } from '../../utils/cuitValidator';
import { ShieldCheck, ShieldAlert } from 'lucide-react';
import clsx from 'clsx';
interface CuitInputProps {
value: string;
onChange: (value: string, isValid: boolean) => void;
required?: boolean;
placeholder?: string;
className?: string;
label?: string; // Opcional, para renderizar el label dentro del componente
}
export default function CuitInput({
value,
onChange,
required = false,
placeholder = "20-12345678-9",
className,
label
}: CuitInputProps) {
const [isValid, setIsValid] = useState(true);
const [isTouched, setIsTouched] = useState(false);
// Validar al montar o cuando cambia el value externamente
useEffect(() => {
if (value) {
setIsValid(isValidCuit(value));
} else {
setIsValid(!required); // Si está vacío y no es requerido, es válido
}
}, [value, required]);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const rawValue = e.target.value;
const formatted = formatCuit(rawValue);
// Solo permitir hasta 13 caracteres (11 dígitos + 2 guiones)
if (formatted.length > 13) return;
const valid = isValidCuit(formatted);
setIsTouched(true);
setIsValid(valid);
// Propagamos el cambio al padre
onChange(formatted, valid);
};
const handleBlur = () => {
setIsTouched(true);
};
const showError = isTouched && !isValid && value.length > 0;
const showSuccess = isValid && value.length === 13;
return (
<div className={className}>
{label && (
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-1.5 flex items-center gap-1">
{label}
{required && <span className="text-rose-500">*</span>}
</label>
)}
<div className="relative">
<input
type="text"
value={value}
onChange={handleChange}
onBlur={handleBlur}
placeholder={placeholder}
required={required}
className={clsx(
"w-full p-3 bg-slate-50 border-2 rounded-xl outline-none font-mono font-bold text-sm transition-all pr-10",
showError
? "border-rose-300 text-rose-700 focus:border-rose-500 bg-rose-50"
: showSuccess
? "border-emerald-300 text-emerald-700 focus:border-emerald-500 bg-emerald-50/30"
: "border-slate-100 text-slate-700 focus:border-blue-500"
)}
/>
{/* Icono de estado absoluto a la derecha */}
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
{showError && <ShieldAlert size={18} className="text-rose-500 animate-pulse" />}
{showSuccess && <ShieldCheck size={18} className="text-emerald-500" />}
</div>
</div>
{showError && (
<p className="text-[10px] font-bold text-rose-500 mt-1 ml-1 uppercase tracking-tight">
CUIT inválido (Verifique dígito verificador)
</p>
)}
</div>
);
}

View File

@@ -2,6 +2,8 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import api from '../services/api'; import api from '../services/api';
import { useDebounce } from '../hooks/useDebounce'; import { useDebounce } from '../hooks/useDebounce';
import { processCategories, type FlatCategory } from '../utils/categoryTreeUtils'; import { processCategories, type FlatCategory } from '../utils/categoryTreeUtils';
import { productService } from '../services/productService';
import type { Product } from '../types/Product';
import { import {
Printer, Save, Printer, Save,
AlignLeft, AlignCenter, AlignRight, AlignJustify, AlignLeft, AlignCenter, AlignRight, AlignJustify,
@@ -13,7 +15,8 @@ import {
X, X,
UploadCloud, UploadCloud,
MessageSquare, MessageSquare,
Star Star,
Package
} from 'lucide-react'; } from 'lucide-react';
import clsx from 'clsx'; import clsx from 'clsx';
import PaymentModal, { type Payment } from '../components/PaymentModal'; import PaymentModal, { type Payment } from '../components/PaymentModal';
@@ -53,8 +56,13 @@ export default function FastEntryPage() {
const [isCatDropdownOpen, setIsCatDropdownOpen] = useState(false); const [isCatDropdownOpen, setIsCatDropdownOpen] = useState(false);
const catWrapperRef = useRef<HTMLDivElement>(null); const catWrapperRef = useRef<HTMLDivElement>(null);
// Estado del selector de productos por rubro
const [categoryProducts, setCategoryProducts] = useState<Product[]>([]);
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [loadingProducts, setLoadingProducts] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
categoryId: '', operationId: '', text: '', title: '', price: '', days: 3, clientName: '', clientDni: '', categoryId: '', operationId: '', text: '', title: '', price: '', days: 3, clientName: '', clientDni: '', clientId: null as number | null,
startDate: new Date(Date.now() + 86400000).toISOString().split('T')[0], startDate: new Date(Date.now() + 86400000).toISOString().split('T')[0],
isFeatured: false, allowContact: false isFeatured: false, allowContact: false
}); });
@@ -212,6 +220,33 @@ export default function FastEntryPage() {
fetchData(); fetchData();
}, []); }, []);
// Cargar productos cuando cambia el rubro seleccionado
useEffect(() => {
if (!formData.categoryId) {
setCategoryProducts([]);
setSelectedProduct(null);
return;
}
setLoadingProducts(true);
productService.getByCategory(parseInt(formData.categoryId))
.then(prods => {
setCategoryProducts(prods);
if (prods.length > 0) setSelectedProduct(prods[0]);
else setSelectedProduct(null);
})
.catch(console.error)
.finally(() => setLoadingProducts(false));
}, [formData.categoryId]);
// Ajustar días automáticamente al seleccionar un producto con duración especial
useEffect(() => {
if (selectedProduct && selectedProduct.priceDurationDays > 1) {
if (formData.days % selectedProduct.priceDurationDays !== 0 || formData.days < selectedProduct.priceDurationDays) {
setFormData(prev => ({ ...prev, days: selectedProduct.priceDurationDays }));
}
}
}, [selectedProduct]);
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
if (!validate()) return; if (!validate()) return;
setShowPaymentModal(true); setShowPaymentModal(true);
@@ -240,6 +275,7 @@ export default function FastEntryPage() {
try { try {
const res = await api.post('/pricing/calculate', { const res = await api.post('/pricing/calculate', {
categoryId: parseInt(formData.categoryId), categoryId: parseInt(formData.categoryId),
productId: selectedProduct?.id || 0,
text: debouncedText || "", text: debouncedText || "",
days: formData.days, days: formData.days,
isBold: options.isBold, isBold: options.isBold,
@@ -250,7 +286,7 @@ export default function FastEntryPage() {
} catch (error) { console.error(error); } } catch (error) { console.error(error); }
}; };
calculatePrice(); calculatePrice();
}, [debouncedText, formData.categoryId, formData.days, options, formData.startDate]); }, [debouncedText, formData.categoryId, selectedProduct, formData.days, options, formData.startDate]);
useEffect(() => { useEffect(() => {
if (debouncedClientSearch.length > 2 && showSuggestions) { if (debouncedClientSearch.length > 2 && showSuggestions) {
@@ -260,7 +296,7 @@ export default function FastEntryPage() {
} }
}, [debouncedClientSearch, showSuggestions]); }, [debouncedClientSearch, showSuggestions]);
const handlePaymentConfirm = async (payments: Payment[]) => { const handlePaymentConfirm = async (payments: Payment[], _isCreditSale: boolean) => {
try { try {
const listingRes = await api.post('/listings', { const listingRes = await api.post('/listings', {
categoryId: parseInt(formData.categoryId), categoryId: parseInt(formData.categoryId),
@@ -308,6 +344,7 @@ export default function FastEntryPage() {
price: '', price: '',
clientName: '', clientName: '',
clientDni: '', clientDni: '',
clientId: null,
startDate: new Date(Date.now() + 86400000).toISOString().split('T')[0], startDate: new Date(Date.now() + 86400000).toISOString().split('T')[0],
isFeatured: false, isFeatured: false,
allowContact: false allowContact: false
@@ -315,6 +352,7 @@ export default function FastEntryPage() {
setOptions({ isBold: false, isFrame: false, fontSize: 'normal', alignment: 'left' }); setOptions({ isBold: false, isFrame: false, fontSize: 'normal', alignment: 'left' });
setSelectedImages([]); setSelectedImages([]);
setImagePreviews([]); setImagePreviews([]);
setSelectedProduct(null);
setShowPaymentModal(false); setShowPaymentModal(false);
setErrors({}); setErrors({});
showToast('Aviso procesado correctamente.', 'success'); showToast('Aviso procesado correctamente.', 'success');
@@ -324,7 +362,7 @@ export default function FastEntryPage() {
}; };
const handleSelectClient = (client: Client) => { const handleSelectClient = (client: Client) => {
setFormData(prev => ({ ...prev, clientName: client.name, clientDni: client.dniOrCuit })); setFormData(prev => ({ ...prev, clientName: client.name, clientDni: client.dniOrCuit, clientId: client.id }));
setShowSuggestions(false); setShowSuggestions(false);
}; };
@@ -434,6 +472,50 @@ export default function FastEntryPage() {
</div> </div>
</div> </div>
{/* SELECTOR DE PRODUCTO */}
{formData.categoryId && (
<div>
<label className="block text-[10px] font-black text-slate-500 uppercase mb-1.5 tracking-widest flex items-center gap-2">
<Package size={12} /> Producto / Tarifa
</label>
{loadingProducts ? (
<div className="py-2 px-4 border-2 border-slate-100 rounded-xl bg-slate-50 text-slate-400 text-xs font-bold animate-pulse">
Cargando productos del rubro...
</div>
) : categoryProducts.length === 0 ? (
<div className="py-2 px-4 border-2 border-amber-100 rounded-xl bg-amber-50 text-amber-600 text-xs font-bold flex items-center gap-2">
Sin productos en este rubro el precio base será $0
</div>
) : (
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${Math.min(categoryProducts.length, 3)}, 1fr)` }}>
{categoryProducts.map(prod => (
<button
key={prod.id}
type="button"
onClick={() => setSelectedProduct(prod)}
className={clsx(
"py-2 px-3 border-2 rounded-xl text-left transition-all duration-200 group",
selectedProduct?.id === prod.id
? "border-blue-500 bg-blue-600 text-white shadow-lg shadow-blue-200"
: "border-slate-100 bg-slate-50 hover:border-blue-300 hover:bg-blue-50"
)}
>
<div className={clsx("text-[10px] font-black uppercase tracking-tighter truncate", selectedProduct?.id === prod.id ? "text-blue-100" : "text-slate-500")}>
{prod.typeCode}
</div>
<div className={clsx("text-xs font-black truncate leading-tight mt-0.5", selectedProduct?.id === prod.id ? "text-white" : "text-slate-800")}>
{prod.name}
</div>
<div className={clsx("text-sm font-mono font-black mt-1", selectedProduct?.id === prod.id ? "text-green-300" : "text-blue-600")}>
${prod.basePrice.toLocaleString()}
</div>
</button>
))}
</div>
)}
</div>
)}
<div className="grid grid-cols-12 gap-6"> <div className="grid grid-cols-12 gap-6">
<div className="col-span-8"> <div className="col-span-8">
<label className="block text-[10px] font-black text-slate-500 uppercase mb-1.5 tracking-widest">Título Web (Opcional)</label> <label className="block text-[10px] font-black text-slate-500 uppercase mb-1.5 tracking-widest">Título Web (Opcional)</label>
@@ -492,14 +574,38 @@ export default function FastEntryPage() {
/> />
</div> </div>
</div> </div>
{selectedProduct?.productTypeId !== 4 && selectedProduct?.productTypeId !== 6 && (
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-[9px] font-black text-slate-500 uppercase mb-1 tracking-widest">Días</label> <label className="block text-[9px] font-black text-slate-500 uppercase mb-1 tracking-widest">
{selectedProduct && selectedProduct.priceDurationDays > 1 ? `Días (Base ${selectedProduct.priceDurationDays})` : 'Días'}
</label>
<div className="flex items-center bg-slate-50 rounded-lg border border-slate-200 overflow-hidden h-9"> <div className="flex items-center bg-slate-50 rounded-lg border border-slate-200 overflow-hidden h-9">
<button onClick={() => setFormData(f => ({ ...f, days: Math.max(1, f.days - 1) }))} className="px-2.5 hover:bg-slate-50 text-slate-400 font-bold transition-colors">-</button> <button
<input type="number" className="w-full text-center font-black text-blue-600 outline-none bg-transparent text-sm" value={formData.days} onChange={e => setFormData({ ...formData, days: Math.max(1, parseInt(e.target.value) || 0) })} /> onClick={() => setFormData(f => ({ ...f, days: Math.max(selectedProduct?.priceDurationDays || 1, f.days - (selectedProduct?.priceDurationDays || 1)) }))}
<button onClick={() => setFormData(f => ({ ...f, days: f.days + 1 }))} className="px-2.5 hover:bg-slate-50 text-slate-400 font-bold transition-colors">+</button> className="px-2.5 hover:bg-slate-50 text-slate-400 font-bold transition-colors"
>
-
</button>
<input
type="number"
className="w-full text-center font-black text-blue-600 outline-none bg-transparent text-sm"
value={formData.days}
onChange={e => {
const val = parseInt(e.target.value) || 0;
const step = selectedProduct?.priceDurationDays || 1;
setFormData({ ...formData, days: Math.max(step, val) });
}}
step={selectedProduct?.priceDurationDays || 1}
/>
<button
onClick={() => setFormData(f => ({ ...f, days: f.days + (selectedProduct?.priceDurationDays || 1) }))}
className="px-2.5 hover:bg-slate-50 text-slate-400 font-bold transition-colors"
>
+
</button>
</div> </div>
</div> </div>
)}
<div className="col-span-5 relative" ref={clientWrapperRef}> <div className="col-span-5 relative" ref={clientWrapperRef}>
<label className="block text-[9px] font-black text-slate-500 uppercase mb-1 tracking-widest">Cliente / Razón Social</label> <label className="block text-[9px] font-black text-slate-500 uppercase mb-1 tracking-widest">Cliente / Razón Social</label>
<div className="relative h-9"> <div className="relative h-9">
@@ -560,6 +666,12 @@ export default function FastEntryPage() {
<div className="text-4xl font-mono font-black text-green-400 flex items-start gap-1"> <div className="text-4xl font-mono font-black text-green-400 flex items-start gap-1">
<span className="text-lg mt-1 opacity-50">$</span>{pricing.totalPrice.toLocaleString()} <span className="text-lg mt-1 opacity-50">$</span>{pricing.totalPrice.toLocaleString()}
</div> </div>
{selectedProduct && (
<div className="mt-2 flex items-center gap-2 py-1.5 px-2 bg-blue-600/20 rounded-lg border border-blue-500/30">
<Package size={10} className="text-blue-400 shrink-0" />
<span className="text-[9px] font-black text-blue-300 truncate uppercase tracking-tight">{selectedProduct.name}</span>
</div>
)}
<div className="mt-3 pt-3 border-t border-slate-800 space-y-1.5 text-[10px] font-bold uppercase tracking-tighter"> <div className="mt-3 pt-3 border-t border-slate-800 space-y-1.5 text-[10px] font-bold uppercase tracking-tighter">
<div className="flex justify-between text-slate-500 italic"><span>Tarifa Base</span><span className="text-slate-300">${pricing.baseCost.toLocaleString()}</span></div> <div className="flex justify-between text-slate-500 italic"><span>Tarifa Base</span><span className="text-slate-300">${pricing.baseCost.toLocaleString()}</span></div>
{pricing.extraCost > 0 && <div className="flex justify-between text-orange-500"><span>Recargos Texto</span><span>+${pricing.extraCost.toLocaleString()}</span></div>} {pricing.extraCost > 0 && <div className="flex justify-between text-orange-500"><span>Recargos Texto</span><span>+${pricing.extraCost.toLocaleString()}</span></div>}
@@ -649,7 +761,7 @@ export default function FastEntryPage() {
</motion.div> </motion.div>
{showPaymentModal && ( {showPaymentModal && (
<PaymentModal totalAmount={pricing.totalPrice} onConfirm={handlePaymentConfirm} onCancel={() => setShowPaymentModal(false)} /> <PaymentModal totalAmount={pricing.totalPrice} clientId={formData.clientId} onConfirm={handlePaymentConfirm} onCancel={() => setShowPaymentModal(false)} />
)} )}
</> </>
); );

View File

@@ -9,8 +9,10 @@ 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';
// Importamos el componente de búsqueda de clientes para el modal (Asumiremos que existe o usamos un simple prompt por ahora para no extender demasiado, idealmente ClientSearchModal) import BundleConfiguratorModal, { type ComponentConfig } from '../components/POS/BundleConfiguratorModal';
// import ClientSearchModal from '../components/POS/ClientSearchModal'; import ClientCreateModal from '../components/POS/ClientCreateModal';
import ClientSearchModal from '../components/POS/ClientSearchModal';
import { AnimatePresence } from 'framer-motion';
export default function UniversalPosPage() { export default function UniversalPosPage() {
const { showToast } = useToast(); const { showToast } = useToast();
@@ -23,6 +25,10 @@ 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 [showClientSearch, setShowClientSearch] = useState(false);
// Estado de carga para agregar combos (puede tardar un poco en traer los hijos) // Estado de carga para agregar combos (puede tardar un poco en traer los hijos)
const [addingProduct, setAddingProduct] = useState(false); const [addingProduct, setAddingProduct] = useState(false);
@@ -56,17 +62,6 @@ export default function UniversalPosPage() {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [items, clientId]); // Dependencias para que handleCheckout tenga el estado fresco }, [items, clientId]); // Dependencias para que handleCheckout tenga el estado fresco
const handleChangeClient = () => {
// Aquí abriríamos el ClientSearchModal.
// Para no bloquear, simulamos un cambio rápido o un prompt simple si no hay modal aún.
// En producción: setShowClientModal(true);
const id = prompt("Ingrese ID de Cliente (Simulación F7):", "1003");
if (id) {
// Buscar nombre real en API...
setClient(parseInt(id), "Cliente #" + id);
}
};
const handleProductSelect = async (product: Product) => { const handleProductSelect = async (product: Product) => {
setAddingProduct(true); setAddingProduct(true);
try { try {
@@ -78,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;
} }
@@ -116,12 +106,51 @@ 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");
setShowPayment(true); setShowPayment(true);
}; };
const handleChangeClient = () => {
setShowClientSearch(true);
};
// Callback cuando seleccionan del buscador
const handleClientSelected = (client: { id: number; name: string }) => {
setClient(client.id, client.name);
// showClientSearch se cierra automáticamente por el componente, o lo forzamos aquí si es necesario
// El componente ClientSearchModal llama a onClose internamente después de onSelect
};
// Callback cuando crean uno nuevo
const handleClientCreated = (client: { id: number; name: string }) => {
setClient(client.id, client.name);
setShowCreateClient(false);
};
// Función puente: Del Buscador -> Al Creador
const switchToCreate = () => {
setShowClientSearch(false);
setTimeout(() => setShowCreateClient(true), 100); // Pequeño delay para transición suave
};
const finalizeOrder = async (_payments: Payment[], isCreditSale: boolean) => { const finalizeOrder = async (_payments: Payment[], isCreditSale: boolean) => {
setIsProcessing(true); setIsProcessing(true);
try { try {
@@ -131,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 => {
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, productId: i.productId,
quantity: i.quantity, quantity: i.quantity,
unitPrice: i.unitPrice,
relatedEntityId: i.relatedEntityId, relatedEntityId: i.relatedEntityId,
relatedEntityType: i.relatedEntityType relatedEntityType: i.relatedEntityType
})) }];
})
}; };
const result = await orderService.createOrder(payload); const result = await orderService.createOrder(payload);
@@ -295,8 +343,39 @@ export default function UniversalPosPage() {
onClose={() => setShowAdEditor(false)} onClose={() => setShowAdEditor(false)}
onConfirm={handleAdConfirmed} onConfirm={handleAdConfirmed}
clientId={clientId || 1005} clientId={clientId || 1005}
productId={selectedAdProduct?.id || 0}
/> />
)} )}
{showBundleConfigurator && selectedBundle && (
<BundleConfiguratorModal
bundle={selectedBundle}
clientId={clientId || 1005}
onClose={() => setShowBundleConfigurator(false)}
onConfirm={handleBundleConfirmed}
/>
)}
{/* MODAL DE BÚSQUEDA DE CLIENTE (F7) */}
<AnimatePresence>
{showClientSearch && (
<ClientSearchModal
onClose={() => setShowClientSearch(false)}
onSelect={handleClientSelected}
onCreateNew={switchToCreate}
/>
)}
</AnimatePresence>
{/* MODAL DE ALTA RÁPIDA */}
<AnimatePresence>
{showCreateClient && (
<ClientCreateModal
onClose={() => setShowCreateClient(false)}
onSuccess={handleClientCreated}
/>
)}
</AnimatePresence>
</div> </div>
); );
} }

View File

@@ -0,0 +1,33 @@
// src/services/clientService.ts
import api from './api';
export interface CreateClientRequest {
name: string;
dniOrCuit: string;
email?: string;
phone?: string;
address?: string;
taxType: string;
}
export const clientService = {
getAll: async (q?: string) => {
const res = await api.get('/clients', { params: { q } });
return res.data;
},
getSummary: async (id: number) => {
const res = await api.get(`/clients/${id}/summary`);
return res.data;
},
update: async (id: number, clientData: any) => {
await api.put(`/clients/${id}`, clientData);
},
resetPassword: async (id: number) => {
const res = await api.post(`/clients/${id}/reset-password`);
return res.data;
},
create: async (data: CreateClientRequest) => {
const res = await api.post('/clients', data);
return res.data; // Retorna { id, name, dniOrCuit }
}
};

View File

@@ -12,6 +12,12 @@ export const productService = {
return response.data; return response.data;
}, },
// Obtiene los productos clasificados vinculados a un rubro
getByCategory: async (categoryId: number): Promise<Product[]> => {
const response = await api.get<Product[]>(`/products/by-category/${categoryId}`);
return response.data;
},
create: async (product: Partial<Product>): Promise<Product> => { create: async (product: Partial<Product>): Promise<Product> => {
const response = await api.post<Product>('/products', product); const response = await api.post<Product>('/products', product);
return response.data; return response.data;
@@ -21,6 +27,10 @@ export const productService = {
await api.put(`/products/${id}`, product); await api.put(`/products/${id}`, product);
}, },
delete: async (id: number): Promise<void> => {
await api.delete(`/products/${id}`);
},
// --- GESTIÓN DE COMBOS (BUNDLES) --- // --- GESTIÓN DE COMBOS (BUNDLES) ---
/** /**

View File

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

View File

@@ -2,18 +2,23 @@ export interface Product {
id: number; id: number;
companyId: number; companyId: number;
productTypeId: number; // 1:Classified, 2:Graphic, 3:Radio, 4:Physical, 5:Service, 6:Bundle productTypeId: number; // 1:Classified, 2:Graphic, 3:Radio, 4:Physical, 5:Service, 6:Bundle
categoryId?: number; // Para relacionarlo a un rubro
name: string; name: string;
description?: string; description?: string;
sku?: string; sku?: string;
externalId?: string; externalId?: string;
basePrice: number; basePrice: number;
priceDurationDays: number;
taxRate: number; taxRate: number;
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 {

View File

@@ -0,0 +1,43 @@
/**
* Valida un CUIT/CUIL argentino aplicando el algoritmo de Módulo 11.
* Soporta formatos con o sin guiones (20-12345678-9 o 20123456789).
*/
export const isValidCuit = (cuit: string): boolean => {
if (!cuit) return false;
// 1. Limpiar el input: dejar solo números
const cleanCuit = cuit.replace(/[^0-9]/g, '');
// 2. Verificar longitud exacta (11 dígitos)
if (cleanCuit.length !== 11) return false;
// 3. Convertir a array de números
const digits = cleanCuit.split('').map(Number);
// 4. Algoritmo de verificación (Módulo 11)
const multipliers = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
let total = 0;
for (let i = 0; i < 10; i++) {
total += digits[i] * multipliers[i];
}
const remainder = total % 11;
const calculatedVerifier = remainder === 0 ? 0 : remainder === 1 ? 9 : 11 - remainder;
const actualVerifier = digits[10];
// Caso especial: Cuando el resto es 1, el resultado suele ser 9 en algunos casos específicos
// o se considera inválido y se debe cambiar el prefijo (Hombres/Mujeres),
// pero para validación estricta matemática:
return calculatedVerifier === actualVerifier;
};
/**
* Formatea un string de números al formato XX-XXXXXXXX-X
*/
export const formatCuit = (cuit: string): string => {
const nums = cuit.replace(/[^0-9]/g, '');
if (nums.length <= 2) return nums;
if (nums.length <= 10) return `${nums.slice(0, 2)}-${nums.slice(2)}`;
return `${nums.slice(0, 2)}-${nums.slice(2, 10)}-${nums.slice(10, 11)}`;
};

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SIGCM.Application.DTOs;
using SIGCM.Domain.Entities; using SIGCM.Domain.Entities;
using SIGCM.Infrastructure.Repositories; using SIGCM.Infrastructure.Repositories;
@@ -90,4 +91,51 @@ public class ClientsController : ControllerBase
return Ok(new { message = "Contraseña restablecida a '1234' correctamente." }); return Ok(new { message = "Contraseña restablecida a '1234' correctamente." });
} }
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateClientDto dto)
{
// 1. Validar si ya existe (por CUIT)
// Usamos el repo de usuarios porque los clientes son usuarios
var existing = await _repo.SearchAsync(dto.DniOrCuit);
if (existing.Any(c => c.DniOrCuit == dto.DniOrCuit))
{
return BadRequest("Ya existe un cliente registrado con este DNI/CUIT.");
}
// 2. Mapeo a Entidad User (Modelo de DB)
// Nota: El repositorio ClientRepository maneja la inserción en la tabla Users
// Vamos a crear un método específico en el repo para esto o usar el existente mejorado.
var client = new Client // Usamos la entidad de dominio Client como DTO de transporte al repo
{
Name = dto.Name,
DniOrCuit = dto.DniOrCuit,
Email = dto.Email,
Phone = dto.Phone,
Address = dto.Address,
TaxType = dto.TaxType,
IsActive = true
};
// Llamamos a un nuevo método en el repo para creación completa
var id = await _repo.CreateFullClientAsync(client);
// Audit Log
var userIdClaim = User.FindFirst("Id")?.Value;
if (int.TryParse(userIdClaim, out int userId))
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "CREATE_CLIENT",
EntityId = id,
EntityType = "Client",
Details = $"Alta Rápida de Cliente: {dto.Name} ({dto.DniOrCuit})",
CreatedAt = DateTime.UtcNow
});
}
return Ok(new { id, name = dto.Name, dniOrCuit = dto.DniOrCuit });
}
} }

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

@@ -40,6 +40,14 @@ public class ProductsController : ControllerBase
return Ok(products); return Ok(products);
} }
// Obtener productos clasificados vinculados a un rubro específico
[HttpGet("by-category/{categoryId}")]
public async Task<IActionResult> GetByCategory(int categoryId)
{
var products = await _repository.GetByCategoryIdAsync(categoryId);
return Ok(products);
}
[HttpPost] [HttpPost]
[Authorize(Roles = "Admin")] // Solo Admins crean productos [Authorize(Roles = "Admin")] // Solo Admins crean productos
public async Task<IActionResult> Create(Product product) public async Task<IActionResult> Create(Product product)
@@ -62,6 +70,21 @@ public class ProductsController : ControllerBase
return NoContent(); return NoContent();
} }
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(int id)
{
try
{
await _repository.DeleteAsync(id);
return NoContent();
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
}
// Helper: Obtener lista de empresas para llenar el combo en el Frontend // Helper: Obtener lista de empresas para llenar el combo en el Frontend
[HttpGet("companies")] [HttpGet("companies")]
public async Task<IActionResult> GetCompanies() public async Task<IActionResult> GetCompanies()

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,11 @@
namespace SIGCM.Application.DTOs;
public class CreateClientDto
{
public required string Name { get; set; } // Razón Social o Nombre
public required string DniOrCuit { get; set; } // Validado por frontend
public string? Email { get; set; }
public string? Phone { get; set; }
public string? Address { get; set; }
public string TaxType { get; set; } = "Consumidor Final";
}

View File

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

View File

@@ -3,6 +3,7 @@ namespace SIGCM.Application.DTOs;
public class CalculatePriceRequest public class CalculatePriceRequest
{ {
public int CategoryId { get; set; } public int CategoryId { get; set; }
public int ProductId { get; set; } // Añadido para obtener el Precio Base
public required string Text { get; set; } public required string Text { get; set; }
public int Days { get; set; } public int Days { get; set; }
public bool IsBold { get; set; } public bool IsBold { get; set; }

View File

@@ -4,11 +4,12 @@ public class CategoryPricing
{ {
public int Id { get; set; } public int Id { get; set; }
public int CategoryId { get; set; } public int CategoryId { get; set; }
public decimal BasePrice { get; set; }
public int BaseWordCount { get; set; } public int BaseWordCount { get; set; }
public decimal ExtraWordPrice { get; set; } public decimal ExtraWordPrice { get; set; }
public string SpecialChars { get; set; } = "!"; public string SpecialChars { get; set; } = "!";
public decimal SpecialCharPrice { get; set; } public decimal SpecialCharPrice { get; set; }
public decimal BoldSurcharge { get; set; } public decimal BoldSurcharge { get; set; }
public decimal FrameSurcharge { get; set; } public decimal FrameSurcharge { get; set; }
public List<WordPricingRange> WordRanges { get; set; } = new();
} }

View File

@@ -5,12 +5,14 @@ public class Product
public int Id { get; set; } public int Id { get; set; }
public int CompanyId { get; set; } public int CompanyId { get; set; }
public int ProductTypeId { get; set; } public int ProductTypeId { get; set; }
public int? CategoryId { get; set; } // Propiedad agregada: Vínculo con Rubro
public required string Name { get; set; } public required string Name { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
public string? SKU { get; set; } public string? SKU { get; set; }
public string? ExternalId { get; set; } public string? ExternalId { get; set; }
public decimal BasePrice { get; set; } public decimal BasePrice { get; set; }
public int PriceDurationDays { get; set; } = 1; // Días que cubre el precio base (ej: 30 para autos)
public decimal TaxRate { get; set; } // 21.0, 10.5, etc. public decimal TaxRate { get; set; } // 21.0, 10.5, etc.
public string Currency { get; set; } = "ARS"; public string Currency { get; set; } = "ARS";
@@ -19,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; }
} }

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,10 @@
namespace SIGCM.Domain.Entities;
public class WordPricingRange
{
public int Id { get; set; }
public int CategoryPricingId { get; set; }
public int FromCount { get; set; }
public int ToCount { get; set; } // Use a large number or 0 for "infinity"
public decimal PricePerWord { get; set; }
}

View File

@@ -6,6 +6,7 @@ public interface IProductRepository
{ {
Task<IEnumerable<Product>> GetAllAsync(); Task<IEnumerable<Product>> GetAllAsync();
Task<IEnumerable<Product>> GetByCompanyIdAsync(int companyId); Task<IEnumerable<Product>> GetByCompanyIdAsync(int companyId);
Task<IEnumerable<Product>> GetByCategoryIdAsync(int categoryId);
Task<Product?> GetByIdAsync(int id); Task<Product?> GetByIdAsync(int id);
Task<int> CreateAsync(Product product); Task<int> CreateAsync(Product product);
Task UpdateAsync(Product product); Task UpdateAsync(Product product);
@@ -15,4 +16,5 @@ public interface IProductRepository
Task RemoveComponentFromBundleAsync(int bundleId, int childProductId); Task RemoveComponentFromBundleAsync(int bundleId, int childProductId);
Task<decimal> GetCurrentPriceAsync(int productId, DateTime date); Task<decimal> GetCurrentPriceAsync(int productId, DateTime date);
Task AddPriceAsync(ProductPrice price); Task AddPriceAsync(ProductPrice price);
Task DeleteAsync(int id);
} }

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 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,12 +131,115 @@ 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);
// --- MIGRACIONES (Schema Update) --- // --- MIGRACIONES (Schema Update) ---
var migrationSql = @" var migrationSql = @"
-- Products Columns
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'CategoryId' AND Object_ID = Object_ID(N'Products'))
BEGIN
ALTER TABLE Products ADD CategoryId INT NULL;
-- Agregar clave foránea opcionalmente
ALTER TABLE Products ADD CONSTRAINT FK_Products_Categories FOREIGN KEY (CategoryId) REFERENCES Categories(Id);
END
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'PriceDurationDays' AND Object_ID = Object_ID(N'Products'))
BEGIN
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
-- Drop the default constraint before dropping the column.
-- By finding the constraint dynamically:
DECLARE @ConstraintName nvarchar(200)
SELECT @ConstraintName = Name FROM sys.default_constraints
WHERE parent_object_id = object_id('CategoryPricing')
AND parent_column_id = columnproperty(object_id('CategoryPricing'), 'BasePrice', 'ColumnId')
IF @ConstraintName IS NOT NULL
EXEC('ALTER TABLE CategoryPricing DROP CONSTRAINT ' + @ConstraintName)
ALTER TABLE CategoryPricing DROP COLUMN BasePrice;
END
-- Listings Columns -- Listings Columns
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'PublicationStartDate' AND Object_ID = Object_ID(N'Listings')) IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'PublicationStartDate' AND Object_ID = Object_ID(N'Listings'))
ALTER TABLE Listings ADD PublicationStartDate DATETIME2 NULL; ALTER TABLE Listings ADD PublicationStartDate DATETIME2 NULL;
@@ -221,6 +340,19 @@ END
ALTER TABLE ListingNotes ADD IsFromModerator BIT NOT NULL DEFAULT 1; ALTER TABLE ListingNotes ADD IsFromModerator BIT NOT NULL DEFAULT 1;
END END
END END
-- Tabla de Rangos de Precios por Palabra
IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'WordPricingRanges') AND type in (N'U'))
BEGIN
CREATE TABLE WordPricingRanges (
Id INT IDENTITY(1,1) PRIMARY KEY,
CategoryPricingId INT NOT NULL,
FromCount INT NOT NULL,
ToCount INT NOT NULL,
PricePerWord DECIMAL(18,2) NOT NULL,
FOREIGN KEY (CategoryPricingId) REFERENCES CategoryPricing(Id) ON DELETE CASCADE
);
END
"; ";
await connection.ExecuteAsync(migrationSql); await connection.ExecuteAsync(migrationSql);
@@ -312,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'");

View File

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

View File

@@ -136,4 +136,24 @@ public class ClientRepository
var sql = "UPDATE Users SET PasswordHash = @Hash, MustChangePassword = 1 WHERE Id = @Id"; var sql = "UPDATE Users SET PasswordHash = @Hash, MustChangePassword = 1 WHERE Id = @Id";
await conn.ExecuteAsync(sql, new { Hash = passwordHash, Id = clientId }); await conn.ExecuteAsync(sql, new { Hash = passwordHash, Id = clientId });
} }
public async Task<int> CreateFullClientAsync(Client client)
{
using var conn = _db.CreateConnection();
var sql = @"
INSERT INTO Users (
Username, PasswordHash, Role,
BillingName, BillingTaxId, Email, Phone, BillingAddress, BillingTaxType,
MustChangePassword, IsActive, CreatedAt
)
VALUES (
@DniOrCuit, 'N/A', 'Client',
@Name, @DniOrCuit, @Email, @Phone, @Address, @TaxType,
0, 1, GETUTCDATE()
);
SELECT CAST(SCOPE_IDENTITY() as int);";
// Usamos el CUIT como username por defecto para garantizar unicidad
return await conn.QuerySingleAsync<int>(sql, client);
}
} }

View File

@@ -16,36 +16,58 @@ public class PricingRepository
public async Task<CategoryPricing?> GetByCategoryIdAsync(int categoryId) public async Task<CategoryPricing?> GetByCategoryIdAsync(int categoryId)
{ {
using var conn = _db.CreateConnection(); using var conn = _db.CreateConnection();
return await conn.QuerySingleOrDefaultAsync<CategoryPricing>( var sql = @"
"SELECT * FROM CategoryPricing WHERE CategoryId = @Id", new { Id = categoryId }); SELECT * FROM CategoryPricing WHERE CategoryId = @Id;
SELECT * FROM WordPricingRanges WHERE CategoryPricingId = (SELECT Id FROM CategoryPricing WHERE CategoryId = @Id) ORDER BY FromCount;";
using var multi = await conn.QueryMultipleAsync(sql, new { Id = categoryId });
var pricing = await multi.ReadSingleOrDefaultAsync<CategoryPricing>();
if (pricing != null)
{
pricing.WordRanges = (await multi.ReadAsync<WordPricingRange>()).ToList();
}
return pricing;
} }
public async Task UpsertPricingAsync(CategoryPricing pricing) public async Task UpsertPricingAsync(CategoryPricing pricing)
{ {
using var conn = _db.CreateConnection(); using var conn = _db.CreateConnection();
// Lógica de "Si existe actualiza, sino inserta" var exists = await conn.QuerySingleOrDefaultAsync<CategoryPricing>(
var exists = await conn.ExecuteScalarAsync<int>( "SELECT Id FROM CategoryPricing WHERE CategoryId = @CategoryId", new { pricing.CategoryId });
"SELECT COUNT(1) FROM CategoryPricing WHERE CategoryId = @CategoryId", new { pricing.CategoryId });
if (exists > 0) if (exists != null)
{ {
pricing.Id = exists.Id;
var updateSql = @" var updateSql = @"
UPDATE CategoryPricing UPDATE CategoryPricing
SET BasePrice = @BasePrice, BaseWordCount = @BaseWordCount, SET BaseWordCount = @BaseWordCount,
ExtraWordPrice = @ExtraWordPrice, SpecialChars = @SpecialChars, ExtraWordPrice = @ExtraWordPrice, SpecialChars = @SpecialChars,
SpecialCharPrice = @SpecialCharPrice, BoldSurcharge = @BoldSurcharge, SpecialCharPrice = @SpecialCharPrice, BoldSurcharge = @BoldSurcharge,
FrameSurcharge = @FrameSurcharge FrameSurcharge = @FrameSurcharge
WHERE CategoryId = @CategoryId"; WHERE Id = @Id";
await conn.ExecuteAsync(updateSql, pricing); await conn.ExecuteAsync(updateSql, pricing);
} }
else else
{ {
var insertSql = @" var insertSql = @"
INSERT INTO CategoryPricing INSERT INTO CategoryPricing
(CategoryId, BasePrice, BaseWordCount, ExtraWordPrice, SpecialChars, SpecialCharPrice, BoldSurcharge, FrameSurcharge) (CategoryId, BaseWordCount, ExtraWordPrice, SpecialChars, SpecialCharPrice, BoldSurcharge, FrameSurcharge)
VALUES VALUES
(@CategoryId, @BasePrice, @BaseWordCount, @ExtraWordPrice, @SpecialChars, @SpecialCharPrice, @BoldSurcharge, @FrameSurcharge)"; (@CategoryId, @BaseWordCount, @ExtraWordPrice, @SpecialChars, @SpecialCharPrice, @BoldSurcharge, @FrameSurcharge);
await conn.ExecuteAsync(insertSql, pricing); SELECT CAST(SCOPE_IDENTITY() as int);";
pricing.Id = await conn.QuerySingleAsync<int>(insertSql, pricing);
}
// Gestionar Rangos de Palabras
await conn.ExecuteAsync("DELETE FROM WordPricingRanges WHERE CategoryPricingId = @Id", new { Id = pricing.Id });
if (pricing.WordRanges != null && pricing.WordRanges.Any())
{
foreach (var range in pricing.WordRanges)
{
range.CategoryPricingId = pricing.Id;
}
var rangeSql = "INSERT INTO WordPricingRanges (CategoryPricingId, FromCount, ToCount, PricePerWord) VALUES (@CategoryPricingId, @FromCount, @ToCount, @PricePerWord)";
await conn.ExecuteAsync(rangeSql, pricing.WordRanges);
} }
} }

View File

@@ -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
@@ -43,12 +45,39 @@ public class ProductRepository : IProductRepository
new { Id = companyId }); new { Id = companyId });
} }
public async Task<IEnumerable<Product>> GetByCategoryIdAsync(int categoryId)
{
using var conn = _db.CreateConnection();
// CTE recursivo: sube por la jerarquía de categorías para incluir
// productos asignados al rubro solicitado O a cualquiera de sus ancestros.
// Ejemplo: consultando "Autos" devuelve productos de "Autos" Y de "Vehículos".
var sql = @"
WITH CategoryAncestors AS (
-- Nodo raíz: la categoría pedida
SELECT Id, ParentId FROM Categories WHERE Id = @CategoryId
UNION ALL
-- Subir un nivel: el padre de cada nodo ya en el CTE
SELECT c.Id, c.ParentId
FROM Categories c
INNER JOIN CategoryAncestors ca ON c.Id = ca.ParentId
)
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
WHERE p.CategoryId IN (SELECT Id FROM CategoryAncestors)
AND p.IsActive = 1
OPTION (MAXRECURSION 20)";
return await conn.QueryAsync<Product>(sql, new { CategoryId = categoryId });
}
public async Task<int> CreateAsync(Product product) public async Task<int> CreateAsync(Product product)
{ {
using var conn = _db.CreateConnection(); using var conn = _db.CreateConnection();
var sql = @" var sql = @"
INSERT INTO Products (CompanyId, ProductTypeId, Name, Description, SKU, BasePrice, TaxRate, IsActive) INSERT INTO Products (CompanyId, ProductTypeId, CategoryId, Name, Description, SKU, BasePrice, PriceDurationDays, TaxRate, IsActive)
VALUES (@CompanyId, @ProductTypeId, @Name, @Description, @SKU, @BasePrice, @TaxRate, @IsActive); VALUES (@CompanyId, @ProductTypeId, @CategoryId, @Name, @Description, @SKU, @BasePrice, @PriceDurationDays, @TaxRate, @IsActive);
SELECT CAST(SCOPE_IDENTITY() as int);"; SELECT CAST(SCOPE_IDENTITY() as int);";
return await conn.QuerySingleAsync<int>(sql, product); return await conn.QuerySingleAsync<int>(sql, product);
} }
@@ -58,7 +87,7 @@ public class ProductRepository : IProductRepository
using var conn = _db.CreateConnection(); using var conn = _db.CreateConnection();
var sql = @" var sql = @"
UPDATE Products UPDATE Products
SET Name = @Name, Description = @Description, BasePrice = @BasePrice, TaxRate = @TaxRate, IsActive = @IsActive SET CategoryId = @CategoryId, Name = @Name, Description = @Description, BasePrice = @BasePrice, PriceDurationDays = @PriceDurationDays, TaxRate = @TaxRate, IsActive = @IsActive
WHERE Id = @Id"; WHERE Id = @Id";
await conn.ExecuteAsync(sql, product); await conn.ExecuteAsync(sql, product);
} }
@@ -73,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
@@ -123,8 +153,8 @@ public class ProductRepository : IProductRepository
{ {
using var conn = _db.CreateConnection(); using var conn = _db.CreateConnection();
await conn.ExecuteAsync( await conn.ExecuteAsync(
"DELETE FROM ProductBundles WHERE ParentProductId = @ParentId AND ChildProductId = @ChildId", "DELETE FROM ProductBundles WHERE ParentProductId = @ParentId AND ChildProductId = @Id",
new { ParentId = bundleId, ChildId = childProductId }); new { ParentId = bundleId, Id = childProductId });
} }
// Obtener el precio vigente para una fecha dada // Obtener el precio vigente para una fecha dada
@@ -161,4 +191,51 @@ public class ProductRepository : IProductRepository
VALUES (@ProductId, @Price, @ValidFrom, @ValidTo, @CreatedByUserId)"; VALUES (@ProductId, @Price, @ValidFrom, @ValidTo, @CreatedByUserId)";
await conn.ExecuteAsync(sql, price); await conn.ExecuteAsync(sql, price);
} }
public async Task DeleteAsync(int id)
{
using var conn = _db.CreateConnection();
// 1. Verificar si está siendo usado como componente de un combo
// Usamos IF OBJECT_ID para que sea robusto si la tabla no existe por algún motivo
var checkBundleSql = @"
IF OBJECT_ID('ProductBundles') IS NOT NULL
SELECT COUNT(1) FROM ProductBundles WHERE ChildProductId = @Id
ELSE
SELECT 0";
var usedInBundle = await conn.ExecuteScalarAsync<int>(checkBundleSql, new { Id = id });
if (usedInBundle > 0)
{
throw new InvalidOperationException("No se puede eliminar el producto porque forma parte de uno o más combos.");
}
// 2. Verificar si tiene registros asociados (ej: Listings)
// Usamos SQL dinámico con sp_executesql para evitar errores de compilación si la columna no existe
var checkSalesSql = @"
IF EXISTS (SELECT 1 FROM sys.columns WHERE Name = 'ProductId' AND Object_ID = OBJECT_ID('Listings'))
BEGIN
EXEC sp_executesql N'SELECT COUNT(1) FROM Listings WHERE ProductId = @Id', N'@Id INT', @Id
END
ELSE
BEGIN
SELECT 0
END";
var hasSales = await conn.ExecuteScalarAsync<int>(checkSalesSql, new { Id = id });
if (hasSales > 0)
{
throw new InvalidOperationException("No se puede eliminar el producto porque ya tiene ventas o registros asociados.");
}
// 3. Eliminar registros permitidos (Precios e hijos de bundle si es un combo)
await conn.ExecuteAsync(@"
IF OBJECT_ID('ProductPrices') IS NOT NULL DELETE FROM ProductPrices WHERE ProductId = @Id;
IF OBJECT_ID('ProductBundles') IS NOT NULL DELETE FROM ProductBundles WHERE ParentProductId = @Id;
", new { Id = id });
// 4. Eliminar el producto final
await conn.ExecuteAsync("DELETE FROM Products WHERE Id = @Id", new { Id = id });
}
} }

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) // 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")

View File

@@ -10,23 +10,47 @@ public class PricingService
{ {
private readonly PricingRepository _repo; private readonly PricingRepository _repo;
private readonly ICouponRepository _couponRepo; private readonly ICouponRepository _couponRepo;
private readonly IProductRepository _productRepo;
public PricingService(PricingRepository repo, ICouponRepository couponRepo) public PricingService(PricingRepository repo, ICouponRepository couponRepo, IProductRepository productRepo)
{ {
_repo = repo; _repo = repo;
_couponRepo = couponRepo; _couponRepo = couponRepo;
_productRepo = productRepo;
} }
public async Task<CalculatePriceResponse> CalculateAsync(CalculatePriceRequest request) public async Task<CalculatePriceResponse> CalculateAsync(CalculatePriceRequest request)
{ {
// 0. Obtener el Producto para saber el Precio Base
// 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)
{
product = await _productRepo.GetByIdAsync(request.ProductId);
if (product == null) return new CalculatePriceResponse
{
TotalPrice = 0,
Details = "Producto no encontrado."
};
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 prodDuration = product.PriceDurationDays > 0 ? product.PriceDurationDays : 1;
productBasePrice = retrievedPrice / prodDuration;
}
// 1. Obtener Reglas // 1. Obtener Reglas
var pricing = await _repo.GetByCategoryIdAsync(request.CategoryId); var pricing = await _repo.GetByCategoryIdAsync(request.CategoryId);
// Si no hay configuración para este rubro, devolvemos 0 o un default seguro // Si no hay configuración para este rubro, devolvemos al menos el precio base del producto
if (pricing == null) return new CalculatePriceResponse if (pricing == null) return new CalculatePriceResponse
{ {
TotalPrice = 0, TotalPrice = productBasePrice * request.Days,
Details = "No hay tarifas configuradas para este rubro." BaseCost = productBasePrice * request.Days,
Details = "El rubro no tiene tarifas configuradas. Se aplica solo el precio base del producto."
}; };
// 2. Análisis del Texto // 2. Análisis del Texto
@@ -43,28 +67,50 @@ public class PricingService
var words = cleanText.Split(new[] { ' ', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); var words = cleanText.Split(new[] { ' ', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
int realWordCount = words.Length; int realWordCount = words.Length;
// 3. Costo Base y Excedente // 3. Costo Base y Excedente (productBasePrice ya viene resuelto desde sección 0)
decimal currentCost = pricing.BasePrice; // Precio base incluye N palabras 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 = extraWords * pricing.ExtraWordPrice; decimal extraWordCost = 0;
decimal specialCharCost = specialCharCount * pricing.SpecialCharPrice;
currentCost += extraWordCost + specialCharCost; if (pricing.WordRanges != null && pricing.WordRanges.Any() && extraWords > 0)
{
var applicableRange = pricing.WordRanges.FirstOrDefault(r =>
extraWords >= r.FromCount && (extraWords <= r.ToCount || r.ToCount == 0));
if (applicableRange != null)
{
extraWordCost = extraWords * applicableRange.PricePerWord;
}
else
{
extraWordCost = extraWords * pricing.ExtraWordPrice;
}
}
else
{
extraWordCost = extraWords * pricing.ExtraWordPrice;
}
// 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 += 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;
} }
@@ -132,13 +178,15 @@ public class PricingService
return new CalculatePriceResponse return new CalculatePriceResponse
{ {
TotalPrice = Math.Max(0, totalBeforeDiscount - totalDiscount), TotalPrice = Math.Max(0, totalBeforeDiscount - totalDiscount),
BaseCost = pricing.BasePrice * 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)
}; };
} }