Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/configuracion/notificaciones/page.tsx
Horux Dev 8a1fbceb38 fix(notificaciones): quitar badge Próximamente de notificaciones ya existentes
- weekly_update y subscription_expiring están implementadas; se marcan como active.
- Se indica con badge 'A nivel despacho' cuando una notificación no se puede
  desactivar por contribuyente y se deshabilita el toggle.
2026-06-16 22:55:53 +00:00

191 lines
8.0 KiB
TypeScript

'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 }> = {
documento_subido: {
label: 'Documento subido',
description: 'Notificación 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,
},
};
interface ContribuyentePrefs {
contribuyenteId: string;
rfc: string;
nombre: string;
preferences: Record<string, boolean>;
}
interface ListResponse {
emailTypes: string[];
data: ContribuyentePrefs[];
}
export default function NotificacionesPage() {
const queryClient = useQueryClient();
const { selectedContribuyenteId } = useContribuyenteStore();
const { data, isLoading } = useQuery<ListResponse>({
queryKey: ['notification-preferences'],
queryFn: async () => {
const res = await apiClient.get<ListResponse>('/notificaciones');
return res.data;
},
});
// 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 },
});
},
onMutate: async ({ contribuyenteId, emailType, 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,
),
});
}
return { previous };
},
onError: (_err, _vars, context) => {
if (context?.previous) queryClient.setQueryData(['notification-preferences'], context.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['notification-preferences'] });
},
});
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 contribuyente
</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.
</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>
) : 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 => {
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">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm 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>
);
})}
</div>
</CardContent>
</Card>
))
)}
</main>
</>
);
}