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:
@@ -17,9 +17,10 @@ SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
N8N_EMAIL_WEBHOOK_URL=https://n8n.tudominio.com/webhook/naturcalabacera-email
|
||||
EMAIL_FROM=Naturcalabacera <reservas@tudominio.com>
|
||||
|
||||
# Destinatarios de notificaciones (parametrizables, sin hardcodear)
|
||||
NOTIFICATION_EMAIL_TENERIFFA=contacto-teneriffa@ejemplo.com
|
||||
NOTIFICATION_EMAIL_NATUR=admin@naturcalabacera.com
|
||||
# Destinatarios de notificaciones (parametrizables)
|
||||
# Teneriffa admite VARIOS correos separados por coma (correo reducido).
|
||||
NOTIFICATION_EMAIL_TENERIFFA=uwe.dotschkis@teneriffa2000.de, j.reichstein@teneriffa2000.de
|
||||
NOTIFICATION_EMAIL_NATUR=administracion@naturcalabacera.com # correo completo
|
||||
NOTIFICATION_EMAIL_POOL_HEATING=jonathan@ejemplo.com # "Jonathan o las chicas"
|
||||
|
||||
# URL del frontend para CORS
|
||||
|
||||
@@ -26,10 +26,12 @@ export const env = {
|
||||
N8N_EMAIL_WEBHOOK_URL: required('N8N_EMAIL_WEBHOOK_URL'),
|
||||
EMAIL_FROM: optional('EMAIL_FROM', 'Naturcalabacera <reservas@naturcalabacera.com>'),
|
||||
|
||||
// Destinatarios de notificaciones (configurables, sin hardcodear)
|
||||
NOTIFICATION_EMAIL_TENERIFFA: required('NOTIFICATION_EMAIL_TENERIFFA'),
|
||||
NOTIFICATION_EMAIL_NATUR: required('NOTIFICATION_EMAIL_NATUR'),
|
||||
NOTIFICATION_EMAIL_POOL_HEATING: required('NOTIFICATION_EMAIL_POOL_HEATING'),
|
||||
// DEPRECADO: los destinatarios ahora se gestionan en la BD (tablas notification_groups /
|
||||
// notification_scenario_recipients) desde la pantalla de la app. Se mantienen como opcionales
|
||||
// por compatibilidad, pero el motor de envío ya NO los usa.
|
||||
NOTIFICATION_EMAIL_TENERIFFA: optional('NOTIFICATION_EMAIL_TENERIFFA', ''),
|
||||
NOTIFICATION_EMAIL_NATUR: optional('NOTIFICATION_EMAIL_NATUR', ''),
|
||||
NOTIFICATION_EMAIL_POOL_HEATING: optional('NOTIFICATION_EMAIL_POOL_HEATING', ''),
|
||||
|
||||
// Jobs
|
||||
JOB_RUNNER_INTERVAL_MS: parseInt(optional('JOB_RUNNER_INTERVAL_MS', '300000'), 10), // 5 min
|
||||
|
||||
@@ -2,7 +2,7 @@ import { readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { sendEmail } from '../services/email.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { resolveRecipients, type ResolvedRecipients } from './recipients.js';
|
||||
import type { Reservation, NotificationEventType } from '@naturcalabacera/shared';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -176,6 +176,7 @@ function renderTeneriffaMinimal(
|
||||
dateRange: string,
|
||||
property: string,
|
||||
cancelled = false,
|
||||
changesBlock = '',
|
||||
): string {
|
||||
const accent = cancelled ? '#ef4444' : actionLabel === 'Nueva Reserva' ? '#3b82f6' : '#f59e0b';
|
||||
return `<!DOCTYPE html>
|
||||
@@ -190,12 +191,15 @@ function renderTeneriffaMinimal(
|
||||
<p style="margin:0 0 18px;font-size:18px;font-weight:800;color:#111;">${dateRange}</p>
|
||||
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#9ca3af;font-weight:700;">Propiedad</p>
|
||||
<p style="margin:0;font-size:16px;font-weight:700;color:#111;">${property}</p>
|
||||
${changesBlock}
|
||||
</div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
async function sendCrudPair(opts: {
|
||||
reducidaEmails: string[];
|
||||
completaEmails: string[];
|
||||
actionLabel: string;
|
||||
subjectTag: string;
|
||||
dateRange: string;
|
||||
@@ -205,28 +209,29 @@ async function sendCrudPair(opts: {
|
||||
originLabel: string;
|
||||
clientName: string;
|
||||
cancelled?: boolean;
|
||||
/** Tabla de cambios (solo modificaciones). Se muestra también en la versión reducida. */
|
||||
changesBlock?: string;
|
||||
}): Promise<SendResult> {
|
||||
// 1) Correo reducido a Teneriffa (solo fechas + propiedad)
|
||||
const minHtml = renderTeneriffaMinimal(opts.actionLabel, opts.dateRange, opts.property, opts.cancelled);
|
||||
const minSubject = `[${opts.subjectTag}] ${opts.dateRange} · ${opts.property}`;
|
||||
const minRes = await sendEmail({
|
||||
to: env.NOTIFICATION_EMAIL_TENERIFFA,
|
||||
subject: minSubject,
|
||||
html: minHtml,
|
||||
});
|
||||
let lastFail: SendResult | null = null;
|
||||
|
||||
// 2) Correo completo a Natur (HTML con todos los detalles)
|
||||
const fullHtml = renderTemplate(opts.template, opts.vars);
|
||||
const fullSubject = `[${opts.subjectTag}] ${opts.originLabel} — ${opts.clientName} | ${opts.property} | ${opts.dateRange}`;
|
||||
const fullRes = await sendEmail({
|
||||
to: env.NOTIFICATION_EMAIL_NATUR,
|
||||
subject: fullSubject,
|
||||
html: fullHtml,
|
||||
});
|
||||
// 1) Versión REDUCIDA (fechas + propiedad, y la tabla de cambios si la hay) → grupos nivel 'reducida'.
|
||||
// Por diseño nunca incluye datos del cliente: imposible filtrar a Teneriffa.
|
||||
if (opts.reducidaEmails.length > 0) {
|
||||
const minHtml = renderTeneriffaMinimal(opts.actionLabel, opts.dateRange, opts.property, opts.cancelled, opts.changesBlock ?? '');
|
||||
const minSubject = `[${opts.subjectTag}] ${opts.dateRange} · ${opts.property}`;
|
||||
const r = await sendEmail({ to: opts.reducidaEmails, subject: minSubject, html: minHtml });
|
||||
if (!r.success) lastFail = r;
|
||||
}
|
||||
|
||||
if (!minRes.success) return minRes;
|
||||
if (!fullRes.success) return fullRes;
|
||||
return { success: true };
|
||||
// 2) Versión COMPLETA (HTML con todos los detalles) → grupos nivel 'completa'.
|
||||
if (opts.completaEmails.length > 0) {
|
||||
const fullHtml = renderTemplate(opts.template, opts.vars);
|
||||
const fullSubject = `[${opts.subjectTag}] ${opts.originLabel} — ${opts.clientName} | ${opts.property} | ${opts.dateRange}`;
|
||||
const r = await sendEmail({ to: opts.completaEmails, subject: fullSubject, html: fullHtml });
|
||||
if (!r.success) lastFail = r;
|
||||
}
|
||||
|
||||
return lastFail ?? { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,7 +241,8 @@ async function sendCrudPair(opts: {
|
||||
export async function handleNotificationEvent(
|
||||
eventType: NotificationEventType,
|
||||
reservation: Reservation,
|
||||
previousReservation?: Reservation
|
||||
previousReservation?: Reservation,
|
||||
overrideRecipients?: ResolvedRecipients
|
||||
): Promise<SendResult> {
|
||||
const nights = nightsCount(reservation.start_date, reservation.end_date);
|
||||
const dateRange = `${formatDateShort(reservation.start_date)} – ${formatDateShort(reservation.end_date)}`;
|
||||
@@ -261,6 +267,10 @@ export async function handleNotificationEvent(
|
||||
CANCEL_ALERT: '',
|
||||
};
|
||||
|
||||
// Destinatarios configurados para este escenario (desde la BD, no env vars).
|
||||
// overrideRecipients permite el envío de prueba a una dirección concreta.
|
||||
const recipients = overrideRecipients ?? await resolveRecipients(eventType);
|
||||
|
||||
switch (eventType) {
|
||||
case 'reservation.created': {
|
||||
const isTeenriffa = reservation.origin === 'Teneriffa2000';
|
||||
@@ -273,6 +283,8 @@ export async function handleNotificationEvent(
|
||||
CHANGES_BLOCK: '',
|
||||
};
|
||||
return sendCrudPair({
|
||||
reducidaEmails: recipients.reducida,
|
||||
completaEmails: recipients.completa,
|
||||
actionLabel: 'Nueva Reserva',
|
||||
subjectTag: 'NUEVA RESERVA',
|
||||
dateRange,
|
||||
@@ -303,6 +315,8 @@ export async function handleNotificationEvent(
|
||||
CHANGES_BLOCK: changesBlock,
|
||||
};
|
||||
return sendCrudPair({
|
||||
reducidaEmails: recipients.reducida,
|
||||
completaEmails: recipients.completa,
|
||||
actionLabel: 'Reserva Modificada',
|
||||
subjectTag: 'MODIFICADA',
|
||||
dateRange,
|
||||
@@ -311,6 +325,7 @@ export async function handleNotificationEvent(
|
||||
vars,
|
||||
originLabel,
|
||||
clientName: reservation.client_name,
|
||||
changesBlock,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -326,6 +341,8 @@ export async function handleNotificationEvent(
|
||||
CANCEL_ALERT: `<div class="cancel-alert"><p>Esta reserva ha sido cancelada y eliminada del sistema.</p></div>`,
|
||||
};
|
||||
return sendCrudPair({
|
||||
reducidaEmails: recipients.reducida,
|
||||
completaEmails: recipients.completa,
|
||||
actionLabel: 'Reserva Cancelada',
|
||||
subjectTag: 'CANCELADA',
|
||||
dateRange,
|
||||
@@ -338,22 +355,18 @@ export async function handleNotificationEvent(
|
||||
});
|
||||
}
|
||||
|
||||
case 'reservation.reminder_24h': {
|
||||
const html = renderTemplate('reminder-24h', baseVars);
|
||||
const subject = `Recordatorio: Check-in mañana — ${reservation.client_name} (Los Dragos)`;
|
||||
return sendEmail({ to: env.NOTIFICATION_EMAIL_TENERIFFA, subject, html });
|
||||
}
|
||||
|
||||
case 'reservation.invoice_second_notice': {
|
||||
if (recipients.all.length === 0) return { success: true };
|
||||
const html = renderTemplate('invoice-10d', baseVars);
|
||||
const subject = `Segunda factura en 10 días — ${reservation.client_name} (${propertyLabel(reservation.property)})`;
|
||||
return sendEmail({ to: env.NOTIFICATION_EMAIL_NATUR, subject, html });
|
||||
return sendEmail({ to: recipients.all, subject, html });
|
||||
}
|
||||
|
||||
case 'reservation.pool_heating_notice': {
|
||||
if (recipients.all.length === 0) return { success: true };
|
||||
const html = renderTemplate('pool-heating-48h', baseVars);
|
||||
const subject = `Calefacción de piscina en 48h — ${reservation.client_name} (${propertyLabel(reservation.property)})`;
|
||||
return sendEmail({ to: env.NOTIFICATION_EMAIL_POOL_HEATING, subject, html });
|
||||
return sendEmail({ to: recipients.all, subject, html });
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
54
apps/api/src/events/recipients.ts
Normal file
54
apps/api/src/events/recipients.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { supabaseAdmin } from '../lib/supabase.js';
|
||||
import type { NotificationEventType } from '@naturcalabacera/shared';
|
||||
|
||||
export interface ResolvedRecipients {
|
||||
/** Emails de grupos nivel 'reducida' (solo reciben la versión reducida). */
|
||||
reducida: string[];
|
||||
/** Emails de grupos nivel 'completa'. */
|
||||
completa: string[];
|
||||
/** Unión de ambos, para escenarios de plantilla única (factura, piscina). */
|
||||
all: string[];
|
||||
}
|
||||
|
||||
interface JoinedRow {
|
||||
notification_groups: {
|
||||
level: 'reducida' | 'completa';
|
||||
emails: string[] | null;
|
||||
enabled: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resuelve los destinatarios de un escenario leyendo la configuración de la BD
|
||||
* (notification_scenario_recipients + notification_groups). Agrupa por nivel.
|
||||
*
|
||||
* Garantía de privacidad: los emails de grupos 'reducida' nunca aparecen en la
|
||||
* lista 'completa', por lo que es imposible enviarles la versión completa.
|
||||
*/
|
||||
export async function resolveRecipients(scenario: NotificationEventType): Promise<ResolvedRecipients> {
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('notification_scenario_recipients')
|
||||
.select('notification_groups(level, emails, enabled)')
|
||||
.eq('scenario', scenario);
|
||||
|
||||
if (error) {
|
||||
console.error(`[recipients] Error al resolver destinatarios de ${scenario}:`, error.message);
|
||||
return { reducida: [], completa: [], all: [] };
|
||||
}
|
||||
|
||||
const reducida = new Set<string>();
|
||||
const completa = new Set<string>();
|
||||
|
||||
for (const row of (data ?? []) as unknown as JoinedRow[]) {
|
||||
const g = row.notification_groups;
|
||||
if (!g || !g.enabled) continue;
|
||||
const bucket = g.level === 'reducida' ? reducida : completa;
|
||||
for (const email of g.emails ?? []) {
|
||||
const clean = email.trim();
|
||||
if (clean) bucket.add(clean);
|
||||
}
|
||||
}
|
||||
|
||||
const all = new Set<string>([...reducida, ...completa]);
|
||||
return { reducida: [...reducida], completa: [...completa], all: [...all] };
|
||||
}
|
||||
@@ -33,13 +33,6 @@ export interface EventDescriptor {
|
||||
* Añadir un nuevo tipo de notificación = añadir una entrada aquí.
|
||||
*/
|
||||
export const EVENT_DESCRIPTORS: EventDescriptor[] = [
|
||||
{
|
||||
eventType: 'reservation.reminder_24h',
|
||||
conditions: { origin: 'Teneriffa2000', property: 'los_dragos' },
|
||||
recipientEnvKey: 'NOTIFICATION_EMAIL_TENERIFFA',
|
||||
templateName: 'reminder-24h',
|
||||
offsetDays: 1,
|
||||
},
|
||||
{
|
||||
eventType: 'reservation.invoice_second_notice',
|
||||
conditions: { origin: 'Naturcalabacera' },
|
||||
|
||||
@@ -3,6 +3,7 @@ import cors from 'cors';
|
||||
import { env } from './config/env.js';
|
||||
import { healthRouter } from './routes/health.js';
|
||||
import { notificationsRouter } from './routes/notifications.js';
|
||||
import { notificationConfigRouter } from './routes/notificationConfig.js';
|
||||
import { usersRouter } from './routes/users.js';
|
||||
import { runPendingJobs } from './jobs/runner.js';
|
||||
|
||||
@@ -18,6 +19,7 @@ app.use(express.json());
|
||||
// Rutas
|
||||
app.use('/health', healthRouter);
|
||||
app.use('/api/notifications', notificationsRouter);
|
||||
app.use('/api/notification-config', notificationConfigRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
|
||||
// Arrancar servidor
|
||||
|
||||
@@ -113,21 +113,7 @@ export async function scheduleNotificationsForReservation(
|
||||
|
||||
// El email CRUD se envía directamente en la ruta, no se encola aquí.
|
||||
|
||||
// 2. Recordatorio 24h antes — solo Teneriffa + Los Dragos
|
||||
if (reservation.origin === 'Teneriffa2000' && reservation.property === 'los_dragos') {
|
||||
const scheduled = new Date(startDate);
|
||||
scheduled.setUTCHours(scheduled.getUTCHours() - 24);
|
||||
if (scheduled > new Date()) {
|
||||
eventsToInsert.push({
|
||||
reservation_id: reservation.id,
|
||||
event_type: 'reservation.reminder_24h',
|
||||
scheduled_for: scheduled.toISOString(),
|
||||
status: 'pending',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Segunda factura 10 días antes — solo Natur
|
||||
// 1. Segunda factura 10 días antes — solo Natur
|
||||
if (reservation.origin === 'Naturcalabacera') {
|
||||
const scheduled = new Date(startDate);
|
||||
scheduled.setUTCDate(scheduled.getUTCDate() - 10);
|
||||
@@ -141,7 +127,7 @@ export async function scheduleNotificationsForReservation(
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Aviso calefacción piscina 48h antes
|
||||
// 2. Aviso calefacción piscina 48h antes
|
||||
if (reservation.has_pool_heating) {
|
||||
const scheduled = new Date(startDate);
|
||||
scheduled.setUTCHours(scheduled.getUTCHours() - 48);
|
||||
|
||||
41
apps/api/src/middleware/requireAdmin.ts
Normal file
41
apps/api/src/middleware/requireAdmin.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { supabaseAdmin } from '../lib/supabase.js';
|
||||
|
||||
/**
|
||||
* Middleware: extrae el JWT del header Authorization, valida la sesión con
|
||||
* Supabase y comprueba que el usuario es admin. Si pasa, deja el id del
|
||||
* caller en res.locals.callerId.
|
||||
*/
|
||||
export async function requireAdmin(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth || !auth.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'Falta token de autenticación' });
|
||||
return;
|
||||
}
|
||||
const token = auth.slice('Bearer '.length);
|
||||
|
||||
const { data: userData, error: userError } = await supabaseAdmin.auth.getUser(token);
|
||||
if (userError || !userData.user) {
|
||||
res.status(401).json({ error: 'Token inválido' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: profile, error: profileError } = await supabaseAdmin
|
||||
.from('user_profiles')
|
||||
.select('role')
|
||||
.eq('id', userData.user.id)
|
||||
.single();
|
||||
|
||||
if (profileError || !profile) {
|
||||
res.status(403).json({ error: 'Perfil no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (profile.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Solo admins pueden realizar esta acción' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.locals.callerId = userData.user.id;
|
||||
next();
|
||||
}
|
||||
184
apps/api/src/routes/notificationConfig.ts
Normal file
184
apps/api/src/routes/notificationConfig.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { Router, type Router as ExpressRouter } from 'express';
|
||||
import { supabaseAdmin } from '../lib/supabase.js';
|
||||
import { requireAdmin } from '../middleware/requireAdmin.js';
|
||||
import { handleNotificationEvent } from '../events/handler.js';
|
||||
import { CONFIGURABLE_SCENARIOS } from '@naturcalabacera/shared';
|
||||
import type { NotificationGroup, NotificationEventType, Reservation } from '@naturcalabacera/shared';
|
||||
|
||||
const router: ExpressRouter = Router();
|
||||
|
||||
const SCENARIOS = CONFIGURABLE_SCENARIOS.map(s => s.scenario);
|
||||
const VALID_LEVELS = ['reducida', 'completa'];
|
||||
|
||||
/**
|
||||
* GET /api/notification-config
|
||||
* Devuelve los grupos y la asignación de escenarios.
|
||||
*/
|
||||
router.get('/', requireAdmin, async (_req, res) => {
|
||||
const { data: groups, error: gErr } = await supabaseAdmin
|
||||
.from('notification_groups')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: true });
|
||||
if (gErr) return res.status(500).json({ error: gErr.message });
|
||||
|
||||
const { data: rows, error: rErr } = await supabaseAdmin
|
||||
.from('notification_scenario_recipients')
|
||||
.select('scenario, group_id');
|
||||
if (rErr) return res.status(500).json({ error: rErr.message });
|
||||
|
||||
const assignments: Record<string, string[]> = {};
|
||||
for (const r of rows ?? []) {
|
||||
(assignments[r.scenario] ??= []).push(r.group_id);
|
||||
}
|
||||
|
||||
return res.json({ groups: groups ?? [], assignments });
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/notification-config
|
||||
* Reemplaza el estado completo: grupos + asignaciones.
|
||||
* Los grupos nuevos llegan con un id temporal "tmp_..."; se mapean a su id real.
|
||||
*/
|
||||
router.put('/', requireAdmin, async (req, res) => {
|
||||
const { groups, assignments } = req.body as {
|
||||
groups: NotificationGroup[];
|
||||
assignments: Record<string, string[]>;
|
||||
};
|
||||
|
||||
if (!Array.isArray(groups) || typeof assignments !== 'object' || assignments === null) {
|
||||
return res.status(400).json({ error: 'Formato inválido: se esperan groups[] y assignments{}' });
|
||||
}
|
||||
|
||||
// Validación de grupos
|
||||
for (const g of groups) {
|
||||
if (!g.name || !g.name.trim()) return res.status(400).json({ error: 'Todos los grupos necesitan nombre' });
|
||||
if (!VALID_LEVELS.includes(g.level)) return res.status(400).json({ error: `Nivel inválido en "${g.name}"` });
|
||||
if (!Array.isArray(g.emails)) return res.status(400).json({ error: `Emails inválidos en "${g.name}"` });
|
||||
}
|
||||
|
||||
const existing = groups.filter(g => g.id && !g.id.startsWith('tmp_'));
|
||||
const nuevos = groups.filter(g => !g.id || g.id.startsWith('tmp_'));
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
// 1) Borrar grupos que ya no están (cascade elimina sus asignaciones)
|
||||
const { data: current, error: curErr } = await supabaseAdmin.from('notification_groups').select('id');
|
||||
if (curErr) throw new Error(curErr.message);
|
||||
const keepIds = existing.map(g => g.id);
|
||||
const toDelete = (current ?? []).map(c => c.id).filter(id => !keepIds.includes(id));
|
||||
if (toDelete.length > 0) {
|
||||
const { error } = await supabaseAdmin.from('notification_groups').delete().in('id', toDelete);
|
||||
if (error) throw new Error(error.message);
|
||||
}
|
||||
|
||||
// 2) Actualizar grupos existentes
|
||||
for (const g of existing) {
|
||||
const { error } = await supabaseAdmin
|
||||
.from('notification_groups')
|
||||
.update({
|
||||
name: g.name.trim(),
|
||||
level: g.level,
|
||||
emails: g.emails.map(e => e.trim()).filter(Boolean),
|
||||
enabled: g.enabled !== false,
|
||||
updated_at: now,
|
||||
})
|
||||
.eq('id', g.id);
|
||||
if (error) throw new Error(error.message);
|
||||
}
|
||||
|
||||
// 3) Insertar grupos nuevos y mapear tmp_id → id real
|
||||
const idMap: Record<string, string> = {};
|
||||
for (const g of nuevos) {
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('notification_groups')
|
||||
.insert({
|
||||
name: g.name.trim(),
|
||||
level: g.level,
|
||||
emails: g.emails.map(e => e.trim()).filter(Boolean),
|
||||
enabled: g.enabled !== false,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
if (error) throw new Error(error.message);
|
||||
if (g.id) idMap[g.id] = data.id;
|
||||
}
|
||||
|
||||
// 4) Reemplazar asignaciones de los escenarios conocidos
|
||||
const { error: delErr } = await supabaseAdmin
|
||||
.from('notification_scenario_recipients')
|
||||
.delete()
|
||||
.in('scenario', SCENARIOS);
|
||||
if (delErr) throw new Error(delErr.message);
|
||||
|
||||
const rows: { scenario: string; group_id: string }[] = [];
|
||||
for (const [scenario, groupIds] of Object.entries(assignments)) {
|
||||
if (!SCENARIOS.includes(scenario as NotificationEventType)) continue;
|
||||
for (const gid of groupIds) {
|
||||
const realId = idMap[gid] ?? gid;
|
||||
rows.push({ scenario, group_id: realId });
|
||||
}
|
||||
}
|
||||
if (rows.length > 0) {
|
||||
const { error } = await supabaseAdmin.from('notification_scenario_recipients').insert(rows);
|
||||
if (error) throw new Error(error.message);
|
||||
}
|
||||
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Error al guardar';
|
||||
console.error('[notification-config] Error al guardar:', message);
|
||||
return res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/notification-config/test
|
||||
* Envía un correo de prueba de un escenario con datos ficticios.
|
||||
* Si se indica testEmail, se envía solo a esa dirección (incluye versión reducida
|
||||
* y completa para los escenarios duales). Si no, va a los destinatarios reales.
|
||||
*/
|
||||
router.post('/test', requireAdmin, async (req, res) => {
|
||||
const { scenario, testEmail } = req.body as { scenario?: NotificationEventType; testEmail?: string };
|
||||
|
||||
if (!scenario || !SCENARIOS.includes(scenario)) {
|
||||
return res.status(400).json({ error: 'Escenario inválido' });
|
||||
}
|
||||
|
||||
const fake = buildFakeReservation();
|
||||
const previous = scenario === 'reservation.updated'
|
||||
? { ...fake, start_date: '2026-10-08', end_date: '2026-10-12' }
|
||||
: undefined;
|
||||
|
||||
const override = testEmail && testEmail.trim()
|
||||
? { reducida: [testEmail.trim()], completa: [testEmail.trim()], all: [testEmail.trim()] }
|
||||
: undefined;
|
||||
|
||||
const result = await handleNotificationEvent(scenario, fake, previous, override);
|
||||
if (!result.success) {
|
||||
return res.status(502).json({ error: result.error ?? 'Fallo al enviar la prueba' });
|
||||
}
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
/** Reserva ficticia coherente para los correos de prueba. */
|
||||
function buildFakeReservation(): Reservation {
|
||||
return {
|
||||
id: 'test-0000',
|
||||
client_name: 'Cliente de Prueba',
|
||||
start_date: '2026-10-15',
|
||||
end_date: '2026-10-20',
|
||||
property: 'los_dragos',
|
||||
origin: 'Naturcalabacera',
|
||||
adults_count: 2,
|
||||
children_count: 2,
|
||||
has_cleaning: true,
|
||||
has_pool_heating: true,
|
||||
has_flies_products: false,
|
||||
government_registration: null,
|
||||
observations: 'Reserva de prueba generada desde la pantalla de configuración.',
|
||||
invoice_number: null,
|
||||
is_event: false,
|
||||
} as unknown as Reservation;
|
||||
}
|
||||
|
||||
export { router as notificationConfigRouter };
|
||||
@@ -1,50 +1,12 @@
|
||||
import { Router, type Request, type Response, type NextFunction, type Router as ExpressRouter } from 'express';
|
||||
import { Router, type Router as ExpressRouter } from 'express';
|
||||
import { supabaseAdmin } from '../lib/supabase.js';
|
||||
import { requireAdmin } from '../middleware/requireAdmin.js';
|
||||
import type { UserRole } from '@naturcalabacera/shared';
|
||||
|
||||
const router: ExpressRouter = Router();
|
||||
|
||||
const VALID_ROLES: UserRole[] = ['admin', 'internal_staff', 'external_availability_viewer'];
|
||||
|
||||
/**
|
||||
* Middleware: extrae el JWT del header Authorization, valida la sesión con
|
||||
* Supabase y comprueba que el usuario es admin. Si pasa, deja el id del
|
||||
* caller en res.locals.callerId.
|
||||
*/
|
||||
async function requireAdmin(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth || !auth.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'Falta token de autenticación' });
|
||||
return;
|
||||
}
|
||||
const token = auth.slice('Bearer '.length);
|
||||
|
||||
const { data: userData, error: userError } = await supabaseAdmin.auth.getUser(token);
|
||||
if (userError || !userData.user) {
|
||||
res.status(401).json({ error: 'Token inválido' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: profile, error: profileError } = await supabaseAdmin
|
||||
.from('user_profiles')
|
||||
.select('role')
|
||||
.eq('id', userData.user.id)
|
||||
.single();
|
||||
|
||||
if (profileError || !profile) {
|
||||
res.status(403).json({ error: 'Perfil no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (profile.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Solo admins pueden gestionar usuarios' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.locals.callerId = userData.user.id;
|
||||
next();
|
||||
}
|
||||
|
||||
// GET /api/users — listar todos los perfiles
|
||||
router.get('/', requireAdmin, async (_req, res) => {
|
||||
const { data, error } = await supabaseAdmin
|
||||
|
||||
@@ -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