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

@@ -31,6 +31,7 @@ export default function ProductModal({ product, companies, categories, allProduc
categoryId: undefined,
productTypeId: 4, // Default Physical
basePrice: 0,
priceDurationDays: 1,
taxRate: 21,
sku: '',
isActive: true
@@ -40,10 +41,12 @@ export default function ProductModal({ product, companies, categories, allProduc
const [bundleComponents, setBundleComponents] = useState<ProductBundleComponent[]>([]);
const [newComponentId, setNewComponentId] = useState<number>(0);
const [loading, setLoading] = useState(false);
const [hasDuration, setHasDuration] = useState(false);
useEffect(() => {
if (product) {
setFormData(product);
setHasDuration(product.priceDurationDays > 1);
const type = PRODUCT_TYPES.find(t => t.id === product.productTypeId);
if (type?.code === 'BUNDLE') {
setIsBundle(true);
@@ -55,6 +58,13 @@ export default function ProductModal({ product, companies, categories, allProduc
useEffect(() => {
const type = PRODUCT_TYPES.find(t => t.id === formData.productTypeId);
setIsBundle(type?.code === 'BUNDLE');
// Si es producto físico o combo, forzamos duración 1 (sin periodo)
if (formData.productTypeId === 4 || formData.productTypeId === 6) {
if (formData.priceDurationDays !== 1) {
setFormData(f => ({ ...f, priceDurationDays: 1 }));
}
}
}, [formData.productTypeId]);
const loadBundleComponents = async (productId: number) => {
@@ -188,6 +198,39 @@ export default function ProductModal({ product, companies, categories, allProduc
value={formData.basePrice} onChange={e => setFormData({ ...formData, basePrice: parseFloat(e.target.value) })} />
</div>
{formData.productTypeId !== 4 && formData.productTypeId !== 6 && (
<div className="col-span-2 space-y-3">
<div
onClick={() => {
const newValue = !hasDuration;
setHasDuration(newValue);
if (!newValue) setFormData({ ...formData, priceDurationDays: 1 });
}}
className={`flex items-center justify-between p-3 rounded-xl border-2 cursor-pointer transition-all ${hasDuration ? 'border-blue-500 bg-blue-50' : 'border-slate-100 bg-slate-50 opacity-60'
}`}
>
<div className="flex flex-col">
<span className="text-[10px] font-black text-slate-500 uppercase tracking-widest leading-none">Precio por Duración</span>
<span className="text-[9px] text-slate-400 font-bold mt-1">Habilitar si el precio base cubre varios días</span>
</div>
<div className={`w-10 h-5 rounded-full relative transition-colors ${hasDuration ? 'bg-blue-600' : 'bg-slate-300'}`}>
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${hasDuration ? 'right-1' : 'left-1'}`} />
</div>
</div>
{hasDuration && (
<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">
<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"

View File

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