diff --git a/apps/api/src/controllers/papeleria.controller.ts b/apps/api/src/controllers/papeleria.controller.ts index 6812301..299c0c4 100644 --- a/apps/api/src/controllers/papeleria.controller.ts +++ b/apps/api/src/controllers/papeleria.controller.ts @@ -4,15 +4,10 @@ import { AppError } from '../middlewares/error.middleware.js'; import * as papeleriaService from '../services/papeleria.service.js'; import { emailService } from '../services/email/email.service.js'; import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js'; +import { getEntidadesVisibles } from '../utils/entidades-visibles.js'; import { env } from '../config/env.js'; import { prisma } from '../config/database.js'; -function rejectClienteRole(req: Request): void { - if (req.user?.role === 'cliente') { - throw new AppError(403, 'Papelería no disponible para usuarios cliente'); - } -} - function effectiveTenantId(req: Request): string { return req.viewingTenantId || req.user!.tenantId; } @@ -24,6 +19,7 @@ const uploadSchema = z.object({ anio: z.number().int().min(2000).max(2100), mes: z.number().int().min(1).max(12), requiereAprobacion: z.boolean(), + requiereAprobacionCliente: z.boolean(), archivoBase64: z.string().min(1), archivoFilename: z.string().min(1).max(255), archivoMime: z.string().min(1).max(100), @@ -31,7 +27,9 @@ const uploadSchema = z.object({ export async function upload(req: Request, res: Response, next: NextFunction) { try { - rejectClienteRole(req); + if (req.user?.role === 'cliente') { + throw new AppError(403, 'Los clientes no pueden subir documentos de papelería'); + } const data = uploadSchema.parse(req.body); const archivo = Buffer.from(data.archivoBase64, 'base64'); @@ -42,18 +40,23 @@ export async function upload(req: Request, res: Response, next: NextFunction) { anio: data.anio, mes: data.mes, requiereAprobacion: data.requiereAprobacion, + requiereAprobacionCliente: data.requiereAprobacionCliente, archivo, archivoFilename: data.archivoFilename, archivoMime: data.archivoMime, subidoPor: req.user!.userId, }); - // Notificación a aprobadores si la papelería requiere aprobación. if (item.requiereAprobacion) { notifyAprobacionRequerida(req, item).catch(err => console.error('[papeleria.upload] notify aprobadores failed:', err?.message || err), ); } + if (item.requiereAprobacionCliente) { + notifyClienteAprobacionRequerida(req, item).catch(err => + console.error('[papeleria.upload] notify clientes failed:', err?.message || err), + ); + } res.status(201).json(item); } catch (error: any) { @@ -74,13 +77,20 @@ const listSchema = z.object({ export async function list(req: Request, res: Response, next: NextFunction) { try { - rejectClienteRole(req); const q = listSchema.parse(req.query); + const entidadIds = await getEntidadesVisibles( + req.tenantPool!, req.user!.userId, req.user!.role, + ); + if (!entidadIds.includes(q.contribuyenteId)) { + return res.json([]); + } const items = await papeleriaService.listPapeleria(req.tenantPool!, { contribuyenteId: q.contribuyenteId, anio: q.anio ? parseInt(q.anio, 10) : undefined, mes: q.mes ? parseInt(q.mes, 10) : undefined, estado: q.estado, + entidadIds, + userRole: req.user!.role, }); res.json(items); } catch (error) { @@ -91,9 +101,22 @@ export async function list(req: Request, res: Response, next: NextFunction) { export async function download(req: Request, res: Response, next: NextFunction) { try { - rejectClienteRole(req); const id = parseInt(String(req.params.id), 10); if (isNaN(id)) return next(new AppError(400, 'ID inválido')); + + const item = await papeleriaService.getById(req.tenantPool!, id); + if (!item) return next(new AppError(404, 'Documento no encontrado')); + + const entidadIds = await getEntidadesVisibles( + req.tenantPool!, req.user!.userId, req.user!.role, + ); + if (!entidadIds.includes(item.contribuyenteId)) { + return next(new AppError(403, 'No tienes acceso a este documento')); + } + if (req.user!.role === 'cliente' && !item.requiereAprobacionCliente) { + return next(new AppError(403, 'No tienes acceso a este documento')); + } + const file = await papeleriaService.downloadArchivo(req.tenantPool!, id); if (!file) return next(new AppError(404, 'Documento no encontrado')); res.setHeader('Content-Type', file.mime); @@ -106,7 +129,9 @@ export async function download(req: Request, res: Response, next: NextFunction) export async function aprobar(req: Request, res: Response, next: NextFunction) { try { - rejectClienteRole(req); + if (req.user?.role === 'cliente') { + throw new AppError(403, 'Los clientes no pueden usar este endpoint'); + } const id = parseInt(String(req.params.id), 10); if (isNaN(id)) return next(new AppError(400, 'ID inválido')); const item = await papeleriaService.aprobar( @@ -127,7 +152,9 @@ const rechazarSchema = z.object({ comentario: z.string().max(2000).nullable().op export async function rechazar(req: Request, res: Response, next: NextFunction) { try { - rejectClienteRole(req); + if (req.user?.role === 'cliente') { + throw new AppError(403, 'Los clientes no pueden usar este endpoint'); + } const id = parseInt(String(req.params.id), 10); if (isNaN(id)) return next(new AppError(400, 'ID inválido')); const { comentario } = rechazarSchema.parse(req.body); @@ -146,9 +173,63 @@ export async function rechazar(req: Request, res: Response, next: NextFunction) } } +export async function aprobarCliente(req: Request, res: Response, next: NextFunction) { + try { + if (req.user?.role !== 'cliente') { + throw new AppError(403, 'Solo clientes pueden usar este endpoint'); + } + const id = parseInt(String(req.params.id), 10); + if (isNaN(id)) return next(new AppError(400, 'ID inválido')); + + const entidadIds = await getEntidadesVisibles( + req.tenantPool!, req.user!.userId, req.user!.role, + ); + const itemCheck = await papeleriaService.getById(req.tenantPool!, id); + if (!itemCheck || !entidadIds.includes(itemCheck.contribuyenteId) || !itemCheck.requiereAprobacionCliente) { + return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación')); + } + + const item = await papeleriaService.aprobarCliente(req.tenantPool!, id, req.user!.userId); + if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación')); + res.json(item); + } catch (error) { + next(error); + } +} + +export async function rechazarCliente(req: Request, res: Response, next: NextFunction) { + try { + if (req.user?.role !== 'cliente') { + throw new AppError(403, 'Solo clientes pueden usar este endpoint'); + } + const id = parseInt(String(req.params.id), 10); + if (isNaN(id)) return next(new AppError(400, 'ID inválido')); + const { comentario } = rechazarSchema.parse(req.body); + + const entidadIds = await getEntidadesVisibles( + req.tenantPool!, req.user!.userId, req.user!.role, + ); + const itemCheck = await papeleriaService.getById(req.tenantPool!, id); + if (!itemCheck || !entidadIds.includes(itemCheck.contribuyenteId) || !itemCheck.requiereAprobacionCliente) { + return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación')); + } + + const item = await papeleriaService.rechazarCliente( + req.tenantPool!, id, req.user!.userId, comentario ?? null, + ); + if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación')); + res.json(item); + } catch (error: any) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + export async function eliminar(req: Request, res: Response, next: NextFunction) { try { - rejectClienteRole(req); + if (req.user?.role === 'cliente') { + throw new AppError(403, 'Los clientes no pueden eliminar documentos'); + } const id = parseInt(String(req.params.id), 10); if (isNaN(id)) return next(new AppError(400, 'ID inválido')); const ok = await papeleriaService.eliminar(req.tenantPool!, id); @@ -161,22 +242,26 @@ export async function eliminar(req: Request, res: Response, next: NextFunction) // ─── Notificaciones ─── +async function getContribuyenteInfo(req: Request, contribuyenteId: string) { + const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>( + `SELECT c.rfc, eg.nombre FROM contribuyentes c + JOIN entidades_gestionadas eg ON eg.id = c.entidad_id + WHERE c.entidad_id = $1`, + [contribuyenteId], + ); + return rows[0] ?? null; +} + /** * Notifica a owners y supervisores cuando una papelería requiere aprobación. - * Owners se obtienen de tenant_memberships (BD central). Supervisores se - * resuelven leyendo carteras del tenant. */ async function notifyAprobacionRequerida( req: Request, item: papeleriaService.PapeleriaItem, ): Promise { const tenantId = effectiveTenantId(req); - - // Owners del despacho const recipients = new Set(await getTenantOwnerEmails(tenantId)); - // Supervisores: cualquier user con rol 'supervisor' o 'cfo' que pertenezca a este tenant. - // Buscamos vía tenant_memberships + roles. const supervisores = await prisma.tenantMembership.findMany({ where: { tenantId, active: true, rol: { nombre: { in: ['supervisor', 'cfo'] } } }, include: { user: { select: { email: true, active: true } } }, @@ -185,23 +270,15 @@ async function notifyAprobacionRequerida( if (m.user.active && m.user.email) recipients.add(m.user.email); } - // No notificarse a sí mismo recipients.delete(req.user!.email); - if (recipients.size === 0) return; const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { nombre: true }, }); - - const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>( - `SELECT c.rfc, eg.nombre FROM contribuyentes c - JOIN entidades_gestionadas eg ON eg.id = c.entidad_id - WHERE c.entidad_id = $1`, - [item.contribuyenteId], - ); - if (rows.length === 0) return; + const info = await getContribuyenteInfo(req, item.contribuyenteId); + if (!info) return; const link = `${env.FRONTEND_URL}/documentos`; const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic']; @@ -210,8 +287,8 @@ async function notifyAprobacionRequerida( for (const to of recipients) { try { await emailService.sendPapeleriaAprobacionRequerida(to, { - contribuyenteRfc: rows[0].rfc, - contribuyenteNombre: rows[0].nombre, + contribuyenteRfc: info.rfc, + contribuyenteNombre: info.nombre, despachoNombre: tenant?.nombre, nombreDocumento: item.nombre, descripcion: item.descripcion, @@ -226,9 +303,7 @@ async function notifyAprobacionRequerida( } /** - * Notifica al uploader (auxiliar) cuando un documento que él subió fue - * aprobado o rechazado. Solo manda si quien aprobó/rechazó NO es el mismo - * uploader (caso edge: owner sube su propia papelería). + * Notifica al uploader cuando un documento fue aprobado o rechazado por owner/supervisor. */ async function notifyDecisionAuxiliar( req: Request, @@ -238,21 +313,16 @@ async function notifyDecisionAuxiliar( const auxiliarEmail = await getUserEmailById(item.subidoPor); if (!auxiliarEmail) return; - const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>( - `SELECT c.rfc, eg.nombre FROM contribuyentes c - JOIN entidades_gestionadas eg ON eg.id = c.entidad_id - WHERE c.entidad_id = $1`, - [item.contribuyenteId], - ); - if (rows.length === 0) return; + const info = await getContribuyenteInfo(req, item.contribuyenteId); + if (!info) return; const link = `${env.FRONTEND_URL}/documentos`; const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic']; const periodo = `${meses[item.mes - 1]} ${item.anio}`; await emailService.sendPapeleriaDecision(auxiliarEmail, { - contribuyenteRfc: rows[0].rfc, - contribuyenteNombre: rows[0].nombre, + contribuyenteRfc: info.rfc, + contribuyenteNombre: info.nombre, nombreDocumento: item.nombre, estado: item.estado as 'aprobado' | 'rechazado', revisor: req.user!.email, @@ -261,3 +331,57 @@ async function notifyDecisionAuxiliar( link, }); } + +/** + * Notifica a los usuarios cliente asociados al contribuyente cuando un documento + * requiere su aprobación. + */ +async function notifyClienteAprobacionRequerida( + req: Request, + item: papeleriaService.PapeleriaItem, +): Promise { + const tenantId = effectiveTenantId(req); + + // Obtener user_ids de clientes con acceso a este contribuyente + const { rows } = await req.tenantPool!.query<{ user_id: string }>( + `SELECT user_id FROM cliente_accesos WHERE entidad_id = $1`, + [item.contribuyenteId], + ); + if (rows.length === 0) return; + + const userIds = rows.map(r => r.user_id); + const users = await prisma.user.findMany({ + where: { id: { in: userIds }, active: true }, + select: { email: true }, + }); + const recipients = users.map(u => u.email).filter(Boolean) as string[]; + if (recipients.length === 0) return; + + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { nombre: true }, + }); + const info = await getContribuyenteInfo(req, item.contribuyenteId); + if (!info) return; + + const link = `${env.FRONTEND_URL}/documentos`; + const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic']; + const periodo = `${meses[item.mes - 1]} ${item.anio}`; + + for (const to of recipients) { + try { + await emailService.sendPapeleriaAprobacionClienteRequerida(to, { + contribuyenteRfc: info.rfc, + contribuyenteNombre: info.nombre, + despachoNombre: tenant?.nombre, + nombreDocumento: item.nombre, + descripcion: item.descripcion, + periodo, + subidoPor: req.user!.email, + link, + }); + } catch (err: any) { + console.error(`[Email] papeleria-aprobacion-cliente a ${to}:`, err?.message || err); + } + } +} diff --git a/apps/api/src/migrations/tenant/050_papeleria_aprobacion_cliente.sql b/apps/api/src/migrations/tenant/050_papeleria_aprobacion_cliente.sql new file mode 100644 index 0000000..5154014 --- /dev/null +++ b/apps/api/src/migrations/tenant/050_papeleria_aprobacion_cliente.sql @@ -0,0 +1,21 @@ +-- Papelería de trabajo: aprobación independiente por cliente + +ALTER TABLE papeleria_trabajo + ADD COLUMN IF NOT EXISTS requiere_aprobacion_cliente boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS estado_cliente varchar(20) + CHECK (estado_cliente IS NULL OR estado_cliente IN ('pendiente','aprobado','rechazado')), + ADD COLUMN IF NOT EXISTS aprobado_por_cliente uuid, + ADD COLUMN IF NOT EXISTS aprobado_at_cliente timestamptz, + ADD COLUMN IF NOT EXISTS comentario_rechazo_cliente text; + +CREATE INDEX IF NOT EXISTS ix_papeleria_estado_cliente + ON papeleria_trabajo(estado_cliente) + WHERE estado_cliente IS NOT NULL; + +CREATE INDEX IF NOT EXISTS ix_papeleria_requiere_cliente + ON papeleria_trabajo(contribuyente_id, requiere_aprobacion_cliente) + WHERE requiere_aprobacion_cliente = true; + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 50, '050_papeleria_aprobacion_cliente') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/routes/papeleria.routes.ts b/apps/api/src/routes/papeleria.routes.ts index 91c3e03..a191a7c 100644 --- a/apps/api/src/routes/papeleria.routes.ts +++ b/apps/api/src/routes/papeleria.routes.ts @@ -13,6 +13,8 @@ router.post('/', ctrl.upload); router.get('/:id/download', ctrl.download); router.post('/:id/aprobar', ctrl.aprobar); router.post('/:id/rechazar', ctrl.rechazar); +router.post('/:id/aprobar-cliente', ctrl.aprobarCliente); +router.post('/:id/rechazar-cliente', ctrl.rechazarCliente); router.delete('/:id', ctrl.eliminar); export { router as papeleriaRoutes }; diff --git a/apps/api/src/services/email/email.service.ts b/apps/api/src/services/email/email.service.ts index 517d890..f11ebf9 100644 --- a/apps/api/src/services/email/email.service.ts +++ b/apps/api/src/services/email/email.service.ts @@ -193,6 +193,19 @@ export const emailService = { ); }, + /** Clientes reciben aviso cuando se sube papelería que requiere su aprobación. */ + sendPapeleriaAprobacionClienteRequerida: async ( + to: string, + data: import('./templates/papeleria.js').PapeleriaAprobacionClienteRequeridaData, + ) => { + const { papeleriaAprobacionClienteRequeridaEmail } = await import('./templates/papeleria.js'); + await sendEmail( + to, + `📋 Documento pendiente de tu aprobación — ${data.contribuyenteRfc}`, + papeleriaAprobacionClienteRequeridaEmail(data), + ); + }, + /** * Cron 8:30 AM — alertas fiscales nuevas activadas hoy. Envía un solo * correo por destinatario con el batch completo. Caller debe deduplicar diff --git a/apps/api/src/services/email/templates/papeleria.ts b/apps/api/src/services/email/templates/papeleria.ts index 580a929..f9698a8 100644 --- a/apps/api/src/services/email/templates/papeleria.ts +++ b/apps/api/src/services/email/templates/papeleria.ts @@ -55,3 +55,32 @@ export function papeleriaDecisionEmail(d: PapeleriaDecisionData): string { `; return baseTemplate(body); } + +export interface PapeleriaAprobacionClienteRequeridaData { + contribuyenteRfc: string; + contribuyenteNombre: string; + despachoNombre?: string; + nombreDocumento: string; + descripcion: string | null; + periodo: string; + subidoPor: string; + link: string; +} + +export function papeleriaAprobacionClienteRequeridaEmail(d: PapeleriaAprobacionClienteRequeridaData): string { + const body = ` + ${heading('Documento pendiente de tu aprobación')} +

${d.subidoPor} subió un documento que requiere tu aprobación como cliente:

+
    +
  • Documento: ${d.nombreDocumento}
  • +
  • Contribuyente: ${d.contribuyenteNombre} (${d.contribuyenteRfc})
  • +
  • Periodo: ${d.periodo}
  • + ${d.descripcion ? `
  • Descripción: ${d.descripcion}
  • ` : ''} +
+ ${infoBox('Revisa el documento y márcalo como aprobado o rechazado desde la sección de Documentos del despacho.')} +
+ ${primaryButton('Ver documento', d.link)} +
+ `; + return baseTemplate(body); +} diff --git a/apps/api/src/services/papeleria.service.ts b/apps/api/src/services/papeleria.service.ts index fe01184..b60432e 100644 --- a/apps/api/src/services/papeleria.service.ts +++ b/apps/api/src/services/papeleria.service.ts @@ -27,6 +27,11 @@ export interface PapeleriaItem { aprobadoPor: string | null; aprobadoAt: Date | null; comentarioRechazo: string | null; + requiereAprobacionCliente: boolean; + estadoCliente: EstadoPapeleria | null; + aprobadoPorCliente: string | null; + aprobadoAtCliente: Date | null; + comentarioRechazoCliente: string | null; subidoPor: string; createdAt: Date; } @@ -36,6 +41,7 @@ const SELECT = ` archivo_filename, archivo_mime, archivo_size, anio, mes, requiere_aprobacion, estado, aprobado_por, aprobado_at, comentario_rechazo, + requiere_aprobacion_cliente, estado_cliente, aprobado_por_cliente, aprobado_at_cliente, comentario_rechazo_cliente, subido_por, created_at `; @@ -54,6 +60,11 @@ const ROW = (r: any): PapeleriaItem => ({ aprobadoPor: r.aprobado_por, aprobadoAt: r.aprobado_at, comentarioRechazo: r.comentario_rechazo, + requiereAprobacionCliente: r.requiere_aprobacion_cliente, + estadoCliente: r.estado_cliente, + aprobadoPorCliente: r.aprobado_por_cliente, + aprobadoAtCliente: r.aprobado_at_cliente, + comentarioRechazoCliente: r.comentario_rechazo_cliente, subidoPor: r.subido_por, createdAt: r.created_at, }); @@ -69,6 +80,7 @@ export interface UploadInput { anio: number; mes: number; requiereAprobacion: boolean; + requiereAprobacionCliente: boolean; archivo: Buffer; archivoFilename: string; archivoMime: string; @@ -87,12 +99,13 @@ export async function uploadPapeleria( } const estadoInicial = input.requiereAprobacion ? 'pendiente' : null; + const estadoClienteInicial = input.requiereAprobacionCliente ? 'pendiente' : null; const { rows: [r] } = await pool.query( `INSERT INTO papeleria_trabajo (contribuyente_id, nombre, descripcion, archivo, archivo_filename, archivo_mime, archivo_size, - anio, mes, requiere_aprobacion, estado, subido_por) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + anio, mes, requiere_aprobacion, estado, requiere_aprobacion_cliente, estado_cliente, subido_por) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING ${SELECT}`, [ sanitizeUuid(input.contribuyenteId), @@ -106,6 +119,8 @@ export async function uploadPapeleria( input.mes, input.requiereAprobacion, estadoInicial, + input.requiereAprobacionCliente, + estadoClienteInicial, input.subidoPor, ], ); @@ -117,6 +132,8 @@ export interface ListFilters { anio?: number; mes?: number; estado?: EstadoPapeleria | 'sin_aprobacion'; + entidadIds?: string[]; + userRole?: string; } export async function listPapeleria(pool: Pool, f: ListFilters): Promise { @@ -126,10 +143,17 @@ export async function listPapeleria(pool: Pool, f: ListFilters): Promise 0) { + conds.push(`contribuyente_id = ANY($${i++})`); + vals.push(f.entidadIds); + } + if (f.userRole === 'cliente') { + conds.push('requiere_aprobacion_cliente = true'); + } const { rows } = await pool.query( `SELECT ${SELECT} FROM papeleria_trabajo WHERE ${conds.join(' AND ')} @@ -202,6 +226,39 @@ export async function rechazar( return r ? ROW(r) : null; } +export async function aprobarCliente( + pool: Pool, + id: number, + userId: string, +): Promise { + const { rows: [r] } = await pool.query( + `UPDATE papeleria_trabajo + SET estado_cliente = 'aprobado', aprobado_por_cliente = $2, aprobado_at_cliente = NOW(), + comentario_rechazo_cliente = NULL + WHERE id = $1 AND requiere_aprobacion_cliente = true + RETURNING ${SELECT}`, + [id, userId], + ); + return r ? ROW(r) : null; +} + +export async function rechazarCliente( + pool: Pool, + id: number, + userId: string, + comentario: string | null, +): Promise { + const { rows: [r] } = await pool.query( + `UPDATE papeleria_trabajo + SET estado_cliente = 'rechazado', aprobado_por_cliente = $2, aprobado_at_cliente = NOW(), + comentario_rechazo_cliente = $3 + WHERE id = $1 AND requiere_aprobacion_cliente = true + RETURNING ${SELECT}`, + [id, userId, comentario], + ); + return r ? ROW(r) : null; +} + export async function eliminar(pool: Pool, id: number): Promise { const { rowCount } = await pool.query( `DELETE FROM papeleria_trabajo WHERE id = $1`, @@ -209,3 +266,30 @@ export async function eliminar(pool: Pool, id: number): Promise { ); return (rowCount ?? 0) > 0; } + +/** + * Calcula el estado visual combinado considerando ambas aprobaciones. + */ +export function estadoGlobal(item: PapeleriaItem): 'pendiente' | 'aprobado' | 'rechazado' | null { + const reqOwner = item.requiereAprobacion; + const reqCliente = item.requiereAprobacionCliente; + const estOwner = item.estado; + const estCliente = item.estadoCliente; + + if (!reqOwner && !reqCliente) return null; + + // Si cualquiera está rechazado, el documento está rechazado + if (estOwner === 'rechazado' || estCliente === 'rechazado') return 'rechazado'; + + // Si ambos requieren aprobación + if (reqOwner && reqCliente) { + if (estOwner === 'aprobado' && estCliente === 'aprobado') return 'aprobado'; + return 'pendiente'; + } + + // Solo owner + if (reqOwner) return estOwner; + + // Solo cliente + return estCliente; +} diff --git a/apps/web/app/(dashboard)/documentos/page.tsx b/apps/web/app/(dashboard)/documentos/page.tsx index e89f548..2578dc0 100644 --- a/apps/web/app/(dashboard)/documentos/page.tsx +++ b/apps/web/app/(dashboard)/documentos/page.tsx @@ -87,7 +87,7 @@ function EstatusBadge({ estatus }: { estatus: string }) { export default function DocumentosPage() { const user = useAuthStore((s) => s.user); const canConsultarOpinion = user?.role === 'owner' || user?.role === 'cfo'; - const canSeePapeleria = user?.role !== 'cliente'; + const canSeePapeleria = true; // Todos los roles pueden ver papelería (cliente con restricciones) return ( <> diff --git a/apps/web/components/documentos/papeleria-tab.tsx b/apps/web/components/documentos/papeleria-tab.tsx index 50f1610..d1990df 100644 --- a/apps/web/components/documentos/papeleria-tab.tsx +++ b/apps/web/components/documentos/papeleria-tab.tsx @@ -10,7 +10,7 @@ import { import { apiClient } from '@/lib/api/client'; import { useAuthStore } from '@/stores/auth-store'; import { useContribuyenteStore } from '@/stores/contribuyente-store'; -import { Upload, Download, Trash2, CheckCircle2, XCircle, Clock, AlertTriangle, MessageSquare } from 'lucide-react'; +import { Upload, Download, Trash2, CheckCircle2, XCircle, Clock, AlertTriangle, MessageSquare, UserCheck } from 'lucide-react'; const MESES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre']; const ALLOWED_MIMES = [ @@ -37,6 +37,9 @@ interface Papeleria { requiereAprobacion: boolean; estado: 'pendiente' | 'aprobado' | 'rechazado' | null; comentarioRechazo: string | null; + requiereAprobacionCliente: boolean; + estadoCliente: 'pendiente' | 'aprobado' | 'rechazado' | null; + comentarioRechazoCliente: string | null; subidoPor: string; createdAt: string; } @@ -54,28 +57,59 @@ function fileToBase64(file: File): Promise { }); } -function EstadoBadge({ estado, requiereAprobacion }: { estado: string | null; requiereAprobacion: boolean }) { - if (!requiereAprobacion) { +function estadoGlobal(item: Papeleria): 'sin_aprobacion' | 'pendiente' | 'aprobado' | 'rechazado' { + const reqOwner = item.requiereAprobacion; + const reqCliente = item.requiereAprobacionCliente; + const estOwner = item.estado; + const estCliente = item.estadoCliente; + + if (!reqOwner && !reqCliente) return 'sin_aprobacion'; + if (estOwner === 'rechazado' || estCliente === 'rechazado') return 'rechazado'; + if (reqOwner && reqCliente) { + if (estOwner === 'aprobado' && estCliente === 'aprobado') return 'aprobado'; + return 'pendiente'; + } + if (reqOwner) return estOwner ?? 'pendiente'; + return estCliente ?? 'pendiente'; +} + +function EstadoBadge({ item }: { item: Papeleria }) { + const global = estadoGlobal(item); + + if (global === 'sin_aprobacion') { return Sin aprobación; } - if (estado === 'aprobado') { + if (global === 'aprobado') { return Aprobado; } - if (estado === 'rechazado') { + if (global === 'rechazado') { return Rechazado; } - return Pendiente; + + // Pendiente — mostrar quién falta + const faltaOwner = item.requiereAprobacion && item.estado !== 'aprobado'; + const faltaCliente = item.requiereAprobacionCliente && item.estadoCliente !== 'aprobado'; + let label = 'Pendiente'; + if (faltaOwner && faltaCliente) label = 'Pendiente (ambos)'; + else if (faltaOwner) label = 'Pendiente (owner)'; + else if (faltaCliente) label = 'Pendiente (cliente)'; + + return {label}; } export function PapeleriaTab() { const user = useAuthStore(s => s.user); const { selectedContribuyenteId } = useContribuyenteStore(); const queryClient = useQueryClient(); - const canApprove = user?.role ? ROLES_APROBADOR.has(user.role) : false; + const isCliente = user?.role === 'cliente'; + const canApproveOwner = user?.role ? ROLES_APROBADOR.has(user.role) : false; + const canUpload = !isCliente; const [showUpload, setShowUpload] = useState(false); const [rechazoFor, setRechazoFor] = useState(null); const [comentarioRechazo, setComentarioRechazo] = useState(''); + const [rechazoClienteFor, setRechazoClienteFor] = useState(null); + const [comentarioRechazoCliente, setComentarioRechazoCliente] = useState(''); // Filtros const currentYear = new Date().getFullYear(); @@ -105,6 +139,7 @@ export function PapeleriaTab() { const [anio, setAnio] = useState(currentYear); const [mes, setMes] = useState(new Date().getMonth() + 1); const [requiereAprobacion, setRequiereAprobacion] = useState(false); + const [requiereAprobacionCliente, setRequiereAprobacionCliente] = useState(false); const [uploadError, setUploadError] = useState(null); const resetUpload = () => { @@ -114,6 +149,7 @@ export function PapeleriaTab() { setAnio(currentYear); setMes(new Date().getMonth() + 1); setRequiereAprobacion(false); + setRequiereAprobacionCliente(false); setUploadError(null); }; @@ -130,6 +166,7 @@ export function PapeleriaTab() { anio, mes, requiereAprobacion, + requiereAprobacionCliente, archivoBase64: base64, archivoFilename: file.name, archivoMime: file.type, @@ -172,6 +209,21 @@ export function PapeleriaTab() { }, }); + const aprobarClienteMutation = useMutation({ + mutationFn: async (id: number) => apiClient.post(`/papeleria/${id}/aprobar-cliente`), + onSuccess: invalidate, + }); + + const rechazarClienteMutation = useMutation({ + mutationFn: async ({ id, comentario }: { id: number; comentario: string | null }) => + apiClient.post(`/papeleria/${id}/rechazar-cliente`, { comentario }), + onSuccess: () => { + setRechazoClienteFor(null); + setComentarioRechazoCliente(''); + invalidate(); + }, + }); + const eliminarMutation = useMutation({ mutationFn: async (id: number) => apiClient.delete(`/papeleria/${id}`), onSuccess: invalidate, @@ -236,9 +288,11 @@ export function PapeleriaTab() { - + {canUpload && ( + + )} {/* Listado */} @@ -258,7 +312,7 @@ export function PapeleriaTab() {
{it.nombre} - + {MESES[it.mes - 1]} {it.anio} @@ -270,10 +324,31 @@ export function PapeleriaTab() { {it.archivoFilename} · {(it.archivoSize / 1024).toFixed(0)} KB · subido {new Date(it.createdAt).toLocaleDateString('es-MX')}

+ {/* Mostrar estado detallado para no-clientes */} + {!isCliente && (it.requiereAprobacion || it.requiereAprobacionCliente) && ( +
+ {it.requiereAprobacion && ( + + Owner: {it.estado ?? '—'} + + )} + {it.requiereAprobacionCliente && ( + + Cliente: {it.estadoCliente ?? '—'} + + )} +
+ )} {it.estado === 'rechazado' && it.comentarioRechazo && (

- {it.comentarioRechazo} + Owner: {it.comentarioRechazo} +

+ )} + {it.estadoCliente === 'rechazado' && it.comentarioRechazoCliente && ( +

+ + Cliente: {it.comentarioRechazoCliente}

)}
@@ -281,7 +356,8 @@ export function PapeleriaTab() { - {canApprove && it.requiereAprobacion && it.estado === 'pendiente' && ( + {/* Botones owner/supervisor */} + {canApproveOwner && it.requiereAprobacion && it.estado === 'pendiente' && ( <> + {/* Botones cliente */} + {isCliente && it.requiereAprobacionCliente && it.estadoCliente === 'pendiente' && ( + <> + + + + )} + {canUpload && ( + + )}
@@ -375,6 +472,14 @@ export function PapeleriaTab() { /> Este documento requiere aprobación de owner/supervisor + {uploadError && (

@@ -394,7 +499,7 @@ export function PapeleriaTab() { - {/* Modal Rechazo */} + {/* Modal Rechazo Owner */}

{ if (!o) { setRechazoFor(null); setComentarioRechazo(''); } }}> @@ -426,6 +531,39 @@ export function PapeleriaTab() { + + {/* Modal Rechazo Cliente */} + { if (!o) { setRechazoClienteFor(null); setComentarioRechazoCliente(''); } }}> + + + Rechazar documento + +
+

+ Vas a rechazar {rechazoClienteFor?.nombre}. El comentario es opcional. +

+
+ + setComentarioRechazoCliente(e.target.value)} + placeholder="Motivo del rechazo..." + /> +
+
+ + + + +
+
); }