feat(pendientes): cotizador (mensualidades, bloqueo de unidad, totales, numMensualidades), cobranza (recibos, atrasos, mora configurable, recordatorios), soporte (adjuntos, filtros, estados), clientes maestro (catalogos reales), contactos (ficha y duplicados) y permisos de captura por rol

This commit is contained in:
2026-09-21 08:34:53 +00:00
parent b5a20aed4e
commit 2fc80e7f9e
66 changed files with 3792 additions and 436 deletions

View File

@@ -13,8 +13,17 @@ export const DEPARTAMENTOS_SOPORTE = [
/** Prioridades de una solicitud */
export const PRIORIDADES_SOPORTE = ['Baja', 'Media', 'Alta', 'Urgente'] as const
/** Estados por los que pasa una solicitud (los mueve admin/manager) */
export const ESTADOS_SOPORTE = ['ABIERTA', 'EN_PROCESO', 'CERRADA'] as const
export type DepartamentoSoporte = (typeof DEPARTAMENTOS_SOPORTE)[number]
export type PrioridadSoporte = (typeof PRIORIDADES_SOPORTE)[number]
export type EstadoSoporte = (typeof ESTADOS_SOPORTE)[number]
/** Tipos y tamaños de adjunto aceptados (igual que el API) */
export const MIMES_ADJUNTO_SOPORTE = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']
export const MAX_BYTES_ADJUNTO_SOPORTE = 10 * 1024 * 1024
export const MAX_ADJUNTOS_SOPORTE = 10
export interface SolicitudSoporte {
id: string
@@ -26,6 +35,7 @@ export interface SolicitudSoporte {
fechaSolicitud: string
fechaLimite: string | null
notas: string | null
/** Rutas relativas de los adjuntos dentro de uploads/ */
archivos: string[]
solicitante: string
solicitanteEmail: string | null
@@ -34,7 +44,44 @@ export interface SolicitudSoporte {
updatedAt: string
}
/** POST /api/soporte — crear solicitud de soporte */
export interface FiltrosSoporte {
estado?: string
departamento?: string
}
/**
* Valida un adjunto antes de subirlo: devuelve el motivo del rechazo o null si
* es válido (el API valida lo mismo, aquí es para no esperar la subida).
*/
export function validarAdjuntoSoporte(archivo: File): string | null {
if (!MIMES_ADJUNTO_SOPORTE.includes(archivo.type)) {
return `${archivo.name}: solo se aceptan PDF, JPG o PNG.`
}
if (archivo.size > MAX_BYTES_ADJUNTO_SOPORTE) {
return `${archivo.name}: excede 10 MB.`
}
return null
}
/** Nombre legible de un adjunto: sin carpeta ni prefijo uuid */
export function nombreArchivoSoporte(ruta: string): string {
const base = ruta.split(/[\\/]/).pop() ?? ruta
return base.replace(/^[0-9a-f]{8}-/i, '')
}
/** Mensaje real del servidor: el error handler responde { error, issues? } */
async function mensajeDeError(resp: Response): Promise<string> {
const body = (await resp.json().catch(() => null)) as
| { error?: string; issues?: Array<{ path?: Array<string | number>; message?: string }> }
| null
const detalles = (body?.issues ?? [])
.map((i) => [i.path?.join('.'), i.message].filter(Boolean).join(': '))
.filter((d) => d.length > 0)
if (detalles.length > 0) return `${body?.error ?? 'Validación fallida'}: ${detalles.join('; ')}`
return body?.error ?? `HTTP ${resp.status}`
}
/** POST /api/soporte — crear solicitud de soporte (multipart si hay adjuntos) */
export async function crearSolicitudSoporte(payload: {
titulo: string
descripcion: string
@@ -46,28 +93,64 @@ export async function crearSolicitudSoporte(payload: {
notas?: string
solicitante?: string
email?: string
archivos?: string[]
/** Adjuntos reales: viajan en el multipart, el API los guarda en disco */
archivos?: File[]
}): Promise<SolicitudSoporte> {
const resp = await fetch(`${API_URL}/soporte`, conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}))
const form = new FormData()
form.set('titulo', payload.titulo)
form.set('descripcion', payload.descripcion)
form.set('departamento', payload.departamento)
form.set('prioridad', payload.prioridad)
form.set('fechaSolicitud', payload.fechaSolicitud)
if (payload.responsable) form.set('responsable', payload.responsable)
if (payload.fechaLimite) form.set('fechaLimite', payload.fechaLimite)
if (payload.notas) form.set('notas', payload.notas)
if (payload.solicitante) form.set('solicitante', payload.solicitante)
if (payload.email) form.set('email', payload.email)
for (const archivo of payload.archivos ?? []) form.append('archivos', archivo)
// Sin Content-Type explícito: el navegador pone el boundary del multipart
const resp = await fetch(`${API_URL}/soporte`, conAuth({ method: 'POST', body: form }))
manejar401(resp)
if (!resp.ok) {
const body = (await resp.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `HTTP ${resp.status}`)
}
if (!resp.ok) throw new Error(await mensajeDeError(resp))
return (await resp.json()) as SolicitudSoporte
}
/** GET /api/soporte — solicitudes propias (admin: todas) */
export async function listarSolicitudesSoporte(): Promise<SolicitudSoporte[]> {
const resp = await fetch(`${API_URL}/soporte`, conAuth())
/** GET /api/soporte — solicitudes propias (admin/manager: todas) con filtros */
export async function listarSolicitudesSoporte(filtros: FiltrosSoporte = {}): Promise<SolicitudSoporte[]> {
const params = new URLSearchParams()
if (filtros.estado) params.set('estado', filtros.estado)
if (filtros.departamento) params.set('departamento', filtros.departamento)
const qs = params.toString()
const resp = await fetch(`${API_URL}/soporte${qs ? `?${qs}` : ''}`, conAuth())
manejar401(resp)
if (!resp.ok) {
const body = (await resp.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `HTTP ${resp.status}`)
}
if (!resp.ok) throw new Error(await mensajeDeError(resp))
return (await resp.json()) as SolicitudSoporte[]
}
/** PATCH /api/soporte/:id — cambiar el estado (admin/manager) */
export async function cambiarEstadoSolicitud(id: string, estado: EstadoSoporte): Promise<SolicitudSoporte> {
const resp = await fetch(
`${API_URL}/soporte/${encodeURIComponent(id)}`,
conAuth({
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ estado }),
}),
)
manejar401(resp)
if (!resp.ok) throw new Error(await mensajeDeError(resp))
return (await resp.json()) as SolicitudSoporte
}
/** GET /api/soporte/:id/archivos/:indice — descarga un adjunto (blob) */
export async function descargarArchivoSolicitud(id: string, indice: number): Promise<Blob> {
const resp = await fetch(
`${API_URL}/soporte/${encodeURIComponent(id)}/archivos/${indice}`,
conAuth(),
)
manejar401(resp)
if (!resp.ok) throw new Error(await mensajeDeError(resp))
return await resp.blob()
}