feat(papeleria): aprobación independiente por cliente
- Agrega migración 050 con columnas de aprobación de cliente (requiere_aprobacion_cliente, estado_cliente, aprobado_por_cliente, etc.) - Backend: endpoints /aprobar-cliente y /rechazar-cliente con validación de permisos - Backend: list/download permiten acceso a clientes filtrando por entidades visibles - Backend: notificación por email a clientes cuando se les solicita aprobación - Frontend: checkbox independiente para solicitar aprobación del cliente - Frontend: badge de estado combinado (owner + cliente) - Frontend: botones de aprobar/rechazar para clientes en su propio flujo
This commit is contained in:
@@ -4,15 +4,10 @@ import { AppError } from '../middlewares/error.middleware.js';
|
|||||||
import * as papeleriaService from '../services/papeleria.service.js';
|
import * as papeleriaService from '../services/papeleria.service.js';
|
||||||
import { emailService } from '../services/email/email.service.js';
|
import { emailService } from '../services/email/email.service.js';
|
||||||
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
|
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
|
||||||
|
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
import { prisma } from '../config/database.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 {
|
function effectiveTenantId(req: Request): string {
|
||||||
return req.viewingTenantId || req.user!.tenantId;
|
return req.viewingTenantId || req.user!.tenantId;
|
||||||
}
|
}
|
||||||
@@ -24,6 +19,7 @@ const uploadSchema = z.object({
|
|||||||
anio: z.number().int().min(2000).max(2100),
|
anio: z.number().int().min(2000).max(2100),
|
||||||
mes: z.number().int().min(1).max(12),
|
mes: z.number().int().min(1).max(12),
|
||||||
requiereAprobacion: z.boolean(),
|
requiereAprobacion: z.boolean(),
|
||||||
|
requiereAprobacionCliente: z.boolean(),
|
||||||
archivoBase64: z.string().min(1),
|
archivoBase64: z.string().min(1),
|
||||||
archivoFilename: z.string().min(1).max(255),
|
archivoFilename: z.string().min(1).max(255),
|
||||||
archivoMime: z.string().min(1).max(100),
|
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) {
|
export async function upload(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
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 data = uploadSchema.parse(req.body);
|
||||||
const archivo = Buffer.from(data.archivoBase64, 'base64');
|
const archivo = Buffer.from(data.archivoBase64, 'base64');
|
||||||
|
|
||||||
@@ -42,18 +40,23 @@ export async function upload(req: Request, res: Response, next: NextFunction) {
|
|||||||
anio: data.anio,
|
anio: data.anio,
|
||||||
mes: data.mes,
|
mes: data.mes,
|
||||||
requiereAprobacion: data.requiereAprobacion,
|
requiereAprobacion: data.requiereAprobacion,
|
||||||
|
requiereAprobacionCliente: data.requiereAprobacionCliente,
|
||||||
archivo,
|
archivo,
|
||||||
archivoFilename: data.archivoFilename,
|
archivoFilename: data.archivoFilename,
|
||||||
archivoMime: data.archivoMime,
|
archivoMime: data.archivoMime,
|
||||||
subidoPor: req.user!.userId,
|
subidoPor: req.user!.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notificación a aprobadores si la papelería requiere aprobación.
|
|
||||||
if (item.requiereAprobacion) {
|
if (item.requiereAprobacion) {
|
||||||
notifyAprobacionRequerida(req, item).catch(err =>
|
notifyAprobacionRequerida(req, item).catch(err =>
|
||||||
console.error('[papeleria.upload] notify aprobadores failed:', err?.message || 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);
|
res.status(201).json(item);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -74,13 +77,20 @@ const listSchema = z.object({
|
|||||||
|
|
||||||
export async function list(req: Request, res: Response, next: NextFunction) {
|
export async function list(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
rejectClienteRole(req);
|
|
||||||
const q = listSchema.parse(req.query);
|
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!, {
|
const items = await papeleriaService.listPapeleria(req.tenantPool!, {
|
||||||
contribuyenteId: q.contribuyenteId,
|
contribuyenteId: q.contribuyenteId,
|
||||||
anio: q.anio ? parseInt(q.anio, 10) : undefined,
|
anio: q.anio ? parseInt(q.anio, 10) : undefined,
|
||||||
mes: q.mes ? parseInt(q.mes, 10) : undefined,
|
mes: q.mes ? parseInt(q.mes, 10) : undefined,
|
||||||
estado: q.estado,
|
estado: q.estado,
|
||||||
|
entidadIds,
|
||||||
|
userRole: req.user!.role,
|
||||||
});
|
});
|
||||||
res.json(items);
|
res.json(items);
|
||||||
} catch (error) {
|
} 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) {
|
export async function download(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
rejectClienteRole(req);
|
|
||||||
const id = parseInt(String(req.params.id), 10);
|
const id = parseInt(String(req.params.id), 10);
|
||||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
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);
|
const file = await papeleriaService.downloadArchivo(req.tenantPool!, id);
|
||||||
if (!file) return next(new AppError(404, 'Documento no encontrado'));
|
if (!file) return next(new AppError(404, 'Documento no encontrado'));
|
||||||
res.setHeader('Content-Type', file.mime);
|
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) {
|
export async function aprobar(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
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);
|
const id = parseInt(String(req.params.id), 10);
|
||||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||||
const item = await papeleriaService.aprobar(
|
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) {
|
export async function rechazar(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
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);
|
const id = parseInt(String(req.params.id), 10);
|
||||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||||
const { comentario } = rechazarSchema.parse(req.body);
|
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) {
|
export async function eliminar(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
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);
|
const id = parseInt(String(req.params.id), 10);
|
||||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||||
const ok = await papeleriaService.eliminar(req.tenantPool!, id);
|
const ok = await papeleriaService.eliminar(req.tenantPool!, id);
|
||||||
@@ -161,22 +242,26 @@ export async function eliminar(req: Request, res: Response, next: NextFunction)
|
|||||||
|
|
||||||
// ─── Notificaciones ───
|
// ─── 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.
|
* 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(
|
async function notifyAprobacionRequerida(
|
||||||
req: Request,
|
req: Request,
|
||||||
item: papeleriaService.PapeleriaItem,
|
item: papeleriaService.PapeleriaItem,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const tenantId = effectiveTenantId(req);
|
const tenantId = effectiveTenantId(req);
|
||||||
|
|
||||||
// Owners del despacho
|
|
||||||
const recipients = new Set<string>(await getTenantOwnerEmails(tenantId));
|
const recipients = new Set<string>(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({
|
const supervisores = await prisma.tenantMembership.findMany({
|
||||||
where: { tenantId, active: true, rol: { nombre: { in: ['supervisor', 'cfo'] } } },
|
where: { tenantId, active: true, rol: { nombre: { in: ['supervisor', 'cfo'] } } },
|
||||||
include: { user: { select: { email: true, active: true } } },
|
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);
|
if (m.user.active && m.user.email) recipients.add(m.user.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No notificarse a sí mismo
|
|
||||||
recipients.delete(req.user!.email);
|
recipients.delete(req.user!.email);
|
||||||
|
|
||||||
if (recipients.size === 0) return;
|
if (recipients.size === 0) return;
|
||||||
|
|
||||||
const tenant = await prisma.tenant.findUnique({
|
const tenant = await prisma.tenant.findUnique({
|
||||||
where: { id: tenantId },
|
where: { id: tenantId },
|
||||||
select: { nombre: true },
|
select: { nombre: true },
|
||||||
});
|
});
|
||||||
|
const info = await getContribuyenteInfo(req, item.contribuyenteId);
|
||||||
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
|
if (!info) return;
|
||||||
`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 link = `${env.FRONTEND_URL}/documentos`;
|
const link = `${env.FRONTEND_URL}/documentos`;
|
||||||
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
|
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) {
|
for (const to of recipients) {
|
||||||
try {
|
try {
|
||||||
await emailService.sendPapeleriaAprobacionRequerida(to, {
|
await emailService.sendPapeleriaAprobacionRequerida(to, {
|
||||||
contribuyenteRfc: rows[0].rfc,
|
contribuyenteRfc: info.rfc,
|
||||||
contribuyenteNombre: rows[0].nombre,
|
contribuyenteNombre: info.nombre,
|
||||||
despachoNombre: tenant?.nombre,
|
despachoNombre: tenant?.nombre,
|
||||||
nombreDocumento: item.nombre,
|
nombreDocumento: item.nombre,
|
||||||
descripcion: item.descripcion,
|
descripcion: item.descripcion,
|
||||||
@@ -226,9 +303,7 @@ async function notifyAprobacionRequerida(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notifica al uploader (auxiliar) cuando un documento que él subió fue
|
* Notifica al uploader cuando un documento fue aprobado o rechazado por owner/supervisor.
|
||||||
* aprobado o rechazado. Solo manda si quien aprobó/rechazó NO es el mismo
|
|
||||||
* uploader (caso edge: owner sube su propia papelería).
|
|
||||||
*/
|
*/
|
||||||
async function notifyDecisionAuxiliar(
|
async function notifyDecisionAuxiliar(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -238,21 +313,16 @@ async function notifyDecisionAuxiliar(
|
|||||||
const auxiliarEmail = await getUserEmailById(item.subidoPor);
|
const auxiliarEmail = await getUserEmailById(item.subidoPor);
|
||||||
if (!auxiliarEmail) return;
|
if (!auxiliarEmail) return;
|
||||||
|
|
||||||
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
|
const info = await getContribuyenteInfo(req, item.contribuyenteId);
|
||||||
`SELECT c.rfc, eg.nombre FROM contribuyentes c
|
if (!info) return;
|
||||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
|
||||||
WHERE c.entidad_id = $1`,
|
|
||||||
[item.contribuyenteId],
|
|
||||||
);
|
|
||||||
if (rows.length === 0) return;
|
|
||||||
|
|
||||||
const link = `${env.FRONTEND_URL}/documentos`;
|
const link = `${env.FRONTEND_URL}/documentos`;
|
||||||
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
|
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
|
||||||
const periodo = `${meses[item.mes - 1]} ${item.anio}`;
|
const periodo = `${meses[item.mes - 1]} ${item.anio}`;
|
||||||
|
|
||||||
await emailService.sendPapeleriaDecision(auxiliarEmail, {
|
await emailService.sendPapeleriaDecision(auxiliarEmail, {
|
||||||
contribuyenteRfc: rows[0].rfc,
|
contribuyenteRfc: info.rfc,
|
||||||
contribuyenteNombre: rows[0].nombre,
|
contribuyenteNombre: info.nombre,
|
||||||
nombreDocumento: item.nombre,
|
nombreDocumento: item.nombre,
|
||||||
estado: item.estado as 'aprobado' | 'rechazado',
|
estado: item.estado as 'aprobado' | 'rechazado',
|
||||||
revisor: req.user!.email,
|
revisor: req.user!.email,
|
||||||
@@ -261,3 +331,57 @@ async function notifyDecisionAuxiliar(
|
|||||||
link,
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -13,6 +13,8 @@ router.post('/', ctrl.upload);
|
|||||||
router.get('/:id/download', ctrl.download);
|
router.get('/:id/download', ctrl.download);
|
||||||
router.post('/:id/aprobar', ctrl.aprobar);
|
router.post('/:id/aprobar', ctrl.aprobar);
|
||||||
router.post('/:id/rechazar', ctrl.rechazar);
|
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);
|
router.delete('/:id', ctrl.eliminar);
|
||||||
|
|
||||||
export { router as papeleriaRoutes };
|
export { router as papeleriaRoutes };
|
||||||
|
|||||||
@@ -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
|
* Cron 8:30 AM — alertas fiscales nuevas activadas hoy. Envía un solo
|
||||||
* correo por destinatario con el batch completo. Caller debe deduplicar
|
* correo por destinatario con el batch completo. Caller debe deduplicar
|
||||||
|
|||||||
@@ -55,3 +55,32 @@ export function papeleriaDecisionEmail(d: PapeleriaDecisionData): string {
|
|||||||
`;
|
`;
|
||||||
return baseTemplate(body);
|
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')}
|
||||||
|
<p>${d.subidoPor} subió un documento que requiere tu aprobación como cliente:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Documento:</strong> ${d.nombreDocumento}</li>
|
||||||
|
<li><strong>Contribuyente:</strong> ${d.contribuyenteNombre} (${d.contribuyenteRfc})</li>
|
||||||
|
<li><strong>Periodo:</strong> ${d.periodo}</li>
|
||||||
|
${d.descripcion ? `<li><strong>Descripción:</strong> ${d.descripcion}</li>` : ''}
|
||||||
|
</ul>
|
||||||
|
${infoBox('Revisa el documento y márcalo como aprobado o rechazado desde la sección de Documentos del despacho.')}
|
||||||
|
<div style="margin-top: 24px;">
|
||||||
|
${primaryButton('Ver documento', d.link)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return baseTemplate(body);
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export interface PapeleriaItem {
|
|||||||
aprobadoPor: string | null;
|
aprobadoPor: string | null;
|
||||||
aprobadoAt: Date | null;
|
aprobadoAt: Date | null;
|
||||||
comentarioRechazo: string | null;
|
comentarioRechazo: string | null;
|
||||||
|
requiereAprobacionCliente: boolean;
|
||||||
|
estadoCliente: EstadoPapeleria | null;
|
||||||
|
aprobadoPorCliente: string | null;
|
||||||
|
aprobadoAtCliente: Date | null;
|
||||||
|
comentarioRechazoCliente: string | null;
|
||||||
subidoPor: string;
|
subidoPor: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
@@ -36,6 +41,7 @@ const SELECT = `
|
|||||||
archivo_filename, archivo_mime, archivo_size,
|
archivo_filename, archivo_mime, archivo_size,
|
||||||
anio, mes,
|
anio, mes,
|
||||||
requiere_aprobacion, estado, aprobado_por, aprobado_at, comentario_rechazo,
|
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
|
subido_por, created_at
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -54,6 +60,11 @@ const ROW = (r: any): PapeleriaItem => ({
|
|||||||
aprobadoPor: r.aprobado_por,
|
aprobadoPor: r.aprobado_por,
|
||||||
aprobadoAt: r.aprobado_at,
|
aprobadoAt: r.aprobado_at,
|
||||||
comentarioRechazo: r.comentario_rechazo,
|
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,
|
subidoPor: r.subido_por,
|
||||||
createdAt: r.created_at,
|
createdAt: r.created_at,
|
||||||
});
|
});
|
||||||
@@ -69,6 +80,7 @@ export interface UploadInput {
|
|||||||
anio: number;
|
anio: number;
|
||||||
mes: number;
|
mes: number;
|
||||||
requiereAprobacion: boolean;
|
requiereAprobacion: boolean;
|
||||||
|
requiereAprobacionCliente: boolean;
|
||||||
archivo: Buffer;
|
archivo: Buffer;
|
||||||
archivoFilename: string;
|
archivoFilename: string;
|
||||||
archivoMime: string;
|
archivoMime: string;
|
||||||
@@ -87,12 +99,13 @@ export async function uploadPapeleria(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const estadoInicial = input.requiereAprobacion ? 'pendiente' : null;
|
const estadoInicial = input.requiereAprobacion ? 'pendiente' : null;
|
||||||
|
const estadoClienteInicial = input.requiereAprobacionCliente ? 'pendiente' : null;
|
||||||
|
|
||||||
const { rows: [r] } = await pool.query(
|
const { rows: [r] } = await pool.query(
|
||||||
`INSERT INTO papeleria_trabajo
|
`INSERT INTO papeleria_trabajo
|
||||||
(contribuyente_id, nombre, descripcion, archivo, archivo_filename, archivo_mime, archivo_size,
|
(contribuyente_id, nombre, descripcion, archivo, archivo_filename, archivo_mime, archivo_size,
|
||||||
anio, mes, requiere_aprobacion, estado, subido_por)
|
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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
RETURNING ${SELECT}`,
|
RETURNING ${SELECT}`,
|
||||||
[
|
[
|
||||||
sanitizeUuid(input.contribuyenteId),
|
sanitizeUuid(input.contribuyenteId),
|
||||||
@@ -106,6 +119,8 @@ export async function uploadPapeleria(
|
|||||||
input.mes,
|
input.mes,
|
||||||
input.requiereAprobacion,
|
input.requiereAprobacion,
|
||||||
estadoInicial,
|
estadoInicial,
|
||||||
|
input.requiereAprobacionCliente,
|
||||||
|
estadoClienteInicial,
|
||||||
input.subidoPor,
|
input.subidoPor,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -117,6 +132,8 @@ export interface ListFilters {
|
|||||||
anio?: number;
|
anio?: number;
|
||||||
mes?: number;
|
mes?: number;
|
||||||
estado?: EstadoPapeleria | 'sin_aprobacion';
|
estado?: EstadoPapeleria | 'sin_aprobacion';
|
||||||
|
entidadIds?: string[];
|
||||||
|
userRole?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listPapeleria(pool: Pool, f: ListFilters): Promise<PapeleriaItem[]> {
|
export async function listPapeleria(pool: Pool, f: ListFilters): Promise<PapeleriaItem[]> {
|
||||||
@@ -126,10 +143,17 @@ export async function listPapeleria(pool: Pool, f: ListFilters): Promise<Papeler
|
|||||||
if (f.anio) { conds.push(`anio = $${i++}`); vals.push(f.anio); }
|
if (f.anio) { conds.push(`anio = $${i++}`); vals.push(f.anio); }
|
||||||
if (f.mes) { conds.push(`mes = $${i++}`); vals.push(f.mes); }
|
if (f.mes) { conds.push(`mes = $${i++}`); vals.push(f.mes); }
|
||||||
if (f.estado === 'sin_aprobacion') {
|
if (f.estado === 'sin_aprobacion') {
|
||||||
conds.push('requiere_aprobacion = false');
|
conds.push('requiere_aprobacion = false AND requiere_aprobacion_cliente = false');
|
||||||
} else if (f.estado) {
|
} else if (f.estado) {
|
||||||
conds.push(`estado = $${i++}`); vals.push(f.estado);
|
conds.push(`estado = $${i++}`); vals.push(f.estado);
|
||||||
}
|
}
|
||||||
|
if (f.entidadIds && f.entidadIds.length > 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(
|
const { rows } = await pool.query(
|
||||||
`SELECT ${SELECT} FROM papeleria_trabajo
|
`SELECT ${SELECT} FROM papeleria_trabajo
|
||||||
WHERE ${conds.join(' AND ')}
|
WHERE ${conds.join(' AND ')}
|
||||||
@@ -202,6 +226,39 @@ export async function rechazar(
|
|||||||
return r ? ROW(r) : null;
|
return r ? ROW(r) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function aprobarCliente(
|
||||||
|
pool: Pool,
|
||||||
|
id: number,
|
||||||
|
userId: string,
|
||||||
|
): Promise<PapeleriaItem | null> {
|
||||||
|
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<PapeleriaItem | null> {
|
||||||
|
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<boolean> {
|
export async function eliminar(pool: Pool, id: number): Promise<boolean> {
|
||||||
const { rowCount } = await pool.query(
|
const { rowCount } = await pool.query(
|
||||||
`DELETE FROM papeleria_trabajo WHERE id = $1`,
|
`DELETE FROM papeleria_trabajo WHERE id = $1`,
|
||||||
@@ -209,3 +266,30 @@ export async function eliminar(pool: Pool, id: number): Promise<boolean> {
|
|||||||
);
|
);
|
||||||
return (rowCount ?? 0) > 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function EstatusBadge({ estatus }: { estatus: string }) {
|
|||||||
export default function DocumentosPage() {
|
export default function DocumentosPage() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const canConsultarOpinion = user?.role === 'owner' || user?.role === 'cfo';
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { useContribuyenteStore } from '@/stores/contribuyente-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 MESES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
|
||||||
const ALLOWED_MIMES = [
|
const ALLOWED_MIMES = [
|
||||||
@@ -37,6 +37,9 @@ interface Papeleria {
|
|||||||
requiereAprobacion: boolean;
|
requiereAprobacion: boolean;
|
||||||
estado: 'pendiente' | 'aprobado' | 'rechazado' | null;
|
estado: 'pendiente' | 'aprobado' | 'rechazado' | null;
|
||||||
comentarioRechazo: string | null;
|
comentarioRechazo: string | null;
|
||||||
|
requiereAprobacionCliente: boolean;
|
||||||
|
estadoCliente: 'pendiente' | 'aprobado' | 'rechazado' | null;
|
||||||
|
comentarioRechazoCliente: string | null;
|
||||||
subidoPor: string;
|
subidoPor: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -54,28 +57,59 @@ function fileToBase64(file: File): Promise<string> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function EstadoBadge({ estado, requiereAprobacion }: { estado: string | null; requiereAprobacion: boolean }) {
|
function estadoGlobal(item: Papeleria): 'sin_aprobacion' | 'pendiente' | 'aprobado' | 'rechazado' {
|
||||||
if (!requiereAprobacion) {
|
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 <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-muted-foreground">Sin aprobación</span>;
|
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-muted-foreground">Sin aprobación</span>;
|
||||||
}
|
}
|
||||||
if (estado === 'aprobado') {
|
if (global === 'aprobado') {
|
||||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"><CheckCircle2 className="h-3 w-3" /> Aprobado</span>;
|
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"><CheckCircle2 className="h-3 w-3" /> Aprobado</span>;
|
||||||
}
|
}
|
||||||
if (estado === 'rechazado') {
|
if (global === 'rechazado') {
|
||||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"><XCircle className="h-3 w-3" /> Rechazado</span>;
|
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"><XCircle className="h-3 w-3" /> Rechazado</span>;
|
||||||
}
|
}
|
||||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"><Clock className="h-3 w-3" /> Pendiente</span>;
|
|
||||||
|
// 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 <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"><Clock className="h-3 w-3" /> {label}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PapeleriaTab() {
|
export function PapeleriaTab() {
|
||||||
const user = useAuthStore(s => s.user);
|
const user = useAuthStore(s => s.user);
|
||||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||||
const queryClient = useQueryClient();
|
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 [showUpload, setShowUpload] = useState(false);
|
||||||
const [rechazoFor, setRechazoFor] = useState<Papeleria | null>(null);
|
const [rechazoFor, setRechazoFor] = useState<Papeleria | null>(null);
|
||||||
const [comentarioRechazo, setComentarioRechazo] = useState('');
|
const [comentarioRechazo, setComentarioRechazo] = useState('');
|
||||||
|
const [rechazoClienteFor, setRechazoClienteFor] = useState<Papeleria | null>(null);
|
||||||
|
const [comentarioRechazoCliente, setComentarioRechazoCliente] = useState('');
|
||||||
|
|
||||||
// Filtros
|
// Filtros
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
@@ -105,6 +139,7 @@ export function PapeleriaTab() {
|
|||||||
const [anio, setAnio] = useState(currentYear);
|
const [anio, setAnio] = useState(currentYear);
|
||||||
const [mes, setMes] = useState(new Date().getMonth() + 1);
|
const [mes, setMes] = useState(new Date().getMonth() + 1);
|
||||||
const [requiereAprobacion, setRequiereAprobacion] = useState(false);
|
const [requiereAprobacion, setRequiereAprobacion] = useState(false);
|
||||||
|
const [requiereAprobacionCliente, setRequiereAprobacionCliente] = useState(false);
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
|
|
||||||
const resetUpload = () => {
|
const resetUpload = () => {
|
||||||
@@ -114,6 +149,7 @@ export function PapeleriaTab() {
|
|||||||
setAnio(currentYear);
|
setAnio(currentYear);
|
||||||
setMes(new Date().getMonth() + 1);
|
setMes(new Date().getMonth() + 1);
|
||||||
setRequiereAprobacion(false);
|
setRequiereAprobacion(false);
|
||||||
|
setRequiereAprobacionCliente(false);
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,6 +166,7 @@ export function PapeleriaTab() {
|
|||||||
anio,
|
anio,
|
||||||
mes,
|
mes,
|
||||||
requiereAprobacion,
|
requiereAprobacion,
|
||||||
|
requiereAprobacionCliente,
|
||||||
archivoBase64: base64,
|
archivoBase64: base64,
|
||||||
archivoFilename: file.name,
|
archivoFilename: file.name,
|
||||||
archivoMime: file.type,
|
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({
|
const eliminarMutation = useMutation({
|
||||||
mutationFn: async (id: number) => apiClient.delete(`/papeleria/${id}`),
|
mutationFn: async (id: number) => apiClient.delete(`/papeleria/${id}`),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
@@ -236,9 +288,11 @@ export function PapeleriaTab() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{canUpload && (
|
||||||
<Button onClick={() => setShowUpload(true)}>
|
<Button onClick={() => setShowUpload(true)}>
|
||||||
<Upload className="h-4 w-4 mr-2" /> Subir documento
|
<Upload className="h-4 w-4 mr-2" /> Subir documento
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Listado */}
|
{/* Listado */}
|
||||||
@@ -258,7 +312,7 @@ export function PapeleriaTab() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-sm font-medium">{it.nombre}</span>
|
<span className="text-sm font-medium">{it.nombre}</span>
|
||||||
<EstadoBadge estado={it.estado} requiereAprobacion={it.requiereAprobacion} />
|
<EstadoBadge item={it} />
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{MESES[it.mes - 1]} {it.anio}
|
{MESES[it.mes - 1]} {it.anio}
|
||||||
</span>
|
</span>
|
||||||
@@ -270,10 +324,31 @@ export function PapeleriaTab() {
|
|||||||
{it.archivoFilename} · {(it.archivoSize / 1024).toFixed(0)} KB
|
{it.archivoFilename} · {(it.archivoSize / 1024).toFixed(0)} KB
|
||||||
· subido {new Date(it.createdAt).toLocaleDateString('es-MX')}
|
· subido {new Date(it.createdAt).toLocaleDateString('es-MX')}
|
||||||
</p>
|
</p>
|
||||||
|
{/* Mostrar estado detallado para no-clientes */}
|
||||||
|
{!isCliente && (it.requiereAprobacion || it.requiereAprobacionCliente) && (
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{it.requiereAprobacion && (
|
||||||
|
<span className={`text-xs inline-flex items-center gap-1 ${it.estado === 'aprobado' ? 'text-green-700 dark:text-green-400' : it.estado === 'rechazado' ? 'text-red-700 dark:text-red-400' : 'text-yellow-700 dark:text-yellow-400'}`}>
|
||||||
|
<UserCheck className="h-3 w-3" /> Owner: {it.estado ?? '—'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{it.requiereAprobacionCliente && (
|
||||||
|
<span className={`text-xs inline-flex items-center gap-1 ${it.estadoCliente === 'aprobado' ? 'text-green-700 dark:text-green-400' : it.estadoCliente === 'rechazado' ? 'text-red-700 dark:text-red-400' : 'text-yellow-700 dark:text-yellow-400'}`}>
|
||||||
|
<UserCheck className="h-3 w-3" /> Cliente: {it.estadoCliente ?? '—'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{it.estado === 'rechazado' && it.comentarioRechazo && (
|
{it.estado === 'rechazado' && it.comentarioRechazo && (
|
||||||
<p className="text-xs mt-1 flex items-start gap-1 text-red-700 dark:text-red-400">
|
<p className="text-xs mt-1 flex items-start gap-1 text-red-700 dark:text-red-400">
|
||||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
<MessageSquare className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||||
<span>{it.comentarioRechazo}</span>
|
<span><strong>Owner:</strong> {it.comentarioRechazo}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{it.estadoCliente === 'rechazado' && it.comentarioRechazoCliente && (
|
||||||
|
<p className="text-xs mt-1 flex items-start gap-1 text-red-700 dark:text-red-400">
|
||||||
|
<MessageSquare className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||||
|
<span><strong>Cliente:</strong> {it.comentarioRechazoCliente}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -281,7 +356,8 @@ export function PapeleriaTab() {
|
|||||||
<Button variant="ghost" size="icon" onClick={() => downloadMutation.mutate(it)} title="Descargar">
|
<Button variant="ghost" size="icon" onClick={() => downloadMutation.mutate(it)} title="Descargar">
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{canApprove && it.requiereAprobacion && it.estado === 'pendiente' && (
|
{/* Botones owner/supervisor */}
|
||||||
|
{canApproveOwner && it.requiereAprobacion && it.estado === 'pendiente' && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost" size="icon"
|
variant="ghost" size="icon"
|
||||||
@@ -299,6 +375,26 @@ export function PapeleriaTab() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{/* Botones cliente */}
|
||||||
|
{isCliente && it.requiereAprobacionCliente && it.estadoCliente === 'pendiente' && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost" size="icon"
|
||||||
|
onClick={() => aprobarClienteMutation.mutate(it.id)}
|
||||||
|
title="Aprobar"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost" size="icon"
|
||||||
|
onClick={() => setRechazoClienteFor(it)}
|
||||||
|
title="Rechazar"
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4 text-red-600" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{canUpload && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost" size="icon"
|
variant="ghost" size="icon"
|
||||||
onClick={() => confirm(`¿Eliminar "${it.nombre}"?`) && eliminarMutation.mutate(it.id)}
|
onClick={() => confirm(`¿Eliminar "${it.nombre}"?`) && eliminarMutation.mutate(it.id)}
|
||||||
@@ -306,6 +402,7 @@ export function PapeleriaTab() {
|
|||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -375,6 +472,14 @@ export function PapeleriaTab() {
|
|||||||
/>
|
/>
|
||||||
Este documento requiere aprobación de owner/supervisor
|
Este documento requiere aprobación de owner/supervisor
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={requiereAprobacionCliente}
|
||||||
|
onChange={e => setRequiereAprobacionCliente(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Este documento requiere aprobación del cliente
|
||||||
|
</label>
|
||||||
{uploadError && (
|
{uploadError && (
|
||||||
<p className="text-xs text-destructive flex items-start gap-1">
|
<p className="text-xs text-destructive flex items-start gap-1">
|
||||||
<AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
<AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||||
@@ -394,7 +499,7 @@ export function PapeleriaTab() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Modal Rechazo */}
|
{/* Modal Rechazo Owner */}
|
||||||
<Dialog open={!!rechazoFor} onOpenChange={(o) => { if (!o) { setRechazoFor(null); setComentarioRechazo(''); } }}>
|
<Dialog open={!!rechazoFor} onOpenChange={(o) => { if (!o) { setRechazoFor(null); setComentarioRechazo(''); } }}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -426,6 +531,39 @@ export function PapeleriaTab() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Modal Rechazo Cliente */}
|
||||||
|
<Dialog open={!!rechazoClienteFor} onOpenChange={(o) => { if (!o) { setRechazoClienteFor(null); setComentarioRechazoCliente(''); } }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Rechazar documento</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm">
|
||||||
|
Vas a rechazar <strong>{rechazoClienteFor?.nombre}</strong>. El comentario es opcional.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<Label>Comentario (opcional)</Label>
|
||||||
|
<Input
|
||||||
|
value={comentarioRechazoCliente}
|
||||||
|
onChange={e => setComentarioRechazoCliente(e.target.value)}
|
||||||
|
placeholder="Motivo del rechazo..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => { setRechazoClienteFor(null); setComentarioRechazoCliente(''); }}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => rechazoClienteFor && rechazarClienteMutation.mutate({ id: rechazoClienteFor.id, comentario: comentarioRechazoCliente || null })}
|
||||||
|
className={cn('bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
|
||||||
|
>
|
||||||
|
Rechazar
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user