Fase 1 - UI: dark mode, skeletons, toasts Fase 2 - Filtros: avanzados Mi día, búsqueda global Ctrl+K Fase 3 - Datos: export Excel, notas apartados, auditoría Fase 4 - Dashboard: gráficas vendedor + métricas uso Fase 5 - Responsive: tablets optimizadas Fase 6 - PWA: IndexedDB offline, push notifications Backend: 3 tablas nuevas, 6 services, 6 endpoints nuevos 721 API tests + fronts verdes.
225 lines
8.3 KiB
TypeScript
225 lines
8.3 KiB
TypeScript
import type { Request, Response } from 'express'
|
|
import { randomUUID } from 'node:crypto'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import multer from 'multer'
|
|
import { z } from 'zod'
|
|
import { config } from '../config/index.js'
|
|
import { mondayService, type MondayService } from '../services/monday.service.js'
|
|
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
|
|
import { prisma } from '../services/prisma.js'
|
|
import { saludMonday } from '../services/salud-monday.service.js'
|
|
import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js'
|
|
import { esFalloMonday } from '../services/sync-monday.service.js'
|
|
import { checkboxParaTipoDocumento } from './clientes.controller.js'
|
|
import * as auditoria from '../services/auditoria.service.js'
|
|
|
|
/**
|
|
* Pipeline Enkontrol del tablero Apartado: las fases que el vendedor puede
|
|
* mover desde los botones de "Mis apartados" en Mi día. Son los labels REALES
|
|
* de la columna status 'Estado' del board (verificados en Monday) — el
|
|
* frontend los manda tal cual.
|
|
*/
|
|
export const FASES_APARTADO_PERMITIDAS = [
|
|
'Pago Apartado',
|
|
'Alta Enkontrol',
|
|
'Entrego Documentacion',
|
|
'Cita Clientes',
|
|
'Contrato Enkontrol',
|
|
'Firma Cliente (Venta)',
|
|
'Cancelacion Apartado',
|
|
] as const
|
|
|
|
const cambiarEstadoSchema = z.object({
|
|
estado: z.enum(FASES_APARTADO_PERMITIDAS),
|
|
})
|
|
|
|
type MondayApartados = Pick<
|
|
MondayService,
|
|
'cambiarValorColumna' | 'columnaIdPorTitulo' | 'getItemConGrupo' | 'moverItemAGrupo'
|
|
>
|
|
|
|
/**
|
|
* Cambia la fase del apartado (columna 'Estado') y mueve el item al grupo que
|
|
* corresponde (idempotente; el webhook de change haría lo mismo, pero así la
|
|
* UI responde al instante).
|
|
*/
|
|
export async function cambiarEstadoApartado(
|
|
monday: MondayApartados,
|
|
itemId: string,
|
|
estado: (typeof FASES_APARTADO_PERMITIDAS)[number],
|
|
): Promise<void> {
|
|
const boardApartado = config.monday.boards.apartado
|
|
await monday.cambiarValorColumna(boardApartado, itemId, 'Estado', estado)
|
|
await moverAGrupoSegunEstado(monday, boardApartado, itemId)
|
|
}
|
|
|
|
/** POST /api/apartados/:itemId/estado { estado } */
|
|
export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response): Promise<void> {
|
|
const body = cambiarEstadoSchema.parse(req.body)
|
|
const itemId = String(req.params['itemId'])
|
|
// Modo local (id loc_* o Monday caído / fallback 502): fase en la fila local
|
|
// + CAMBIO_ETAPA encolado; ids Monday viejos sin fila crean sombra
|
|
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
|
|
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
|
|
usuarioId: req.usuarioToken?.sub,
|
|
sombra: !esIdLocal(itemId),
|
|
})
|
|
} else {
|
|
try {
|
|
await cambiarEstadoApartado(mondayService, itemId, body.estado)
|
|
} catch (err) {
|
|
if (!esFalloMonday(err)) throw err
|
|
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
|
|
usuarioId: req.usuarioToken?.sub,
|
|
sombra: true,
|
|
})
|
|
}
|
|
}
|
|
res.json({ itemId, estado: body.estado })
|
|
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ESTADO', 'apartado', itemId, body.estado)
|
|
}
|
|
|
|
// ---------- Precarga de documentos del expediente (desde "Mis apartados") ----------
|
|
|
|
/**
|
|
* Tipos de documento de la precarga de apartados. Los dos primeros son los
|
|
* obligatorios del proceso; OTRO cubre los documentos generales (INE, CURP,
|
|
* comprobantes, etc.). El tipo viaja en el campo `tipo` del multipart.
|
|
*/
|
|
export const TIPOS_DOCUMENTO_APARTADO = ['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO'] as const
|
|
|
|
const MIME_PERMITIDOS_APARTADO = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'])
|
|
const EXT_POR_MIME_APARTADO: Record<string, string> = {
|
|
'application/pdf': 'pdf',
|
|
'image/jpeg': 'jpg',
|
|
'image/jpg': 'jpg',
|
|
'image/png': 'png',
|
|
}
|
|
|
|
// Storage propio: archiva por item de Monday (uploads/apartados/<itemId>/)
|
|
const storageApartados = multer.diskStorage({
|
|
destination: (req, _file, cb) => {
|
|
const dir = path.join(config.uploadsDir, 'apartados', String(req.params['itemId']))
|
|
fs.mkdirSync(dir, { recursive: true })
|
|
cb(null, dir)
|
|
},
|
|
filename: (req, file, cb) => {
|
|
const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase()
|
|
const ext = EXT_POR_MIME_APARTADO[file.mimetype] ?? 'bin'
|
|
cb(null, `${tipo}_${randomUUID()}.${ext}`)
|
|
},
|
|
})
|
|
|
|
export const uploadDocumentosApartado = multer({
|
|
storage: storageApartados,
|
|
limits: { fileSize: 10 * 1024 * 1024, files: 10 }, // 10 MB por archivo, máx 10
|
|
fileFilter: (_req, file, cb) => {
|
|
if (!MIME_PERMITIDOS_APARTADO.has(file.mimetype)) {
|
|
cb(new Error(`Tipo de archivo no permitido: ${file.mimetype}`))
|
|
return
|
|
}
|
|
cb(null, true)
|
|
},
|
|
})
|
|
|
|
/**
|
|
* Cliente local ligado al apartado de Monday; si aún no existe (apartado
|
|
* creado sin pasar por el alta progresiva), se crea la ficha mínima para que
|
|
* los documentos tengan dónde vivir. El nombre se toma de Monday; si Monday
|
|
* está caído o el id es local (loc_*), se usa el de la fila LeadCaptura del
|
|
* apartado (board APARTADO, creada por el "Aparto" local) como fallback.
|
|
*/
|
|
async function clienteDeApartado(itemId: string, capturadoPorId?: string) {
|
|
const existente = await prisma.cliente.findFirst({ where: { mondayApartadoId: itemId } })
|
|
if (existente) return existente
|
|
let nombre: string | null = null
|
|
if (!esIdLocal(itemId) && saludMonday.estaDisponible()) {
|
|
try {
|
|
nombre = await mondayService.obtenerNombreItem(itemId)
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`No se pudo leer el nombre del apartado ${itemId} en Monday; se usa el nombre local:`, err)
|
|
}
|
|
}
|
|
if (nombre === null) {
|
|
const captura = await prisma.leadCaptura.findFirst({
|
|
where: { OR: [{ leadId: itemId }, { mondayItemId: itemId }] },
|
|
})
|
|
nombre = captura?.nombreLead ?? 'Cliente'
|
|
}
|
|
return prisma.cliente.create({
|
|
data: {
|
|
nombreCompleto: nombre,
|
|
curp: '',
|
|
rfc: '',
|
|
proyecto: '',
|
|
payloadJson: '{}',
|
|
mondayApartadoId: itemId,
|
|
capturadoPorId: capturadoPorId ?? null,
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* POST /api/apartados/:itemId/documentos — multipart, campo `archivos` (hasta
|
|
* 10) + campo `tipo`. Los documentos se guardan en el CLIENTE ligado al
|
|
* apartado (Documento.clienteId); si el tipo tiene checkbox en el tablero
|
|
* Apartado (CURP/RFC/Comprobante), se prende en Monday.
|
|
*/
|
|
export async function subirDocumentosApartado(req: Request, res: Response): Promise<void> {
|
|
const itemId = String(req.params['itemId'])
|
|
const cliente = await clienteDeApartado(itemId, req.usuarioToken?.sub)
|
|
|
|
const archivos = (req.files ?? []) as Express.Multer.File[]
|
|
const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase()
|
|
const documentos = await Promise.all(
|
|
archivos.map((f) =>
|
|
prisma.documento.create({
|
|
data: {
|
|
clienteId: cliente.id,
|
|
tipo,
|
|
nombreArchivo: f.originalname,
|
|
ruta: path.relative(config.uploadsDir, f.path),
|
|
mime: f.mimetype,
|
|
tamano: f.size,
|
|
},
|
|
}),
|
|
),
|
|
)
|
|
|
|
// Prende el checkbox del documento en el item de Apartado (si el tipo tiene
|
|
// checkbox); un fallo de Monday no rompe la subida: queda encolado
|
|
// (CHECKBOX_DOC) y el worker lo aplica al sincronizar.
|
|
const checkbox = checkboxParaTipoDocumento(tipo)
|
|
if (checkbox !== null) {
|
|
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
|
|
await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId }, itemId)
|
|
} else {
|
|
try {
|
|
await mondayService.cambiarValorColumna(config.monday.boards.apartado, itemId, checkbox, true)
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${itemId}:`, err)
|
|
await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId }, itemId)
|
|
}
|
|
}
|
|
}
|
|
res.status(201).json(documentos)
|
|
}
|
|
|
|
/** GET /api/apartados/:itemId/documentos — documentos del cliente ligado (vacío si aún no hay) */
|
|
export async function listarDocumentosApartado(req: Request, res: Response): Promise<void> {
|
|
const itemId = String(req.params['itemId'])
|
|
const cliente = await prisma.cliente.findFirst({ where: { mondayApartadoId: itemId } })
|
|
if (!cliente) {
|
|
res.json([])
|
|
return
|
|
}
|
|
const documentos = await prisma.documento.findMany({
|
|
where: { clienteId: cliente.id },
|
|
orderBy: { createdAt: 'desc' },
|
|
})
|
|
res.json(documentos)
|
|
}
|