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:
@@ -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} />
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
308
apps/web/src/components/NotificationSettings.tsx
Normal file
308
apps/web/src/components/NotificationSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user