Files
FRASA-CAS/apps/alta-clientes/src/services/api.ts
Consult AS Technology cbd50e2066 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
2026-09-04 22:32:40 +00:00

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)
}