feat: configuración de destinatarios de notificaciones desde la app

Gestiona grupos de email y asignación por escenario en BD
(natur_reservas.notification_groups / notification_scenario_recipients)
en lugar de variables de entorno hardcodeadas. Incluye pantalla de ajustes,
endpoint admin protegido, envío de prueba y niveles reducida/completa.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 11:12:58 +01:00
parent 5656ad13ad
commit ee03f0b36d
19 changed files with 991 additions and 103 deletions

View File

@@ -9,6 +9,7 @@ import { SearchBar } from './components/SearchBar';
import { SettingsPage } from './components/SettingsPage';
import { YearlyCalendar } from './components/YearlyCalendar';
import { UserManagement } from './components/UserManagement';
import { NotificationSettings } from './components/NotificationSettings';
import { LoginPage } from './components/LoginPage';
import { ChatbotContainer } from './components/ChatbotContainer';
import { useAuth } from './hooks/useAuth';
@@ -196,6 +197,7 @@ function AppContent() {
/>
)}
{currentView === 'users' && isAdmin && <UserManagement />}
{currentView === 'notifications' && isAdmin && <NotificationSettings />}
</main>
<MobileNavigation currentView={currentView} onNavigate={setCurrentView} isViewer={isViewer} isAdmin={isAdmin} />

View File

@@ -1,4 +1,4 @@
import { Calendar, Settings, CalendarDays, Home, Users } from 'lucide-react';
import { Calendar, Settings, CalendarDays, Home, Users, Bell } from 'lucide-react';
import { useProperty } from '../contexts/PropertyContext';
import { PROPERTY_CONFIG, PROPERTIES } from '@naturcalabacera/shared';
import type { Property } from '@naturcalabacera/shared';
@@ -16,6 +16,7 @@ export function MobileNavigation({ currentView, onNavigate, isViewer = false, is
{ id: 'calendar', label: 'Mensual', icon: Calendar, requires: 'all' as const },
{ id: 'yearly', label: 'Anual', icon: CalendarDays, requires: 'all' as const },
{ id: 'users', label: 'Usuarios', icon: Users, requires: 'admin' as const },
{ id: 'notifications', label: 'Avisos', icon: Bell, requires: 'admin' as const },
{ id: 'settings', label: 'Ajustes', icon: Settings, requires: 'staff' as const },
];
const menuItems = allMenuItems.filter(item => {

View File

@@ -0,0 +1,308 @@
import { useEffect, useState } from 'react';
import { Loader2, Save, Plus, Trash2, X, Send, Lock, Bell, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
import { supabase } from '../lib/supabase';
import { CONFIGURABLE_SCENARIOS } from '@naturcalabacera/shared';
import type { NotificationGroup, NotificationLevel } from '@naturcalabacera/shared';
const apiUrl = import.meta.env.VITE_API_URL;
async function authFetch(path: string, init: RequestInit = {}): Promise<Response> {
const { data } = await supabase.auth.getSession();
const token = data.session?.access_token;
const headers = new Headers(init.headers);
headers.set('Content-Type', 'application/json');
if (token) headers.set('Authorization', `Bearer ${token}`);
return fetch(`${apiUrl}${path}`, { ...init, headers });
}
const newTmpId = () => `tmp_${crypto.randomUUID()}`;
export function NotificationSettings() {
const [groups, setGroups] = useState<NotificationGroup[]>([]);
const [assignments, setAssignments] = useState<Record<string, string[]>>({});
const [emailDraft, setEmailDraft] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [testing, setTesting] = useState<string | null>(null);
useEffect(() => { void load(); }, []);
async function load() {
setLoading(true);
setError(null);
try {
const res = await authFetch('/api/notification-config');
if (!res.ok) {
const body = await res.json().catch(() => ({} as { error?: string }));
throw new Error(body?.error ?? `Error ${res.status}`);
}
const body = await res.json() as { groups: NotificationGroup[]; assignments: Record<string, string[]> };
setGroups(body.groups);
setAssignments(body.assignments ?? {});
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar la configuración');
} finally {
setLoading(false);
}
}
// ── Grupos ──
const patchGroup = (id: string, patch: Partial<NotificationGroup>) =>
setGroups(gs => gs.map(g => (g.id === id ? { ...g, ...patch } : g)));
const addGroup = () =>
setGroups(gs => [...gs, { id: newTmpId(), name: 'Nuevo grupo', level: 'completa', emails: [], enabled: true }]);
const removeGroup = (id: string) => {
setGroups(gs => gs.filter(g => g.id !== id));
setAssignments(a => {
const next: Record<string, string[]> = {};
for (const [sc, ids] of Object.entries(a)) next[sc] = ids.filter(x => x !== id);
return next;
});
};
const addEmail = (groupId: string) => {
const raw = (emailDraft[groupId] ?? '').trim();
if (!raw) return;
patchGroup(groupId, { emails: [...(groups.find(g => g.id === groupId)?.emails ?? []), raw] });
setEmailDraft(d => ({ ...d, [groupId]: '' }));
};
const removeEmail = (groupId: string, email: string) => {
const g = groups.find(x => x.id === groupId);
if (g) patchGroup(groupId, { emails: g.emails.filter(e => e !== email) });
};
// ── Asignaciones ──
const isAssigned = (scenario: string, groupId: string) => (assignments[scenario] ?? []).includes(groupId);
const toggleAssignment = (scenario: string, groupId: string) =>
setAssignments(a => {
const current = a[scenario] ?? [];
const next = current.includes(groupId) ? current.filter(x => x !== groupId) : [...current, groupId];
return { ...a, [scenario]: next };
});
// ── Guardar ──
async function save() {
setSaving(true);
// Incorpora los emails escritos en el casillero pero aún no confirmados con Enter/+,
// para que no se pierdan si el usuario pulsa Guardar directamente.
const groupsToSave = groups.map(g => {
const draft = (emailDraft[g.id] ?? '').trim();
if (!draft || g.emails.includes(draft)) return g;
return { ...g, emails: [...g.emails, draft] };
});
try {
const res = await authFetch('/api/notification-config', {
method: 'PUT',
body: JSON.stringify({ groups: groupsToSave, assignments }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({} as { error?: string }));
throw new Error(body?.error ?? `Error ${res.status}`);
}
setEmailDraft({});
toast.success('Configuración guardada');
await load();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Error al guardar');
} finally {
setSaving(false);
}
}
// ── Enviar prueba ──
async function sendTest(scenario: string) {
const testEmail = window.prompt(
'Email de prueba (déjalo vacío para enviar a los destinatarios reales configurados):',
'',
);
if (testEmail === null) return; // cancelado
setTesting(scenario);
try {
const res = await authFetch('/api/notification-config/test', {
method: 'POST',
body: JSON.stringify({ scenario, testEmail: testEmail.trim() || undefined }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({} as { error?: string }));
throw new Error(body?.error ?? `Error ${res.status}`);
}
toast.success(testEmail.trim() ? `Prueba enviada a ${testEmail.trim()}` : 'Prueba enviada a los destinatarios configurados');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Error al enviar la prueba');
} finally {
setTesting(null);
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
</div>
);
}
return (
<div className="max-w-4xl mx-auto space-y-8">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-emerald-500/10 rounded-xl">
<Bell className="w-6 h-6 text-emerald-500" />
</div>
<div>
<h1 className="text-2xl font-black text-white">Notificaciones</h1>
<p className="text-sm text-slate-400">Quién recibe cada correo y con qué nivel de información.</p>
</div>
</div>
{error && (
<div className="flex items-center gap-2 p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">
<AlertCircle size={18} /> {error}
</div>
)}
{/* ── Grupos ── */}
<section className="space-y-4">
<h2 className="text-xs uppercase tracking-wider font-bold text-slate-500">Grupos de destinatarios</h2>
{groups.map(g => (
<div
key={g.id}
className={`rounded-2xl p-5 border ${
g.level === 'reducida'
? 'bg-amber-500/5 border-amber-500/30'
: 'bg-emerald-500/5 border-emerald-500/20'
} ${g.enabled === false ? 'opacity-50' : ''}`}
>
<div className="flex flex-wrap items-center gap-3 justify-between">
<input
value={g.name}
onChange={e => patchGroup(g.id, { name: e.target.value })}
className="flex-1 min-w-[160px] bg-slate-800/60 border border-slate-700 rounded-lg px-3 py-2 text-white font-bold focus:outline-none focus:ring-2 focus:ring-emerald-500/40"
/>
<select
value={g.level}
onChange={e => patchGroup(g.id, { level: e.target.value as NotificationLevel })}
className="bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-slate-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/40"
>
<option value="reducida">Reducida 🔒</option>
<option value="completa">Completa</option>
</select>
<label className="flex items-center gap-2 text-xs text-slate-400">
<input
type="checkbox"
checked={g.enabled !== false}
onChange={e => patchGroup(g.id, { enabled: e.target.checked })}
/>
Activo
</label>
<button
onClick={() => removeGroup(g.id)}
className="p-2 text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
title="Borrar grupo"
>
<Trash2 size={16} />
</button>
</div>
{g.level === 'reducida' && (
<p className="flex items-center gap-1.5 mt-2 text-xs text-amber-400/80">
<Lock size={12} /> Solo recibe la versión reducida (sin datos del cliente).
</p>
)}
<div className="mt-3 flex flex-wrap gap-2 items-center">
{g.emails.map(email => (
<span
key={email}
className="flex items-center gap-1.5 bg-slate-800/70 border border-slate-700 rounded-full pl-3 pr-1.5 py-1 text-sm text-slate-200"
>
{email}
<button onClick={() => removeEmail(g.id, email)} className="p-0.5 hover:bg-slate-700 rounded-full">
<X size={13} />
</button>
</span>
))}
<div className="flex items-center gap-1">
<input
value={emailDraft[g.id] ?? ''}
onChange={e => setEmailDraft(d => ({ ...d, [g.id]: e.target.value }))}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addEmail(g.id); } }}
placeholder="añadir email…"
className="bg-slate-800/60 border border-slate-700 rounded-full px-3 py-1 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/40"
/>
<button onClick={() => addEmail(g.id)} className="p-1.5 text-emerald-400 hover:bg-emerald-500/10 rounded-full">
<Plus size={16} />
</button>
</div>
</div>
</div>
))}
<button
onClick={addGroup}
className="flex items-center gap-2 px-4 py-2.5 bg-slate-800/60 hover:bg-slate-800 border border-slate-700 rounded-xl text-slate-200 text-sm font-medium transition-colors"
>
<Plus size={16} /> Nuevo grupo
</button>
</section>
{/* ── Asignación ── */}
<section className="space-y-3">
<h2 className="text-xs uppercase tracking-wider font-bold text-slate-500">Qué grupo recibe cada escenario</h2>
<div className="rounded-2xl border border-slate-700/50 divide-y divide-slate-800">
{CONFIGURABLE_SCENARIOS.map(sc => (
<div key={sc.scenario} className="p-4 flex flex-wrap items-center gap-3 justify-between">
<div>
<p className="font-semibold text-white text-sm">{sc.label}</p>
{sc.dualVersion && (
<p className="text-xs text-slate-500">Versión según el nivel del grupo (reducida/completa)</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{groups.map(g => (
<button
key={g.id}
onClick={() => toggleAssignment(sc.scenario, g.id)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
isAssigned(sc.scenario, g.id)
? g.level === 'reducida'
? 'bg-amber-500/20 border-amber-500/40 text-amber-200'
: 'bg-emerald-500/20 border-emerald-500/40 text-emerald-200'
: 'bg-slate-800/40 border-slate-700 text-slate-500 hover:text-slate-300'
}`}
>
{g.name}{isAssigned(sc.scenario, g.id) ? ' ✓' : ''}
</button>
))}
<button
onClick={() => sendTest(sc.scenario)}
disabled={testing === sc.scenario}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-blue-500/10 border border-blue-500/30 text-blue-300 hover:bg-blue-500/20 transition-colors disabled:opacity-50"
title="Enviar email de prueba"
>
{testing === sc.scenario ? <Loader2 size={13} className="animate-spin" /> : <Send size={13} />}
Prueba
</button>
</div>
</div>
))}
</div>
</section>
<div className="flex justify-end">
<button
onClick={save}
disabled={saving}
className="flex items-center gap-2 px-6 py-3 bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl font-bold transition-colors disabled:opacity-60"
>
{saving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
Guardar cambios
</button>
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { Calendar, Settings, CalendarDays, Users } from 'lucide-react';
import { Calendar, Settings, CalendarDays, Users, Bell } from 'lucide-react';
import { PropertySelector } from './PropertySelector';
import { usePropertyTheme } from '../hooks/usePropertyTheme';
@@ -16,6 +16,7 @@ export function Sidebar({ currentView, onNavigate, isViewer = false, isAdmin = f
{ id: 'calendar', label: 'Mensual', icon: Calendar, requires: 'all' as const },
{ id: 'yearly', label: 'Anual', icon: CalendarDays, requires: 'all' as const },
{ id: 'users', label: 'Usuarios', icon: Users, requires: 'admin' as const },
{ id: 'notifications', label: 'Notificaciones', icon: Bell, requires: 'admin' as const },
{ id: 'settings', label: 'Ajustes', icon: Settings, requires: 'staff' as const },
];
const menuItems = allMenuItems.filter(item => {