feat: horarios opcionales en reservas, calendarios en lunes y emails filtrados
- Reservas: campos opcionales start_time/end_time (migración 011, schema natur_reservas) + toggle en el modal y detección de solapamiento por horario cuando ambas reservas los tienen definidos. Permite encajar varios eventos el mismo día. - Calendario mensual y anual ahora empiezan en lunes; vista móvil incluida. - Celdas con varios eventos el mismo día se dividen en franjas horizontales mostrando el horario; las reservas multi-día siguen ocupando la celda completa. - Modal: reset de campos vacíos (client_name, fechas, factura) para evitar que el nombre de la última reserva se filtre al crear una nueva. - Emails: las modificaciones solo disparan correo cuando cambian fechas u horas; el correo a Teneriffa pasa a formato reducido (solo fechas + propiedad) mientras que Natur sigue recibiendo el detalle completo. Mantenimiento sin cambios. - CLAUDE.md con guía operativa (schema natur_reservas, stack, convenciones). - Scripts de preview/envío de emails para pruebas. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -146,6 +146,89 @@ interface SendResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Campos cuya modificación dispara el email de "reserva modificada".
|
||||
* El resto se considera cambio menor (observaciones, registro, factura,
|
||||
* snapshot de precios, servicios, asistentes, etc.) y NO envía correo
|
||||
* para no saturar las bandejas de entrada.
|
||||
*/
|
||||
const MAJOR_UPDATE_FIELDS: (keyof Reservation)[] = [
|
||||
'start_date',
|
||||
'end_date',
|
||||
'start_time',
|
||||
'end_time',
|
||||
];
|
||||
|
||||
function hasMajorChange(prev: Reservation, curr: Reservation): boolean {
|
||||
return MAJOR_UPDATE_FIELDS.some(field => {
|
||||
const a = prev[field] ?? '';
|
||||
const b = curr[field] ?? '';
|
||||
return a !== b;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Email reducido para Teneriffa: fechas + propiedad + acción.
|
||||
* Sin datos del cliente, sin servicios, sin observaciones.
|
||||
*/
|
||||
function renderTeneriffaMinimal(
|
||||
actionLabel: string,
|
||||
dateRange: string,
|
||||
property: string,
|
||||
cancelled = false,
|
||||
): string {
|
||||
const accent = cancelled ? '#ef4444' : actionLabel === 'Nueva Reserva' ? '#3b82f6' : '#f59e0b';
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="es"><head><meta charset="UTF-8"></head>
|
||||
<body style="font-family:Arial,sans-serif;background:#f4f4f4;margin:0;padding:24px;">
|
||||
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
|
||||
<div style="background:${accent};color:#fff;padding:20px 24px;">
|
||||
<p style="margin:0;font-size:11px;text-transform:uppercase;letter-spacing:0.1em;opacity:0.85;font-weight:700;">${actionLabel}</p>
|
||||
</div>
|
||||
<div style="padding:24px;">
|
||||
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#9ca3af;font-weight:700;">Fechas</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
async function sendCrudPair(opts: {
|
||||
actionLabel: string;
|
||||
subjectTag: string;
|
||||
dateRange: string;
|
||||
property: string;
|
||||
template: 'teneriffa-crud' | 'natur-crud';
|
||||
vars: Record<string, string>;
|
||||
originLabel: string;
|
||||
clientName: string;
|
||||
cancelled?: boolean;
|
||||
}): 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,
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
if (!minRes.success) return minRes;
|
||||
if (!fullRes.success) return fullRes;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Envía el email correspondiente a un evento de notificación.
|
||||
* Para reservation.updated acepta previousReservation para mostrar el diff.
|
||||
@@ -189,12 +272,24 @@ export async function handleNotificationEvent(
|
||||
ACTION_LABEL: 'Nueva Reserva',
|
||||
CHANGES_BLOCK: '',
|
||||
};
|
||||
const html = renderTemplate(template, vars);
|
||||
const subject = `[NUEVA RESERVA] ${originLabel} — ${reservation.client_name} | ${propertyLabel(reservation.property)} | ${dateRange}`;
|
||||
return sendEmail({ to: [env.NOTIFICATION_EMAIL_TENERIFFA, env.NOTIFICATION_EMAIL_NATUR], subject, html });
|
||||
return sendCrudPair({
|
||||
actionLabel: 'Nueva Reserva',
|
||||
subjectTag: 'NUEVA RESERVA',
|
||||
dateRange,
|
||||
property: propertyLabel(reservation.property),
|
||||
template,
|
||||
vars,
|
||||
originLabel,
|
||||
clientName: reservation.client_name,
|
||||
});
|
||||
}
|
||||
|
||||
case 'reservation.updated': {
|
||||
// Skip si los cambios son solo en campos menores (observaciones, factura, servicios, etc.)
|
||||
// Solo dispara email cuando cambian fechas u horarios. Evita inundar bandejas.
|
||||
if (previousReservation && !hasMajorChange(previousReservation, reservation)) {
|
||||
return { success: true };
|
||||
}
|
||||
const isTeenriffa = reservation.origin === 'Teneriffa2000';
|
||||
const template = isTeenriffa ? 'teneriffa-crud' : 'natur-crud';
|
||||
const originLabel = isTeenriffa ? 'Teneriffa' : 'Natur';
|
||||
@@ -207,9 +302,16 @@ export async function handleNotificationEvent(
|
||||
ACTION_LABEL: 'Reserva Modificada',
|
||||
CHANGES_BLOCK: changesBlock,
|
||||
};
|
||||
const html = renderTemplate(template, vars);
|
||||
const subject = `[MODIFICADA] ${originLabel} — ${reservation.client_name} | ${propertyLabel(reservation.property)} | ${dateRange}`;
|
||||
return sendEmail({ to: [env.NOTIFICATION_EMAIL_TENERIFFA, env.NOTIFICATION_EMAIL_NATUR], subject, html });
|
||||
return sendCrudPair({
|
||||
actionLabel: 'Reserva Modificada',
|
||||
subjectTag: 'MODIFICADA',
|
||||
dateRange,
|
||||
property: propertyLabel(reservation.property),
|
||||
template,
|
||||
vars,
|
||||
originLabel,
|
||||
clientName: reservation.client_name,
|
||||
});
|
||||
}
|
||||
|
||||
case 'reservation.cancelled': {
|
||||
@@ -223,9 +325,17 @@ export async function handleNotificationEvent(
|
||||
CHANGES_BLOCK: '',
|
||||
CANCEL_ALERT: `<div class="cancel-alert"><p>Esta reserva ha sido cancelada y eliminada del sistema.</p></div>`,
|
||||
};
|
||||
const html = renderTemplate(template, vars);
|
||||
const subject = `[CANCELADA] ${originLabel} — ${reservation.client_name} | ${propertyLabel(reservation.property)} | ${dateRange}`;
|
||||
return sendEmail({ to: [env.NOTIFICATION_EMAIL_TENERIFFA, env.NOTIFICATION_EMAIL_NATUR], subject, html });
|
||||
return sendCrudPair({
|
||||
actionLabel: 'Reserva Cancelada',
|
||||
subjectTag: 'CANCELADA',
|
||||
dateRange,
|
||||
property: propertyLabel(reservation.property),
|
||||
template,
|
||||
vars,
|
||||
originLabel,
|
||||
clientName: reservation.client_name,
|
||||
cancelled: true,
|
||||
});
|
||||
}
|
||||
|
||||
case 'reservation.reminder_24h': {
|
||||
|
||||
Reference in New Issue
Block a user