- Campo 'Medio de contacto' reemplazado por 'Número de dependientes' - Dropdown con opciones: 1, 2, 3, 4, 5 o más - Backend + frontend + tipos API actualizados
395 lines
13 KiB
TypeScript
395 lines
13 KiB
TypeScript
import type { AltaCliente } from '../types'
|
|
import { conAuth, manejar401 } from './auth'
|
|
|
|
const API_URL = import.meta.env.VITE_API_URL ?? '/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
|
|
}
|
|
|
|
/** GET /altas/prefill-apartado/:itemId — todo lo recolectado de un apartado de Monday (?apartado=<id>) */
|
|
export async function obtenerPrefillApartado(itemId: string): Promise<import('../lib/apartadoAlta').PrefillApartado> {
|
|
const res = await fetch(`${API_URL}/altas/prefill-apartado/${itemId}`, conAuth())
|
|
manejar401(res)
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
return (await res.json()) as import('../lib/apartadoAlta').PrefillApartado
|
|
}
|
|
|
|
// ---------- 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'
|
|
/** true cuando se actualizó una oportunidad existente (vino de Mis oportunidades) */
|
|
actualizada?: boolean
|
|
}
|
|
|
|
/** 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
|
|
/** Oportunidad de Monday a actualizar (botón "Afluencia" de Mi día) */
|
|
oportunidadId?: string
|
|
modelo?: string
|
|
numDependientes?: 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)
|
|
}
|