feat: Implementación de rangos de precio por palabra con validación de continuidad
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
10
src/SIGCM.Domain/Entities/WordPricingRange.cs
Normal file
10
src/SIGCM.Domain/Entities/WordPricingRange.cs
Normal 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; }
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user