Initial commit: Horux Despachos project
This commit is contained in:
180
apps/web/app/(dashboard)/configuracion/notificaciones/page.tsx
Normal file
180
apps/web/app/(dashboard)/configuracion/notificaciones/page.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'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' }> = {
|
||||
documento_subido: {
|
||||
label: 'Documento subido',
|
||||
description: 'Notificación 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: 'pending',
|
||||
},
|
||||
subscription_expiring: {
|
||||
label: 'Vencimiento de suscripción',
|
||||
description: 'Aviso cuando la suscripción del despacho está por vencer.',
|
||||
status: 'pending',
|
||||
},
|
||||
recordatorio_fiscal: {
|
||||
label: 'Recordatorios fiscales',
|
||||
description: 'Avisos de obligaciones próximas a vencer (declaraciones, pagos provisionales).',
|
||||
status: 'pending',
|
||||
},
|
||||
};
|
||||
|
||||
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';
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{meta.description}</p>
|
||||
</div>
|
||||
<label className="inline-flex items-center cursor-pointer flex-shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={checked}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user