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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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=) */ export async function obtenerCotizacion(id: string): Promise { 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 { 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 { 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/ expiraEn: string } /** POST /clientes/:id/liga — genera la liga pública de firma/documentos */ export async function generarLigaRemota(clienteId: string): Promise { 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 { 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 { 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 { 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) }