Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/configuracion/notificaciones/page.tsx
Horux Dev b217342a96 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.
2026-06-17 00:04:37 +00:00

186 lines
7.3 KiB
TypeScript

'use client';
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 { Bell, Loader2 } from 'lucide-react';
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: 'Cuando se sube una declaración o documento extra del contribuyente.',
status: 'active',
},
weekly_update: {
label: 'Reporte semanal',
description: 'Resumen de KPIs, alertas y discrepancias enviado los lunes 8:00 AM.',
status: 'active',
},
subscription_expiring: {
label: 'Vencimiento de suscripción',
description: 'Aviso cuando la suscripción del despacho está por vencer.',
status: 'active',
},
recordatorio_fiscal: {
label: 'Recordatorios fiscales',
description: 'Avisos de obligaciones próximas a vencer (declaraciones, pagos provisionales).',
status: 'pending',
},
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 ListResponse {
emailTypes: string[];
roles: string[];
preferences: Record<string, Record<string, boolean>>;
}
export default function NotificacionesPage() {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<ListResponse>({
queryKey: ['notification-preferences'],
queryFn: async () => {
const res = await apiClient.get<ListResponse>('/notificaciones');
return res.data;
},
});
const mutation = useMutation({
mutationFn: async ({ emailType, role, enabled }: { emailType: string; role: string; enabled: boolean }) => {
await apiClient.put('/notificaciones', { emailType, role, 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,
preferences: {
...previous.preferences,
[emailType]: {
...previous.preferences[emailType],
[role]: enabled,
},
},
});
}
return { previous };
},
onError: (_err, _vars, context) => {
if (context?.previous) queryClient.setQueryData(['notification-preferences'], context.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['notification-preferences'] });
},
});
const roles = data?.roles ?? [];
const emailTypes = data?.emailTypes ?? [];
return (
<>
<Header title="Notificaciones" />
<main className="p-6 space-y-6 max-w-5xl mx-auto">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Bell className="h-4 w-4" />
Correos informativos por rol
</CardTitle>
<CardDescription>
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>
{isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
Cargando...
</div>
) : (
<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 isPending = meta.status === 'pending';
return (
<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="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>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">{meta.description}</p>
</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>
);
})}
</tbody>
</table>
</CardContent>
</Card>
)}
</main>
</>
);
}