feat: Implementación de rangos de precio por palabra con validación de continuidad

This commit is contained in:
2026-02-21 20:28:50 -03:00
parent da99fd5843
commit b8f1ed8a68
6 changed files with 212 additions and 21 deletions

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import api from '../../services/api';
import { Save, DollarSign, FileText, Package, ExternalLink, ArrowRight } from 'lucide-react';
import { Save, DollarSign, FileText, Package, ExternalLink, ArrowRight, Plus, Trash2, Info, AlertCircle } from 'lucide-react';
import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils';
// Datos mínimos de un Producto del Catálogo
@@ -12,19 +12,36 @@ interface ProductSummary {
basePrice: number;
}
interface WordRange {
id: number;
fromCount: number;
toCount: number;
pricePerWord: number;
}
interface PricingConfig {
id: number;
categoryId: number;
baseWordCount: number;
extraWordPrice: number;
specialChars: string;
specialCharPrice: number;
boldSurcharge: number;
frameSurcharge: number;
wordRanges: WordRange[];
}
// Configuración por defecto
const defaultConfig: PricingConfig = {
baseWordCount: 15, extraWordPrice: 0,
specialChars: '!', specialCharPrice: 0, boldSurcharge: 0, frameSurcharge: 0
id: 0,
categoryId: 0,
baseWordCount: 15,
extraWordPrice: 0,
specialChars: '!',
specialCharPrice: 0,
boldSurcharge: 0,
frameSurcharge: 0,
wordRanges: []
};
export default function PricingManager() {
@@ -175,24 +192,128 @@ export default function PricingManager() {
</div>
{/* TARJETA: REGLAS POR PALABRAS */}
<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">
<FileText size={20} className="text-blue-500" /> Reglas por Cantidad de 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="grid grid-cols-2 gap-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>
<label className="block text-sm font-medium text-gray-600 mb-1">Palabras Incluidas</label>
<label className="block text-sm font-medium text-gray-600 mb-1">Palabras Incluidas en Base</label>
<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) })} />
<p className="text-[10px] text-gray-400 mt-1">Cantidad de palabras ya cubiertas por el Precio Base del Producto.</p>
</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"
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>
{/* 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>
<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>

View File

@@ -10,4 +10,6 @@ public class CategoryPricing
public decimal SpecialCharPrice { get; set; }
public decimal BoldSurcharge { get; set; }
public decimal FrameSurcharge { get; set; }
public List<WordPricingRange> WordRanges { get; set; } = new();
}

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

@@ -245,6 +245,19 @@ END
ALTER TABLE ListingNotes ADD IsFromModerator BIT NOT NULL DEFAULT 1;
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);

View File

@@ -16,26 +16,35 @@ public class PricingRepository
public async Task<CategoryPricing?> GetByCategoryIdAsync(int categoryId)
{
using var conn = _db.CreateConnection();
return await conn.QuerySingleOrDefaultAsync<CategoryPricing>(
"SELECT * FROM CategoryPricing WHERE CategoryId = @Id", new { Id = categoryId });
var sql = @"
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)
{
using var conn = _db.CreateConnection();
// Lógica de "Si existe actualiza, sino inserta"
var exists = await conn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM CategoryPricing WHERE CategoryId = @CategoryId", new { pricing.CategoryId });
var exists = await conn.QuerySingleOrDefaultAsync<CategoryPricing>(
"SELECT Id FROM CategoryPricing WHERE CategoryId = @CategoryId", new { pricing.CategoryId });
if (exists > 0)
if (exists != null)
{
pricing.Id = exists.Id;
var updateSql = @"
UPDATE CategoryPricing
SET BaseWordCount = @BaseWordCount,
ExtraWordPrice = @ExtraWordPrice, SpecialChars = @SpecialChars,
SpecialCharPrice = @SpecialCharPrice, BoldSurcharge = @BoldSurcharge,
FrameSurcharge = @FrameSurcharge
WHERE CategoryId = @CategoryId";
WHERE Id = @Id";
await conn.ExecuteAsync(updateSql, pricing);
}
else
@@ -44,8 +53,21 @@ public class PricingRepository
INSERT INTO CategoryPricing
(CategoryId, BaseWordCount, ExtraWordPrice, SpecialChars, SpecialCharPrice, BoldSurcharge, FrameSurcharge)
VALUES
(@CategoryId, @BaseWordCount, @ExtraWordPrice, @SpecialChars, @SpecialCharPrice, @BoldSurcharge, @FrameSurcharge)";
await conn.ExecuteAsync(insertSql, pricing);
(@CategoryId, @BaseWordCount, @ExtraWordPrice, @SpecialChars, @SpecialCharPrice, @BoldSurcharge, @FrameSurcharge);
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

@@ -69,7 +69,30 @@ public class PricingService
// o suman al conteo de palabras. Aquí implemento: Se cobran APARTE.
int extraWords = Math.Max(0, realWordCount - pricing.BaseWordCount);
decimal extraWordCost = extraWords * pricing.ExtraWordPrice;
decimal extraWordCost = 0;
if (pricing.WordRanges != null && pricing.WordRanges.Any() && extraWords > 0)
{
// Buscamos el rango aplicable para la cantidad TOTAL de palabras extra
// El ToCount = 0 se interpreta como "sin límite superior"
var applicableRange = pricing.WordRanges.FirstOrDefault(r =>
extraWords >= r.FromCount && (extraWords <= r.ToCount || r.ToCount == 0));
if (applicableRange != null)
{
extraWordCost = extraWords * applicableRange.PricePerWord;
}
else
{
// Fallback al precio base si no se define rango que cubra la cantidad
extraWordCost = extraWords * pricing.ExtraWordPrice;
}
}
else
{
extraWordCost = extraWords * pricing.ExtraWordPrice;
}
decimal specialCharCost = specialCharCount * pricing.SpecialCharPrice;
currentCost += extraWordCost + specialCharCost;