feat(notificaciones): configuración de notificaciones por rol

- Nueva tabla tenant notification_role_preferences para guardar (email_type, role, enabled).
- Migración 051 aplicada a todos los tenants.
- Backend expone endpoint /notificaciones con matriz de preferencias por rol.
- Filtrado por rol en documento_subido, weekly_update, subscription_expiring,
  alertas_nuevas y recordatorio_proximo.
- Frontend rediseñado como tabla notificación × rol con toggles inmediatos.
This commit is contained in:
Horux Dev
2026-06-17 00:04:37 +00:00
parent 8a1fbceb38
commit b217342a96
8 changed files with 380 additions and 192 deletions

View File

@@ -1,55 +1,59 @@
'use client';
import { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { Bell, Loader2 } from 'lucide-react';
const EMAIL_LABELS: Record<string, { label: string; description: string; status: 'active' | 'pending'; configurable: boolean }> = {
const ROLE_LABELS: Record<string, string> = {
owner: 'Owner',
supervisor: 'Supervisor',
auxiliar: 'Auxiliar',
cliente: 'Cliente',
};
const EMAIL_LABELS: Record<string, { label: string; description: string; status: 'active' | 'pending' }> = {
documento_subido: {
label: 'Documento subido',
description: 'Notificación cuando se sube una declaración o documento extra del contribuyente.',
description: 'Cuando se sube una declaración o documento extra del contribuyente.',
status: 'active',
configurable: true,
},
weekly_update: {
label: 'Reporte semanal',
description: 'Resumen de KPIs, alertas y discrepancias enviado los lunes 8:00 AM.',
status: 'active',
configurable: false,
},
subscription_expiring: {
label: 'Vencimiento de suscripción',
description: 'Aviso cuando la suscripción del despacho está por vencer.',
status: 'active',
configurable: false,
},
recordatorio_fiscal: {
label: 'Recordatorios fiscales',
description: 'Avisos de obligaciones próximas a vencer (declaraciones, pagos provisionales).',
status: 'pending',
configurable: false,
},
alertas_nuevas: {
label: 'Alertas nuevas',
description: 'Notificación diaria cuando aparecen alertas fiscales nuevas para un contribuyente.',
status: 'active',
},
recordatorio_proximo: {
label: 'Recordatorios próximos',
description: 'Avisos de recordatorios del calendario a 3, 1 y 0 días de su fecha límite.',
status: 'active',
},
};
interface ContribuyentePrefs {
contribuyenteId: string;
rfc: string;
nombre: string;
preferences: Record<string, boolean>;
}
interface ListResponse {
emailTypes: string[];
data: ContribuyentePrefs[];
roles: string[];
preferences: Record<string, Record<string, boolean>>;
}
export default function NotificacionesPage() {
const queryClient = useQueryClient();
const { selectedContribuyenteId } = useContribuyenteStore();
const { data, isLoading } = useQuery<ListResponse>({
queryKey: ['notification-preferences'],
@@ -59,32 +63,23 @@ export default function NotificacionesPage() {
},
});
// Aplica el filtro del selector global de contribuyente. Si hay uno
// seleccionado, solo se muestra esa fila. "Todos" muestra todos.
const visibles = useMemo(() => {
if (!data) return [];
if (!selectedContribuyenteId) return data.data;
return data.data.filter(c => c.contribuyenteId === selectedContribuyenteId);
}, [data, selectedContribuyenteId]);
const mutation = useMutation({
mutationFn: async ({ contribuyenteId, emailType, enabled }: { contribuyenteId: string; emailType: string; enabled: boolean }) => {
await apiClient.put('/notificaciones', {
contribuyenteId,
preferences: { [emailType]: enabled },
});
mutationFn: async ({ emailType, role, enabled }: { emailType: string; role: string; enabled: boolean }) => {
await apiClient.put('/notificaciones', { emailType, role, enabled });
},
onMutate: async ({ contribuyenteId, emailType, enabled }) => {
onMutate: async ({ emailType, role, enabled }) => {
await queryClient.cancelQueries({ queryKey: ['notification-preferences'] });
const previous = queryClient.getQueryData<ListResponse>(['notification-preferences']);
if (previous) {
queryClient.setQueryData<ListResponse>(['notification-preferences'], {
...previous,
data: previous.data.map(c =>
c.contribuyenteId === contribuyenteId
? { ...c, preferences: { ...c.preferences, [emailType]: enabled } }
: c,
),
preferences: {
...previous.preferences,
[emailType]: {
...previous.preferences[emailType],
[role]: enabled,
},
},
});
}
return { previous };
@@ -97,6 +92,9 @@ export default function NotificacionesPage() {
},
});
const roles = data?.roles ?? [];
const emailTypes = data?.emailTypes ?? [];
return (
<>
<Header title="Notificaciones" />
@@ -105,10 +103,10 @@ export default function NotificacionesPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Bell className="h-4 w-4" />
Correos informativos por contribuyente
Correos informativos por rol
</CardTitle>
<CardDescription>
Por default todos los correos están activados. Desactiva los que no quieras recibir para cada cliente. Los correos críticos (welcome, recuperación de contraseña, confirmación de pago) siempre se envían independientemente de esta configuración.
Activa o desactiva cada notificación según el rol del usuario en el despacho. Por default todos están activados. Los correos críticos (welcome, recuperación de contraseña, confirmación de pago) siempre se envían.
</CardDescription>
</CardHeader>
</Card>
@@ -118,71 +116,68 @@ export default function NotificacionesPage() {
<Loader2 className="h-4 w-4 animate-spin" />
Cargando...
</div>
) : visibles.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
{selectedContribuyenteId
? 'El contribuyente seleccionado no tiene preferencias configuradas todavía.'
: 'No hay contribuyentes en este despacho.'}
</CardContent>
</Card>
) : (
visibles.map(contrib => (
<Card key={contrib.contribuyenteId}>
<CardHeader>
<CardTitle className="text-sm font-medium">
{contrib.nombre}
</CardTitle>
<CardDescription className="font-mono text-xs">{contrib.rfc}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{(data?.emailTypes ?? []).map(type => {
<Card>
<CardContent className="p-0 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="text-left font-medium px-4 py-3 w-1/3">Notificación</th>
{roles.map(role => (
<th key={role} className="text-center font-medium px-4 py-3 min-w-[100px]">
{ROLE_LABELS[role] ?? role}
</th>
))}
</tr>
</thead>
<tbody>
{emailTypes.map(type => {
const meta = EMAIL_LABELS[type];
if (!meta) return null;
const checked = contrib.preferences[type] !== false;
const isPending = meta.status === 'pending';
const isConfigurable = meta.configurable;
return (
<div key={type} className="flex items-start justify-between gap-4 py-2 border-b last:border-0">
<div className="flex-1 min-w-0">
<tr key={type} className="border-b last:border-0">
<td className="px-4 py-3 align-top">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{meta.label}</span>
{isPending ? (
<span className="font-medium">{meta.label}</span>
{isPending && (
<span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5">
Próximamente
</span>
) : !isConfigurable ? (
<span className="text-[10px] uppercase tracking-wide bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300 rounded px-1.5 py-0.5">
A nivel despacho
</span>
) : null}
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">{meta.description}</p>
</div>
<label className={`inline-flex items-center flex-shrink-0 ${isConfigurable && !isPending ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`}>
<input
type="checkbox"
className="sr-only peer"
checked={checked}
disabled={!isConfigurable || isPending}
onChange={e =>
mutation.mutate({
contribuyenteId: contrib.contribuyenteId,
emailType: type,
enabled: e.target.checked,
})
}
/>
<div className="relative w-10 h-6 bg-muted peer-checked:bg-primary rounded-full peer-focus:ring-2 peer-focus:ring-primary/30 transition-colors after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-transform peer-checked:after:translate-x-4" />
</label>
</div>
</td>
{roles.map(role => {
const checked = data?.preferences?.[type]?.[role] !== false;
return (
<td key={role} className="px-4 py-3 text-center align-middle">
<label className={`inline-flex items-center ${isPending ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
<input
type="checkbox"
className="sr-only peer"
checked={checked}
disabled={isPending}
onChange={e =>
mutation.mutate({
emailType: type,
role,
enabled: e.target.checked,
})
}
/>
<div className="relative w-10 h-6 bg-muted peer-checked:bg-primary rounded-full peer-focus:ring-2 peer-focus:ring-primary/30 transition-colors after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-transform peer-checked:after:translate-x-4" />
</label>
</td>
);
})}
</tr>
);
})}
</div>
</CardContent>
</Card>
))
})}
</tbody>
</table>
</CardContent>
</Card>
)}
</main>
</>