fix(6-mejoras): subcategoría+apartado libre+PDF+coacreditado+docs+dedup
1. Subcategoría en modal descarte (catálogo completo + select) 2. Apartado sin monto mínimo (eliminada validación de 0,000) 3. PDF solo con propuesta elegida (no las 4) 4. Co-acreditado: checkbox 'no tiene' valida sin campos obligatorios 5. Documentos apartado: previsualizar + descargar (endpoint + frontend) 6. Oportunidades: sin duplicados, orden Confirmar→Afluencia→Cotizar→Aparto(verde)→Descartar
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
SLOTS_DOCS_APARTADO,
|
||||
componerTelefono,
|
||||
LADAS,
|
||||
SUBCATEGORIAS_NO_COMPRA,
|
||||
textoDiasRestantes,
|
||||
urlAfluenciaParaOportunidad,
|
||||
urlCotizadorParaOportunidad,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
type ClaveLada,
|
||||
type FaseApartado,
|
||||
type LeadMiDia,
|
||||
type MotivoNoCompra,
|
||||
type OportunidadMiDia,
|
||||
type ResumenMiDia,
|
||||
type SlotDocApartado,
|
||||
@@ -40,6 +42,7 @@ import {
|
||||
convertirLead,
|
||||
crearLead,
|
||||
descartarLead,
|
||||
fetchArchivoDocumentoApartado,
|
||||
fetchResumenMiDia,
|
||||
listarDocumentosApartado,
|
||||
subirDocumentoApartado,
|
||||
@@ -140,7 +143,7 @@ function EnlacesContacto({ nombre, telefono }: { nombre: string; telefono: strin
|
||||
}
|
||||
|
||||
|
||||
/** Fila de oportunidad con botones de fase del funnel (Agendo Cita lleva fecha) */
|
||||
/** Fila de oportunidad: acciones en orden (Agendo Cita lleva fecha opcional) */
|
||||
function FilaOportunidad({
|
||||
op,
|
||||
onEtapa,
|
||||
@@ -162,7 +165,6 @@ function FilaOportunidad({
|
||||
}) {
|
||||
const [conCita, setConCita] = useState(!!op.fechaCita)
|
||||
const [fechaCita, setFechaCita] = useState(op.fechaCita ?? '')
|
||||
const fases = ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto'] as const
|
||||
return (
|
||||
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -200,16 +202,25 @@ function FilaOportunidad({
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
{/* Cotizar: cambia la etapa y abre el cotizador con los datos prellenados */}
|
||||
{/* 1. Confirmar cita (etapa Agendo Cita): al primer toque pide la fecha */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={enviando}
|
||||
onClick={onCotizar}
|
||||
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
|
||||
onClick={() => (conCita ? onEtapa('Agendo Cita', fechaCita || undefined) : setConCita(true))}
|
||||
className={CLASE_BOTON_ACCION}
|
||||
>
|
||||
Cotizar →
|
||||
{conCita ? 'Confirmar cita' : ETIQUETA_FASE_OPORTUNIDAD['Agendo Cita']}
|
||||
</button>
|
||||
{/* Afluencia: abre el formulario de alta afluencia prellenado */}
|
||||
{conCita && (
|
||||
<input
|
||||
type="datetime-local"
|
||||
aria-label="Fecha y hora de la cita"
|
||||
value={fechaCita}
|
||||
onChange={(e) => setFechaCita(e.target.value)}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-2 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
)}
|
||||
{/* 2. Afluencia: abre el formulario de alta afluencia prellenado */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={enviando}
|
||||
@@ -218,39 +229,25 @@ function FilaOportunidad({
|
||||
>
|
||||
Afluencia →
|
||||
</button>
|
||||
{fases.map((f) =>
|
||||
f === 'Agendo Cita' ? (
|
||||
<span key={f} className="contents">
|
||||
<button
|
||||
type="button"
|
||||
disabled={enviando}
|
||||
onClick={() => (conCita ? onEtapa(f, fechaCita || undefined) : setConCita(true))}
|
||||
className={CLASE_BOTON_ACCION}
|
||||
>
|
||||
{conCita ? 'Confirmar cita' : ETIQUETA_FASE_OPORTUNIDAD[f]}
|
||||
</button>
|
||||
{conCita && (
|
||||
<input
|
||||
type="datetime-local"
|
||||
aria-label="Fecha y hora de la cita"
|
||||
value={fechaCita}
|
||||
onChange={(e) => setFechaCita(e.target.value)}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-2 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
disabled={enviando}
|
||||
onClick={() => onEtapa(f)}
|
||||
className={CLASE_BOTON_ACCION}
|
||||
>
|
||||
{ETIQUETA_FASE_OPORTUNIDAD[f]}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
{/* 3. Cotizar: cambia la etapa y abre el cotizador con los datos prellenados */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={enviando}
|
||||
onClick={onCotizar}
|
||||
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
Cotizar →
|
||||
</button>
|
||||
{/* 4. Aparto: marca la etapa (verde) */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={enviando}
|
||||
onClick={() => onEtapa('Aparto')}
|
||||
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
Aparto
|
||||
</button>
|
||||
{/* 5. Descartar */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={enviando || (acciones !== undefined && !acciones.includes('descartar_lead'))}
|
||||
@@ -279,8 +276,11 @@ function ModalDescarteGenerico({
|
||||
enviando: boolean
|
||||
}) {
|
||||
const [motivo, setMotivo] = useState<string>('Timing')
|
||||
const [subcategoria] = useState('')
|
||||
const [subcategoria, setSubcategoria] = useState('')
|
||||
const [comentario, setComentario] = useState('')
|
||||
// Subcategorías del catálogo para el motivo elegido; se limpia al cambiar de motivo
|
||||
const subcategorias =
|
||||
motivo in SUBCATEGORIAS_NO_COMPRA ? SUBCATEGORIAS_NO_COMPRA[motivo as MotivoNoCompra] : []
|
||||
return (
|
||||
<div className="fixed inset-0 z-10 flex items-center justify-center bg-black/40 px-4" role="dialog" aria-modal="true">
|
||||
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
|
||||
@@ -298,7 +298,10 @@ function ModalDescarteGenerico({
|
||||
<select
|
||||
id="motivo-cancel"
|
||||
value={motivo}
|
||||
onChange={(e) => setMotivo(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setMotivo(e.target.value)
|
||||
setSubcategoria('')
|
||||
}}
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm"
|
||||
>
|
||||
{MOTIVOS_NO_COMPRA.map((m) => (
|
||||
@@ -306,6 +309,20 @@ function ModalDescarteGenerico({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="subcategoria-cancel" className="block mb-2 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Subcategoría</label>
|
||||
<select
|
||||
id="subcategoria-cancel"
|
||||
value={subcategoria}
|
||||
onChange={(e) => setSubcategoria(e.target.value)}
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm"
|
||||
>
|
||||
<option value="">Sin especificar</option>
|
||||
{subcategorias.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="comentario-cancel" className="block mb-2 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Comentario (opcional)</label>
|
||||
<textarea
|
||||
@@ -429,6 +446,7 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
|
||||
const [documentos, setDocumentos] = useState<DocumentoApartado[] | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [subiendo, setSubiendo] = useState<SlotDocApartado | null>(null)
|
||||
const [obteniendo, setObteniendo] = useState<string | null>(null)
|
||||
|
||||
const cargar = useCallback(async () => {
|
||||
try {
|
||||
@@ -457,6 +475,49 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Baja el archivo autenticado y opera sobre su blob URL. Para previsualizar,
|
||||
* la pestaña se abre DENTRO del gesto del usuario (después del await el
|
||||
* navegador bloquearía el window.open) y luego se navega al blob.
|
||||
*/
|
||||
const conArchivo = async (doc: DocumentoApartado, modo: 'previsualizar' | 'descargar') => {
|
||||
setObteniendo(doc.id)
|
||||
setError('')
|
||||
const pestaña = modo === 'previsualizar' ? window.open('', '_blank') : null
|
||||
try {
|
||||
const blob = await fetchArchivoDocumentoApartado(itemId, doc.id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
if (pestaña) {
|
||||
// Previsualizar: navega la pestaña ya abierta al archivo (PDF/imagen)
|
||||
pestaña.location.href = url
|
||||
} else {
|
||||
// Descargar: link con atributo download (nombre original del archivo)
|
||||
const enlace = document.createElement('a')
|
||||
enlace.href = url
|
||||
if (modo === 'descargar') enlace.download = doc.nombreArchivo
|
||||
else enlace.target = '_blank'
|
||||
enlace.rel = 'noopener'
|
||||
document.body.appendChild(enlace)
|
||||
enlace.click()
|
||||
enlace.remove()
|
||||
}
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
} catch (err) {
|
||||
pestaña?.close()
|
||||
setError(err instanceof Error ? err.message : 'No se pudo obtener el archivo.')
|
||||
} finally {
|
||||
setObteniendo(null)
|
||||
}
|
||||
}
|
||||
|
||||
const previsualizar = (doc: DocumentoApartado) => {
|
||||
void conArchivo(doc, 'previsualizar')
|
||||
}
|
||||
|
||||
const descargar = (doc: DocumentoApartado) => {
|
||||
void conArchivo(doc, 'descargar')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border border-dashed border-[#D1D5DB] bg-[#F8FAFC] dark:border-[#334155] dark:bg-[#0F172A] p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
@@ -503,7 +564,29 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{delSlot.map((d) => (
|
||||
<li key={d.id} className="text-[11px] text-[#64748B] dark:text-[#94A3B8]">
|
||||
{d.nombreArchivo} · {formatoFechaCorta(d.createdAt.slice(0, 10))}
|
||||
<div className="flex flex-wrap items-center justify-between gap-1">
|
||||
<span className="min-w-0 truncate">
|
||||
{d.nombreArchivo} · {formatoFechaCorta(d.createdAt.slice(0, 10))}
|
||||
</span>
|
||||
<span className="flex shrink-0 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={obteniendo !== null}
|
||||
onClick={() => previsualizar(d)}
|
||||
className="font-medium text-accent hover:underline disabled:opacity-50"
|
||||
>
|
||||
Previsualizar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={obteniendo !== null}
|
||||
onClick={() => descargar(d)}
|
||||
className="font-medium text-accent hover:underline disabled:opacity-50"
|
||||
>
|
||||
{obteniendo === d.id ? 'Descargando…' : 'Descargar'}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -121,6 +121,21 @@ export function listarDocumentosApartado(itemId: string): Promise<DocumentoApart
|
||||
return fetchJson<DocumentoApartado[]>(`/apartados/${itemId}/documentos`)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /apartados/:itemId/documentos/:docId/archivo — el archivo del documento
|
||||
* como blob. Va por fetch autenticado porque window.open / <a download> no
|
||||
* pueden mandar el header Authorization.
|
||||
*/
|
||||
export async function fetchArchivoDocumentoApartado(itemId: string, docId: string): Promise<Blob> {
|
||||
const res = await fetch(`${API_URL}/apartados/${itemId}/documentos/${docId}/archivo`, conAuth({ cache: 'no-store' }))
|
||||
manejar401(res)
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => null)) as { error?: string } | null
|
||||
throw new Error(body?.error ?? `Error ${res.status}`)
|
||||
}
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
/** POST /apartados/:itemId/documentos — multipart; el archivo se guarda en el cliente */
|
||||
export async function subirDocumentoApartado(itemId: string, tipo: string, archivo: File): Promise<void> {
|
||||
const form = new FormData()
|
||||
|
||||
Reference in New Issue
Block a user