- 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
383 lines
12 KiB
TypeScript
383 lines
12 KiB
TypeScript
import type { AltaCliente } from '../types'
|
|
import { conAuth, manejar401 } from './auth'
|
|
|
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
|
|
// TODO produccion: '/api' same-origin detras de NGINX
|
|
|
|
export interface AltaClienteCreada {
|
|
id: string
|
|
}
|
|
|
|
/** Resultado del registro en el API: distingue fallo de red de rechazo por validación. */
|
|
export type ResultadoAlta =
|
|
| { ok: true; id: string }
|
|
| { ok: false; motivo: 'conexion' | 'validacion'; detalle?: string }
|
|
|
|
interface DocumentoMeta {
|
|
tipo: string
|
|
nombre: string
|
|
tamano: number
|
|
mime: string
|
|
}
|
|
|
|
/**
|
|
* Serializa el alta para el POST JSON: los File no viajan en el cuerpo,
|
|
* solo sus metadatos; la firma ya es un string base64 (data URL).
|
|
*/
|
|
const serializarAlta = (payload: AltaCliente) => ({
|
|
...payload,
|
|
documentos: payload.documentos.map(
|
|
(d): DocumentoMeta => ({ tipo: d.slot, nombre: d.nombre, tamano: d.tamano, mime: d.tipoMime }),
|
|
),
|
|
})
|
|
|
|
/**
|
|
* POST JSON del alta contra `ruta` (`/clientes` o `/clientes/progreso/:id/completar`).
|
|
* Si el API no responde devuelve motivo 'conexion' y el flujo continúa solo
|
|
* local: los PDFs se generan igual. Si el API rechaza por validación (400)
|
|
* devuelve motivo 'validacion' con el detalle.
|
|
*/
|
|
async function enviarAlta(ruta: string, payload: AltaCliente): Promise<ResultadoAlta> {
|
|
let res: Response
|
|
try {
|
|
res = await fetch(
|
|
`${API_URL}${ruta}`,
|
|
conAuth({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(serializarAlta(payload)),
|
|
}),
|
|
)
|
|
} catch (err) {
|
|
console.warn('[api] alta: sin conexión al servidor, el expediente se genera solo localmente', err)
|
|
return { ok: false, motivo: 'conexion' }
|
|
}
|
|
manejar401(res)
|
|
if (res.ok) {
|
|
const data = (await res.json()) as { id: string }
|
|
return { ok: true, id: data.id }
|
|
}
|
|
if (res.status === 400) {
|
|
// El API rechazó por validación: NO es un problema de conexión.
|
|
const cuerpo = (await res.json().catch(() => null)) as {
|
|
issues?: { path: (string | number)[]; message: string }[]
|
|
} | null
|
|
const detalle = cuerpo?.issues?.map((i) => `${i.path.join('.')}: ${i.message}`).join(' · ')
|
|
console.warn('[api] alta: validación rechazada', detalle)
|
|
return { ok: false, motivo: 'validacion', detalle: detalle ?? 'Datos incompletos o inválidos' }
|
|
}
|
|
console.warn(`[api] alta: HTTP ${res.status}`)
|
|
return { ok: false, motivo: 'conexion' }
|
|
}
|
|
|
|
/**
|
|
* Registra el alta en el API Gateway (POST /clientes). El backend persiste en
|
|
* SQLite y replica en Monday.
|
|
*/
|
|
export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoAlta> {
|
|
return enviarAlta('/clientes', payload)
|
|
}
|
|
|
|
// ---------- Progreso del alta etapa por etapa (auth) ----------
|
|
|
|
export interface ProgresoIniciado {
|
|
id: string
|
|
mondayContactoId: string | null
|
|
}
|
|
|
|
/** POST /clientes/progreso/iniciar — crea el alta en progreso (Contacto PROSPECTO en Monday) */
|
|
export async function iniciarProgreso(payload: {
|
|
datosGenerales: unknown
|
|
leadMondayId?: string
|
|
}): Promise<ProgresoIniciado> {
|
|
const res = await fetch(
|
|
`${API_URL}/clientes/progreso/iniciar`,
|
|
conAuth({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
)
|
|
manejar401(res)
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
return (await res.json()) as ProgresoIniciado
|
|
}
|
|
|
|
/** PATCH /clientes/progreso/:id — guarda el avance del wizard y lo replica en el Contacto */
|
|
export async function actualizarProgreso(id: string, datos: unknown): Promise<void> {
|
|
const res = await fetch(
|
|
`${API_URL}/clientes/progreso/${id}`,
|
|
conAuth({
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(datos),
|
|
}),
|
|
)
|
|
manejar401(res)
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
}
|
|
|
|
/** POST /clientes/progreso/:id/completar — cierra el alta (mismo contrato que POST /clientes) */
|
|
export async function completarProgreso(id: string, payload: AltaCliente): Promise<ResultadoAlta> {
|
|
return enviarAlta(`/clientes/progreso/${id}/completar`, payload)
|
|
}
|
|
|
|
/**
|
|
* Sube los documentos reales del expediente (POST multipart
|
|
* /clientes/:id/documentos, campo 'archivos'). El backend acepta un solo
|
|
* `tipo` por petición, así que se envía una petición por documento con su
|
|
* slot como tipo. Los fallos se reportan con console.warn sin interrumpir
|
|
* el flujo local.
|
|
*/
|
|
export async function subirDocumentos(clienteId: string, payload: AltaCliente): Promise<void> {
|
|
for (const doc of payload.documentos) {
|
|
try {
|
|
const form = new FormData()
|
|
form.append('tipo', doc.slot)
|
|
form.append('archivos', doc.archivo, doc.nombre)
|
|
const res = await fetch(
|
|
`${API_URL}/clientes/${clienteId}/documentos`,
|
|
conAuth({
|
|
method: 'POST',
|
|
body: form,
|
|
}),
|
|
)
|
|
manejar401(res)
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
} catch (err) {
|
|
console.warn(`[api] subirDocumentos: no se pudo subir ${doc.nombre} (${doc.slot})`, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- Borradores del alta (auth) ----------
|
|
|
|
export interface BorradorResumen {
|
|
id: string
|
|
nombre: string
|
|
paso: number
|
|
createdAt: string
|
|
updatedAt: string
|
|
}
|
|
|
|
export interface Borrador extends BorradorResumen {
|
|
datosJson: string
|
|
}
|
|
|
|
/** GET /borradores — los míos, sin datosJson */
|
|
export async function listarBorradores(): Promise<BorradorResumen[]> {
|
|
const res = await fetch(`${API_URL}/borradores`, conAuth())
|
|
manejar401(res)
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
return (await res.json()) as BorradorResumen[]
|
|
}
|
|
|
|
/** POST /borradores — upsert: con `id` actualiza, sin `id` crea */
|
|
export async function guardarBorrador(payload: {
|
|
id?: string
|
|
paso: number
|
|
datos: unknown
|
|
}): Promise<Borrador> {
|
|
const res = await fetch(
|
|
`${API_URL}/borradores`,
|
|
conAuth({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
)
|
|
manejar401(res)
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
return (await res.json()) as Borrador
|
|
}
|
|
|
|
/** GET /borradores/:id — borrador completo */
|
|
export async function obtenerBorrador(id: string): Promise<Borrador> {
|
|
const res = await fetch(`${API_URL}/borradores/${id}`, conAuth())
|
|
manejar401(res)
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
return (await res.json()) as Borrador
|
|
}
|
|
|
|
/** DELETE /borradores/:id */
|
|
export async function eliminarBorrador(id: string): Promise<void> {
|
|
const res = await fetch(`${API_URL}/borradores/${id}`, conAuth({ method: 'DELETE' }))
|
|
manejar401(res)
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
}
|
|
|
|
// ---------- Pre-llenado desde cotización (auth) ----------
|
|
|
|
/** GET /cotizaciones/:id — datos para prellenar el alta (?cotizacion=<id>) */
|
|
export async function obtenerCotizacion(id: string): Promise<import('../lib/cotizacionAlta').CotizacionParaAlta> {
|
|
const res = await fetch(`${API_URL}/cotizaciones/${id}`, conAuth())
|
|
manejar401(res)
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
return (await res.json()) as import('../lib/cotizacionAlta').CotizacionParaAlta
|
|
}
|
|
|
|
// ---------- Pre-llenado desde lead de Monday (auth) ----------
|
|
|
|
export interface LeadResumen {
|
|
id: string
|
|
nombre: string
|
|
telefono: string
|
|
email: string
|
|
estado: string
|
|
origen: string
|
|
/** 'lead' = archivo histórico (tablero Leads); 'oportunidad' = modelo nuevo (tablero Oportunidades) */
|
|
fuente: 'lead' | 'oportunidad'
|
|
/** Desarrollo de interés ('' si no tiene); prellena la afluencia */
|
|
desarrollo?: string
|
|
/** Modelo de interés ('' si no tiene) */
|
|
modeloInteres?: string
|
|
}
|
|
|
|
/** GET /leads/buscar?q= — autocompletado para prellenar el alta (busca en AMBOS tableros) */
|
|
export async function buscarLeads(q: string): Promise<LeadResumen[]> {
|
|
const res = await fetch(`${API_URL}/leads/buscar?q=${encodeURIComponent(q)}`, conAuth())
|
|
manejar401(res)
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
return (await res.json()) as LeadResumen[]
|
|
}
|
|
|
|
export interface LeadCreado {
|
|
/** id de la oportunidad creada (la afluencia ES la oportunidad: id === oportunidadId) */
|
|
id: string
|
|
estado: string // 'Afluencia'
|
|
oportunidadId: string | null
|
|
/** 'capturista' = asignado a quien lo registró; 'round-robin' = entra al reparto */
|
|
asignacion?: 'capturista' | 'round-robin'
|
|
}
|
|
|
|
/** Resultado del alta de afluencia: distingue duplicado (409) de fallo de red */
|
|
export type ResultadoCrearLead =
|
|
| { ok: true; lead: LeadCreado }
|
|
| { ok: false; motivo: 'duplicado'; leadId: string; nombre: string }
|
|
| { ok: false; motivo: 'conexion' }
|
|
|
|
/**
|
|
* POST /afluencias — Alta Afluencia (forma completa): crea UN item en el
|
|
* tablero Oportunidades (etapa Afluencia, con todo el perfil; la asigna el
|
|
* capturista o el round-robin). Si el teléfono ya existe el API responde 409
|
|
* con la oportunidad existente (motivo 'duplicado').
|
|
*/
|
|
export async function crearAfluencia(payload: {
|
|
nombre: string
|
|
telefono: string
|
|
email?: string
|
|
origen?: string
|
|
desarrollo?: string
|
|
notas?: string
|
|
modelo?: string
|
|
medioContacto?: string
|
|
edad?: string
|
|
estadoCivil?: string
|
|
tipoEmpleo?: string
|
|
ocupacion?: string
|
|
rangoSalarial?: string
|
|
credito?: string
|
|
tipoEconomia?: string
|
|
califica?: string
|
|
loQuierePara?: string
|
|
}): Promise<ResultadoCrearLead> {
|
|
let res: Response
|
|
try {
|
|
res = await fetch(
|
|
`${API_URL}/afluencias`,
|
|
conAuth({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
)
|
|
} catch (err) {
|
|
console.warn('[api] crearAfluencia: sin conexión al servidor', err)
|
|
return { ok: false, motivo: 'conexion' }
|
|
}
|
|
manejar401(res)
|
|
if (res.status === 201) {
|
|
return { ok: true, lead: (await res.json()) as LeadCreado }
|
|
}
|
|
if (res.status === 409) {
|
|
const cuerpo = (await res.json()) as { leadId: string; nombre: string }
|
|
return { ok: false, motivo: 'duplicado', leadId: cuerpo.leadId, nombre: cuerpo.nombre }
|
|
}
|
|
console.warn(`[api] crearAfluencia: HTTP ${res.status}`)
|
|
return { ok: false, motivo: 'conexion' }
|
|
}
|
|
|
|
// ---------- Liga remota de firma (auth) ----------
|
|
|
|
export interface LigaRemota {
|
|
token: string
|
|
url: string // ruta pública relativa: /client/firma/<token>
|
|
expiraEn: string
|
|
}
|
|
|
|
/** POST /clientes/:id/liga — genera la liga pública de firma/documentos */
|
|
export async function generarLigaRemota(clienteId: string): Promise<LigaRemota> {
|
|
const res = await fetch(`${API_URL}/clientes/${clienteId}/liga`, conAuth({ method: 'POST' }))
|
|
manejar401(res)
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
return (await res.json()) as LigaRemota
|
|
}
|
|
|
|
// ---------- Endpoints públicos de la liga (SIN auth; el token va en la URL) ----------
|
|
|
|
export interface DocumentoPublico {
|
|
tipo: string
|
|
nombreArchivo: string
|
|
createdAt: string
|
|
}
|
|
|
|
export interface ClientePublico {
|
|
nombre: string
|
|
proyecto: string
|
|
unidad: string | null
|
|
firma: boolean
|
|
documentos: DocumentoPublico[]
|
|
}
|
|
|
|
/** Error de liga inválida/expirada con mensaje para el cliente final */
|
|
export class ErrorLiga extends Error {
|
|
constructor(message = 'La liga es inválida o ya expiró. Pide una nueva a tu asesor.') {
|
|
super(message)
|
|
this.name = 'ErrorLiga'
|
|
}
|
|
}
|
|
|
|
const manejarErrorLiga = (res: Response): void => {
|
|
if (res.status === 401 || res.status === 404) throw new ErrorLiga()
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
}
|
|
|
|
/** GET /clientes/publico/:token — solo nombre, proyecto, unidad y estado */
|
|
export async function obtenerClientePublico(token: string): Promise<ClientePublico> {
|
|
const res = await fetch(`${API_URL}/clientes/publico/${token}`)
|
|
manejarErrorLiga(res)
|
|
return (await res.json()) as ClientePublico
|
|
}
|
|
|
|
/** POST /clientes/publico/:token/firma — guarda la firma (PNG data URL) */
|
|
export async function enviarFirmaRemota(token: string, firmaDataUrl: string): Promise<void> {
|
|
const res = await fetch(`${API_URL}/clientes/publico/${token}/firma`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ firmaDataUrl }),
|
|
})
|
|
manejarErrorLiga(res)
|
|
}
|
|
|
|
/** POST /clientes/publico/:token/documentos — adjunta UN documento por slot */
|
|
export async function subirDocumentoRemoto(token: string, tipo: string, archivo: File): Promise<void> {
|
|
const form = new FormData()
|
|
form.append('tipo', tipo)
|
|
form.append('archivos', archivo, archivo.name)
|
|
const res = await fetch(`${API_URL}/clientes/publico/${token}/documentos`, {
|
|
method: 'POST',
|
|
body: form,
|
|
})
|
|
manejarErrorLiga(res)
|
|
}
|