362 lines
16 KiB
TypeScript
362 lines
16 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { X, Save, Calendar, Type, AlignLeft, AlignCenter, AlignRight, AlignJustify, Bold, Square as FrameIcon } from 'lucide-react';
|
|
import { useDebounce } from '../../hooks/useDebounce';
|
|
import api from '../../services/api';
|
|
import clsx from 'clsx';
|
|
import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils';
|
|
import { productService } from '../../services/productService';
|
|
import type { Product } from '../../types/Product';
|
|
|
|
interface AdEditorModalProps {
|
|
isOpen: boolean;
|
|
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 {
|
|
totalPrice: number;
|
|
wordCount: number;
|
|
details: string;
|
|
}
|
|
|
|
export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId, productId }: AdEditorModalProps) {
|
|
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
|
|
const [operations, setOperations] = useState<any[]>([]);
|
|
const [product, setProduct] = useState<Product | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [calculating, setCalculating] = useState(false);
|
|
|
|
// Form State
|
|
const [categoryId, setCategoryId] = useState('');
|
|
const [operationId, setOperationId] = useState('');
|
|
const [text, setText] = useState('');
|
|
const debouncedText = useDebounce(text, 500);
|
|
const [days, setDays] = useState(3);
|
|
const [startDate, setStartDate] = useState(new Date(Date.now() + 86400000).toISOString().split('T')[0]); // Mañana default
|
|
|
|
// Styles
|
|
const [styles, setStyles] = useState({
|
|
isBold: false,
|
|
isFrame: false,
|
|
fontSize: 'normal',
|
|
alignment: 'left'
|
|
});
|
|
|
|
const [pricing, setPricing] = useState<PricingResult>({ totalPrice: 0, wordCount: 0, details: '' });
|
|
|
|
// Carga inicial de datos
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
// Reset state on open
|
|
setText('');
|
|
setDays(3);
|
|
setPricing({ totalPrice: 0, wordCount: 0, details: '' });
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
const [catRes, opRes, prodData] = await Promise.all([
|
|
api.get('/categories'),
|
|
api.get('/operations'),
|
|
productService.getById(productId)
|
|
]);
|
|
const allCategories = processCategories(catRes.data);
|
|
let filteredCategories = allCategories;
|
|
|
|
if (prodData.categoryId) {
|
|
const rootCategory = allCategories.find(c => c.id === prodData.categoryId);
|
|
if (rootCategory) {
|
|
// Filtrar para mostrar solo la categoría raíz y sus descendientes
|
|
filteredCategories = allCategories.filter(c =>
|
|
c.id === rootCategory.id || c.path.startsWith(rootCategory.path + ' > ')
|
|
);
|
|
}
|
|
}
|
|
|
|
setFlatCategories(filteredCategories);
|
|
setOperations(opRes.data);
|
|
setProduct(prodData);
|
|
|
|
// Ajustar días iniciales según la duración del producto
|
|
if (prodData.priceDurationDays > 1) {
|
|
setDays(prodData.priceDurationDays);
|
|
} else {
|
|
setDays(3);
|
|
}
|
|
|
|
// Si hay una sola opción elegible, seleccionarla automáticamente
|
|
const selectable = filteredCategories.filter(c => c.isSelectable);
|
|
if (selectable.length === 1) {
|
|
setCategoryId(selectable[0].id.toString());
|
|
}
|
|
} catch (e) {
|
|
console.error("Error cargando configuración", e);
|
|
}
|
|
};
|
|
loadData();
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Calculadora de Precio en Tiempo Real
|
|
useEffect(() => {
|
|
if (!categoryId) {
|
|
setPricing({ totalPrice: 0, wordCount: 0, details: '' });
|
|
return;
|
|
}
|
|
|
|
const calculate = async () => {
|
|
setCalculating(true);
|
|
try {
|
|
const res = await api.post('/pricing/calculate', {
|
|
categoryId: parseInt(categoryId),
|
|
productId: productId,
|
|
text: debouncedText,
|
|
days: days,
|
|
isBold: styles.isBold,
|
|
isFrame: styles.isFrame,
|
|
startDate: startDate
|
|
});
|
|
setPricing(res.data);
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setCalculating(false);
|
|
}
|
|
};
|
|
calculate();
|
|
}, [debouncedText, categoryId, days, styles, startDate, productId]);
|
|
|
|
const handleSave = async () => {
|
|
if (!categoryId || !operationId || !text) return alert("Complete los campos obligatorios");
|
|
if (!clientId) return alert("Error interno: Cliente no identificado");
|
|
|
|
setLoading(true);
|
|
try {
|
|
// Creamos el aviso en estado 'Draft' (Borrador) o 'PendingPayment'
|
|
const payload = {
|
|
categoryId: parseInt(categoryId),
|
|
operationId: parseInt(operationId),
|
|
title: text.substring(0, 30) + '...',
|
|
description: text,
|
|
price: 0, // Precio del bien (opcional, no lo pedimos en este form simple)
|
|
adFee: pricing.totalPrice, // Costo del aviso
|
|
status: 'Draft', // Importante: Nace como borrador hasta que se paga la Orden
|
|
origin: 'Mostrador',
|
|
clientId: clientId,
|
|
// Datos técnicos de impresión
|
|
printText: text,
|
|
printStartDate: startDate,
|
|
printDaysCount: days,
|
|
isBold: styles.isBold,
|
|
isFrame: styles.isFrame,
|
|
printFontSize: styles.fontSize,
|
|
printAlignment: styles.alignment
|
|
};
|
|
|
|
const res = await api.post('/listings', payload);
|
|
|
|
// Devolvemos el ID y el Precio al Carrito
|
|
onConfirm(res.data.id, pricing.totalPrice, `Aviso: ${text.substring(0, 20)}... (${days} días)`);
|
|
onClose();
|
|
} catch (error) {
|
|
console.error(error);
|
|
alert("Error al guardar el borrador del aviso");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[200] flex items-center justify-center p-4">
|
|
<div className="bg-white w-full max-w-4xl rounded-[2rem] shadow-2xl flex flex-col max-h-[90vh] overflow-hidden border border-slate-200">
|
|
|
|
{/* Header */}
|
|
<div className="bg-slate-50 px-8 py-5 border-b border-slate-100 flex justify-between items-center">
|
|
<div>
|
|
<h3 className="text-lg font-black text-slate-800 uppercase tracking-tight">Redacción de Aviso</h3>
|
|
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Configuración Técnica e Impresión</p>
|
|
</div>
|
|
<button onClick={onClose} className="p-2 hover:bg-white rounded-xl text-slate-400 transition-colors"><X /></button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="p-8 overflow-y-auto flex-1 custom-scrollbar space-y-6">
|
|
|
|
{/* Clasificación */}
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<div className="space-y-1.5">
|
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Rubro</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"
|
|
value={categoryId} onChange={e => setCategoryId(e.target.value)}
|
|
>
|
|
<option value="">Seleccione Rubro...</option>
|
|
{flatCategories.map(c => (
|
|
<option key={c.id} value={c.id} disabled={!c.isSelectable}>
|
|
{'\u00A0'.repeat(c.level * 2)} {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">Operación</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"
|
|
value={operationId} onChange={e => setOperationId(e.target.value)}
|
|
>
|
|
<option value="">Seleccione Operación...</option>
|
|
{operations.map(op => <option key={op.id} value={op.id}>{op.name}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Editor de Texto */}
|
|
<div className="flex gap-6 min-h-[200px]">
|
|
<div className="flex-[2] flex flex-col gap-2">
|
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Texto del Aviso</label>
|
|
<textarea
|
|
className={clsx(
|
|
"w-full h-full p-4 border-2 border-slate-100 rounded-2xl resize-none outline-none focus:border-blue-500 transition-all font-mono text-sm leading-relaxed",
|
|
styles.isBold && "font-bold",
|
|
styles.fontSize === 'small' ? 'text-xs' : styles.fontSize === 'large' ? 'text-base' : 'text-sm',
|
|
styles.alignment === 'center' ? 'text-center' : styles.alignment === 'right' ? 'text-right' : styles.alignment === 'justify' ? 'text-justify' : 'text-left'
|
|
)}
|
|
placeholder="Escriba el contenido aquí..."
|
|
value={text} onChange={e => setText(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Panel Lateral de Estilos */}
|
|
<div className="flex-1 bg-slate-50 p-4 rounded-2xl border border-slate-100 space-y-4 h-fit">
|
|
<div>
|
|
<label className="text-[9px] font-black text-slate-400 uppercase mb-2 block">Alineación</label>
|
|
<div className="flex bg-white p-1 rounded-lg border border-slate-200 justify-between">
|
|
{['left', 'center', 'right', 'justify'].map(align => (
|
|
<button
|
|
key={align}
|
|
onClick={() => setStyles({ ...styles, alignment: align })}
|
|
className={clsx("p-2 rounded-md transition-all", styles.alignment === align ? "bg-blue-100 text-blue-600" : "text-slate-400 hover:text-slate-600")}
|
|
>
|
|
{align === 'left' && <AlignLeft size={16} />}
|
|
{align === 'center' && <AlignCenter size={16} />}
|
|
{align === 'right' && <AlignRight size={16} />}
|
|
{align === 'justify' && <AlignJustify size={16} />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-[9px] font-black text-slate-400 uppercase mb-2 block">Estilos</label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<button
|
|
onClick={() => setStyles({ ...styles, isBold: !styles.isBold })}
|
|
className={clsx("p-2 rounded-lg border text-[10px] font-black uppercase flex items-center justify-center gap-1 transition-all", styles.isBold ? "bg-slate-800 text-white border-slate-800" : "bg-white border-slate-200 text-slate-500")}
|
|
>
|
|
<Bold size={12} /> Negrita
|
|
</button>
|
|
<button
|
|
onClick={() => setStyles({ ...styles, isFrame: !styles.isFrame })}
|
|
className={clsx("p-2 rounded-lg border text-[10px] font-black uppercase flex items-center justify-center gap-1 transition-all", styles.isFrame ? "bg-slate-800 text-white border-slate-800" : "bg-white border-slate-200 text-slate-500")}
|
|
>
|
|
<FrameIcon size={12} /> Recuadro
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-[9px] font-black text-slate-400 uppercase mb-2 block">Tamaño Fuente</label>
|
|
<div className="flex bg-white p-1 rounded-lg border border-slate-200 gap-1">
|
|
{['small', 'normal', 'large'].map(size => (
|
|
<button
|
|
key={size}
|
|
onClick={() => setStyles({ ...styles, fontSize: size })}
|
|
className={clsx("flex-1 p-1.5 rounded-md flex justify-center items-end transition-all", styles.fontSize === size ? "bg-blue-100 text-blue-600" : "text-slate-400 hover:text-slate-600")}
|
|
>
|
|
<Type size={size === 'small' ? 12 : size === 'normal' ? 16 : 20} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Configuración de Fecha */}
|
|
<div className="grid grid-cols-3 gap-6 p-4 bg-blue-50/50 rounded-2xl border border-blue-100">
|
|
<div className="col-span-1">
|
|
<label className="text-[10px] font-black text-blue-400 uppercase tracking-widest ml-1 mb-1 block">Inicio Publicación</label>
|
|
<div className="relative">
|
|
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-blue-300" size={16} />
|
|
<input
|
|
type="date"
|
|
className="w-full pl-10 pr-4 py-2 bg-white border border-blue-200 rounded-xl text-xs font-bold text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20"
|
|
value={startDate} onChange={e => setStartDate(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{product?.productTypeId !== 4 && product?.productTypeId !== 6 && (
|
|
<div className="col-span-1">
|
|
<label className="text-[10px] font-black text-blue-400 uppercase tracking-widest ml-1 mb-1 block">
|
|
{product && product.priceDurationDays > 1 ? `Cant. de Módulos (${product.priceDurationDays}d)` : 'Cantidad Días'}
|
|
</label>
|
|
<div className="flex items-center bg-white border border-blue-200 rounded-xl overflow-hidden h-[38px]">
|
|
<button
|
|
onClick={() => setDays(Math.max(product?.priceDurationDays || 1, days - (product?.priceDurationDays || 1)))}
|
|
className="px-3 hover:bg-blue-50 text-blue-400 transition-colors"
|
|
>
|
|
-
|
|
</button>
|
|
<input
|
|
type="number"
|
|
className="w-full text-center font-black text-slate-700 text-sm outline-none"
|
|
value={days}
|
|
onChange={e => {
|
|
const val = parseInt(e.target.value) || 0;
|
|
const step = product?.priceDurationDays || 1;
|
|
// Redondear al múltiplo más cercano si es necesario, o simplemente dejarlo
|
|
setDays(Math.max(step, val));
|
|
}}
|
|
step={product?.priceDurationDays || 1}
|
|
/>
|
|
<button
|
|
onClick={() => setDays(days + (product?.priceDurationDays || 1))}
|
|
className="px-3 hover:bg-blue-50 text-blue-400 transition-colors"
|
|
>
|
|
+
|
|
</button>
|
|
</div>
|
|
{product && product.priceDurationDays > 1 && (
|
|
<p className="text-[9px] text-blue-400 font-bold mt-1 ml-1 uppercase">Mínimo: {product.priceDurationDays} días</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="col-span-1 flex flex-col justify-center items-end">
|
|
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-0.5">Costo Estimado</span>
|
|
<div className="text-2xl font-mono font-black text-slate-900">
|
|
{calculating ? <span className="animate-pulse">...</span> : `$ ${pricing.totalPrice.toLocaleString()}`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-6 bg-slate-50 border-t border-slate-100 flex justify-end gap-3">
|
|
<button onClick={onClose} className="px-6 py-3 rounded-xl font-bold text-slate-500 hover:bg-white transition-all text-xs uppercase tracking-wider">
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={loading || calculating || !text}
|
|
className="bg-blue-600 text-white px-8 py-3 rounded-xl font-black text-xs uppercase tracking-widest shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<Save size={16} /> {loading ? 'Procesando...' : 'Confirmar y Agregar al Carrito'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |