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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user