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:
@@ -48,6 +48,12 @@ export interface ResultadoListado {
|
||||
limite: number
|
||||
}
|
||||
|
||||
/** Valores reales de la base para armar los filtros (no listas fijas) */
|
||||
export interface CatalogosClientesMaestro {
|
||||
desarrollos: string[]
|
||||
estatuses: string[]
|
||||
}
|
||||
|
||||
// ─── API calls ──────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/clientes-maestro — listar con filtros y paginación */
|
||||
@@ -64,6 +70,17 @@ export async function listarClientesMaestro(filtros: FiltrosClientesMaestro = {}
|
||||
return (await resp.json()) as ResultadoListado
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/clientes-maestro/catalogos — desarrollos y estatus que existen hoy
|
||||
* en la base; el front arma los selectores de filtro con esto.
|
||||
*/
|
||||
export async function obtenerCatalogosClientesMaestro(): Promise<CatalogosClientesMaestro> {
|
||||
const resp = await fetch(`${API_URL}/clientes-maestro/catalogos`, conAuth())
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
return (await resp.json()) as CatalogosClientesMaestro
|
||||
}
|
||||
|
||||
/** GET /api/clientes-maestro/exportar — array de objetos para generar CSV */
|
||||
export async function exportarClientesMaestro(filtros: Omit<FiltrosClientesMaestro, 'pagina' | 'limite'> = {}): Promise<ClienteMaestro[]> {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
176
apps/landing/src/services/soporte.test.ts
Normal file
176
apps/landing/src/services/soporte.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ESTADOS_SOPORTE,
|
||||
cambiarEstadoSolicitud,
|
||||
crearSolicitudSoporte,
|
||||
descargarArchivoSolicitud,
|
||||
listarSolicitudesSoporte,
|
||||
nombreArchivoSoporte,
|
||||
validarAdjuntoSoporte,
|
||||
MAX_BYTES_ADJUNTO_SOPORTE,
|
||||
} from './soporte'
|
||||
|
||||
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
const jsonOk = (data: unknown) =>
|
||||
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
const solicitudFake = {
|
||||
id: 'sol-1',
|
||||
titulo: 'Impresora',
|
||||
descripcion: 'No imprime',
|
||||
departamento: 'Sistemas',
|
||||
prioridad: 'Media',
|
||||
responsable: null,
|
||||
fechaSolicitud: '2026-09-21T00:00:00.000Z',
|
||||
fechaLimite: null,
|
||||
notas: null,
|
||||
archivos: ['soporte/sol-1/a1b2c3d4-factura.pdf'],
|
||||
solicitante: 'Vendedor',
|
||||
solicitanteEmail: 'vendedor@frasa.mx',
|
||||
estado: 'ABIERTA',
|
||||
createdAt: '2026-09-21T00:00:00.000Z',
|
||||
updatedAt: '2026-09-21T00:00:00.000Z',
|
||||
}
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<FetchFn>()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// ── crearSolicitudSoporte (multipart con adjuntos reales) ────────────
|
||||
|
||||
describe('crearSolicitudSoporte', () => {
|
||||
it('manda multipart con los campos y los archivos adjuntos', async () => {
|
||||
fetchMock.mockResolvedValue(jsonOk(solicitudFake))
|
||||
const archivo = new File(['%PDF-1.4'], 'factura.pdf', { type: 'application/pdf' })
|
||||
|
||||
await crearSolicitudSoporte({
|
||||
titulo: 'Impresora',
|
||||
descripcion: 'No imprime',
|
||||
departamento: 'Sistemas',
|
||||
prioridad: 'Media',
|
||||
fechaSolicitud: '2026-09-21',
|
||||
email: 'vendedor@frasa.mx',
|
||||
archivos: [archivo],
|
||||
})
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]!
|
||||
expect(String(url)).toBe('/api/soporte')
|
||||
expect(init?.method).toBe('POST')
|
||||
const form = init?.body as FormData
|
||||
expect(form).toBeInstanceOf(FormData)
|
||||
expect(form.get('titulo')).toBe('Impresora')
|
||||
expect(form.get('fechaSolicitud')).toBe('2026-09-21')
|
||||
expect(form.get('email')).toBe('vendedor@frasa.mx')
|
||||
const adjuntos = form.getAll('archivos')
|
||||
expect(adjuntos).toHaveLength(1)
|
||||
expect((adjuntos[0] as File).name).toBe('factura.pdf')
|
||||
})
|
||||
|
||||
it('muestra el mensaje real del servidor con los issues de validación', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: 'Validación fallida',
|
||||
issues: [{ path: ['fechaSolicitud'], message: 'Invalid date' }],
|
||||
}),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
)
|
||||
|
||||
await expect(
|
||||
crearSolicitudSoporte({
|
||||
titulo: 'Impresora',
|
||||
descripcion: 'No imprime',
|
||||
departamento: 'Sistemas',
|
||||
prioridad: 'Media',
|
||||
fechaSolicitud: '',
|
||||
}),
|
||||
).rejects.toThrow('Validación fallida: fechaSolicitud: Invalid date')
|
||||
})
|
||||
})
|
||||
|
||||
// ── listarSolicitudesSoporte (filtros) ──────────────────────────────
|
||||
|
||||
describe('listarSolicitudesSoporte', () => {
|
||||
it('sin filtros no agrega query', async () => {
|
||||
fetchMock.mockResolvedValue(jsonOk([]))
|
||||
await listarSolicitudesSoporte()
|
||||
expect(String(fetchMock.mock.calls[0]![0])).toBe('/api/soporte')
|
||||
})
|
||||
|
||||
it('manda estado y departamento como query params', async () => {
|
||||
fetchMock.mockResolvedValue(jsonOk([]))
|
||||
await listarSolicitudesSoporte({ estado: 'EN_PROCESO', departamento: 'Cobranza' })
|
||||
expect(String(fetchMock.mock.calls[0]![0])).toBe('/api/soporte?estado=EN_PROCESO&departamento=Cobranza')
|
||||
})
|
||||
|
||||
it('ignora filtros vacíos', async () => {
|
||||
fetchMock.mockResolvedValue(jsonOk([]))
|
||||
await listarSolicitudesSoporte({ estado: '', departamento: '' })
|
||||
expect(String(fetchMock.mock.calls[0]![0])).toBe('/api/soporte')
|
||||
})
|
||||
})
|
||||
|
||||
// ── cambiarEstadoSolicitud ──────────────────────────────────────────
|
||||
|
||||
describe('cambiarEstadoSolicitud', () => {
|
||||
it('hace PATCH con el estado nuevo', async () => {
|
||||
fetchMock.mockResolvedValue(jsonOk({ ...solicitudFake, estado: 'CERRADA' }))
|
||||
const actualizada = await cambiarEstadoSolicitud('sol-1', 'CERRADA')
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]!
|
||||
expect(String(url)).toBe('/api/soporte/sol-1')
|
||||
expect(init?.method).toBe('PATCH')
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ estado: 'CERRADA' })
|
||||
expect(actualizada.estado).toBe('CERRADA')
|
||||
expect(ESTADOS_SOPORTE).toContain('CERRADA')
|
||||
})
|
||||
})
|
||||
|
||||
// ── descargarArchivoSolicitud ───────────────────────────────────────
|
||||
|
||||
describe('descargarArchivoSolicitud', () => {
|
||||
it('pide el adjunto por índice y devuelve el blob', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('contenido', { status: 200 }))
|
||||
const blob = await descargarArchivoSolicitud('sol-1', 0)
|
||||
expect(String(fetchMock.mock.calls[0]![0])).toBe('/api/soporte/sol-1/archivos/0')
|
||||
expect(blob).toBeInstanceOf(Blob)
|
||||
expect(blob.size).toBe('contenido'.length)
|
||||
})
|
||||
|
||||
it('propaga el mensaje del servidor si falla', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: 'Archivo no encontrado en disco' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
await expect(descargarArchivoSolicitud('sol-1', 3)).rejects.toThrow('Archivo no encontrado en disco')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Helpers de UI ───────────────────────────────────────────────────
|
||||
|
||||
describe('helpers de adjuntos', () => {
|
||||
it('valida tipo y tamaño', () => {
|
||||
expect(validarAdjuntoSoporte(new File(['x'], 'a.pdf', { type: 'application/pdf' }))).toBeNull()
|
||||
expect(validarAdjuntoSoporte(new File(['x'], 'a.txt', { type: 'text/plain' }))).toContain('a.txt')
|
||||
const grande = new File(['x'], 'a.pdf', { type: 'application/pdf' })
|
||||
Object.defineProperty(grande, 'size', { value: MAX_BYTES_ADJUNTO_SOPORTE + 1 })
|
||||
expect(validarAdjuntoSoporte(grande)).toContain('10 MB')
|
||||
})
|
||||
|
||||
it('nombreArchivoSoporte quita carpeta y prefijo uuid', () => {
|
||||
expect(nombreArchivoSoporte('soporte/sol-1/a1b2c3d4-factura.pdf')).toBe('factura.pdf')
|
||||
expect(nombreArchivoSoporte('a1b2c3d4-foto.png')).toBe('foto.png')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user