feat: selector de producto por rubro en FastEntryPage y Mostrador Universal
This commit is contained in:
@@ -2,6 +2,8 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import api from '../services/api';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
import { processCategories, type FlatCategory } from '../utils/categoryTreeUtils';
|
||||
import { productService } from '../services/productService';
|
||||
import type { Product } from '../types/Product';
|
||||
import {
|
||||
Printer, Save,
|
||||
AlignLeft, AlignCenter, AlignRight, AlignJustify,
|
||||
@@ -13,7 +15,8 @@ import {
|
||||
X,
|
||||
UploadCloud,
|
||||
MessageSquare,
|
||||
Star
|
||||
Star,
|
||||
Package
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import PaymentModal, { type Payment } from '../components/PaymentModal';
|
||||
@@ -53,6 +56,11 @@ export default function FastEntryPage() {
|
||||
const [isCatDropdownOpen, setIsCatDropdownOpen] = useState(false);
|
||||
const catWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Estado del selector de productos por rubro
|
||||
const [categoryProducts, setCategoryProducts] = useState<Product[]>([]);
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
const [loadingProducts, setLoadingProducts] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
categoryId: '', operationId: '', text: '', title: '', price: '', days: 3, clientName: '', clientDni: '', clientId: null as number | null,
|
||||
startDate: new Date(Date.now() + 86400000).toISOString().split('T')[0],
|
||||
@@ -212,6 +220,25 @@ export default function FastEntryPage() {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Cargar productos cuando cambia el rubro seleccionado
|
||||
useEffect(() => {
|
||||
if (!formData.categoryId) {
|
||||
setCategoryProducts([]);
|
||||
setSelectedProduct(null);
|
||||
return;
|
||||
}
|
||||
setLoadingProducts(true);
|
||||
productService.getByCategory(parseInt(formData.categoryId))
|
||||
.then(prods => {
|
||||
setCategoryProducts(prods);
|
||||
// Auto-seleccionar el primero si solo hay uno
|
||||
if (prods.length === 1) setSelectedProduct(prods[0]);
|
||||
else setSelectedProduct(null);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoadingProducts(false));
|
||||
}, [formData.categoryId]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!validate()) return;
|
||||
setShowPaymentModal(true);
|
||||
@@ -240,7 +267,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
|
||||
productId: selectedProduct?.id || 0,
|
||||
text: debouncedText || "",
|
||||
days: formData.days,
|
||||
isBold: options.isBold,
|
||||
@@ -251,7 +278,7 @@ export default function FastEntryPage() {
|
||||
} catch (error) { console.error(error); }
|
||||
};
|
||||
calculatePrice();
|
||||
}, [debouncedText, formData.categoryId, formData.days, options, formData.startDate]);
|
||||
}, [debouncedText, formData.categoryId, selectedProduct, formData.days, options, formData.startDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedClientSearch.length > 2 && showSuggestions) {
|
||||
@@ -317,6 +344,7 @@ export default function FastEntryPage() {
|
||||
setOptions({ isBold: false, isFrame: false, fontSize: 'normal', alignment: 'left' });
|
||||
setSelectedImages([]);
|
||||
setImagePreviews([]);
|
||||
setSelectedProduct(null);
|
||||
setShowPaymentModal(false);
|
||||
setErrors({});
|
||||
showToast('Aviso procesado correctamente.', 'success');
|
||||
@@ -436,6 +464,50 @@ export default function FastEntryPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SELECTOR DE PRODUCTO */}
|
||||
{formData.categoryId && (
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-slate-500 uppercase mb-1.5 tracking-widest flex items-center gap-2">
|
||||
<Package size={12} /> Producto / Tarifa
|
||||
</label>
|
||||
{loadingProducts ? (
|
||||
<div className="py-2 px-4 border-2 border-slate-100 rounded-xl bg-slate-50 text-slate-400 text-xs font-bold animate-pulse">
|
||||
Cargando productos del rubro...
|
||||
</div>
|
||||
) : categoryProducts.length === 0 ? (
|
||||
<div className="py-2 px-4 border-2 border-amber-100 rounded-xl bg-amber-50 text-amber-600 text-xs font-bold flex items-center gap-2">
|
||||
⚠️ Sin productos en este rubro — el precio base será $0
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${Math.min(categoryProducts.length, 3)}, 1fr)` }}>
|
||||
{categoryProducts.map(prod => (
|
||||
<button
|
||||
key={prod.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedProduct(prod)}
|
||||
className={clsx(
|
||||
"py-2 px-3 border-2 rounded-xl text-left transition-all duration-200 group",
|
||||
selectedProduct?.id === prod.id
|
||||
? "border-blue-500 bg-blue-600 text-white shadow-lg shadow-blue-200"
|
||||
: "border-slate-100 bg-slate-50 hover:border-blue-300 hover:bg-blue-50"
|
||||
)}
|
||||
>
|
||||
<div className={clsx("text-[10px] font-black uppercase tracking-tighter truncate", selectedProduct?.id === prod.id ? "text-blue-100" : "text-slate-500")}>
|
||||
{prod.typeCode}
|
||||
</div>
|
||||
<div className={clsx("text-xs font-black truncate leading-tight mt-0.5", selectedProduct?.id === prod.id ? "text-white" : "text-slate-800")}>
|
||||
{prod.name}
|
||||
</div>
|
||||
<div className={clsx("text-sm font-mono font-black mt-1", selectedProduct?.id === prod.id ? "text-green-300" : "text-blue-600")}>
|
||||
${prod.basePrice.toLocaleString()}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
<div className="col-span-8">
|
||||
<label className="block text-[10px] font-black text-slate-500 uppercase mb-1.5 tracking-widest">Título Web (Opcional)</label>
|
||||
@@ -562,6 +634,12 @@ export default function FastEntryPage() {
|
||||
<div className="text-4xl font-mono font-black text-green-400 flex items-start gap-1">
|
||||
<span className="text-lg mt-1 opacity-50">$</span>{pricing.totalPrice.toLocaleString()}
|
||||
</div>
|
||||
{selectedProduct && (
|
||||
<div className="mt-2 flex items-center gap-2 py-1.5 px-2 bg-blue-600/20 rounded-lg border border-blue-500/30">
|
||||
<Package size={10} className="text-blue-400 shrink-0" />
|
||||
<span className="text-[9px] font-black text-blue-300 truncate uppercase tracking-tight">{selectedProduct.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 pt-3 border-t border-slate-800 space-y-1.5 text-[10px] font-bold uppercase tracking-tighter">
|
||||
<div className="flex justify-between text-slate-500 italic"><span>Tarifa Base</span><span className="text-slate-300">${pricing.baseCost.toLocaleString()}</span></div>
|
||||
{pricing.extraCost > 0 && <div className="flex justify-between text-orange-500"><span>Recargos Texto</span><span>+${pricing.extraCost.toLocaleString()}</span></div>}
|
||||
|
||||
@@ -12,6 +12,12 @@ export const productService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Obtiene los productos clasificados vinculados a un rubro
|
||||
getByCategory: async (categoryId: number): Promise<Product[]> => {
|
||||
const response = await api.get<Product[]>(`/products/by-category/${categoryId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (product: Partial<Product>): Promise<Product> => {
|
||||
const response = await api.post<Product>('/products', product);
|
||||
return response.data;
|
||||
|
||||
Reference in New Issue
Block a user