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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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