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:
@@ -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,6 +209,21 @@ export function PapeleriaTab() {
|
||||
},
|
||||
});
|
||||
|
||||
const aprobarClienteMutation = useMutation({
|
||||
mutationFn: async (id: number) => apiClient.post(`/papeleria/${id}/aprobar-cliente`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const rechazarClienteMutation = useMutation({
|
||||
mutationFn: async ({ id, comentario }: { id: number; comentario: string | null }) =>
|
||||
apiClient.post(`/papeleria/${id}/rechazar-cliente`, { comentario }),
|
||||
onSuccess: () => {
|
||||
setRechazoClienteFor(null);
|
||||
setComentarioRechazoCliente('');
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const eliminarMutation = useMutation({
|
||||
mutationFn: async (id: number) => apiClient.delete(`/papeleria/${id}`),
|
||||
onSuccess: invalidate,
|
||||
@@ -236,9 +288,11 @@ export function PapeleriaTab() {
|
||||
</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