Compare commits
4 Commits
2208cee87f
...
0439a84e6d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0439a84e6d | ||
|
|
0815269f1b | ||
|
|
9b535354fb | ||
|
|
e01422e443 |
@@ -42,7 +42,24 @@ export async function list(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
|
||||
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds, req.user!.tenantId);
|
||||
return res.json({ data: rows });
|
||||
|
||||
// Batch lookup de nombres de supervisores
|
||||
const supervisorIds = [...new Set(rows.map(r => r.supervisorUserId).filter(Boolean))] as string[];
|
||||
const supervisorNames: Record<string, string> = {};
|
||||
if (supervisorIds.length > 0) {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: supervisorIds } },
|
||||
select: { id: true, nombre: true },
|
||||
});
|
||||
for (const u of users) supervisorNames[u.id] = u.nombre;
|
||||
}
|
||||
|
||||
return res.json({
|
||||
data: rows.map(r => ({
|
||||
...r,
|
||||
supervisorNombre: r.supervisorUserId ? (supervisorNames[r.supervisorUserId] ?? null) : null,
|
||||
})),
|
||||
});
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
|
||||
@@ -580,7 +580,13 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
|
||||
const params: any[] = [];
|
||||
if (q.length >= 2) {
|
||||
params.push(`%${q}%`);
|
||||
whereSearch = `AND (cc.descripcion ILIKE $1 OR cc.clave_prod_serv ILIKE $1)`;
|
||||
whereSearch = `AND (cc.descripcion ILIKE $${params.length} OR cc.clave_prod_serv ILIKE $${params.length})`;
|
||||
}
|
||||
|
||||
let whereContribuyente = '';
|
||||
if (contribuyenteId) {
|
||||
params.push(contribuyenteId);
|
||||
whereContribuyente = `AND c.contribuyente_id = $${params.length}`;
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
@@ -605,6 +611,7 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
|
||||
WHERE c.status NOT IN ('Cancelado', '0')
|
||||
${whereType}
|
||||
${whereSearch}
|
||||
${whereContribuyente}
|
||||
ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC
|
||||
LIMIT 30
|
||||
`, params);
|
||||
@@ -708,7 +715,7 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
|
||||
const q = (req.query.q as string || '').trim();
|
||||
if (q.length < 3) return res.json([]);
|
||||
|
||||
const contribuyenteId = (req.query.contribuyenteId as string || '').trim();
|
||||
const contribuyenteId = (req.query.contribuyenteId as string || '').replace(/[^a-f0-9-]/gi, '');
|
||||
const pool = req.tenantPool!;
|
||||
|
||||
// RFC del tenant despacho para excluirlo (no se factura a sí mismo)
|
||||
@@ -719,10 +726,17 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
|
||||
});
|
||||
const tenantRfc = tenant?.rfc || '';
|
||||
|
||||
// Búsqueda en el catálogo completo de RFCs. El contribuyente activo solo
|
||||
// filtra CFDIs relacionados / PPD, no el autocompleto de RFCs — de lo
|
||||
// contrario no se podría facturar a un cliente nuevo que nunca haya
|
||||
// aparecido en un CFDI previo.
|
||||
const params: any[] = [tenantRfc, `%${q}%`];
|
||||
let whereContribuyente = '';
|
||||
if (contribuyenteId) {
|
||||
params.push(contribuyenteId);
|
||||
whereContribuyente = `AND id IN (
|
||||
SELECT rfc_receptor_id FROM cfdis WHERE contribuyente_id = $${params.length} AND rfc_receptor_id IS NOT NULL
|
||||
UNION
|
||||
SELECT rfc_emisor_id FROM cfdis WHERE contribuyente_id = $${params.length} AND rfc_emisor_id IS NOT NULL
|
||||
)`;
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, rfc, razon_social as "razonSocial",
|
||||
regimen_fiscal as "regimenFiscal",
|
||||
@@ -730,9 +744,10 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
|
||||
FROM rfcs
|
||||
WHERE rfc != $1
|
||||
AND (rfc ILIKE $2 OR razon_social ILIKE $2)
|
||||
${whereContribuyente}
|
||||
ORDER BY razon_social
|
||||
LIMIT 10
|
||||
`, [tenantRfc, `%${q}%`]);
|
||||
`, params);
|
||||
|
||||
res.json(rows);
|
||||
} catch (error) { next(error); }
|
||||
|
||||
@@ -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<void> {
|
||||
const tenantId = effectiveTenantId(req);
|
||||
|
||||
// Owners del despacho
|
||||
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({
|
||||
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<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.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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')}
|
||||
<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;
|
||||
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<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.mes) { conds.push(`mes = $${i++}`); vals.push(f.mes); }
|
||||
if (f.estado === 'sin_aprobacion') {
|
||||
conds.push('requiere_aprobacion = false');
|
||||
conds.push('requiere_aprobacion = false AND requiere_aprobacion_cliente = false');
|
||||
} else if (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(
|
||||
`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<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> {
|
||||
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<boolean> {
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ export default function ContribuyentesPage() {
|
||||
<p className="font-semibold">{c.nombre}</p>
|
||||
<p className="text-sm text-muted-foreground font-mono">{c.rfc}</p>
|
||||
{c.regimenFiscal && <p className="text-xs text-muted-foreground mt-1">Régimen: {c.regimenFiscal}</p>}
|
||||
{c.supervisorNombre && <p className="text-xs text-muted-foreground mt-1">Supervisor: {c.supervisorNombre}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(user?.role === 'owner' || user?.role === 'cfo') && (
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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<string> {
|
||||
});
|
||||
}
|
||||
|
||||
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 <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>;
|
||||
}
|
||||
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-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() {
|
||||
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<Papeleria | null>(null);
|
||||
const [comentarioRechazo, setComentarioRechazo] = useState('');
|
||||
const [rechazoClienteFor, setRechazoClienteFor] = useState<Papeleria | null>(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<string | null>(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,11 +209,33 @@ 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,
|
||||
});
|
||||
|
||||
const items = query.data ?? [];
|
||||
const años = useMemo(() => {
|
||||
const set = new Set<number>([currentYear]);
|
||||
items.forEach(i => set.add(i.anio));
|
||||
return [...set].sort((a, b) => b - a);
|
||||
}, [items, currentYear]);
|
||||
|
||||
if (!selectedContribuyenteId) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -187,13 +246,6 @@ export function PapeleriaTab() {
|
||||
);
|
||||
}
|
||||
|
||||
const items = query.data ?? [];
|
||||
const años = useMemo(() => {
|
||||
const set = new Set<number>([currentYear]);
|
||||
items.forEach(i => set.add(i.anio));
|
||||
return [...set].sort((a, b) => b - a);
|
||||
}, [items, currentYear]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filtros + upload */}
|
||||
@@ -236,9 +288,11 @@ export function PapeleriaTab() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setShowUpload(true)}>
|
||||
<Upload className="h-4 w-4 mr-2" /> Subir documento
|
||||
</Button>
|
||||
{canUpload && (
|
||||
<Button onClick={() => setShowUpload(true)}>
|
||||
<Upload className="h-4 w-4 mr-2" /> Subir documento
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Listado */}
|
||||
@@ -258,7 +312,7 @@ export function PapeleriaTab() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<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">
|
||||
{MESES[it.mes - 1]} {it.anio}
|
||||
</span>
|
||||
@@ -270,10 +324,31 @@ export function PapeleriaTab() {
|
||||
{it.archivoFilename} · {(it.archivoSize / 1024).toFixed(0)} KB
|
||||
· subido {new Date(it.createdAt).toLocaleDateString('es-MX')}
|
||||
</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 && (
|
||||
<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>{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>
|
||||
)}
|
||||
</div>
|
||||
@@ -281,7 +356,8 @@ export function PapeleriaTab() {
|
||||
<Button variant="ghost" size="icon" onClick={() => downloadMutation.mutate(it)} title="Descargar">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
{canApprove && it.requiereAprobacion && it.estado === 'pendiente' && (
|
||||
{/* Botones owner/supervisor */}
|
||||
{canApproveOwner && it.requiereAprobacion && it.estado === 'pendiente' && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
@@ -299,13 +375,34 @@ export function PapeleriaTab() {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
onClick={() => confirm(`¿Eliminar "${it.nombre}"?`) && eliminarMutation.mutate(it.id)}
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</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
|
||||
variant="ghost" size="icon"
|
||||
onClick={() => confirm(`¿Eliminar "${it.nombre}"?`) && eliminarMutation.mutate(it.id)}
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -375,6 +472,14 @@ export function PapeleriaTab() {
|
||||
/>
|
||||
Este documento requiere aprobación de owner/supervisor
|
||||
</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 && (
|
||||
<p className="text-xs text-destructive flex items-start gap-1">
|
||||
<AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||
@@ -394,7 +499,7 @@ export function PapeleriaTab() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Modal Rechazo */}
|
||||
{/* Modal Rechazo Owner */}
|
||||
<Dialog open={!!rechazoFor} onOpenChange={(o) => { if (!o) { setRechazoFor(null); setComentarioRechazo(''); } }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -426,6 +531,39 @@ export function PapeleriaTab() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user