feat(7-mejoras): notas persisten + soporte + captura visible

1. Notas propagan entre etapas (convertirLead/apartar updateMany)
2. Afluencia: conexión → aviso amarillo
3. Escrituración: click nombre abre bitácora
4. Co-acreditado: opción 'no tiene'
5. Nota del alta de lead → bitácora
6. Módulo Soporte: formulario + listado solicitudes
7. Captura visible para todos
This commit is contained in:
2026-09-11 23:17:25 +00:00
parent 1ba7aa49da
commit c8357fe582
22 changed files with 693 additions and 19 deletions

View File

@@ -0,0 +1,24 @@
-- Tabla del módulo Soporte: solicitudes de los departamentos.
-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
-- CreateTable SolicitudSoporte
CREATE TABLE IF NOT EXISTS "SolicitudSoporte" (
"id" TEXT NOT NULL PRIMARY KEY,
"usuarioId" TEXT NOT NULL,
"titulo" TEXT NOT NULL,
"descripcion" TEXT NOT NULL,
"departamento" TEXT NOT NULL,
"prioridad" TEXT NOT NULL,
"responsable" TEXT,
"fechaSolicitud" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"fechaLimite" DATETIME,
"notas" TEXT,
"archivos" TEXT,
"solicitante" TEXT NOT NULL DEFAULT '',
"solicitanteEmail" TEXT,
"estado" TEXT NOT NULL DEFAULT 'ABIERTA',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS "SolicitudSoporte_usuarioId_idx" ON "SolicitudSoporte"("usuarioId");
CREATE INDEX IF NOT EXISTS "SolicitudSoporte_departamento_idx" ON "SolicitudSoporte"("departamento");

View File

@@ -504,3 +504,29 @@ model ClienteMaestro {
@@index([estatus]) @@index([estatus])
@@index([vendedor]) @@index([vendedor])
} }
// ── Módulo Soporte: solicitudes de los departamentos (Sistemas, Admin, ...) ──
model SolicitudSoporte {
id String @id @default(uuid())
usuarioId String
titulo String
descripcion String
departamento String
prioridad String
responsable String?
fechaSolicitud DateTime @default(now())
fechaLimite DateTime?
notas String?
archivos String? // JSON array de rutas
// Quién pide la solicitud (nombre y email; el front los manda o el API usa
// el usuario del token)
solicitante String @default("")
solicitanteEmail String?
estado String @default("ABIERTA") // ABIERTA, EN_PROCESO, CERRADA
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([usuarioId])
@@index([departamento])
}

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { MODULOS, esRolValido, modulosDeRol } from './roles.js' import { MODULOS, esRolValido, modulosDeRol } from './roles.js'
const ESPERADOS: Record<string, string[]> = { const ESPERADOS: Record<string, string[]> = {
vendedor: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'], vendedor: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro', 'captura', 'soporte'],
administracion_venta: [ administracion_venta: [
'mi-dia', 'mi-dia',
'alta-cliente', 'alta-cliente',
@@ -12,6 +12,8 @@ const ESPERADOS: Record<string, string[]> = {
'contactos', 'contactos',
'chat', 'chat',
'clientes-maestro', 'clientes-maestro',
'captura',
'soporte',
'cobranza', 'cobranza',
], ],
director_ventas: [ director_ventas: [
@@ -23,6 +25,8 @@ const ESPERADOS: Record<string, string[]> = {
'contactos', 'contactos',
'chat', 'chat',
'clientes-maestro', 'clientes-maestro',
'captura',
'soporte',
'cobranza', 'cobranza',
'tablero-comercial', 'tablero-comercial',
], ],
@@ -35,9 +39,11 @@ const ESPERADOS: Record<string, string[]> = {
'contactos', 'contactos',
'chat', 'chat',
'clientes-maestro', 'clientes-maestro',
'captura',
'soporte',
'cobranza', 'cobranza',
], ],
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro'], direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'],
admin: [...MODULOS], admin: [...MODULOS],
} }

View File

@@ -13,6 +13,8 @@ export const MODULOS = [
'contactos', 'contactos',
'chat', 'chat',
'clientes-maestro', 'clientes-maestro',
'captura',
'soporte',
] as const ] as const
export type Modulo = (typeof MODULOS)[number] export type Modulo = (typeof MODULOS)[number]
@@ -26,6 +28,7 @@ export const ROLES = [
] as const ] as const
export type Rol = (typeof ROLES)[number] export type Rol = (typeof ROLES)[number]
// 'captura' y 'soporte' son transversales: todos los roles los ven
const MODULOS_VENDEDOR: readonly Modulo[] = [ const MODULOS_VENDEDOR: readonly Modulo[] = [
'mi-dia', 'mi-dia',
'alta-cliente', 'alta-cliente',
@@ -35,6 +38,8 @@ const MODULOS_VENDEDOR: readonly Modulo[] = [
'contactos', 'contactos',
'chat', 'chat',
'clientes-maestro', 'clientes-maestro',
'captura',
'soporte',
] ]
export const MATRIZ: Record<Rol, readonly Modulo[]> = { export const MATRIZ: Record<Rol, readonly Modulo[]> = {
@@ -42,7 +47,7 @@ export const MATRIZ: Record<Rol, readonly Modulo[]> = {
administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'], administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'],
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'], director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'], escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro'], direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'],
admin: MODULOS, admin: MODULOS,
} }

View File

@@ -25,6 +25,9 @@ const prismaMock = vi.hoisted(() => ({
findUnique: vi.fn(), findUnique: vi.fn(),
upsert: vi.fn(), upsert: vi.fn(),
}, },
recordatorioVendedor: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
auditoriaLog: { auditoriaLog: {
create: vi.fn().mockResolvedValue({}), create: vi.fn().mockResolvedValue({}),
}, },

View File

@@ -37,6 +37,9 @@ const prismaMock = vi.hoisted(() => ({
findUnique: vi.fn(), findUnique: vi.fn(),
upsert: vi.fn(), upsert: vi.fn(),
}, },
recordatorioVendedor: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
cliente: { cliente: {
findMany: vi.fn(), findMany: vi.fn(),
}, },

View File

@@ -6,6 +6,7 @@ import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import * as auditoria from '../services/auditoria.service.js' import * as auditoria from '../services/auditoria.service.js'
import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js' import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js'
import { AppError, NotFoundError } from '../utils/errors.js' import { AppError, NotFoundError } from '../utils/errors.js'
import { recordatorioVendedorService } from '../services/recordatorio-vendedor.service.js'
/** /**
* Captura y acciones del CRM ligero, sobre DOS tableros locales (tabla * Captura y acciones del CRM ligero, sobre DOS tableros locales (tabla
@@ -162,6 +163,14 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
}) })
return return
} }
// La nota del alta se persiste como recordatorio en la bitácora del lead
if (body.notas !== undefined && body.notas.trim() !== '') {
await recordatorioVendedorService.crear(usuarioSub ?? 'sistema', {
titulo: body.notas,
leadId: resultado.id,
fechaRecordar: new Date().toISOString(),
})
}
res.status(201).json({ res.status(201).json({
id: resultado.id, id: resultado.id,
estado: 'NUEVO', estado: 'NUEVO',

View File

@@ -21,6 +21,9 @@ const prismaMock = vi.hoisted(() => ({
findUnique: vi.fn(), findUnique: vi.fn(),
upsert: vi.fn(), upsert: vi.fn(),
}, },
recordatorioVendedor: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
auditoriaLog: { auditoriaLog: {
create: vi.fn().mockResolvedValue({}), create: vi.fn().mockResolvedValue({}),
}, },

View File

@@ -0,0 +1,111 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { prisma } from '../services/prisma.js'
import * as auditoria from '../services/auditoria.service.js'
/**
* Módulo Soporte: solicitudes dirigidas a los departamentos internos
* (Sistemas, Administración, Ventas, Cobranza, Escrituración, Dirección).
*
* - POST /api/soporte — crear solicitud (cualquier usuario autenticado).
* - GET /api/soporte — listar solicitudes: propias para cualquier usuario,
* TODAS para admin (con filtro opcional ?estado=ABIERTA|EN_PROCESO|CERRADA).
*/
const DEPARTAMENTOS_SOPORTE = ['Sistemas', 'Administración', 'Ventas', 'Cobranza', 'Escrituración', 'Dirección'] as const
const PRIORIDADES_SOPORTE = ['Baja', 'Media', 'Alta', 'Urgente'] as const
const ESTADOS_SOPORTE = ['ABIERTA', 'EN_PROCESO', 'CERRADA'] as const
const crearSolicitudSchema = z.object({
titulo: z.string().trim().min(1, 'El título es obligatorio'),
descripcion: z.string().trim().min(1, 'La descripción es obligatoria'),
departamento: z.enum(DEPARTAMENTOS_SOPORTE),
prioridad: z.enum(PRIORIDADES_SOPORTE),
responsable: z.string().trim().min(1).optional(),
fechaSolicitud: z.coerce.date(),
fechaLimite: z.coerce.date().optional(),
notas: z.string().trim().min(1).optional(),
// Quién pide la solicitud; el front lo manda desde la sesión y el API cae al
// username del token si viene vacío
solicitante: z.string().trim().min(1).optional(),
email: z.string().trim().email('Email inválido').optional(),
// Rutas/nombres de archivos adjuntos (sin endpoint de upload: el front manda
// nombres; se guardan como JSON array)
archivos: z.array(z.string().trim().min(1)).optional(),
})
const listarSolicitudSchema = z.object({
estado: z.enum(ESTADOS_SOPORTE).optional(),
})
/** Serializa la fila para el front: archivos JSON → array (tolerante a basura) */
function aPublico(fila: {
id: string
usuarioId: string
titulo: string
descripcion: string
departamento: string
prioridad: string
responsable: string | null
fechaSolicitud: Date
fechaLimite: Date | null
notas: string | null
archivos: string | null
solicitante: string
solicitanteEmail: string | null
estado: string
createdAt: Date
updatedAt: Date
}) {
let archivos: string[] = []
if (fila.archivos) {
try {
const parseado: unknown = JSON.parse(fila.archivos)
if (Array.isArray(parseado)) archivos = parseado.filter((a): a is string => typeof a === 'string')
} catch {
archivos = []
}
}
return { ...fila, archivos }
}
/** POST /api/soporte — crear solicitud de soporte */
export async function crearSolicitudSoporteEndpoint(req: Request, res: Response): Promise<void> {
const body = crearSolicitudSchema.parse(req.body)
const usuarioId = req.usuarioToken?.sub ?? 'desconocido'
const fila = await prisma.solicitudSoporte.create({
data: {
usuarioId,
titulo: body.titulo,
descripcion: body.descripcion,
departamento: body.departamento,
prioridad: body.prioridad,
responsable: body.responsable ?? null,
fechaSolicitud: body.fechaSolicitud,
fechaLimite: body.fechaLimite ?? null,
notas: body.notas ?? null,
archivos: body.archivos !== undefined ? JSON.stringify(body.archivos) : null,
solicitante: body.solicitante ?? req.usuarioToken?.username ?? '',
solicitanteEmail: body.email ?? null,
},
})
res.status(201).json(aPublico(fila))
auditoria.registrar(usuarioId, 'CREAR_SOLICITUD_SOPORTE', 'soporte', fila.id, body.departamento)
}
/** GET /api/soporte — listar solicitudes (propias; admin ve todas) */
export async function listarSolicitudesSoporteEndpoint(req: Request, res: Response): Promise<void> {
const { estado } = listarSolicitudSchema.parse(req.query)
const where: Record<string, unknown> = {}
// Admin ve todas; el resto solo las suyas
if (req.usuarioToken?.rol !== 'admin') {
where['usuarioId'] = req.usuarioToken?.sub ?? 'desconocido'
}
if (estado !== undefined) where['estado'] = estado
const filas = await prisma.solicitudSoporte.findMany({
where,
orderBy: { createdAt: 'desc' },
})
res.json(filas.map(aPublico))
}

View File

@@ -133,6 +133,10 @@ import {
importarEndpoint as importarMaestroEndpoint, importarEndpoint as importarMaestroEndpoint,
requireAdmin as requireAdminMaestro, requireAdmin as requireAdminMaestro,
} from '../controllers/cliente-maestro.controller.js' } from '../controllers/cliente-maestro.controller.js'
import {
crearSolicitudSoporteEndpoint,
listarSolicitudesSoporteEndpoint,
} from '../controllers/soporte.controller.js'
export const apiRouter = Router() export const apiRouter = Router()
@@ -292,3 +296,7 @@ apiRouter.get('/clientes-maestro', listarMaestroEndpoint)
apiRouter.get('/clientes-maestro/exportar', exportarMaestroEndpoint) apiRouter.get('/clientes-maestro/exportar', exportarMaestroEndpoint)
apiRouter.get('/clientes-maestro/:numCliente', obtenerMaestroEndpoint) apiRouter.get('/clientes-maestro/:numCliente', obtenerMaestroEndpoint)
apiRouter.post('/clientes-maestro/importar', requireAdminMaestro, importarMaestroEndpoint) apiRouter.post('/clientes-maestro/importar', requireAdminMaestro, importarMaestroEndpoint)
// ── Módulo Soporte: solicitudes a departamentos internos ──────────
apiRouter.post('/soporte', crearSolicitudSoporteEndpoint)
apiRouter.get('/soporte', listarSolicitudesSoporteEndpoint)

View File

@@ -54,7 +54,7 @@ describe('login', () => {
rol: 'vendedor', rol: 'vendedor',
asignacion: 'MONTEBELLO', asignacion: 'MONTEBELLO',
mondayUserId: null, mondayUserId: null,
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'], modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro', 'captura', 'soporte'],
acciones: [], acciones: [],
}) })
expect(verificarToken(resultado.token).username).toBe('erick israel pena ramos') expect(verificarToken(resultado.token).username).toBe('erick israel pena ramos')
@@ -103,7 +103,7 @@ describe('me', () => {
it('incluye los módulos visibles del rol (matriz de config/roles)', async () => { it('incluye los módulos visibles del rol (matriz de config/roles)', async () => {
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ rol: 'direccion_general' })) prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ rol: 'direccion_general' }))
const resultado = await me({ sub: 'u-1', username: 'x', rol: 'direccion_general' }) const resultado = await me({ sub: 'u-1', username: 'x', rol: 'direccion_general' })
expect(resultado.modulos).toEqual(['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro']) expect(resultado.modulos).toEqual(['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'])
}) })
it('rechaza si el usuario ya no existe o está inactivo', async () => { it('rechaza si el usuario ya no existe o está inactivo', async () => {

View File

@@ -19,6 +19,9 @@ const prismaMock = vi.hoisted(() => ({
findUnique: vi.fn(), findUnique: vi.fn(),
upsert: vi.fn(), upsert: vi.fn(),
}, },
recordatorioVendedor: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
cliente: { cliente: {
findMany: vi.fn(), findMany: vi.fn(),
}, },

View File

@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import { AppError, NotFoundError } from '../utils/errors.js' import { AppError, NotFoundError } from '../utils/errors.js'
import { claveApartadoCreado } from './automatizaciones.service.js' import { claveApartadoCreado } from './automatizaciones.service.js'
import { calcularClienteClave } from './recordatorio-vendedor.service.js'
import { prisma } from './prisma.js' import { prisma } from './prisma.js'
/** /**
@@ -256,11 +257,13 @@ export class CrmLocalService {
}) })
} }
/** /**
* Conversión local lead → oportunidad (botón "Agendo" de Mis leads): el lead * Conversión local lead → oportunidad (botón "Agendo" de Mis leads): el lead
* queda 'Calificado', se crea la oportunidad local en 'Agendo Cita' copiando * queda 'Calificado', se crea la oportunidad local en 'Agendo Cita' copiando
* los datos y se liga LeadOportunidad + `oportunidadId` en la captura. Si el * los datos y se liga LeadOportunidad + `oportunidadId` en la captura. Las
* lead ya fue convertido → 409. Devuelve la fila local de la oportunidad. * notas (recordatorios) del lead pasan a apuntar a la oportunidad para que
* sobrevivan al cambio de etapa. Si el lead ya fue convertido → 409. Devuelve
* la fila local de la oportunidad.
*/ */
async convertirLead(leadIdLocal: string, _usuarioId?: string) { async convertirLead(leadIdLocal: string, _usuarioId?: string) {
const lead = await prisma.leadCaptura.findUnique({ where: { leadId: leadIdLocal } }) const lead = await prisma.leadCaptura.findUnique({ where: { leadId: leadIdLocal } })
@@ -291,6 +294,11 @@ export class CrmLocalService {
}, },
}), }),
prisma.leadOportunidad.create({ data: { leadId: leadIdLocal, oportunidadId } }), prisma.leadOportunidad.create({ data: { leadId: leadIdLocal, oportunidadId } }),
// Las notas del lead viejo siguen a la nueva oportunidad (bitácora persiste)
prisma.recordatorioVendedor.updateMany({
where: { leadId: leadIdLocal },
data: { leadId: oportunidadId, clienteClave: calcularClienteClave(lead.nombreLead, lead.telefono) },
}),
]) ])
return resultados[1] return resultados[1]
} }
@@ -298,8 +306,10 @@ export class CrmLocalService {
/** /**
* "Aparto" local: la oportunidad pasa a etapa 'Aparto' y se crea el apartado * "Aparto" local: la oportunidad pasa a etapa 'Aparto' y se crea el apartado
* local (board=APARTADO, etapa 'Pago Apartado', primer paso del pipeline * local (board=APARTADO, etapa 'Pago Apartado', primer paso del pipeline
* Enkontrol) con los datos conocidos. Idempotente con el mismo dedup * Enkontrol) con los datos conocidos. Las notas (recordatorios) de la
* `apartado-creado:<opId>` de las automatizaciones. * oportunidad pasan a apuntar al apartado para que sobrevivan al cambio de
* board. Idempotente con el mismo dedup `apartado-creado:<opId>` de las
* automatizaciones.
*/ */
async apartar(oportunidadIdLocal: string, usuarioId?: string) { async apartar(oportunidadIdLocal: string, usuarioId?: string) {
// Sombra para ids heredados sin fila local // Sombra para ids heredados sin fila local
@@ -343,6 +353,11 @@ export class CrmLocalService {
update: { valor: apartadoId }, update: { valor: apartadoId },
create: { clave, valor: apartadoId }, create: { clave, valor: apartadoId },
}), }),
// Las notas de la oportunidad siguen al apartado (bitácora persiste)
prisma.recordatorioVendedor.updateMany({
where: { leadId: oportunidadIdLocal },
data: { leadId: apartadoId, clienteClave: calcularClienteClave(oportunidad.nombreLead, oportunidad.telefono) },
}),
]) ])
return resultados[1] return resultados[1]
} }

View File

@@ -174,14 +174,14 @@ describe('AltaAfluenciaForm', () => {
) )
}) })
it('con error de red muestra un mensaje reintentable', async () => { it('con error de red muestra aviso amarillo: el registro se guardó igualmente', async () => {
fetchMock.mockRejectedValueOnce(new Error('red caída')) fetchMock.mockRejectedValueOnce(new Error('red caída'))
render(<AltaAfluenciaForm onVolver={() => undefined} onPasarAAltaCliente={() => undefined} />) render(<AltaAfluenciaForm onVolver={() => undefined} onPasarAAltaCliente={() => undefined} />)
llenarRequeridos() llenarRequeridos()
fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ })) fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ }))
expect((await screen.findByRole('alert')).textContent).toContain( expect((await screen.findByRole('status')).textContent).toContain(
'No se pudo registrar el lead. Revisa tu conexión e intenta de nuevo.', 'La afluencia se registró, pero hubo un problema de conexión. Verifica en Mi día.',
) )
}) })

View File

@@ -75,6 +75,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
const [leadCreado, setLeadCreado] = useState<LeadResumen | null>(null) const [leadCreado, setLeadCreado] = useState<LeadResumen | null>(null)
const [asignacion, setAsignacion] = useState<'capturista' | 'round-robin' | null>(null) const [asignacion, setAsignacion] = useState<'capturista' | 'round-robin' | null>(null)
const [aviso, setAviso] = useState('') const [aviso, setAviso] = useState('')
const [avisoTipo, setAvisoTipo] = useState<'error' | 'advertencia'>('error')
const cambiar = (campo: keyof typeof FORM_INICIAL) => (e: { target: { value: string } }) => { const cambiar = (campo: keyof typeof FORM_INICIAL) => (e: { target: { value: string } }) => {
setForm((prev) => ({ ...prev, [campo]: e.target.value })) setForm((prev) => ({ ...prev, [campo]: e.target.value }))
@@ -190,8 +191,15 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
}) })
setAsignacion(res.lead.asignacion ?? 'round-robin') setAsignacion(res.lead.asignacion ?? 'round-robin')
} else if (res.motivo === 'duplicado') { } else if (res.motivo === 'duplicado') {
setAvisoTipo('error')
setAviso(`Ya existe un lead con ese teléfono: ${res.nombre}.`) setAviso(`Ya existe un lead con ese teléfono: ${res.nombre}.`)
} else if (res.motivo === 'conexion') {
// El backend es local: el registro YA se guardó aunque el fetch se haya
// cortado (timeout/red). Aviso amarillo en vez de error rojo.
setAvisoTipo('advertencia')
setAviso('La afluencia se registró, pero hubo un problema de conexión. Verifica en Mi día.')
} else { } else {
setAvisoTipo('error')
setAviso('No se pudo registrar el lead. Revisa tu conexión e intenta de nuevo.') setAviso('No se pudo registrar el lead. Revisa tu conexión e intenta de nuevo.')
} }
} }
@@ -583,7 +591,13 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
</label> </label>
</div> </div>
{aviso && ( {aviso && avisoTipo === 'advertencia' && (
<p role="status" className="mt-6 rounded-lg border border-yellow-200 bg-yellow-50 px-4 py-3 text-xs font-medium text-yellow-800">
{aviso}
</p>
)}
{aviso && avisoTipo === 'error' && (
<p role="alert" className="mt-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700"> <p role="alert" className="mt-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700">
{aviso} {aviso}
</p> </p>

View File

@@ -17,9 +17,28 @@ export default function CoacreditadoForm({ valores, onAnterior, onSiguiente }: P
}) })
const tieneCoacreditado = metodos.watch('tieneCoacreditado') const tieneCoacreditado = metodos.watch('tieneCoacreditado')
// "El cliente no tiene co-acreditado" es el espejo del toggle: marcado oculta
// todos los campos y el schema valida sin datos (superRefine solo exige el
// nombre cuando hay co-acreditado).
const cambiarSinCoacreditado = (e: { target: { checked: boolean } }) => {
metodos.setValue('tieneCoacreditado', !e.target.checked, { shouldValidate: false })
metodos.clearErrors()
}
return ( return (
<FormProvider {...metodos}> <FormProvider {...metodos}>
<form onSubmit={metodos.handleSubmit(onSiguiente)} noValidate> <form onSubmit={metodos.handleSubmit(onSiguiente)} noValidate>
<label htmlFor="sinCoacreditado" className="flex items-center gap-3 px-6 py-5">
<input
id="sinCoacreditado"
type="checkbox"
className="h-5 w-5 accent-accent"
checked={!tieneCoacreditado}
onChange={cambiarSinCoacreditado}
/>
<span className="text-sm font-medium text-[#111827]">El cliente no tiene co-acreditado</span>
</label>
<label htmlFor="tieneCoacreditado" className="flex items-center gap-3 px-6 py-5"> <label htmlFor="tieneCoacreditado" className="flex items-center gap-3 px-6 py-5">
<input <input
id="tieneCoacreditado" id="tieneCoacreditado"

View File

@@ -7,10 +7,11 @@ import { VistaContactos } from './components/VistaContactos'
import { ConfigPage } from './components/config/ConfigPage' import { ConfigPage } from './components/config/ConfigPage'
import { CapturaPage } from './pages/CapturaPage' import { CapturaPage } from './pages/CapturaPage'
import { ClientesMaestroPage } from './pages/ClientesMaestroPage' import { ClientesMaestroPage } from './pages/ClientesMaestroPage'
import { SoportePage } from './pages/SoportePage'
import { getToken, type UsuarioSesion } from './services/auth' import { getToken, type UsuarioSesion } from './services/auth'
import { getMe } from './services/api' import { getMe } from './services/api'
type Vista = 'home' | 'contactos' | 'captura' | 'clientes-maestro' | 'configuraciones' type Vista = 'home' | 'contactos' | 'captura' | 'clientes-maestro' | 'soporte' | 'configuraciones'
export default function App() { export default function App() {
const [vista, setVista] = useState<Vista>('home') const [vista, setVista] = useState<Vista>('home')
@@ -74,6 +75,16 @@ export default function App() {
) )
} }
if (vista === 'soporte') {
return (
<div className="flex min-h-screen flex-col">
<Navbar usuario={usuario} onConfiguraciones={() => setVista('configuraciones')} />
<SoportePage onVolver={() => setVista('home')} />
</div>
)
}
if (vista === 'configuraciones' && usuario?.rol === 'admin') { if (vista === 'configuraciones' && usuario?.rol === 'admin') {
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
@@ -115,7 +126,9 @@ export default function App() {
? () => setVista('captura') ? () => setVista('captura')
: modulo.id === 'clientes-maestro' : modulo.id === 'clientes-maestro'
? () => setVista('clientes-maestro') ? () => setVista('clientes-maestro')
: undefined : modulo.id === 'soporte'
? () => setVista('soporte')
: undefined
} }
/> />
))} ))}

View File

@@ -8,8 +8,8 @@ const porId = (id: string): Modulo => {
} }
describe('MODULOS', () => { describe('MODULOS', () => {
it('expone los 9 módulos del ecosistema', () => { it('expone los 10 módulos del ecosistema', () => {
expect(MODULOS).toHaveLength(9) expect(MODULOS).toHaveLength(10)
expect(MODULOS.map((m) => m.id)).toEqual([ expect(MODULOS.map((m) => m.id)).toEqual([
'cotizador', 'cotizador',
'alta-clientes', 'alta-clientes',
@@ -20,6 +20,7 @@ describe('MODULOS', () => {
'contactos', 'contactos',
'captura', 'captura',
'clientes-maestro', 'clientes-maestro',
'soporte',
]) ])
}) })

View File

@@ -71,6 +71,13 @@ export const MODULOS: Modulo[] = [
descripcion: 'Base de datos maestra de clientes: búsqueda, filtros y exportación', descripcion: 'Base de datos maestra de clientes: búsqueda, filtros y exportación',
claves: ['clientes-maestro'], claves: ['clientes-maestro'],
}, },
{
id: 'soporte',
nombre: 'Soporte',
descripcion: 'Solicitudes a los departamentos internos: Sistemas, Administración, Ventas y más',
// Visible para todos: la clave 'soporte' se agrega a todos los roles del API
claves: ['soporte'],
},
] ]
/** /**

View File

@@ -0,0 +1,325 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react'
import {
DEPARTAMENTOS_SOPORTE,
PRIORIDADES_SOPORTE,
crearSolicitudSoporte,
listarSolicitudesSoporte,
type SolicitudSoporte,
} from '../services/soporte'
import { getSesion } from '../services/auth'
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<string, string> = {
ABIERTA: 'Abierta',
EN_PROCESO: 'En proceso',
CERRADA: 'Cerrada',
}
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',
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 [titulo, setTitulo] = useState('')
const [descripcion, setDescripcion] = useState('')
const [departamento, setDepartamento] = useState<string>(DEPARTAMENTOS_SOPORTE[0])
const [prioridad, setPrioridad] = useState<string>('Media')
const [responsable, setResponsable] = useState('')
const [fechaSolicitud, setFechaSolicitud] = useState(hoyISO())
const [fechaLimite, setFechaLimite] = useState('')
const [notas, setNotas] = useState('')
const [archivos, setArchivos] = useState<string[]>([])
const [enviando, setEnviando] = useState(false)
const [aviso, setAviso] = useState('')
const [error, setError] = useState('')
const [solicitudes, setSolicitudes] = useState<SolicitudSoporte[]>([])
const [cargando, setCargando] = useState(true)
const cargar = useCallback(async () => {
setCargando(true)
try {
setSolicitudes(await listarSolicitudesSoporte())
} catch {
// La lista es secundaria: el form sigue usable si el listado falla
} finally {
setCargando(false)
}
}, [])
useEffect(() => {
void cargar()
}, [cargar])
const agregarArchivos = (lista: FileList | null) => {
if (lista === null) return
setArchivos((prev) => [...prev, ...Array.from(lista).map((f) => f.name)])
}
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 } : {}),
...(archivos.length > 0 ? { archivos } : {}),
})
setAviso(`Solicitud registrada (${ETIQUETA_ESTADO[creada.estado] ?? creada.estado}).`)
setTitulo('')
setDescripcion('')
setResponsable('')
setNotas('')
setArchivos([])
setFechaLimite('')
await cargar()
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo registrar la solicitud.')
} finally {
setEnviando(false)
}
}
return (
<main className="w-full flex-1 px-4 py-12 sm:px-6 lg:px-8 sm:py-16">
{/* Header */}
<div className="flex flex-wrap items-center gap-4">
<button
type="button"
onClick={onVolver}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
>
&larr; Volver
</button>
<h1 className="text-2xl font-semibold tracking-tight text-[#111827] dark:text-[#E2E8F0] sm:text-3xl">
Soporte
</h1>
</div>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-[#64748B] dark:text-[#94A3B8]">
Registra una solicitud para los departamentos internos y dale seguimiento desde esta misma
pantalla.
</p>
{/* Formulario */}
<form onSubmit={(e) => void enviar(e)} noValidate className="mt-8 max-w-3xl">
<div className="grid gap-5 sm:grid-cols-2">
<label htmlFor="soporte-titulo" className="block sm:col-span-2">
<span className={labelClase}>Título<span className="text-red-700"> *</span></span>
<input
id="soporte-titulo"
type="text"
value={titulo}
onChange={(e) => setTitulo(e.target.value)}
className={inputClase}
/>
</label>
<label htmlFor="soporte-descripcion" className="block sm:col-span-2">
<span className={labelClase}>Descripción<span className="text-red-700"> *</span></span>
<textarea
id="soporte-descripcion"
rows={4}
value={descripcion}
onChange={(e) => setDescripcion(e.target.value)}
className={inputClase}
/>
</label>
<label htmlFor="soporte-departamento" className="block">
<span className={labelClase}>Departamento</span>
<select
id="soporte-departamento"
value={departamento}
onChange={(e) => setDepartamento(e.target.value)}
className={inputClase}
>
{DEPARTAMENTOS_SOPORTE.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
</label>
<label htmlFor="soporte-prioridad" className="block">
<span className={labelClase}>Prioridad</span>
<select
id="soporte-prioridad"
value={prioridad}
onChange={(e) => setPrioridad(e.target.value)}
className={inputClase}
>
{PRIORIDADES_SOPORTE.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
</label>
<label htmlFor="soporte-responsable" className="block">
<span className={labelClase}>Responsable</span>
<input
id="soporte-responsable"
type="text"
value={responsable}
onChange={(e) => setResponsable(e.target.value)}
className={inputClase}
/>
</label>
<label htmlFor="soporte-fecha-solicitud" className="block">
<span className={labelClase}>Fecha de solicitud</span>
<input
id="soporte-fecha-solicitud"
type="date"
value={fechaSolicitud}
onChange={(e) => setFechaSolicitud(e.target.value)}
className={inputClase}
/>
</label>
<label htmlFor="soporte-fecha-limite" className="block">
<span className={labelClase}>Fecha límite</span>
<input
id="soporte-fecha-limite"
type="date"
value={fechaLimite}
onChange={(e) => setFechaLimite(e.target.value)}
className={inputClase}
/>
</label>
<label htmlFor="soporte-archivos" className="block">
<span className={labelClase}>Archivos</span>
<input
id="soporte-archivos"
type="file"
multiple
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]`}
/>
</label>
<label htmlFor="soporte-notas" className="block sm:col-span-2">
<span className={labelClase}>Notas</span>
<textarea
id="soporte-notas"
rows={2}
value={notas}
onChange={(e) => setNotas(e.target.value)}
className={inputClase}
/>
</label>
</div>
{archivos.length > 0 && (
<ul className="mt-3 flex flex-wrap gap-2">
{archivos.map((nombre, i) => (
<li
key={`${nombre}-${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}
<button
type="button"
aria-label={`Quitar ${nombre}`}
onClick={() => setArchivos((prev) => prev.filter((_, j) => j !== i))}
className="text-[#64748B] hover:text-red-600 dark:text-[#94A3B8]"
>
✕
</button>
</li>
))}
</ul>
)}
{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}
</p>
)}
{aviso && (
<p role="status" className="mt-4 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-xs font-medium text-green-700">
{aviso}
</p>
)}
<button
type="submit"
disabled={enviando}
className="mt-6 rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
>
{enviando ? 'Registrando…' : 'Enviar solicitud'}
</button>
</form>
{/* Listado de solicitudes */}
<section aria-label="Mis solicitudes" className="mt-12 max-w-3xl">
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">
{sesion?.usuario.rol === 'admin' ? 'Todas las solicitudes' : 'Mis solicitudes'}
</h2>
{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>
) : (
<ul className="mt-4 space-y-3">
{solicitudes.map((s) => (
<li
key={s.id}
className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]"
>
<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>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{s.departamento} · {s.prioridad} · Solicitó {s.solicitante || '—'} ·{' '}
{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>
)}
</li>
))}
</ul>
)}
</section>
</main>
)
}

View File

@@ -0,0 +1,73 @@
import { API_URL, conAuth, manejar401 } from './auth'
/** Departamentos a los que se puede dirigir una solicitud de soporte */
export const DEPARTAMENTOS_SOPORTE = [
'Sistemas',
'Administración',
'Ventas',
'Cobranza',
'Escrituración',
'Dirección',
] as const
/** Prioridades de una solicitud */
export const PRIORIDADES_SOPORTE = ['Baja', 'Media', 'Alta', 'Urgente'] as const
export type DepartamentoSoporte = (typeof DEPARTAMENTOS_SOPORTE)[number]
export type PrioridadSoporte = (typeof PRIORIDADES_SOPORTE)[number]
export interface SolicitudSoporte {
id: string
titulo: string
descripcion: string
departamento: string
prioridad: string
responsable: string | null
fechaSolicitud: string
fechaLimite: string | null
notas: string | null
archivos: string[]
solicitante: string
solicitanteEmail: string | null
estado: string
createdAt: string
updatedAt: string
}
/** POST /api/soporte — crear solicitud de soporte */
export async function crearSolicitudSoporte(payload: {
titulo: string
descripcion: string
departamento: DepartamentoSoporte
prioridad: PrioridadSoporte
responsable?: string
fechaSolicitud: string
fechaLimite?: string
notas?: string
solicitante?: string
email?: string
archivos?: string[]
}): Promise<SolicitudSoporte> {
const resp = await fetch(`${API_URL}/soporte`, conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}))
manejar401(resp)
if (!resp.ok) {
const body = (await resp.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `HTTP ${resp.status}`)
}
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())
manejar401(resp)
if (!resp.ok) {
const body = (await resp.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `HTTP ${resp.status}`)
}
return (await resp.json()) as SolicitudSoporte[]
}

View File

@@ -1290,7 +1290,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<li key={apartado.id} className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]"> <li key={apartado.id} className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<p className="font-medium text-[#111827] dark:text-[#E2E8F0]">{apartado.nombre}</p> <button
type="button"
onClick={() => setLeadSeleccionado(apartado)}
className="font-medium text-[#111827] hover:text-accent hover:underline dark:text-[#E2E8F0]"
>
{apartado.nombre}
</button>
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">{apartado.unidadCodigo || 'Sin unidad'} · {apartado.estado}</p> <p className="text-xs text-[#64748B] dark:text-[#94A3B8]">{apartado.unidadCodigo || 'Sin unidad'} · {apartado.estado}</p>
</div> </div>
</div> </div>