feat: docs en apartados, enganche seleccionable y prefill de clientes
- Mis apartados (Mi día): botón Documentos con precarga del expediente — captura del cotizador y captura de pago apartado (obligatorios) más documentos generales; los archivos se guardan en el cliente ligado al apartado (se crea la ficha mínima si no existe). Endpoints GET/POST /apartados/:id/documentos (multipart, 10 MB, pdf/jpg/png) - Cotizador: se oculta el editor de descuento (usan los % del Excel); enganche seleccionable con mínimo 2.5% del valor de la propiedad, solo en esquemas con crédito (bancario/Infonavit); mensualidades se recalculan con el enganche elegido y nunca quedan en negativo - Cotizador: buscador de cliente en Monday (lead u oportunidad) que prellena nombre/contacto/desarrollo y vincula la cotización a la oportunidad al guardar - Alta Afluencia: buscador que jala nombre, contacto, origen, desarrollo y modelo desde leads y oportunidades existentes - buscarLeads devuelve desarrollo y modelo de interés - Landing: el módulo Clientes vuelve a llamarse Cobranza - Tests: 456 API + 94 cotizador + 14 mi-dia + 116 alta-clientes, verde
This commit is contained in:
@@ -156,6 +156,16 @@ export const ETIQUETA_FASE_OPORTUNIDAD: Record<FaseOportunidad, string> = {
|
||||
Abandono: 'Abandono',
|
||||
}
|
||||
|
||||
// Slots de la precarga de documentos en "Mis apartados"; los obligatorios son
|
||||
// los que pide el proceso de venta (captura del cotizador y de pago apartado)
|
||||
export const SLOTS_DOCS_APARTADO = [
|
||||
{ tipo: 'CAPTURA_COTIZADOR', etiqueta: 'Captura del cotizador', obligatorio: true },
|
||||
{ tipo: 'CAPTURA_PAGO_APARTADO', etiqueta: 'Captura de pago apartado', obligatorio: true },
|
||||
{ tipo: 'OTRO', etiqueta: 'Documentos generales (INE, CURP, comprobantes…)', obligatorio: false },
|
||||
] as const
|
||||
|
||||
export type SlotDocApartado = (typeof SLOTS_DOCS_APARTADO)[number]['tipo']
|
||||
|
||||
/**
|
||||
* URL del cotizador (/quote/) con los datos del cliente prellenados y el id de
|
||||
* la oportunidad: al guardar, la cotización queda vinculada a ese cliente en
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
FASES_APARTADO,
|
||||
MOTIVOS_NO_COMPRA,
|
||||
ORIGENES_LEAD,
|
||||
SLOTS_DOCS_APARTADO,
|
||||
textoDiasRestantes,
|
||||
urlCotizadorParaOportunidad,
|
||||
type ApartadoMiDia,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
type MotivoNoCompra,
|
||||
type OportunidadMiDia,
|
||||
type ResumenMiDia,
|
||||
type SlotDocApartado,
|
||||
} from '../lib/mi-dia'
|
||||
import {
|
||||
cambiarEstadoApartado,
|
||||
@@ -28,6 +30,9 @@ import {
|
||||
crearLead,
|
||||
descartarLead,
|
||||
fetchResumenMiDia,
|
||||
listarDocumentosApartado,
|
||||
subirDocumentoApartado,
|
||||
type DocumentoApartado,
|
||||
} from '../services/mi-dia'
|
||||
import { getSesion } from '../services/auth'
|
||||
|
||||
@@ -234,6 +239,7 @@ function FilaApartado({
|
||||
onFase: (fase: FaseApartado) => void
|
||||
enviando: boolean
|
||||
}) {
|
||||
const [docsAbiertos, setDocsAbiertos] = useState(false)
|
||||
return (
|
||||
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -276,11 +282,119 @@ function FilaApartado({
|
||||
{ETIQUETA_FASE_APARTADO[fase]}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDocsAbiertos((v) => !v)}
|
||||
aria-expanded={docsAbiertos}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
{docsAbiertos ? 'Cerrar documentos' : 'Documentos'}
|
||||
</button>
|
||||
</div>
|
||||
{docsAbiertos && <PanelDocumentosApartado itemId={apartado.id} />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Precarga de documentos del expediente desde "Mis apartados": captura del
|
||||
* cotizador, captura de pago apartado (ambos obligatorios) y documentos
|
||||
* generales. Los archivos se guardan en el cliente ligado al apartado.
|
||||
*/
|
||||
function PanelDocumentosApartado({ itemId }: { itemId: string }) {
|
||||
const [documentos, setDocumentos] = useState<DocumentoApartado[] | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [subiendo, setSubiendo] = useState<SlotDocApartado | null>(null)
|
||||
|
||||
const cargar = useCallback(async () => {
|
||||
try {
|
||||
setDocumentos(await listarDocumentosApartado(itemId))
|
||||
setError('')
|
||||
} catch {
|
||||
setError('No se pudieron cargar los documentos.')
|
||||
}
|
||||
}, [itemId])
|
||||
|
||||
useEffect(() => {
|
||||
void cargar()
|
||||
}, [cargar])
|
||||
|
||||
const subir = async (tipo: SlotDocApartado, archivo: File | undefined) => {
|
||||
if (!archivo) return
|
||||
setSubiendo(tipo)
|
||||
setError('')
|
||||
try {
|
||||
await subirDocumentoApartado(itemId, tipo, archivo)
|
||||
await cargar()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'No se pudo subir el archivo.')
|
||||
} finally {
|
||||
setSubiendo(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border border-dashed border-[#D1D5DB] bg-[#F8FAFC] p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
Expediente del cliente
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<ul className="mt-3 space-y-3">
|
||||
{SLOTS_DOCS_APARTADO.map((slot) => {
|
||||
const delSlot = (documentos ?? []).filter((d) => d.tipo === slot.tipo)
|
||||
return (
|
||||
<li key={slot.tipo}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-xs font-medium text-[#374151]">
|
||||
{slot.etiqueta}
|
||||
{slot.obligatorio && <span className="ml-1 text-red-600">*</span>}
|
||||
</p>
|
||||
<label
|
||||
className={`cursor-pointer rounded-lg border border-accent bg-white px-3 py-1.5 text-xs font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white ${
|
||||
subiendo !== null ? 'pointer-events-none opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
{subiendo === slot.tipo ? 'Subiendo…' : 'Subir archivo'}
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
className="sr-only"
|
||||
disabled={subiendo !== null}
|
||||
onChange={(e) => {
|
||||
void subir(slot.tipo, e.target.files?.[0])
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{documentos === null ? (
|
||||
<p className="mt-1 text-[11px] text-[#94A3B8]">Cargando…</p>
|
||||
) : delSlot.length === 0 ? (
|
||||
<p className="mt-1 text-[11px] text-[#94A3B8]">Sin archivos</p>
|
||||
) : (
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{delSlot.map((d) => (
|
||||
<li key={d.id} className="text-[11px] text-[#64748B]">
|
||||
{d.nombreArchivo} · {formatoFechaCorta(d.createdAt.slice(0, 10))}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<p className="mt-3 text-[11px] text-[#94A3B8]">
|
||||
* Obligatorios para el proceso de venta. PDF, JPG o PNG, máximo 10 MB.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados */
|
||||
type TabCrm = 'leads' | 'oportunidades' | 'apartados'
|
||||
|
||||
|
||||
@@ -85,3 +85,30 @@ export function cambiarEstadoApartado(itemId: string, estado: string): Promise<u
|
||||
body: JSON.stringify({ estado }),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Precarga de documentos del expediente (Mis apartados) ----------
|
||||
|
||||
export interface DocumentoApartado {
|
||||
id: string
|
||||
tipo: string // CAPTURA_COTIZADOR | CAPTURA_PAGO_APARTADO | OTRO | INE | ...
|
||||
nombreArchivo: string
|
||||
createdAt: string // ISO
|
||||
}
|
||||
|
||||
/** GET /apartados/:itemId/documentos — documentos ya cargados del cliente ligado */
|
||||
export function listarDocumentosApartado(itemId: string): Promise<DocumentoApartado[]> {
|
||||
return fetchJson<DocumentoApartado[]>(`/apartados/${itemId}/documentos`)
|
||||
}
|
||||
|
||||
/** 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()
|
||||
form.append('tipo', tipo)
|
||||
form.append('archivos', archivo)
|
||||
const res = await fetch(`${API_URL}/apartados/${itemId}/documentos`, conAuth({ method: 'POST', body: form }))
|
||||
manejar401(res)
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => null)) as { error?: string } | null
|
||||
throw new Error(body?.error ?? `Error ${res.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user