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:
@@ -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");
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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({}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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({}),
|
||||||
},
|
},
|
||||||
|
|||||||
111
api-gateway/src/controllers/soporte.controller.ts
Normal file
111
api-gateway/src/controllers/soporte.controller.ts
Normal 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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
325
apps/landing/src/pages/SoportePage.tsx
Normal file
325
apps/landing/src/pages/SoportePage.tsx
Normal 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]"
|
||||||
|
>
|
||||||
|
← 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
apps/landing/src/services/soporte.ts
Normal file
73
apps/landing/src/services/soporte.ts
Normal 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[]
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user