- 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.
186 lines
7.3 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|