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 api from '../services/api';
|
||||||
import { useDebounce } from '../hooks/useDebounce';
|
import { useDebounce } from '../hooks/useDebounce';
|
||||||
import { processCategories, type FlatCategory } from '../utils/categoryTreeUtils';
|
import { processCategories, type FlatCategory } from '../utils/categoryTreeUtils';
|
||||||
|
import { productService } from '../services/productService';
|
||||||
|
import type { Product } from '../types/Product';
|
||||||
import {
|
import {
|
||||||
Printer, Save,
|
Printer, Save,
|
||||||
AlignLeft, AlignCenter, AlignRight, AlignJustify,
|
AlignLeft, AlignCenter, AlignRight, AlignJustify,
|
||||||
@@ -13,7 +15,8 @@ import {
|
|||||||
X,
|
X,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Star
|
Star,
|
||||||
|
Package
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import PaymentModal, { type Payment } from '../components/PaymentModal';
|
import PaymentModal, { type Payment } from '../components/PaymentModal';
|
||||||
@@ -53,6 +56,11 @@ export default function FastEntryPage() {
|
|||||||
const [isCatDropdownOpen, setIsCatDropdownOpen] = useState(false);
|
const [isCatDropdownOpen, setIsCatDropdownOpen] = useState(false);
|
||||||
const catWrapperRef = useRef<HTMLDivElement>(null);
|
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({
|
const [formData, setFormData] = useState({
|
||||||
categoryId: '', operationId: '', text: '', title: '', price: '', days: 3, clientName: '', clientDni: '', clientId: null as number | null,
|
categoryId: '', operationId: '', text: '', title: '', price: '', days: 3, clientName: '', clientDni: '', clientId: null as number | null,
|
||||||
startDate: new Date(Date.now() + 86400000).toISOString().split('T')[0],
|
startDate: new Date(Date.now() + 86400000).toISOString().split('T')[0],
|
||||||
@@ -212,6 +220,25 @@ export default function FastEntryPage() {
|
|||||||
fetchData();
|
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 () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!validate()) return;
|
if (!validate()) return;
|
||||||
setShowPaymentModal(true);
|
setShowPaymentModal(true);
|
||||||
@@ -240,7 +267,7 @@ export default function FastEntryPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await api.post('/pricing/calculate', {
|
const res = await api.post('/pricing/calculate', {
|
||||||
categoryId: parseInt(formData.categoryId),
|
categoryId: parseInt(formData.categoryId),
|
||||||
productId: 0, // En FastEntry no hay producto aún
|
productId: selectedProduct?.id || 0,
|
||||||
text: debouncedText || "",
|
text: debouncedText || "",
|
||||||
days: formData.days,
|
days: formData.days,
|
||||||
isBold: options.isBold,
|
isBold: options.isBold,
|
||||||
@@ -251,7 +278,7 @@ export default function FastEntryPage() {
|
|||||||
} catch (error) { console.error(error); }
|
} catch (error) { console.error(error); }
|
||||||
};
|
};
|
||||||
calculatePrice();
|
calculatePrice();
|
||||||
}, [debouncedText, formData.categoryId, formData.days, options, formData.startDate]);
|
}, [debouncedText, formData.categoryId, selectedProduct, formData.days, options, formData.startDate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (debouncedClientSearch.length > 2 && showSuggestions) {
|
if (debouncedClientSearch.length > 2 && showSuggestions) {
|
||||||
@@ -317,6 +344,7 @@ export default function FastEntryPage() {
|
|||||||
setOptions({ isBold: false, isFrame: false, fontSize: 'normal', alignment: 'left' });
|
setOptions({ isBold: false, isFrame: false, fontSize: 'normal', alignment: 'left' });
|
||||||
setSelectedImages([]);
|
setSelectedImages([]);
|
||||||
setImagePreviews([]);
|
setImagePreviews([]);
|
||||||
|
setSelectedProduct(null);
|
||||||
setShowPaymentModal(false);
|
setShowPaymentModal(false);
|
||||||
setErrors({});
|
setErrors({});
|
||||||
showToast('Aviso procesado correctamente.', 'success');
|
showToast('Aviso procesado correctamente.', 'success');
|
||||||
@@ -436,6 +464,50 @@ export default function FastEntryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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="grid grid-cols-12 gap-6">
|
||||||
<div className="col-span-8">
|
<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>
|
<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">
|
<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()}
|
<span className="text-lg mt-1 opacity-50">$</span>{pricing.totalPrice.toLocaleString()}
|
||||||
</div>
|
</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="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>
|
<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>}
|
{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;
|
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> => {
|
create: async (product: Partial<Product>): Promise<Product> => {
|
||||||
const response = await api.post<Product>('/products', product);
|
const response = await api.post<Product>('/products', product);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ public class ProductsController : ControllerBase
|
|||||||
return Ok(products);
|
return Ok(products);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Obtener productos clasificados vinculados a un rubro específico
|
||||||
|
[HttpGet("by-category/{categoryId}")]
|
||||||
|
public async Task<IActionResult> GetByCategory(int categoryId)
|
||||||
|
{
|
||||||
|
var products = await _repository.GetByCategoryIdAsync(categoryId);
|
||||||
|
return Ok(products);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Roles = "Admin")] // Solo Admins crean productos
|
[Authorize(Roles = "Admin")] // Solo Admins crean productos
|
||||||
public async Task<IActionResult> Create(Product product)
|
public async Task<IActionResult> Create(Product product)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ public interface IProductRepository
|
|||||||
{
|
{
|
||||||
Task<IEnumerable<Product>> GetAllAsync();
|
Task<IEnumerable<Product>> GetAllAsync();
|
||||||
Task<IEnumerable<Product>> GetByCompanyIdAsync(int companyId);
|
Task<IEnumerable<Product>> GetByCompanyIdAsync(int companyId);
|
||||||
|
Task<IEnumerable<Product>> GetByCategoryIdAsync(int categoryId);
|
||||||
Task<Product?> GetByIdAsync(int id);
|
Task<Product?> GetByIdAsync(int id);
|
||||||
Task<int> CreateAsync(Product product);
|
Task<int> CreateAsync(Product product);
|
||||||
Task UpdateAsync(Product product);
|
Task UpdateAsync(Product product);
|
||||||
|
|||||||
@@ -43,6 +43,18 @@ public class ProductRepository : IProductRepository
|
|||||||
new { Id = companyId });
|
new { Id = companyId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Product>> GetByCategoryIdAsync(int categoryId)
|
||||||
|
{
|
||||||
|
using var conn = _db.CreateConnection();
|
||||||
|
var sql = @"
|
||||||
|
SELECT p.*, c.Name as CompanyName, pt.Code as TypeCode
|
||||||
|
FROM Products p
|
||||||
|
JOIN Companies c ON p.CompanyId = c.Id
|
||||||
|
JOIN ProductTypes pt ON p.ProductTypeId = pt.Id
|
||||||
|
WHERE p.CategoryId = @CategoryId AND p.IsActive = 1";
|
||||||
|
return await conn.QueryAsync<Product>(sql, new { CategoryId = categoryId });
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<int> CreateAsync(Product product)
|
public async Task<int> CreateAsync(Product product)
|
||||||
{
|
{
|
||||||
using var conn = _db.CreateConnection();
|
using var conn = _db.CreateConnection();
|
||||||
|
|||||||
Reference in New Issue
Block a user