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

@@ -1,5 +1,10 @@
import { useCallback, useEffect, useState } from 'react'
import { listarClientesMaestro, exportarClientesMaestro, type ClienteMaestro } from '../services/clientes-maestro'
import {
listarClientesMaestro,
exportarClientesMaestro,
obtenerCatalogosClientesMaestro,
type ClienteMaestro,
} from '../services/clientes-maestro'
import { ClientesMaestroTable } from '../components/ClientesMaestroTable'
import ModalClienteMaestro from '../components/ModalClienteMaestro'
import toast from 'react-hot-toast'
@@ -8,13 +13,11 @@ interface Props {
onVolver: () => void
}
/** Desarrollos disponibles para el filtro */
const DESARROLLOS = ['', 'SENDA', 'ATELIER', 'ESTANZA', 'DIAGONAL', 'SOLANA'] as const
const ESTATUSES = ['', 'VENDIDO', 'APARTADO', 'ESCRITURADO', 'PENDIENTE', 'CANCELADO'] as const
/**
* Página / clientes-maestro: tabla paginada con filtros de búsqueda,
* desarrollo y estatus. Botón exportar genera CSV desde los datos filtrados.
* desarrollo y estatus. Las opciones de los selectores salen de la base
* (GET /clientes-maestro/catalogos), no de una lista fija. Botón exportar
* genera CSV desde los datos filtrados.
*/
export function ClientesMaestroPage({ onVolver }: Props) {
const [datos, setDatos] = useState<ClienteMaestro[]>([])
@@ -26,6 +29,20 @@ export function ClientesMaestroPage({ onVolver }: Props) {
const [filtroDesarrollo, setFiltroDesarrollo] = useState('')
const [filtroEstatus, setFiltroEstatus] = useState('')
const [busquedaDebounced, setBusquedaDebounced] = useState('')
const [desarrollos, setDesarrollos] = useState<string[]>([])
const [estatuses, setEstatuses] = useState<string[]>([])
// Catálogos reales de la base para los filtros
useEffect(() => {
obtenerCatalogosClientesMaestro()
.then((catalogos) => {
setDesarrollos(catalogos.desarrollos)
setEstatuses(catalogos.estatuses)
})
.catch(() => {
toast.error('No se pudieron cargar los catálogos de filtros.')
})
}, [])
// Debounce búsqueda
useEffect(() => {
@@ -135,7 +152,7 @@ export function ClientesMaestroPage({ onVolver }: Props) {
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
>
<option value="">Todos los desarrollos</option>
{DESARROLLOS.filter(Boolean).map((d) => (
{desarrollos.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
@@ -145,7 +162,7 @@ export function ClientesMaestroPage({ onVolver }: Props) {
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
>
<option value="">Todos los estatus</option>
{ESTATUSES.filter(Boolean).map((e) => (
{estatuses.map((e) => (
<option key={e} value={e}>{e}</option>
))}
</select>

View File

@@ -1,9 +1,16 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react'
import {
DEPARTAMENTOS_SOPORTE,
ESTADOS_SOPORTE,
MAX_ADJUNTOS_SOPORTE,
PRIORIDADES_SOPORTE,
cambiarEstadoSolicitud,
crearSolicitudSoporte,
descargarArchivoSolicitud,
listarSolicitudesSoporte,
nombreArchivoSoporte,
validarAdjuntoSoporte,
type EstadoSoporte,
type SolicitudSoporte,
} from '../services/soporte'
import { getSesion } from '../services/auth'
@@ -23,6 +30,16 @@ const ETIQUETA_ESTADO: Record<string, string> = {
CERRADA: 'Cerrada',
}
const OPCIONES_ESTADO = [
{ valor: '', etiqueta: 'Todos los estatus' },
...ESTADOS_SOPORTE.map((e) => ({ valor: e, etiqueta: ETIQUETA_ESTADO[e] ?? e })),
]
const OPCIONES_DEPARTAMENTO = [
{ valor: '', etiqueta: 'Todos los departamentos' },
...DEPARTAMENTOS_SOPORTE.map((d) => ({ valor: d, etiqueta: d })),
]
const CLASE_ESTADO: Record<string, string> = {
ABIERTA: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300',
EN_PROCESO: 'border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-300',
@@ -39,6 +56,7 @@ function hoyISO(): string {
export function SoportePage({ onVolver }: Props) {
const sesion = getSesion()
const puedeGestionar = sesion?.usuario.rol === 'admin' || sesion?.usuario.rol === 'manager'
const [titulo, setTitulo] = useState('')
const [descripcion, setDescripcion] = useState('')
const [departamento, setDepartamento] = useState<string>(DEPARTAMENTOS_SOPORTE[0])
@@ -47,23 +65,33 @@ export function SoportePage({ onVolver }: Props) {
const [fechaSolicitud, setFechaSolicitud] = useState(hoyISO())
const [fechaLimite, setFechaLimite] = useState('')
const [notas, setNotas] = useState('')
const [archivos, setArchivos] = useState<string[]>([])
const [email, setEmail] = useState('')
const [archivos, setArchivos] = useState<File[]>([])
const [errorArchivos, setErrorArchivos] = useState('')
const [enviando, setEnviando] = useState(false)
const [aviso, setAviso] = useState('')
const [error, setError] = useState('')
const [solicitudes, setSolicitudes] = useState<SolicitudSoporte[]>([])
const [cargando, setCargando] = useState(true)
const [filtroEstado, setFiltroEstado] = useState('')
const [filtroDepartamento, setFiltroDepartamento] = useState('')
const [ocupado, setOcupado] = useState('')
const cargar = useCallback(async () => {
setCargando(true)
try {
setSolicitudes(await listarSolicitudesSoporte())
setSolicitudes(
await listarSolicitudesSoporte({
...(filtroEstado ? { estado: filtroEstado } : {}),
...(filtroDepartamento ? { departamento: filtroDepartamento } : {}),
}),
)
} catch {
// La lista es secundaria: el form sigue usable si el listado falla
} finally {
setCargando(false)
}
}, [])
}, [filtroEstado, filtroDepartamento])
useEffect(() => {
void cargar()
@@ -71,7 +99,19 @@ export function SoportePage({ onVolver }: Props) {
const agregarArchivos = (lista: FileList | null) => {
if (lista === null) return
setArchivos((prev) => [...prev, ...Array.from(lista).map((f) => f.name)])
const nuevos: File[] = []
const rechazados: string[] = []
for (const archivo of Array.from(lista)) {
const problema = validarAdjuntoSoporte(archivo)
if (problema) rechazados.push(problema)
else nuevos.push(archivo)
}
const combinados = [...archivos, ...nuevos]
if (combinados.length > MAX_ADJUNTOS_SOPORTE) {
rechazados.push(`Máximo ${MAX_ADJUNTOS_SOPORTE} archivos por solicitud.`)
}
setArchivos(combinados.slice(0, MAX_ADJUNTOS_SOPORTE))
setErrorArchivos(rechazados.join(' '))
}
const enviar = async (e: FormEvent) => {
@@ -94,6 +134,7 @@ export function SoportePage({ onVolver }: Props) {
...(fechaLimite ? { fechaLimite } : {}),
...(notas.trim() ? { notas: notas.trim() } : {}),
...(sesion?.usuario.nombre ? { solicitante: sesion.usuario.nombre } : {}),
...(email.trim() ? { email: email.trim() } : {}),
...(archivos.length > 0 ? { archivos } : {}),
})
setAviso(`Solicitud registrada (${ETIQUETA_ESTADO[creada.estado] ?? creada.estado}).`)
@@ -102,6 +143,7 @@ export function SoportePage({ onVolver }: Props) {
setResponsable('')
setNotas('')
setArchivos([])
setErrorArchivos('')
setFechaLimite('')
await cargar()
} catch (err) {
@@ -111,6 +153,42 @@ export function SoportePage({ onVolver }: Props) {
}
}
const descargar = async (solicitud: SolicitudSoporte, indice: number) => {
const ruta = solicitud.archivos[indice] ?? ''
setError('')
setOcupado(`${solicitud.id}:${indice}`)
try {
const blob = await descargarArchivoSolicitud(solicitud.id, indice)
const url = URL.createObjectURL(blob)
const enlace = document.createElement('a')
enlace.href = url
enlace.download = nombreArchivoSoporte(ruta)
document.body.appendChild(enlace)
enlace.click()
enlace.remove()
URL.revokeObjectURL(url)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo descargar el archivo.')
} finally {
setOcupado('')
}
}
const cambiarEstado = async (solicitud: SolicitudSoporte, estado: string) => {
setAviso('')
setError('')
setOcupado(solicitud.id)
try {
const actualizada = await cambiarEstadoSolicitud(solicitud.id, estado as EstadoSoporte)
setSolicitudes((prev) => prev.map((s) => (s.id === actualizada.id ? actualizada : s)))
setAviso(`"${actualizada.titulo}" ahora está ${ETIQUETA_ESTADO[actualizada.estado] ?? actualizada.estado}.`)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cambiar el estado.')
} finally {
setOcupado('')
}
}
return (
<main className="w-full flex-1 px-4 py-12 sm:px-6 lg:px-8 sm:py-16">
{/* Header */}
@@ -206,6 +284,18 @@ export function SoportePage({ onVolver }: Props) {
/>
</label>
<label htmlFor="soporte-email" className="block">
<span className={labelClase}>Correo del solicitante</span>
<input
id="soporte-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="nombre@frasa.mx"
className={inputClase}
/>
</label>
<label htmlFor="soporte-fecha-limite" className="block">
<span className={labelClase}>Fecha límite</span>
<input
@@ -218,11 +308,12 @@ export function SoportePage({ onVolver }: Props) {
</label>
<label htmlFor="soporte-archivos" className="block">
<span className={labelClase}>Archivos</span>
<span className={labelClase}>Archivos (PDF, JPG o PNG · máx 10 MB)</span>
<input
id="soporte-archivos"
type="file"
multiple
accept="application/pdf,image/jpeg,image/png"
onChange={(e) => agregarArchivos(e.target.files)}
className={`${inputClase} file:mr-3 file:rounded-md file:border-0 file:bg-[#F1F5F9] file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-[#374151]`}
/>
@@ -242,15 +333,15 @@ export function SoportePage({ onVolver }: Props) {
{archivos.length > 0 && (
<ul className="mt-3 flex flex-wrap gap-2">
{archivos.map((nombre, i) => (
{archivos.map((archivo, i) => (
<li
key={`${nombre}-${i}`}
key={`${archivo.name}-${i}`}
className="flex items-center gap-2 rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] px-3 py-1.5 text-xs text-[#374151] dark:border-[#1E293B] dark:bg-[#1E293B] dark:text-[#CBD5E1]"
>
{nombre}
{archivo.name}
<button
type="button"
aria-label={`Quitar ${nombre}`}
aria-label={`Quitar ${archivo.name}`}
onClick={() => setArchivos((prev) => prev.filter((_, j) => j !== i))}
className="text-[#64748B] hover:text-red-600 dark:text-[#94A3B8]"
>
@@ -261,6 +352,12 @@ export function SoportePage({ onVolver }: Props) {
</ul>
)}
{errorArchivos && (
<p role="alert" className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800">
{errorArchivos}
</p>
)}
{error && (
<p role="alert" className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700">
{error}
@@ -282,14 +379,49 @@ export function SoportePage({ onVolver }: Props) {
</form>
{/* Listado de solicitudes */}
<section aria-label="Mis solicitudes" className="mt-12 max-w-3xl">
<section aria-label={puedeGestionar ? 'Todas las solicitudes' : 'Mis solicitudes'} className="mt-12 max-w-3xl">
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">
{sesion?.usuario.rol === 'admin' || sesion?.usuario.rol === 'manager' ? 'Todas las solicitudes' : 'Mis solicitudes'}
{puedeGestionar ? 'Todas las solicitudes' : 'Mis solicitudes'}
</h2>
{/* Filtros del listado */}
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<label htmlFor="soporte-filtro-estado" className="block">
<span className={labelClase}>Filtrar por estatus</span>
<select
id="soporte-filtro-estado"
value={filtroEstado}
onChange={(e) => setFiltroEstado(e.target.value)}
className={inputClase}
>
{OPCIONES_ESTADO.map((o) => (
<option key={o.valor} value={o.valor}>{o.etiqueta}</option>
))}
</select>
</label>
<label htmlFor="soporte-filtro-departamento" className="block">
<span className={labelClase}>Filtrar por departamento</span>
<select
id="soporte-filtro-departamento"
value={filtroDepartamento}
onChange={(e) => setFiltroDepartamento(e.target.value)}
className={inputClase}
>
{OPCIONES_DEPARTAMENTO.map((o) => (
<option key={o.valor} value={o.valor}>{o.etiqueta}</option>
))}
</select>
</label>
</div>
{cargando ? (
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando…</p>
) : solicitudes.length === 0 ? (
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">Aún no hay solicitudes registradas.</p>
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">
{filtroEstado || filtroDepartamento
? 'Ninguna solicitud coincide con los filtros.'
: 'Aún no hay solicitudes registradas.'}
</p>
) : (
<ul className="mt-4 space-y-3">
{solicitudes.map((s) => (
@@ -299,21 +431,48 @@ export function SoportePage({ onVolver }: Props) {
>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-medium text-[#111827] dark:text-[#E2E8F0]">{s.titulo}</p>
<span
className={`rounded-full border px-2.5 py-0.5 text-xs font-medium ${CLASE_ESTADO[s.estado] ?? 'border-[#E5E7EB] text-[#64748B] dark:border-[#334155] dark:text-[#94A3B8]'}`}
>
{ETIQUETA_ESTADO[s.estado] ?? s.estado}
</span>
<div className="flex items-center gap-2">
<span
className={`rounded-full border px-2.5 py-0.5 text-xs font-medium ${CLASE_ESTADO[s.estado] ?? 'border-[#E5E7EB] text-[#64748B] dark:border-[#334155] dark:text-[#94A3B8]'}`}
>
{ETIQUETA_ESTADO[s.estado] ?? s.estado}
</span>
{puedeGestionar && (
<select
aria-label={`Cambiar estatus de ${s.titulo}`}
value={s.estado}
disabled={ocupado === s.id}
onChange={(e) => void cambiarEstado(s, e.target.value)}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1 text-xs text-[#374151] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
>
{ESTADOS_SOPORTE.map((e) => (
<option key={e} value={e}>{ETIQUETA_ESTADO[e] ?? e}</option>
))}
</select>
)}
</div>
</div>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{s.departamento} · {s.prioridad} · Solicitó {s.solicitante || '—'} ·{' '}
{s.departamento} · {s.prioridad} · Solicitó {s.solicitante || '—'}
{s.solicitanteEmail ? ` (${s.solicitanteEmail})` : ''} ·{' '}
{new Date(s.fechaSolicitud).toLocaleDateString('es-MX')}
</p>
<p className="mt-2 text-sm text-[#374151] dark:text-[#CBD5E1]">{s.descripcion}</p>
{s.archivos.length > 0 && (
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">
Archivos: {s.archivos.join(', ')}
</p>
<ul className="mt-2 flex flex-wrap gap-2">
{s.archivos.map((ruta, i) => (
<li key={`${ruta}-${i}`}>
<button
type="button"
onClick={() => void descargar(s, i)}
disabled={ocupado === `${s.id}:${i}`}
className="rounded-lg border border-[#D1D5DB] bg-[#F9FAFB] px-2.5 py-1 text-xs text-[#374151] transition-colors hover:bg-[#F1F5F9] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
>
⬇ {nombreArchivoSoporte(ruta)}
</button>
</li>
))}
</ul>
)}
</li>
))}

View File

@@ -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()

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

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