feat(alta): borrador, pre-llenado desde cotización y liga remota de firma

- Borradores por vendedor (guardar/continuar/eliminar), se limpia al registrar
- Pre-llenado con ?cotizacion=<id> (cliente, contacto, proyecto, montos);
  botón 'Iniciar alta' en Mis cotizaciones del cotizador
- Liga remota pública /client/firma/<token> (JWT scope firma-remota, 72h,
  rate limit, solo datos mínimos sin PLD): firma digital + subida de
  documentos que prende checkboxes en Monday Apartado
- Tokens de liga no sirven como sesión y viceversa
- Tests: api 228, alta 82, cotizador 82
This commit is contained in:
2026-08-28 07:29:22 +00:00
parent 4f31d1c171
commit 59944668a8
18 changed files with 1589 additions and 33 deletions

View File

@@ -97,3 +97,143 @@ export async function subirDocumentos(clienteId: string, payload: AltaCliente):
}
}
}
// ---------- 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
}
// ---------- 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)
}