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.
This commit is contained in:
2026-02-21 21:17:50 -03:00
parent f3638195a6
commit a8b8229b41
10 changed files with 182 additions and 46 deletions

View File

@@ -4,6 +4,8 @@ import { useDebounce } from '../../hooks/useDebounce';
import api from '../../services/api';
import clsx from 'clsx';
import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils';
import { productService } from '../../services/productService';
import type { Product } from '../../types/Product';
interface AdEditorModalProps {
isOpen: boolean;
@@ -22,6 +24,7 @@ interface PricingResult {
export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId, productId }: AdEditorModalProps) {
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
const [operations, setOperations] = useState<any[]>([]);
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(false);
const [calculating, setCalculating] = useState(false);
@@ -53,9 +56,21 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId, pr
const loadData = async () => {
try {
const [catRes, opRes] = await Promise.all([api.get('/categories'), api.get('/operations')]);
const [catRes, opRes, prodData] = await Promise.all([
api.get('/categories'),
api.get('/operations'),
productService.getById(productId)
]);
setFlatCategories(processCategories(catRes.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);
}
} catch (e) {
console.error("Error cargando configuración", e);
}
@@ -66,7 +81,10 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId, pr
// Calculadora de Precio en Tiempo Real
useEffect(() => {
if (!categoryId || !text) return;
if (!categoryId) {
setPricing({ totalPrice: 0, wordCount: 0, details: '' });
return;
}
const calculate = async () => {
setCalculating(true);
@@ -88,7 +106,7 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId, pr
}
};
calculate();
}, [debouncedText, categoryId, days, styles, startDate]);
}, [debouncedText, categoryId, days, styles, startDate, productId]);
const handleSave = async () => {
if (!categoryId || !operationId || !text) return alert("Complete los campos obligatorios");
@@ -260,18 +278,42 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId, pr
/>
</div>
</div>
<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>
<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>
<input
type="number"
className="w-full text-center font-black text-slate-700 text-sm outline-none"
value={days} onChange={e => setDays(parseInt(e.target.value) || 1)}
/>
<button onClick={() => setDays(days + 1)} className="px-3 hover:bg-blue-50 text-blue-400 transition-colors">+</button>
{product?.productTypeId !== 4 && product?.productTypeId !== 6 && (
<div className="col-span-1">
<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]">
<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
type="number"
className="w-full text-center font-black text-slate-700 text-sm outline-none"
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 + (product?.priceDurationDays || 1))}
className="px-3 hover:bg-blue-50 text-blue-400 transition-colors"
>
+
</button>
</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">
<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">

View File

@@ -231,14 +231,22 @@ export default function FastEntryPage() {
productService.getByCategory(parseInt(formData.categoryId))
.then(prods => {
setCategoryProducts(prods);
// Auto-seleccionar el primero si solo hay uno
if (prods.length === 1) setSelectedProduct(prods[0]);
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 () => {
if (!validate()) return;
setShowPaymentModal(true);
@@ -566,14 +574,38 @@ export default function FastEntryPage() {
/>
</div>
</div>
<div className="col-span-2">
<label className="block text-[9px] font-black text-slate-500 uppercase mb-1 tracking-widest">Días</label>
<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>
<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) })} />
<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>
{selectedProduct?.productTypeId !== 4 && selectedProduct?.productTypeId !== 6 && (
<div className="col-span-2">
<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">
<button
onClick={() => setFormData(f => ({ ...f, days: Math.max(selectedProduct?.priceDurationDays || 1, f.days - (selectedProduct?.priceDurationDays || 1)) }))}
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 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>
<div className="relative h-9">

View File

@@ -8,6 +8,7 @@ export interface Product {
sku?: string;
externalId?: string;
basePrice: number;
priceDurationDays: number;
taxRate: number;
currency: string;
isActive: boolean;