Refactor product pricing: catalog owns base price, pricing manager owns rules

This commit is contained in:
2026-02-21 19:52:25 -03:00
parent 841cc961b5
commit 6d1eb908a0
17 changed files with 90 additions and 26 deletions

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import type { Product, ProductBundleComponent } from '../../types/Product';
import type { Company } from '../../types/Company';
import type { Category } from '../../types/Category';
import { productService } from '../../services/productService';
import { X, Save, Layers, Plus, Trash2, AlertCircle, Package } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
@@ -8,6 +9,7 @@ import { motion, AnimatePresence } from 'framer-motion';
interface Props {
product: Product | null;
companies: Company[];
categories: Category[];
allProducts: Product[];
onClose: (refresh?: boolean) => void;
}
@@ -21,11 +23,12 @@ const PRODUCT_TYPES = [
{ id: 6, code: 'BUNDLE', name: 'Paquete Promocional (Combo)' },
];
export default function ProductModal({ product, companies, allProducts, onClose }: Props) {
export default function ProductModal({ product, companies, categories, allProducts, onClose }: Props) {
const [formData, setFormData] = useState<Partial<Product>>({
name: '',
description: '',
companyId: 0,
categoryId: undefined,
productTypeId: 4, // Default Physical
basePrice: 0,
taxRate: 21,
@@ -169,6 +172,16 @@ export default function ProductModal({ product, companies, allProducts, onClose
</select>
</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">
<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"

View File

@@ -4,7 +4,6 @@ import { Save, DollarSign, FileText, Type, AlertCircle } from 'lucide-react';
import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils';
interface PricingConfig {
basePrice: number;
baseWordCount: number;
extraWordPrice: number;
specialChars: string;
@@ -15,7 +14,7 @@ interface PricingConfig {
// Configuración por defecto
const defaultConfig: PricingConfig = {
basePrice: 0, baseWordCount: 15, extraWordPrice: 0,
baseWordCount: 15, extraWordPrice: 0,
specialChars: '!', specialCharPrice: 0, boldSurcharge: 0, frameSurcharge: 0
};
@@ -113,15 +112,9 @@ export default function PricingManager() {
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-600 mb-1">Precio Mínimo ($)</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>
<p className="text-xs text-blue-600 mb-2 bg-blue-50/50 p-2 rounded-lg border border-blue-100">
El Precio Base (Precio Mínimo) ahora se define directamente en los Productos del Catálogo.
</p>
<div className="grid grid-cols-2 gap-4">
<div>

View File

@@ -2,14 +2,17 @@ import { useState, useEffect } from 'react';
import { Plus, Search, Edit, Box, Layers } from 'lucide-react';
import { productService } from '../../../../counter-panel/src/services/productService';
import { companyService } from '../../../../counter-panel/src/services/companyService';
import { categoryService } from '../../services/categoryService';
import type { Product } from '../../../../counter-panel/src/types/Product';
import type { Company } from '../../../../counter-panel/src/types/Company';
import type { Category } from '../../types/Category';
import ProductModal from '../../components/Products/ProductModal';
import clsx from 'clsx';
export default function ProductManager() {
const [products, setProducts] = useState<Product[]>([]);
const [companies, setCompanies] = useState<Company[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
@@ -24,12 +27,14 @@ export default function ProductManager() {
const loadData = async () => {
setLoading(true);
try {
const [prodRes, compRes] = await Promise.all([
const [prodRes, compRes, catRes] = await Promise.all([
productService.getAll(),
companyService.getAll()
companyService.getAll(),
categoryService.getAll()
]);
setProducts(prodRes);
setCompanies(compRes);
setCategories(catRes);
} catch (error) {
console.error("Error cargando catálogo", error);
} finally {
@@ -150,6 +155,7 @@ export default function ProductManager() {
<ProductModal
product={editingProduct}
companies={companies}
categories={categories}
allProducts={products} // Pasamos todos los productos para poder armar combos
onClose={handleModalClose}
/>

View File

@@ -2,6 +2,7 @@ export interface Product {
id: number;
companyId: number;
productTypeId: number; // 1:Classified, 2:Graphic, 3:Radio, 4:Physical, 5:Service, 6:Bundle
categoryId?: number; // Para relacionarlo a un rubro
name: string;
description?: string;
sku?: string;

View File

@@ -10,6 +10,7 @@ interface AdEditorModalProps {
onClose: () => void;
onConfirm: (listingId: number, price: number, description: string) => void;
clientId: number | null; // El aviso se vinculará a este cliente
productId: number; // Necesario para el precio base
}
interface PricingResult {
@@ -18,7 +19,7 @@ interface PricingResult {
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 [operations, setOperations] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
@@ -72,6 +73,7 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId }:
try {
const res = await api.post('/pricing/calculate', {
categoryId: parseInt(categoryId),
productId: productId,
text: debouncedText,
days: days,
isBold: styles.isBold,

View File

@@ -240,6 +240,7 @@ export default function FastEntryPage() {
try {
const res = await api.post('/pricing/calculate', {
categoryId: parseInt(formData.categoryId),
productId: 0, // En FastEntry no hay producto aún
text: debouncedText || "",
days: formData.days,
isBold: options.isBold,

View File

@@ -310,6 +310,7 @@ export default function UniversalPosPage() {
onClose={() => setShowAdEditor(false)}
onConfirm={handleAdConfirmed}
clientId={clientId || 1005}
productId={selectedAdProduct?.id || 0}
/>
)}

View File

@@ -2,6 +2,7 @@ export interface Product {
id: number;
companyId: number;
productTypeId: number; // 1:Classified, 2:Graphic, 3:Radio, 4:Physical, 5:Service, 6:Bundle
categoryId?: number; // Para relacionarlo a un rubro
name: string;
description?: string;
sku?: string;