diff --git a/api-gateway/src/config/vocabulario.ts b/api-gateway/src/config/vocabulario.ts index 35c4fd5..2fa7823 100644 --- a/api-gateway/src/config/vocabulario.ts +++ b/api-gateway/src/config/vocabulario.ts @@ -60,8 +60,27 @@ export const VOCABULARIO = { // Labels oficiales de desarrollo (verificados el 2026-09-04; SOLE y MUTUO // agregados 2026-09 — existen en captura e inventario) DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA', 'SOLE', 'MUTUO'], + // Campos de calificación que hacen "completa" una afluencia: sin ellos la + // oportunidad NO puede avanzar a 'Aparto' (regla de negocio del cliente) + CAMPOS_AFLUENCIA_REQUERIDOS: ['estadoCivil', 'tipoEmpleo', 'rangoSalarial', 'tipoEconomia', 'califica', 'loQuierePara'], } as const +/** Campos de afluencia faltantes en un datosJson ('[]' = afluencia completa) */ +export function camposAfluenciaFaltantes(datosJson: string | null): string[] { + let datos: Record = {} + if (datosJson) { + try { datos = JSON.parse(datosJson) as Record } catch { datos = {} } + } + return VOCABULARIO.CAMPOS_AFLUENCIA_REQUERIDOS.filter( + (c) => typeof datos[c] !== 'string' || (datos[c] as string).trim() === '', + ) +} + +/** ¿La fila tiene la afluencia completa? (gate de 'Aparto' y flag de Mi día) */ +export function afluenciaCompleta(datosJson: string | null): boolean { + return camposAfluenciaFaltantes(datosJson).length === 0 +} + export type FaseLead = (typeof VOCABULARIO.FASES_LEAD)[number] export type FaseOportunidad = (typeof VOCABULARIO.FASES_OPORTUNIDAD)[number] export type FaseApartado = (typeof VOCABULARIO.FASES_APARTADO)[number] diff --git a/api-gateway/src/controllers/apartados.controller.ts b/api-gateway/src/controllers/apartados.controller.ts index 324e138..de46855 100644 --- a/api-gateway/src/controllers/apartados.controller.ts +++ b/api-gateway/src/controllers/apartados.controller.ts @@ -22,6 +22,8 @@ const cambiarEstadoSchema = z.object({ motivo: z.string().optional(), subcategoria: z.string().optional(), comentario: z.string().optional(), + // En 'Cita Clientes' puede setear la fecha y hora de la cita + fecha: z.string().optional(), }) /** @@ -33,9 +35,32 @@ export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response) const body = cambiarEstadoSchema.parse(req.body) const itemId = String(req.params['itemId']) await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', { + fecha: body.fecha, usuarioId: req.usuarioToken?.sub, sombra: !esIdLocal(itemId), }) + // 'Cita Clientes' con fecha: agenda la cita en el calendario del vendedor del + // apartado (ActividadLocal ligada a la captura; reaperturas reagendan: se + // cierran las citas abiertas previas de ese apartado y se crea la nueva). + if (body.estado === 'Cita Clientes' && body.fecha !== undefined && body.fecha !== '') { + const fila = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } }) + const nombreCliente = fila?.nombreLead ?? 'Cliente' + const usuarioDestino = fila?.usuarioId ?? req.usuarioToken?.sub ?? 'desconocido' + await prisma.actividadLocal.updateMany({ + where: { leadCapturaId: itemId, tipo: 'CITA', estado: 'ABIERTA' }, + data: { estado: 'CERRADA', fechaCierre: new Date() }, + }) + await prisma.actividadLocal.create({ + data: { + titulo: `Cita cliente: ${nombreCliente}`, + descripcion: `Cita de cliente del apartado (${itemId})`, + usuarioId: usuarioDestino, + leadCapturaId: itemId, + fechaVencimiento: new Date(body.fecha), + tipo: 'CITA', + }, + }) + } // Las cancelaciones (apartado y escrituración) guardan el motivo en la fila // para los reportes de cancelación (Cliente/Motivo). const esCancelacion = body.estado === 'Cancelacion Apartado' || body.estado === 'Cancelo' diff --git a/api-gateway/src/controllers/clientes.controller.test.ts b/api-gateway/src/controllers/clientes.controller.test.ts index 514665a..dcdd06c 100644 --- a/api-gateway/src/controllers/clientes.controller.test.ts +++ b/api-gateway/src/controllers/clientes.controller.test.ts @@ -124,7 +124,15 @@ const oportunidadLocal = { email: null, desarrollo: 'SENDA', modelo: 'SOHO', - datosJson: null, + // Afluencia completa: el gate de negocio exige el perfil para apartar + datosJson: JSON.stringify({ + estadoCivil: 'Casado(a)', + tipoEmpleo: 'Asalariado', + rangoSalarial: '20,000-40,000', + tipoEconomia: 'Economía mexicana', + califica: 'Sí', + loQuierePara: 'Para vivir', + }), } describe('crearCliente (POST /api/clientes) — alta local', () => { diff --git a/api-gateway/src/controllers/leads.controller.ts b/api-gateway/src/controllers/leads.controller.ts index cb75a0b..8fef35a 100644 --- a/api-gateway/src/controllers/leads.controller.ts +++ b/api-gateway/src/controllers/leads.controller.ts @@ -1,6 +1,6 @@ import type { Request, Response } from 'express' import { z } from 'zod' -import { VOCABULARIO } from '../config/vocabulario.js' +import { VOCABULARIO, afluenciaCompleta } from '../config/vocabulario.js' import { prisma } from '../services/prisma.js' import { crmLocal, esIdLocal } from '../services/crm-local.service.js' import * as auditoria from '../services/auditoria.service.js' @@ -226,6 +226,26 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi auditoria.registrar(usuarioSub ?? 'sistema', 'CREAR_AFLUENCIA', 'afluencia', resultado.id) } +/** + * GET /api/afluencias/:leadId — perfil de afluencia capturado en el datosJson + * de la fila (lead/oportunidad/apartado). Solo valores de texto no vacíos. + * Alimenta la pestaña "Afluencia" de la bitácora del cliente en Mi día. + */ +export async function obtenerAfluenciaEndpoint(req: Request, res: Response): Promise { + const leadId = String(req.params['leadId']) + const fila = await prisma.leadCaptura.findUnique({ where: { leadId } }) + if (!fila) throw new NotFoundError('No existe el registro solicitado') + let datos: Record = {} + if (fila.datosJson) { + try { datos = JSON.parse(fila.datosJson) as Record } catch { datos = {} } + } + const perfil: Record = {} + for (const [clave, valor] of Object.entries(datos)) { + if (typeof valor === 'string' && valor.trim() !== '') perfil[clave] = valor + } + res.json({ leadId, nombre: fila.nombreLead, perfil, afluenciaCompleta: afluenciaCompleta(fila.datosJson) }) +} + /** * Cambio de estado en modo local: guarda la etapa (label del board) en la fila * local. Con id heredado sin fila local (items viejos del CRM anterior) diff --git a/api-gateway/src/controllers/oportunidades.controller.test.ts b/api-gateway/src/controllers/oportunidades.controller.test.ts index c84037a..763dfab 100644 --- a/api-gateway/src/controllers/oportunidades.controller.test.ts +++ b/api-gateway/src/controllers/oportunidades.controller.test.ts @@ -155,6 +155,8 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa) }) it("'Aparto': crea el apartado local (crmLocal.apartar) con la unidad en datosJson y bloquea la unidad del inventario", async () => { + // La oportunidad YA tiene afluencia completa (gate de negocio: sin + // afluencia no se puede apartar) prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op', usuarioId: 'u-1', @@ -163,7 +165,15 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa) email: null, desarrollo: 'ATELIER', modelo: 'SOHO', - datosJson: JSON.stringify({ unidadCodigo: 'U-101' }), + datosJson: JSON.stringify({ + estadoCivil: 'Casado', + tipoEmpleo: 'Formal', + rangoSalarial: 'B. 20,000-40,000', + tipoEconomia: 'Formal', + califica: 'Si', + loQuierePara: 'Para vivir', + unidadCodigo: 'U-101', + }), }) const { res, json } = resFake() @@ -179,7 +189,15 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa) board: 'APARTADO', etapa: 'Pago Apartado', nombreLead: 'Luis Demo', - datosJson: JSON.stringify({ unidadCodigo: 'U-101' }), + datosJson: JSON.stringify({ + estadoCivil: 'Casado', + tipoEmpleo: 'Formal', + rangoSalarial: 'B. 20,000-40,000', + tipoEconomia: 'Formal', + califica: 'Si', + loQuierePara: 'Para vivir', + unidadCodigo: 'U-101', + }), }), }) // La unidad asignada queda APARTADA en el espejo local, sin pisar vendidas @@ -190,6 +208,26 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa) expect(json).toHaveBeenCalledWith({ itemId: 'loc_op', etapa: 'Aparto' }) }) + it("'Aparto' SIN afluencia completa: se rechaza con 400 antes de crear nada", async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ + leadId: 'loc_op', + usuarioId: 'u-1', + nombreLead: 'Luis Demo', + telefono: '6641112233', + email: null, + desarrollo: 'ATELIER', + modelo: 'SOHO', + datosJson: JSON.stringify({ unidadCodigo: 'U-101' }), + }) + const { res } = resFake() + + await expect( + cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res), + ).rejects.toThrow(/afluencia/i) + expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() + expect(prismaMock.unidad.updateMany).not.toHaveBeenCalled() + }) + it("'Aparto' sin unidad en la oportunidad: la toma de la última cotización ligada", async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op', @@ -199,7 +237,14 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa) email: null, desarrollo: 'SENDA', modelo: null, - datosJson: null, + datosJson: JSON.stringify({ + estadoCivil: 'Casado', + tipoEmpleo: 'Formal', + rangoSalarial: 'B. 20,000-40,000', + tipoEconomia: 'Formal', + califica: 'Si', + loQuierePara: 'Para vivir', + }), }) prismaMock.cotizacion.findFirst.mockResolvedValue({ unidadCodigo: 'SENDA-411-1-7' }) const { res } = resFake() @@ -212,7 +257,17 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa) select: { unidadCodigo: true }, }) expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ datosJson: JSON.stringify({ unidadCodigo: 'SENDA-411-1-7' }) }), + data: expect.objectContaining({ + datosJson: JSON.stringify({ + estadoCivil: 'Casado', + tipoEmpleo: 'Formal', + rangoSalarial: 'B. 20,000-40,000', + tipoEconomia: 'Formal', + califica: 'Si', + loQuierePara: 'Para vivir', + unidadCodigo: 'SENDA-411-1-7', + }), + }), }) expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({ where: { codigo: 'SENDA-411-1-7', status: { notIn: [...STATUS_NO_BLOQUEABLES] } }, @@ -229,7 +284,14 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa) email: null, desarrollo: null, modelo: null, - datosJson: null, + datosJson: JSON.stringify({ + estadoCivil: 'Casado', + tipoEmpleo: 'Formal', + rangoSalarial: 'B. 20,000-40,000', + tipoEconomia: 'Formal', + califica: 'Si', + loQuierePara: 'Para vivir', + }), }) const { res } = resFake() diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index 10c5594..9ca3b59 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -57,6 +57,7 @@ import { correrRecordatorios, listarRecordatorios } from '../controllers/recorda import { cambiarEstadoLeadEndpoint, crearAfluenciaEndpoint, + obtenerAfluenciaEndpoint, crearLeadEndpoint, buscarLeadsEndpoint, convertirLeadEndpoint, @@ -305,6 +306,8 @@ apiRouter.post('/leads/:itemId/mover', requireAdmin, moverLeadEndpoint) // Alta Afluencia (forma completa de /client): vive en el tablero Oportunidades apiRouter.post('/afluencias', crearAfluenciaEndpoint) +// Perfil de afluencia capturado de un lead/oportunidad/apartado (bitácora de Mi día) +apiRouter.get('/afluencias/:leadId', obtenerAfluenciaEndpoint) apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint) diff --git a/api-gateway/src/services/crm-local.service.test.ts b/api-gateway/src/services/crm-local.service.test.ts index 8c33782..22377a5 100644 --- a/api-gateway/src/services/crm-local.service.test.ts +++ b/api-gateway/src/services/crm-local.service.test.ts @@ -234,9 +234,26 @@ describe('CrmLocalService.apartar', () => { email: 'luis@correo.com', desarrollo: 'ATELIER', modelo: 'SOHO', - datosJson: '{"edad":35}', + datosJson: JSON.stringify({ + edad: 35, + estadoCivil: 'Casado', + tipoEmpleo: 'Formal', + rangoSalarial: 'B. 20,000-40,000', + tipoEconomia: 'Formal', + califica: 'Si', + loQuierePara: 'Para vivir', + }), } + it('sin afluencia completa rechaza el apartado (regla de negocio)', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ + ...filaOportunidad, + datosJson: '{"edad":35}', + }) + await expect(svc.apartar('loc_op', 'u-1')).rejects.toThrow(/afluencia/i) + expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() + }) + it('pone la oportunidad en Aparto y crea el apartado local (Pago Apartado)', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue(filaOportunidad) const apartado = await svc.apartar('loc_op', 'u-1') diff --git a/api-gateway/src/services/crm-local.service.ts b/api-gateway/src/services/crm-local.service.ts index 06ad65b..423e6bb 100644 --- a/api-gateway/src/services/crm-local.service.ts +++ b/api-gateway/src/services/crm-local.service.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' import { AppError, NotFoundError } from '../utils/errors.js' +import { afluenciaCompleta } from '../config/vocabulario.js' import { claveApartadoCreado } from './automatizaciones.service.js' import { calcularClienteClave } from './recordatorio-vendedor.service.js' import { prisma } from './prisma.js' @@ -229,9 +230,10 @@ export class CrmLocalService { opciones: { fecha?: string | undefined; usuarioId?: string | undefined; sombra?: boolean | undefined } = {}, ): Promise { await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false) - // Guardar fechaCita en datosJson para que la oportunidad la muestre en el calendario + // Guardar fechaCita en datosJson (oportunidad y apartado) para que la + // muestre Mi día y el calendario const updateData: Record = { etapa: label, pendienteSync: true } - if (opciones.fecha !== undefined && board === 'OPORTUNIDADES') { + if (opciones.fecha !== undefined && (board === 'OPORTUNIDADES' || board === 'APARTADO')) { const existente = await prisma.leadCaptura.findUnique({ where: { leadId: itemId }, select: { datosJson: true } }) let datos: Record = {} if (existente?.datosJson) { @@ -347,6 +349,11 @@ export class CrmLocalService { !esIdLocal(oportunidadIdLocal), ) + // Regla de negocio: no se aparta sin la afluencia completa del cliente + if (!afluenciaCompleta(oportunidad.datosJson)) { + throw new AppError(400, 'Primero debes capturar la afluencia del cliente (botón Afluencia) antes de apartar') + } + const clave = claveApartadoCreado(oportunidadIdLocal) const previo = await prisma.automatizacionState.findUnique({ where: { clave } }) if (previo !== null) { diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts index 95161fa..233fd67 100644 --- a/api-gateway/src/services/mi-dia.service.ts +++ b/api-gateway/src/services/mi-dia.service.ts @@ -5,6 +5,8 @@ * resuelve por el usuarioId de cada fila. */ +import { camposAfluenciaFaltantes } from '../config/vocabulario.js' + // Estados del board Leads en los que el lead ya no es trabajo activo del vendedor export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO'] @@ -133,6 +135,8 @@ export interface OportunidadMiDia { ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null /** Fecha y hora de la cita agendada (ej. '2026-09-10T14:30' o '2026-09-10') */ fechaCita?: string | undefined + /** ¿Afluencia con perfil completo? Sin esto no puede avanzar a 'Aparto' */ + afluenciaCompleta: boolean } /** Apartado asignado al vendedor (tab "Mis apartados" de Mi día) */ @@ -339,6 +343,7 @@ export function resumenMiDiaLocal( updatedAt: c.updatedAt.toISOString(), ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''), fechaCita, + afluenciaCompleta: camposAfluenciaFaltantes(c.datosJson).length === 0, pendienteSync: c.pendienteSync, } }) diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx index 2e854de..5547172 100644 --- a/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx @@ -125,7 +125,7 @@ describe('AltaAfluenciaForm', () => { fireEvent.change(screen.getByLabelText(/^Estado civil/), { target: { value: 'Casado(a)' } }) fireEvent.change(screen.getByLabelText(/Tipo de empleo/), { target: { value: 'Asalariado' } }) fireEvent.change(screen.getByLabelText(/^Ocupación/), { target: { value: 'Ingeniero' } }) - fireEvent.change(screen.getByLabelText(/Rango salarial/), { target: { value: 'F-25001.00-30000.00' } }) + fireEvent.change(screen.getByLabelText(/Rango salarial/), { target: { value: '20,000-40,000' } }) fireEvent.change(screen.getByLabelText(/^Crédito/), { target: { value: 'Infonavit' } }) fireEvent.change(screen.getByLabelText(/Tipo de economía/), { target: { value: 'Economía mexicana' } }) fireEvent.change(screen.getByLabelText(/^Califica/), { target: { value: 'Sí' } }) @@ -141,7 +141,7 @@ describe('AltaAfluenciaForm', () => { estadoCivil: 'Casado(a)', tipoEmpleo: 'Asalariado', ocupacion: 'Ingeniero', - rangoSalarial: 'F-25001.00-30000.00', + rangoSalarial: '20,000-40,000', credito: 'Infonavit', tipoEconomia: 'Economía mexicana', califica: 'Sí', diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx index 6de5116..af1dd60 100644 --- a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx @@ -582,19 +582,14 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor className={inputClase} > - - - - - - - - - - - - - + + + + + + + + diff --git a/apps/landing/src/pages/SoportePage.tsx b/apps/landing/src/pages/SoportePage.tsx index f08dac8..135c4e5 100644 --- a/apps/landing/src/pages/SoportePage.tsx +++ b/apps/landing/src/pages/SoportePage.tsx @@ -1,197 +1,18 @@ -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' +/** + * Módulo de Soporte: acceso directo al formulario oficial de solicitudes + * internas (Monday WorkForms). Ya no se registra nada en este sistema: el + * botón abre el formulario oficial en una pestaña nueva. + */ + +const URL_FORMULARIO_SOPORTE = 'https://wkf.ms/3T5NyTY' interface Props { onVolver: () => void } -const inputClase = - 'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]' - -const labelClase = 'mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]' - -const ETIQUETA_ESTADO: Record = { - ABIERTA: 'Abierta', - EN_PROCESO: 'En proceso', - 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 = { - 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', - CERRADA: 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-300', -} - -/** Fecha de hoy en formato YYYY-MM-DD (hora local) */ -function hoyISO(): string { - const hoy = new Date() - const mes = String(hoy.getMonth() + 1).padStart(2, '0') - const dia = String(hoy.getDate()).padStart(2, '0') - return `${hoy.getFullYear()}-${mes}-${dia}` -} - 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(DEPARTAMENTOS_SOPORTE[0]) - const [prioridad, setPrioridad] = useState('Media') - const [responsable, setResponsable] = useState('') - const [fechaSolicitud, setFechaSolicitud] = useState(hoyISO()) - const [fechaLimite, setFechaLimite] = useState('') - const [notas, setNotas] = useState('') - const [email, setEmail] = useState('') - const [archivos, setArchivos] = useState([]) - const [errorArchivos, setErrorArchivos] = useState('') - const [enviando, setEnviando] = useState(false) - const [aviso, setAviso] = useState('') - const [error, setError] = useState('') - const [solicitudes, setSolicitudes] = useState([]) - 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({ - ...(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() - }, [cargar]) - - const agregarArchivos = (lista: FileList | null) => { - if (lista === null) return - 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) => { - e.preventDefault() - setAviso('') - setError('') - if (!titulo.trim() || !descripcion.trim()) { - setError('El título y la descripción son obligatorios.') - return - } - setEnviando(true) - try { - const creada = await crearSolicitudSoporte({ - titulo: titulo.trim(), - descripcion: descripcion.trim(), - departamento: departamento as (typeof DEPARTAMENTOS_SOPORTE)[number], - prioridad: prioridad as (typeof PRIORIDADES_SOPORTE)[number], - ...(responsable.trim() ? { responsable: responsable.trim() } : {}), - fechaSolicitud, - ...(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}).`) - setTitulo('') - setDescripcion('') - setResponsable('') - setNotas('') - setArchivos([]) - setErrorArchivos('') - setFechaLimite('') - await cargar() - } catch (err) { - setError(err instanceof Error ? err.message : 'No se pudo registrar la solicitud.') - } finally { - setEnviando(false) - } - } - - 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 ( -
- {/* Header */} +
-

- Registra una solicitud para los departamentos internos y dale seguimiento desde esta misma - pantalla. -

- {/* Formulario */} -
void enviar(e)} noValidate className="mt-8 max-w-3xl"> -
- - -