From 6066f27ba037fcfe7720587678f04612132f764e Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Wed, 28 Oct 2026 11:04:41 -0500 Subject: [PATCH] feat: version 4.4.0 -- Calendario de reuniones Menu Calendario (Mes/Semana/Dia/Agenda) para agendar reuniones con participantes internos y externos, RSVP, vinculo a proyecto, vencimientos de actividades, invitaciones iCalendar por correo, recurrencia con excepciones, recordatorios in-app y aviso de choques. Migracion 034_meetings.sql. Co-Authored-By: Claude Opus 5.5 --- API.md | 25 ++- BASE_DE_DATOS.md | 9 + CHANGELOG.md | 9 + ESTADO.md | 15 +- SPEC.md | 12 +- VERSION | 2 +- backend/database/migrations/034_meetings.sql | 65 ++++++ backend/package.json | 2 +- backend/src/app.js | 2 + backend/src/jobs/meetingReminders.js | 12 ++ backend/src/middleware/modules.js | 2 +- backend/src/modules/meetings/controller.js | 10 + backend/src/modules/meetings/ical.js | 40 ++++ backend/src/modules/meetings/recurrence.js | 77 +++++++ backend/src/modules/meetings/repository.js | 133 ++++++++++++ backend/src/modules/meetings/routes.js | 15 ++ backend/src/modules/meetings/service.js | 198 +++++++++++++++++ .../src/modules/notifications/repository.js | 4 +- backend/src/server.js | 2 + backend/src/utils/mailer.js | 6 +- frontend/package.json | 2 +- frontend/src/app/App.jsx | 2 + frontend/src/app/AppShell.jsx | 3 +- frontend/src/components/DatePicker.jsx | 55 +++++ frontend/src/components/NavigationView.jsx | 3 +- frontend/src/hooks/useDraftForm.js | 4 +- frontend/src/i18n/messages.js | 28 +++ .../src/pages/Calendario/CalendarioPage.jsx | 203 ++++++++++++++++++ frontend/src/pages/Calendario/calendar.css | 27 +++ .../pages/Proyectos/ProjectCalendarTab.jsx | 11 +- frontend/src/services/meetings.js | 10 + frontend/src/styles/app.css | 11 + frontend/src/utils/drafts.js | 2 + frontend/src/utils/modules.js | 1 + 34 files changed, 986 insertions(+), 16 deletions(-) create mode 100644 backend/database/migrations/034_meetings.sql create mode 100644 backend/src/jobs/meetingReminders.js create mode 100644 backend/src/modules/meetings/controller.js create mode 100644 backend/src/modules/meetings/ical.js create mode 100644 backend/src/modules/meetings/recurrence.js create mode 100644 backend/src/modules/meetings/repository.js create mode 100644 backend/src/modules/meetings/routes.js create mode 100644 backend/src/modules/meetings/service.js create mode 100644 frontend/src/components/DatePicker.jsx create mode 100644 frontend/src/pages/Calendario/CalendarioPage.jsx create mode 100644 frontend/src/pages/Calendario/calendar.css create mode 100644 frontend/src/services/meetings.js diff --git a/API.md b/API.md index 5b4ddd2..3359bf8 100644 --- a/API.md +++ b/API.md @@ -31,6 +31,29 @@ catálogos, notas, tareas recurrentes, equipos y SMTP exigen `editVersion` (ente obtenido de la lectura correspondiente como `edit_version`. El `PUT /api/company` exige el mismo campo en multipart. La API responde `428` si falta o es inválido y `409` si otro guardado cambió el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`. +Las reuniones siguen el mismo contrato (`PUT /api/meetings/:id`). + +## Calendario de reuniones (v4.4.0) + +Todas las rutas requieren JWT y módulo `calendar`. Las fechas son `YYYY-MM-DD HH:mm:ss` en hora +civil `America/Guayaquil` (UTC-5). `GET /api/meetings?from=&to=&page=&pageSize=` devuelve +`{items,total,page,pageSize}` con ocurrencias expandidas (`occurrence_start`, `occurrence_end`); +rango máximo 93 días y hasta 1500 ocurrencias por página (predeterminado: 1500). +`GET /api/meetings/activities?from=&to=` devuelve hasta 1000 vencimientos legibles. + +- `GET /api/meetings/:id`: detalle, participantes con RSVP y excepciones. +- `POST /api/meetings`: `{title,description,startsAt,endsAt,allDay,location,videoUrl,projectId,reminderMinutes,participantUserIds,externalEmails,recurrence}`. +- `PUT /api/meetings/:id`: mismo cuerpo con `editVersion` (aplica a toda la serie). +- `DELETE /api/meetings/:id`: **cancelación lógica** de toda la serie, con `METHOD:CANCEL`. +- `POST /api/meetings/:id/occurrences/:date/cancel`: excluye una ocurrencia; `date` URL encoded. +- `PUT /api/meetings/:id/rsvp`: `{status:"accepted"|"declined"|"tentative"}`; solo participante propio. + +`recurrence` es `null` o `{frequency:"daily"|"weekly"|"monthly",interval,weekdays,until,count}`; +`weekdays` usa 0=lunes a 6=domingo. Se exige exactamente uno de `until` o `count` y máximo +365 ocurrencias. Cuando hay choque de participantes, POST/PUT devuelve +`{requiresConfirmation:true,warnings:[nombres]}` sin guardar; reenviar con +`confirmConflicts:true` confirma. Correo y avisos se despachan en segundo plano; sin SMTP el correo +se registra en consola de desarrollo. Antes de usar estas rutas se debe aplicar la migración 034. ## Endpoints principales @@ -66,7 +89,7 @@ el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`. - `POST /api/users/:id/approve` — `{ roleId?, departmentId?, editVersion }`, solo sobre usuarios `pending_approval`; también rechaza cambios concurrentes con `409`. - `POST /api/users/:id/reject` — borra al usuario `pending_approval` (se audita el email antes) - `GET /api/users/:id/modules` · `PUT /api/users/:id/modules` `{ modules: [...] }` — solo Superadministrador. - Módulos: `projects`, `recurring_tasks`, `notes`, `vault`, `devices`, `organization`. No aplica a + Módulos: `projects`, `calendar`, `recurring_tasks`, `notes`, `vault`, `devices`, `organization`. No aplica a un superadmin (`400`). `GET /api/auth/me` y el login devuelven `modules` (efectivos del usuario). Las rutas de un módulo deshabilitado responden `403`; la búsqueda global devuelve vacío. diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 9b2b7af..7273b54 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -10,6 +10,11 @@ - Charset: `utf8mb4` - Collation: `utf8mb4_unicode_ci` +Migración `034_meetings.sql` (v4.4.0): pendiente de aplicar tanto en desarrollo como en +producción. Crea tablas e índices de reuniones y amplía el tipo de entidad de notificaciones. +`DATETIME` se guarda como hora civil de `America/Guayaquil`; la conexión usa `dateStrings: true`. +La recurrencia se almacena como JSON de texto y se expande al consultar, sin filas por ocurrencia. + ## Fuente de verdad - La base ya existe: sí (creada en dev el 2026-09-03, usuario `project_linkedcorp`). @@ -34,6 +39,10 @@ | `activity_dependencies` | dependencias simples entre actividades ("depende de") | | `tasks` | checklist liviano dentro de una actividad ("Tareas" en la UI; renombrada desde `subtasks` en v1.8.0) | | `recurring_tasks` | recordatorios periódicos (ej. renovación de licencias), menú propio "Tareas recurrentes" | +| `meetings` | serie de reunión en hora civil America/Guayaquil; UID iCalendar, secuencia, versión y cancelación lógica | +| `meeting_participants` / `meeting_external_participants` | usuarios con RSVP y correos externos sin respuesta | +| `meeting_exceptions` | ocurrencias canceladas, sin materializar el resto de la serie | +| `meeting_reminders` | clave `(meeting_id, occurrence_start)` para deduplicar avisos | | `comments` | comentarios sobre proyecto o actividad (`entity_type` + `entity_id`) | | `attachments` | archivos adjuntos a proyecto o actividad | | `notifications` | notificaciones in-app por usuario | diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fbcaec..0267bb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog - project.linkedcorp.net +## [4.4.0] - 2026-10-27 +### Added +- Menú Calendario con vistas Mes, Semana, Día y Agenda; reuniones con participantes internos/externos, + RSVP interno (avisa al organizador), vínculo opcional a proyecto y vencimientos de actividades diferenciados. +- Invitaciones iCalendar por correo, notificaciones in-app, recurrencia con excepciones, recordatorios + por minuto y advertencia de choques. Requiere migración `034_meetings.sql`. +- Acceso al módulo `calendar` configurable por usuario y edición con versión optimista. + + ## [4.3.0] - 2026-10-26 ### Added - Sesiones activas: cada inicio de sesión queda registrado (`user_sessions`, migración 033) con diff --git a/ESTADO.md b/ESTADO.md index 874001c..9b6eaeb 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -1,9 +1,22 @@ # ESTADO — project.linkedcorp.net -Ultima actualizacion: 2026-10-23 (v3.0.1 en desarrollo, sin desplegar) +Ultima actualizacion: 2026-10-27 (v4.4.0 en desarrollo, sin desplegar) Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo + autorregistro/aprobacion/SMTP. +## Calendario de reuniones (v4.4.0, sin desplegar) + +- Menú y API de reuniones, recurrencia, RSVP, vencimientos de actividades, correo iCalendar y + recordatorios in-app implementados en código. `DELETE` marca cancelada toda la serie. +- Migración `034_meetings.sql` **aplicada en dev** (2026-10-27), **no en producción**. Aplicarla + antes de arrancar el backend 4.4.0 en prod. El backend de dev en :4019 sigue con el código + anterior hasta reiniciarlo. +- Probado en dev con instancia temporal (4099/5197, datos de prueba ya borrados): alta, choque con + confirmación, validaciones, expansión semanal, rango máx. 93 días, edición con versión (409), + cancelar ocurrencia (EXDATE/RECURRENCE-ID), cancelar serie (METHOD:CANCEL), RSVP solo + participante (aviso in-app al organizador), recordatorio in-app a tiempo, UI escritorio/móvil sin + errores de consola. Pendiente: probar envío real con SMTP (dev no tiene SMTP → log). + ## Auditoría de seguridad y calidad (v3.0.0, sin desplegar) - Se corrigieron controles de roles, alcance de búsqueda y tareas recurrentes, bootstrap diff --git a/SPEC.md b/SPEC.md index 18341ff..8a65805 100644 --- a/SPEC.md +++ b/SPEC.md @@ -66,7 +66,17 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa promedio. Lista de actividades vencidas y de hoy del usuario. ### 7. Calendario -- Vista mensual de actividades por fecha límite (incluye hitos marcados). +- Menú propio de reuniones (v4.4.0): Mes/Semana/Día/Agenda, creación desde hueco o botón, + detalle de lectura y edición de toda la serie. Título, descripción, inicio/fin, todo el día, + ubicación, enlace de videollamada, proyecto opcional, recordatorio y recurrencia. +- Participantes internos (RSVP aceptar/rechazar/quizás) y hasta 50 correos externos sin RSVP. + Solo organizador, admin y superadmin editan/cancelan. Lectura: organizador, participante o + acceso al proyecto asociado. El módulo `calendar` se puede deshabilitar por usuario. +- Recurrencia diaria, semanal por días o mensual por día del mes; hasta 365 ocurrencias y fin + obligatorio por fecha o cantidad. Cancelación puntual con EXDATE; DELETE cancela lógicamente + toda la serie. Invitaciones/cancelaciones iCalendar y recordatorios in-app. +- Los vencimientos de actividades se muestran en solo lectura y se pueden ocultar. La pestaña + Calendario del proyecto muestra sus actividades y reuniones vinculadas. ### 8. Gantt - Vista de solo lectura por proyecto: barras por actividad (inicio/fin/avance), sin edición directa diff --git a/VERSION b/VERSION index 8089590..fdc6698 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.3.0 +4.4.0 diff --git a/backend/database/migrations/034_meetings.sql b/backend/database/migrations/034_meetings.sql new file mode 100644 index 0000000..b37dce5 --- /dev/null +++ b/backend/database/migrations/034_meetings.sql @@ -0,0 +1,65 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS meetings ( + id INT AUTO_INCREMENT PRIMARY KEY, + uid CHAR(36) NOT NULL UNIQUE, + title VARCHAR(160) NOT NULL, + description TEXT NULL, + starts_at DATETIME NOT NULL, + ends_at DATETIME NOT NULL, + all_day TINYINT(1) NOT NULL DEFAULT 0, + location VARCHAR(255) NULL, + video_url VARCHAR(2048) NULL, + project_id INT NULL, + organizer_id INT NOT NULL, + reminder_minutes SMALLINT UNSIGNED NULL, + recurrence_json TEXT NULL, + sequence_number INT UNSIGNED NOT NULL DEFAULT 0, + edit_version INT UNSIGNED NOT NULL DEFAULT 1, + cancelled_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_meetings_range (starts_at, ends_at, cancelled_at), + INDEX idx_meetings_project (project_id, starts_at), + INDEX idx_meetings_organizer (organizer_id, starts_at), + CONSTRAINT fk_meetings_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL, + CONSTRAINT fk_meetings_organizer FOREIGN KEY (organizer_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS meeting_participants ( + meeting_id INT NOT NULL, + user_id INT NOT NULL, + rsvp ENUM('pending','accepted','declined','tentative') NOT NULL DEFAULT 'pending', + responded_at DATETIME NULL, + PRIMARY KEY (meeting_id, user_id), + INDEX idx_meeting_participants_user (user_id, meeting_id), + CONSTRAINT fk_mp_meeting FOREIGN KEY (meeting_id) REFERENCES meetings(id) ON DELETE CASCADE, + CONSTRAINT fk_mp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS meeting_external_participants ( + meeting_id INT NOT NULL, + email VARCHAR(160) NOT NULL, + PRIMARY KEY (meeting_id, email), + CONSTRAINT fk_mep_meeting FOREIGN KEY (meeting_id) REFERENCES meetings(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS meeting_exceptions ( + meeting_id INT NOT NULL, + occurrence_start DATETIME NOT NULL, + created_by INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (meeting_id, occurrence_start), + CONSTRAINT fk_mex_meeting FOREIGN KEY (meeting_id) REFERENCES meetings(id) ON DELETE CASCADE, + CONSTRAINT fk_mex_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS meeting_reminders ( + meeting_id INT NOT NULL, + occurrence_start DATETIME NOT NULL, + sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (meeting_id, occurrence_start), + CONSTRAINT fk_mr_meeting FOREIGN KEY (meeting_id) REFERENCES meetings(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE notifications MODIFY COLUMN entity_type ENUM('project','activity','meeting') NULL; diff --git a/backend/package.json b/backend/package.json index 68c8cc2..b2ae758 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "4.3.0", + "version": "4.4.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/app.js b/backend/src/app.js index 4315bbc..66d70c6 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -27,6 +27,7 @@ import vaultRoutes from './modules/vault/routes.js'; import devicesRoutes from './modules/devices/routes.js'; import inventoryIngestRoutes from './modules/devices/ingest.js'; import aiRoutes from './modules/ai/routes.js'; +import meetingsRoutes from './modules/meetings/routes.js'; export const app = express(); @@ -68,6 +69,7 @@ app.use('/api/notes', notesRoutes); app.use('/api/vault', vaultRoutes); app.use('/api/devices', devicesRoutes); app.use('/api/ai', aiRoutes); +app.use('/api/meetings', meetingsRoutes); // Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no // interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas // especifico registrado despues (ver ESTADO.md, deuda tecnica). diff --git a/backend/src/jobs/meetingReminders.js b/backend/src/jobs/meetingReminders.js new file mode 100644 index 0000000..d9f2945 --- /dev/null +++ b/backend/src/jobs/meetingReminders.js @@ -0,0 +1,12 @@ +import { sendDueReminders } from '../modules/meetings/service.js'; +export function startMeetingReminderJob() { + let running = false; + const run = async () => { + if (running) return; + running = true; + try { await sendDueReminders(); } catch (err) { console.error('[meetings] recordatorios:', err.message); } + finally { running = false; } + }; + setInterval(run, 60_000); + void run(); +} diff --git a/backend/src/middleware/modules.js b/backend/src/middleware/modules.js index bca3b19..55689e1 100644 --- a/backend/src/middleware/modules.js +++ b/backend/src/middleware/modules.js @@ -3,7 +3,7 @@ import { fail } from '../utils/http.js'; // Modulos que el superadmin puede habilitar/deshabilitar por usuario. Panel y Configuracion no // estan aqui: el Panel lo ve todo usuario y Configuracion sigue siendo solo de superadmin. -export const MODULES = ['projects', 'recurring_tasks', 'notes', 'vault', 'devices', 'organization']; +export const MODULES = ['projects', 'calendar', 'recurring_tasks', 'notes', 'vault', 'devices', 'organization']; // Sin fila en user_modules = habilitado. El superadmin siempre tiene todo. export async function loadModules(userId, roleCode) { diff --git a/backend/src/modules/meetings/controller.js b/backend/src/modules/meetings/controller.js new file mode 100644 index 0000000..4a4732a --- /dev/null +++ b/backend/src/modules/meetings/controller.js @@ -0,0 +1,10 @@ +import { asyncHandler, ok } from '../../utils/http.js'; +import * as service from './service.js'; +export const getMeetings = asyncHandler(async (req,res) => ok(res, await service.list(req.query, req.currentUser))); +export const getActivities = asyncHandler(async (req,res) => ok(res, await service.activities(req.query, req.currentUser))); +export const getMeeting = asyncHandler(async (req,res) => ok(res, await service.detail(Number(req.params.id), req.currentUser))); +export const postMeeting = asyncHandler(async (req,res) => { const result = await service.create(req.body || {}, req.currentUser); ok(res, result, result.requiresConfirmation ? 200 : 201); }); +export const putMeeting = asyncHandler(async (req,res) => ok(res, await service.update(Number(req.params.id), req.body || {}, req.currentUser))); +export const deleteMeeting = asyncHandler(async (req,res) => ok(res, await service.cancel(Number(req.params.id), req.currentUser))); +export const postCancelOccurrence = asyncHandler(async (req,res) => ok(res, await service.cancelOccurrence(Number(req.params.id), req.params.date, req.currentUser))); +export const putRsvp = asyncHandler(async (req,res) => ok(res, await service.rsvp(Number(req.params.id), req.body?.status, req.currentUser))); diff --git a/backend/src/modules/meetings/ical.js b/backend/src/modules/meetings/ical.js new file mode 100644 index 0000000..15fc518 --- /dev/null +++ b/backend/src/modules/meetings/ical.js @@ -0,0 +1,40 @@ +const escapeText = (value = '') => String(value).replace(/\\/g, '\\\\').replace(/\r\n|\r|\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;'); +const safeEmail = (value) => String(value || '').replace(/[^A-Za-z0-9._%+@-]/g, ''); +const stamp = (s) => String(s).replace(/[-:]/g, '').replace(' ', 'T') + 'Z'; +// Hora civil de Guayaquil -> UTC para iCalendar. Ecuador continental no usa DST. +const utc = (s) => stamp(new Date(`${s.replace(' ', 'T')}-05:00`).toISOString().slice(0, 19).replace('T', ' ')); +function fold(line) { + const chunks = []; let part = ''; + for (const char of line) { + if (Buffer.byteLength(part + char, 'utf8') > 75) { chunks.push(part); part = ' ' + char; } else part += char; + } + chunks.push(part); + return chunks.join('\r\n'); +} +export function buildIcs(meeting, method = 'REQUEST', occurrenceStart = null) { + const duration = new Date(`${meeting.ends_at.replace(' ', 'T')}-05:00`) - new Date(`${meeting.starts_at.replace(' ', 'T')}-05:00`); + const start = occurrenceStart || meeting.starts_at; + const end = occurrenceStart ? new Date(new Date(`${start.replace(' ', 'T')}-05:00`).getTime() + duration - 5 * 3600000).toISOString().slice(0, 19).replace('T', ' ') : meeting.ends_at; + const allDay = !!meeting.all_day; + const dateValue = (s) => s.slice(0, 10).replace(/-/g, ''); + const exclusiveEnd = (s) => new Date(new Date(`${s.slice(0,10)}T00:00:00Z`).getTime() + 86400000).toISOString().slice(0,10).replace(/-/g,''); + const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//LinkedCorp//Project Calendar//ES', 'CALSCALE:GREGORIAN', 'X-WR-TIMEZONE:America/Guayaquil', `METHOD:${method}`, 'BEGIN:VEVENT', `UID:${meeting.uid}@project.linkedcorp.net`, `SEQUENCE:${meeting.sequence_number}`, `DTSTAMP:${new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')}`, + allDay ? `DTSTART;VALUE=DATE:${dateValue(start)}` : `DTSTART:${utc(start)}`, + allDay ? `DTEND;VALUE=DATE:${exclusiveEnd(end)}` : `DTEND:${utc(end)}`, + `SUMMARY:${escapeText(meeting.title)}`, `DESCRIPTION:${escapeText(meeting.description)}`, `LOCATION:${escapeText(meeting.location)}`, `ORGANIZER:mailto:${safeEmail(meeting.organizer_email)}`, `STATUS:${method === 'CANCEL' ? 'CANCELLED' : 'CONFIRMED'}`]; + for (const participant of meeting.participants || []) lines.push(`ATTENDEE;RSVP=TRUE;PARTSTAT=${({accepted:'ACCEPTED',declined:'DECLINED',tentative:'TENTATIVE'})[participant.rsvp] || 'NEEDS-ACTION'}:mailto:${safeEmail(participant.email)}`); + for (const email of meeting.external_emails || []) lines.push(`ATTENDEE;RSVP=FALSE:mailto:${safeEmail(email)}`); + if (occurrenceStart) lines.push(allDay ? `RECURRENCE-ID;VALUE=DATE:${dateValue(start)}` : `RECURRENCE-ID:${utc(start)}`); + if (meeting.video_url) lines.push(`URL:${meeting.video_url}`); + const rule = meeting.recurrence_json ? JSON.parse(meeting.recurrence_json) : null; + if (rule && !occurrenceStart) { + const parts = [`FREQ=${rule.frequency.toUpperCase()}`, `INTERVAL=${rule.interval}`]; + if (rule.frequency === 'weekly') parts.push(`BYDAY=${rule.weekdays.map((i) => ['MO','TU','WE','TH','FR','SA','SU'][i]).join(',')}`); + if (rule.count) parts.push(`COUNT=${rule.count}`); + if (rule.until) parts.push(`UNTIL=${allDay ? dateValue(rule.until) : utc(`${rule.until} 23:59:59`)}`); + lines.push(`RRULE:${parts.join(';')}`); + } + if (meeting.exceptions?.length && !occurrenceStart) lines.push(allDay ? `EXDATE;VALUE=DATE:${meeting.exceptions.map(dateValue).join(',')}` : `EXDATE:${meeting.exceptions.map(utc).join(',')}`); + lines.push('END:VEVENT', 'END:VCALENDAR'); + return lines.map(fold).join('\r\n') + '\r\n'; +} diff --git a/backend/src/modules/meetings/recurrence.js b/backend/src/modules/meetings/recurrence.js new file mode 100644 index 0000000..310bae9 --- /dev/null +++ b/backend/src/modules/meetings/recurrence.js @@ -0,0 +1,77 @@ +import { HttpError } from '../../utils/http.js'; + +// DATETIME representa hora civil de America/Guayaquil (UTC-5 sin DST). Date en UTC se usa +// solo para aritmética de calendario; no convierte estos valores a la zona del servidor. +const parse = (s) => new Date(`${s.replace(' ', 'T')}Z`); +const format = (d) => d.toISOString().slice(0, 19).replace('T', ' '); +export const addMinutes = (s, n) => format(new Date(parse(s).getTime() + n * 60000)); +export const durationMinutes = (a, b) => (parse(b) - parse(a)) / 60000; +export const guayaquilNow = () => format(new Date(Date.now() - 5 * 3600000)); + +function* occurrenceStarts(start, rule, max = 365) { + const first = parse(start); + if (!rule) { yield start; return; } + let emitted = 0; + const accept = (d) => d >= first && d.getUTCFullYear() <= 9999 && (!rule.until || format(d).slice(0, 10) <= rule.until); + if (rule.frequency === 'daily') { + for (let n = 0; emitted < max && (!rule.count || emitted < rule.count); n++) { + const d = new Date(first.getTime() + n * rule.interval * 86400000); + if (!accept(d)) break; + emitted++; yield format(d); + } + } else if (rule.frequency === 'weekly') { + const monday = new Date(first.getTime() - ((first.getUTCDay() + 6) % 7) * 86400000); + for (let block = 0; emitted < max && (!rule.count || emitted < rule.count); block++) { + const week = new Date(monday.getTime() + block * rule.interval * 7 * 86400000); + if (week.getUTCFullYear() > 9999 || (rule.until && format(week).slice(0, 10) > rule.until)) break; + for (const weekday of rule.weekdays) { + const d = new Date(week.getTime() + weekday * 86400000); + if (d < first) continue; + if (!accept(d)) return; + emitted++; yield format(d); + if (emitted >= max || (rule.count && emitted >= rule.count)) return; + } + } + } else if (rule.frequency === 'monthly') { + const time = [first.getUTCHours(), first.getUTCMinutes(), first.getUTCSeconds()]; + for (let block = 0; emitted < max && (!rule.count || emitted < rule.count); block++) { + const totalMonths = first.getUTCFullYear() * 12 + first.getUTCMonth() + block * rule.interval; + const year = Math.floor(totalMonths / 12), month = totalMonths % 12; + if (year > 9999 || (rule.until && `${year}-${String(month+1).padStart(2,'0')}-01` > rule.until)) break; + const d = new Date(Date.UTC(year, month, first.getUTCDate(), ...time)); + if (d.getUTCMonth() !== month) continue; // p. ej. no hay 31 de febrero. + if (!accept(d)) break; + emitted++; yield format(d); + } + } +} + +export function validateRecurrence(raw, start) { + if (!raw) return null; + const { frequency, interval = 1, weekdays, until, count } = raw; + if (!['daily', 'weekly', 'monthly'].includes(frequency) || !Number.isInteger(interval) || interval < 1 || interval > 365) throw new HttpError(400, 'Recurrencia invalida'); + if ((!!until) === (!!count)) throw new HttpError(400, 'Indica fecha final o numero de ocurrencias'); + if (count && (!Number.isInteger(count) || count < 1 || count > 365)) throw new HttpError(400, 'Maximo 365 ocurrencias'); + if (until && (typeof until !== 'string' || !/^\d{4}-\d\d-\d\d$/.test(until) || Number.isNaN(Date.parse(`${until}T00:00:00Z`)) || new Date(`${until}T00:00:00Z`).toISOString().slice(0,10) !== until || until < start.slice(0, 10))) throw new HttpError(400, 'Fecha final de recurrencia invalida'); + if (frequency === 'weekly' && (!Array.isArray(weekdays) || !weekdays.length || weekdays.some((v) => !Number.isInteger(v) || v < 0 || v > 6))) throw new HttpError(400, 'Dias semanales invalidos'); + if (frequency === 'weekly' && !weekdays.includes((parse(start).getUTCDay() + 6) % 7)) throw new HttpError(400, 'El primer dia debe estar incluido'); + const rule = { frequency, interval, weekdays: frequency === 'weekly' ? [...new Set(weekdays)].sort() : [], until: until || null, count: count || null }; + const occurrences = [...occurrenceStarts(start, rule, 366)]; + if (occurrences.length > 365) throw new HttpError(400, 'Maximo 365 ocurrencias'); + if (count && occurrences.length < count) throw new HttpError(400, 'La serie supera el rango de fechas admitido'); + return rule; +} + +export function expand(meeting, from, to) { + if (meeting.cancelled_at) return []; + const rule = meeting.recurrence_json ? JSON.parse(meeting.recurrence_json) : null; + const duration = durationMinutes(meeting.starts_at, meeting.ends_at); + const exclusions = new Set(meeting.exceptions || []); + const out = []; + for (const start of occurrenceStarts(meeting.starts_at, rule)) { + if (start >= to) break; + const end = addMinutes(start, duration); + if (end > from && !exclusions.has(start)) out.push({ ...meeting, occurrence_start: start, occurrence_end: end }); + } + return out; +} diff --git a/backend/src/modules/meetings/repository.js b/backend/src/modules/meetings/repository.js new file mode 100644 index 0000000..5464d2b --- /dev/null +++ b/backend/src/modules/meetings/repository.js @@ -0,0 +1,133 @@ +import { pool } from '../../config/db.js'; +import * as notificationsRepo from '../notifications/repository.js'; + +const base = `SELECT m.*, u.name AS organizer_name, u.email AS organizer_email, p.name AS project_name FROM meetings m + JOIN users u ON u.id=m.organizer_id LEFT JOIN projects p ON p.id=m.project_id`; + +export async function findById(id) { + const [[row]] = await pool.query(`${base} WHERE m.id=?`, [id]); + if (!row) return null; + const [users] = await pool.query(`SELECT mp.user_id, mp.rsvp, u.name, u.email FROM meeting_participants mp JOIN users u ON u.id=mp.user_id WHERE mp.meeting_id=?`, [id]); + const [externals] = await pool.query('SELECT email FROM meeting_external_participants WHERE meeting_id=?', [id]); + const [exceptions] = await pool.query('SELECT occurrence_start FROM meeting_exceptions WHERE meeting_id=?', [id]); + return { ...row, participants: users, external_emails: externals.map((r) => r.email), exceptions: exceptions.map((r) => r.occurrence_start) }; +} + +export async function findCandidates(from, to) { + const [rows] = await pool.query(`${base} WHERE m.cancelled_at IS NULL AND m.starts_at < ? AND (m.recurrence_json IS NOT NULL OR m.ends_at > ?) ORDER BY m.starts_at`, [to, from]); + return rows; +} + +export async function findParticipantConflicts(from, to, ids, excludeId = null) { + const [rows] = await pool.query(`SELECT m.*, mp.user_id AS conflict_user_id, u.name AS conflict_name + FROM meeting_participants mp JOIN meetings m ON m.id=mp.meeting_id JOIN users u ON u.id=mp.user_id + WHERE mp.user_id IN (${ids.map(() => '?').join(',')}) AND m.cancelled_at IS NULL AND m.starts_at?) AND (? IS NULL OR m.id<>?) + ORDER BY m.starts_at`, [...ids, to, from, excludeId, excludeId]); + const grouped = new Map(); + for (const row of rows) { + if (!grouped.has(row.id)) grouped.set(row.id, { ...row, conflict_names: [] }); + grouped.get(row.id).conflict_names.push(row.conflict_name); + } + return [...grouped.values()]; +} + +export async function findVisibleCandidates(from, to, user) { + const roles = ['superadmin', 'admin', 'it_manager']; + const access = roles.includes(user.roleCode) ? '' : `AND (m.organizer_id=? OR EXISTS (SELECT 1 FROM meeting_participants mp WHERE mp.meeting_id=m.id AND mp.user_id=?) OR (m.project_id IS NOT NULL AND p.deleted_at IS NULL AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id=m.project_id AND pm.user_id=?)))`; + // Roles globales ven proyectos; las reuniones sin proyecto siguen privadas. + const globalAccess = !user.modules.includes('projects') ? `AND (m.organizer_id=? OR EXISTS (SELECT 1 FROM meeting_participants mp WHERE mp.meeting_id=m.id AND mp.user_id=?))` : roles.includes(user.roleCode) ? `AND (m.organizer_id=? OR EXISTS (SELECT 1 FROM meeting_participants mp WHERE mp.meeting_id=m.id AND mp.user_id=?) OR (m.project_id IS NOT NULL AND p.deleted_at IS NULL))` : access; + const [rows] = await pool.query(`${base} WHERE m.cancelled_at IS NULL AND m.starts_at < ? AND (m.recurrence_json IS NOT NULL OR m.ends_at > ?) ${globalAccess} ORDER BY m.starts_at`, [to, from, user.id, user.id, ...(roles.includes(user.roleCode) || !user.modules.includes('projects') ? [] : [user.id])]); + return rows; +} + +export async function exceptionsFor(ids) { + if (!ids.length) return new Map(); + const [rows] = await pool.query(`SELECT meeting_id, occurrence_start FROM meeting_exceptions WHERE meeting_id IN (${ids.map(() => '?').join(',')})`, ids); + const map = new Map(); + for (const row of rows) { if (!map.has(row.meeting_id)) map.set(row.meeting_id, []); map.get(row.meeting_id).push(row.occurrence_start); } + return map; +} + +export async function activeUsers(ids) { + if (!ids.length) return []; + const [rows] = await pool.query(`SELECT id,name,email FROM users WHERE id IN (${ids.map(() => '?').join(',')}) AND is_active=1 AND status='active'`, ids); + return rows; +} + +async function replaceParticipants(conn, id, data) { + if (data.participantUserIds.length) await conn.query(`DELETE FROM meeting_participants WHERE meeting_id=? AND user_id NOT IN (${data.participantUserIds.map(() => '?').join(',')})`, [id, ...data.participantUserIds]); + else await conn.query('DELETE FROM meeting_participants WHERE meeting_id=?', [id]); + await conn.query('DELETE FROM meeting_external_participants WHERE meeting_id=?', [id]); + for (const userId of data.participantUserIds) await conn.query('INSERT IGNORE INTO meeting_participants (meeting_id,user_id) VALUES (?,?)', [id, userId]); + for (const email of data.externalEmails) await conn.query('INSERT INTO meeting_external_participants (meeting_id,email) VALUES (?,?)', [id, email]); +} + +export async function create(data, organizerId, uid) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [result] = await conn.query(`INSERT INTO meetings (uid,title,description,starts_at,ends_at,all_day,location,video_url,project_id,organizer_id,reminder_minutes,recurrence_json) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, [uid, data.title, data.description, data.startsAt, data.endsAt, data.allDay, data.location, data.videoUrl, data.projectId, organizerId, data.reminderMinutes, data.recurrence ? JSON.stringify(data.recurrence) : null]); + await replaceParticipants(conn, result.insertId, data); + await conn.commit(); + return findById(result.insertId); + } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } +} + +export async function update(id, data, version, resetExceptions) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [result] = await conn.query(`UPDATE meetings SET title=?,description=?,starts_at=?,ends_at=?,all_day=?,location=?,video_url=?,project_id=?,reminder_minutes=?,recurrence_json=?,sequence_number=sequence_number+1,edit_version=edit_version+1 + WHERE id=? AND edit_version=? AND cancelled_at IS NULL`, [data.title, data.description, data.startsAt, data.endsAt, data.allDay, data.location, data.videoUrl, data.projectId, data.reminderMinutes, data.recurrence ? JSON.stringify(data.recurrence) : null, id, version]); + if (!result.affectedRows) { await conn.rollback(); return null; } + await replaceParticipants(conn, id, data); + if (resetExceptions) await conn.query('DELETE FROM meeting_exceptions WHERE meeting_id=?', [id]); + await conn.commit(); + return findById(id); + } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } +} + +export async function cancel(id) { + const [result] = await pool.query('UPDATE meetings SET cancelled_at=NOW(),sequence_number=sequence_number+1,edit_version=edit_version+1 WHERE id=? AND cancelled_at IS NULL', [id]); + return result.affectedRows ? findById(id) : null; +} +export async function cancelOccurrence(id, date, userId) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [[row]] = await conn.query('SELECT cancelled_at FROM meetings WHERE id=? FOR UPDATE', [id]); + if (!row || row.cancelled_at) { await conn.rollback(); return null; } + const [insert] = await conn.query('INSERT IGNORE INTO meeting_exceptions (meeting_id,occurrence_start,created_by) VALUES (?,?,?)', [id, date, userId]); + if (!insert.affectedRows) { await conn.rollback(); return null; } + await conn.query('UPDATE meetings SET sequence_number=sequence_number+1,edit_version=edit_version+1 WHERE id=?', [id]); + await conn.commit(); + return findById(id); + } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } +} +export async function rsvp(id, userId, status) { + const [result] = await pool.query('UPDATE meeting_participants SET rsvp=?,responded_at=NOW() WHERE meeting_id=? AND user_id=?', [status, id, userId]); + return !!result.affectedRows; +} +export async function activityDue(from, to, user) { + const admin = ['superadmin', 'admin', 'it_manager'].includes(user.roleCode); + const [rows] = await pool.query(`SELECT a.id,a.name,a.due_date,a.project_id,p.name AS project_name FROM activities a JOIN projects p ON p.id=a.project_id + WHERE a.due_date>=? AND a.due_date String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[c]); +const normalizeDate = (s) => { + if (typeof s !== 'string' || !DATE.test(s) || Number(s.slice(0, 4)) < 1000) return null; + const short = `${s.slice(0, 16)}:00`; + const date = new Date(`${short.replace(' ', 'T')}Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 19).replace('T', ' ') === short ? short : null; +}; +function range(from, to) { + const a = normalizeDate(from), b = normalizeDate(to); + if (!a || !b || a >= b || durationMinutes(a, b) > 93 * 1440) throw new HttpError(400, 'Rango invalido (maximo 93 dias)'); + return [a, b]; +} +async function normalize(raw, actor) { + const title = String(raw.title || '').trim(); + const description = String(raw.description || '').trim(); + const location = String(raw.location || '').trim(); + let videoUrl = String(raw.videoUrl || '').trim(); + const startsAt = normalizeDate(raw.startsAt), endsAt = normalizeDate(raw.endsAt); + if (!title || title.length > 160 || /[\r\n\u0000]/.test(title) || description.length > 5000 || location.length > 255 || videoUrl.length > 2048) throw new HttpError(400, 'Titulo o texto invalido'); + if (!startsAt || !endsAt || endsAt <= startsAt || durationMinutes(startsAt, endsAt) > 30 * 1440) throw new HttpError(400, 'Horario invalido'); + if (videoUrl && (!/^https?:\/\//i.test(videoUrl) || /[\u0000-\u001f\u007f]/.test(videoUrl))) throw new HttpError(400, 'Enlace http/https invalido'); + try { if (videoUrl) videoUrl = new URL(videoUrl).href; } catch { throw new HttpError(400, 'Enlace invalido'); } + const reminderMinutes = raw.reminderMinutes === null || raw.reminderMinutes === '' || raw.reminderMinutes === undefined ? null : Number(raw.reminderMinutes); + if (reminderMinutes !== null && ![5,10,15,30,60,1440].includes(reminderMinutes)) throw new HttpError(400, 'Recordatorio invalido'); + const projectId = raw.projectId ? Number(raw.projectId) : null; + if (projectId !== null && (!Number.isSafeInteger(projectId) || projectId <= 0)) throw new HttpError(400, 'Proyecto invalido'); + if (projectId) { + if (!actor.modules.includes('projects')) throw new HttpError(403, 'Sin acceso al modulo Proyectos'); + const project = await projectsRepo.findById(projectId); + if (!project || !(await canReadProject(actor, project))) throw new HttpError(404, 'Proyecto no encontrado'); + } + if (!Array.isArray(raw.participantUserIds || []) || !Array.isArray(raw.externalEmails || [])) throw new HttpError(400, 'Participantes invalidos'); + const ids = [...new Set((raw.participantUserIds || []).map(Number))]; + const emails = [...new Set((raw.externalEmails || []).map((e) => String(e).trim().toLowerCase()))]; + if (ids.length + emails.length > 50 || ids.some((id) => !Number.isSafeInteger(id) || id <= 0) || emails.some((e) => e.length > 160 || !EMAIL.test(e))) throw new HttpError(400, 'Participantes invalidos (maximo 50)'); + const users = await repo.activeUsers(ids); + if (users.length !== ids.length) throw new HttpError(400, 'Selecciona solo usuarios activos'); + return { title, description, location, videoUrl: videoUrl || null, startsAt, endsAt, allDay: !!raw.allDay, reminderMinutes, projectId, participantUserIds: ids.filter((id) => id !== actor.id), externalEmails: emails, recurrence: validateRecurrence(raw.recurrence, startsAt) }; +} +async function visible(meeting, user) { + if (meeting.organizer_id === user.id || meeting.participants.some((p) => p.user_id === user.id)) return true; + if (!meeting.project_id || !user.modules.includes('projects')) return false; + const project = await projectsRepo.findById(meeting.project_id); + return !!project && canReadProject(user, project); +} +export async function detail(id, user) { + if (!Number.isSafeInteger(id) || id <= 0) throw new HttpError(404, 'Reunion no encontrada'); + const row = await repo.findById(id); + if (!row || !(await visible(row, user))) throw new HttpError(404, 'Reunion no encontrada'); + return row; +} +function canManage(row, user) { return row.organizer_id === user.id || ROLES.includes(user.roleCode); } +async function notify(row, action, extraIds = []) { + const recipients = [...new Set([...row.participants.map((p) => p.user_id), ...extraIds])]; + await Promise.allSettled(recipients.map((userId) => createNotification({ userId, type: `meeting_${action}`, message: row.title.slice(0, 255), entityType: 'meeting', entityId: row.id }))); +} +async function mail(row, method, recipientsOverride = null, occurrenceStart = null) { + const recipients = recipientsOverride || [...new Set([row.organizer_email, ...row.participants.map((p) => p.email), ...row.external_emails].filter(Boolean))]; + const html = `

${htmlEscape(row.title)}

${htmlEscape(row.description).replace(/\n/g, '
')}

${htmlEscape(occurrenceStart || row.starts_at)} – ${htmlEscape(row.ends_at)} (America/Guayaquil)

${htmlEscape(row.location)}

${row.video_url ? `

${htmlEscape(row.video_url)}

` : ''}`; + await Promise.allSettled(recipients.map((to) => sendMail({ to, subject: `${method === 'CANCEL' ? 'Reunión cancelada / Meeting cancelled' : 'Invitación / Invitation'}: ${row.title}`, html, icalEvent: { method, content: buildIcs(row, method, occurrenceStart), filename: 'reunion.ics' } }))); +} +function dispatch(row, action, method = 'REQUEST', extraIds = []) { + setImmediate(() => { void Promise.allSettled([notify(row, action, extraIds), mail(row, method)]); }); +} +function deferMail(row, method, recipients = null, occurrenceStart = null) { + setImmediate(() => { void mail(row, method, recipients, occurrenceStart).catch((err) => console.error('[meetings] correo:', err.message)); }); +} +function overlaps(a, b) { + let i = 0, j = 0; + while (i < a.length && j < b.length) { + if (a[i].occurrence_start < b[j].occurrence_end && a[i].occurrence_end > b[j].occurrence_start) return true; + if (a[i].occurrence_end <= b[j].occurrence_end) i++; else j++; + } + return false; +} +async function conflictWarnings(data, excludeId) { + const ids = data.participantUserIds; + if (!ids.length) return []; + const proposed = expand({ starts_at: data.startsAt, ends_at: data.endsAt, recurrence_json: data.recurrence ? JSON.stringify(data.recurrence) : null, exceptions: [] }, data.startsAt, '9999-12-31 23:59:59'); + const end = proposed.at(-1)?.occurrence_end || data.endsAt; + const rows = await repo.findParticipantConflicts(data.startsAt, end, ids, excludeId || null); + const exceptionMap = await repo.exceptionsFor(rows.map((r) => r.id)); + const conflicts = new Set(); + for (const row of rows) { + const existing = expand({ ...row, exceptions: exceptionMap.get(row.id) || [] }, data.startsAt, end); + if (overlaps(proposed, existing)) row.conflict_names.forEach((name) => conflicts.add(name)); + } + return [...conflicts]; +} +export async function list(query, user) { + const [from, to] = range(query.from, query.to); + const page = Number(query.page || 1), pageSize = Number(query.pageSize || 1500); + if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 1500 || !Number.isSafeInteger((page-1)*pageSize)) throw new HttpError(400, 'Paginacion invalida'); + const rows = await repo.findVisibleCandidates(from, to, user); + const exceptions = await repo.exceptionsFor(rows.map((r) => r.id)); + const all = rows.flatMap((r) => expand({ ...r, exceptions: exceptions.get(r.id) || [] }, from, to)) + .sort((a, b) => a.occurrence_start.localeCompare(b.occurrence_start) || a.id - b.id); + return { items: all.slice((page-1)*pageSize, page*pageSize), total: all.length, page, pageSize }; +} +export async function activities(query, user) { + const [from, to] = range(query.from, query.to); + if (!user.modules.includes('projects')) return []; + return repo.activityDue(from.slice(0, 10), to.slice(0, 10), user); +} +export async function create(raw, user) { + const data = await normalize(raw, user); + const warnings = await conflictWarnings(data); + if (warnings.length && raw.confirmConflicts !== true) return { requiresConfirmation: true, warnings }; + const row = await repo.create(data, user.id, randomUUID()); + await logAudit({ userId: user.id, action: 'create', entityType: 'meeting', entityId: row.id }); + dispatch(row, 'created'); + return { meeting: row, warnings }; +} +export async function update(id, raw, user) { + const before = await detail(id, user); + if (!canManage(before, user)) throw new HttpError(403, 'Sin permiso para editar'); + if (before.cancelled_at) throw new HttpError(409, 'Reunion cancelada'); + requireEditVersion(raw.editVersion); + const data = await normalize(raw, user); + const warnings = await conflictWarnings(data, id); + if (warnings.length && raw.confirmConflicts !== true) return { requiresConfirmation: true, warnings }; + const resetExceptions = before.starts_at !== data.startsAt || before.ends_at !== data.endsAt || before.recurrence_json !== (data.recurrence ? JSON.stringify(data.recurrence) : null); + const row = requireUpdated(await repo.update(id, data, raw.editVersion, resetExceptions)); + await logAudit({ userId: user.id, action: 'update', entityType: 'meeting', entityId: id }); + const currentEmails = new Set([...row.participants.map((p) => p.email), ...row.external_emails]); + const removedEmails = [...new Set([...before.participants.map((p) => p.email), ...before.external_emails])].filter((email) => !currentEmails.has(email)); + const currentIds = new Set(row.participants.map((p) => p.user_id)); + const removedIds = before.participants.filter((p) => !currentIds.has(p.user_id)).map((p) => p.user_id); + if (removedEmails.length) deferMail(row, 'CANCEL', removedEmails); + if (before.starts_at !== row.starts_at || before.ends_at !== row.ends_at || before.location !== row.location || before.video_url !== row.video_url || before.recurrence_json !== row.recurrence_json || removedIds.length || before.participants.length !== row.participants.length) dispatch(row, 'updated', 'REQUEST', removedIds); + else deferMail(row, 'REQUEST'); + return { meeting: row, warnings }; +} +export async function cancel(id, user) { + const before = await detail(id, user); + if (!canManage(before, user)) throw new HttpError(403, 'Sin permiso para cancelar'); + if (before.cancelled_at) throw new HttpError(409, 'Reunion ya cancelada'); + const row = requireUpdated(await repo.cancel(id)); + await logAudit({ userId: user.id, action: 'cancel', entityType: 'meeting', entityId: id }); + dispatch(row, 'cancelled', 'CANCEL'); + return { cancelled: true }; +} +export async function cancelOccurrence(id, date, user) { + const before = await detail(id, user); + if (!canManage(before, user)) throw new HttpError(403, 'Sin permiso para cancelar'); + if (!before.recurrence_json) throw new HttpError(400, 'Esta reunion no es recurrente'); + const start = normalizeDate(date); + if (!start || !expand(before, start, addMinutes(start, 1)).some((r) => r.occurrence_start === start)) throw new HttpError(400, 'Ocurrencia invalida'); + const row = requireUpdated(await repo.cancelOccurrence(id, start, user.id)); + await logAudit({ userId: user.id, action: 'cancel_occurrence', entityType: 'meeting', entityId: id, detail: { start } }); + dispatch(row, 'occurrence_cancelled'); + deferMail(row, 'CANCEL', null, start); + return row; +} +export async function rsvp(id, status, user) { + if (!['accepted','declined','tentative'].includes(status)) throw new HttpError(400, 'Respuesta invalida'); + const row = await detail(id, user); + if (row.cancelled_at) throw new HttpError(409, 'Reunion cancelada'); + if (!(await repo.rsvp(id, user.id, status))) throw new HttpError(403, 'Solo el participante puede responder'); + await logAudit({ userId: user.id, action: 'rsvp', entityType: 'meeting', entityId: id, detail: { status } }); + if (row.organizer_id !== user.id) { + setImmediate(() => { void createNotification({ userId: row.organizer_id, type: `meeting_rsvp_${status}`, message: `${user.name || ''} · ${row.title}`.slice(0, 255), entityType: 'meeting', entityId: id }).catch(() => {}); }); + } + return repo.findById(id); +} +export async function sendDueReminders() { + const now = guayaquilNow(), end = addMinutes(now, 1441); + const rows = await repo.findCandidates(now, end); + const map = await repo.exceptionsFor(rows.map((r) => r.id)); + let count = 0; + for (const row of rows) { + if (!row.reminder_minutes) continue; + const occurrences = expand({ ...row, exceptions: map.get(row.id) || [] }, now, end); + for (const occurrence of occurrences) { + const due = addMinutes(occurrence.occurrence_start, -row.reminder_minutes); + if (due > now || due < addMinutes(now, -2)) continue; + const full = await repo.findById(row.id); + const recipientIds = [...new Set([row.organizer_id, ...full.participants.map((p) => p.user_id)])]; + if (await repo.sendReminderOnce(row.id, occurrence.occurrence_start, recipientIds, row.title)) count++; + } + } + return count; +} diff --git a/backend/src/modules/notifications/repository.js b/backend/src/modules/notifications/repository.js index 9850dcf..b63177c 100644 --- a/backend/src/modules/notifications/repository.js +++ b/backend/src/modules/notifications/repository.js @@ -14,8 +14,8 @@ export async function countUnread(userId) { return rows[0].c; } -export async function create({ userId, type, message, entityType, entityId }) { - const [result] = await pool.query( +export async function create({ userId, type, message, entityType, entityId }, connection = pool) { + const [result] = await connection.query( 'INSERT INTO notifications (user_id, type, message, entity_type, entity_id) VALUES (?, ?, ?, ?, ?)', [userId, type, message, entityType || null, entityId || null] ); diff --git a/backend/src/server.js b/backend/src/server.js index 2bdb355..12d5b91 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -1,9 +1,11 @@ import { app } from './app.js'; import { env } from './config/env.js'; import { startRecurringTaskReminderJob } from './jobs/recurringTaskReminders.js'; +import { startMeetingReminderJob } from './jobs/meetingReminders.js'; app.listen(env.port, () => { console.log(`[server] project.linkedcorp.net backend escuchando en :${env.port} (${env.nodeEnv})`); }); startRecurringTaskReminderJob(); +startMeetingReminderJob(); diff --git a/backend/src/utils/mailer.js b/backend/src/utils/mailer.js index 1c8c211..7445930 100644 --- a/backend/src/utils/mailer.js +++ b/backend/src/utils/mailer.js @@ -3,12 +3,12 @@ import { getSmtpConfigInternal } from '../modules/settings/service.js'; // Degradacion a modo dev cuando el SMTP no esta configurado (regla del orquestador): en vez de // fallar, se loguea el contenido/enlace y el flujo que llamo a esto sigue funcionando. -export async function sendMail({ to, subject, html }) { +export async function sendMail({ to, subject, html, icalEvent }) { try { const config = await getSmtpConfigInternal(); if (!config) { - console.log(`[mail:dev] to=${to} subject="${subject}"\n${html}`); + console.log(`[mail:dev] to=${to} subject="${subject}"\n${html}${icalEvent?.content ? `\n[icalEvent]\n${icalEvent.content}` : ''}`); return { sent: false, dev: true }; } @@ -22,7 +22,7 @@ export async function sendMail({ to, subject, html }) { // Nunca dejar que un fallo de envio (SMTP mal configurado, host caido) tumbe al que llama — // critico para forgot-password, que siempre debe responder generico sin importar el resultado. - await transporter.sendMail({ from: config.from, to, subject, html }); + await transporter.sendMail({ from: config.from, to, subject, html, ...(icalEvent ? { icalEvent } : {}) }); return { sent: true, dev: false }; } catch (err) { console.error('[mail:error]', err.message); diff --git a/frontend/package.json b/frontend/package.json index bc22067..55b3221 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "4.3.0", + "version": "4.4.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index 5f04315..0919201 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -24,6 +24,7 @@ import ContrasenasPage from '../pages/Contrasenas/ContrasenasPage.jsx'; import EquiposPage from '../pages/Equipos/EquiposPage.jsx'; import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx'; import MiCuentaPage from '../pages/MiCuenta/MiCuentaPage.jsx'; +import CalendarioPage from '../pages/Calendario/CalendarioPage.jsx'; // Router de datos (createBrowserRouter): necesario para useBlocker, que avisa al salir de una // seccion con cambios sin guardar (ver components/NavigationGuard.jsx). Mismas rutas de antes. @@ -40,6 +41,7 @@ const router = createBrowserRouter(createRoutesFromElements( } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/app/AppShell.jsx b/frontend/src/app/AppShell.jsx index 4e0d243..6347f00 100644 --- a/frontend/src/app/AppShell.jsx +++ b/frontend/src/app/AppShell.jsx @@ -67,6 +67,7 @@ export default function AppShell() { if (!notification.is_read) await markNotificationRead(notification.id); await reloadNotifications(); if (notification.entity_type === 'project') navigate(`/proyectos/${notification.entity_id}`); + else if (notification.entity_type === 'meeting') navigate(`/calendario?meeting=${notification.entity_id}`); else if (notification.entity_type === 'activity') navigate(`/proyectos`); } @@ -94,7 +95,7 @@ export default function AppShell() { )} {notifData?.items?.slice(0, 8).map((n) => ( handleNotificationClick(n)}> - {n.message} + {n.type?.startsWith('meeting_') ? `${t(`notification_${n.type}`)}: ${n.message}` : n.message} ))} {notifData?.items?.length > 0 && ( diff --git a/frontend/src/components/DatePicker.jsx b/frontend/src/components/DatePicker.jsx new file mode 100644 index 0000000..ed7fee2 --- /dev/null +++ b/frontend/src/components/DatePicker.jsx @@ -0,0 +1,55 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react'; +import { useUi } from '../context/ui-context.jsx'; + +const key = (d) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; +const fromKey = (s) => { const [y,m,d] = String(s || '').split('-').map(Number); return Number.isFinite(y) && Number.isFinite(m) && Number.isFinite(d) ? new Date(y,m-1,d) : new Date(); }; + +export function DatePicker({ id, value, onChange, label }) { + const { lang, t } = useUi(); + const triggerRef = useRef(null), panelRef = useRef(null); + const [open, setOpen] = useState(false); + const [cursor, setCursor] = useState(() => fromKey(value)); + const [pos, setPos] = useState(null); + useEffect(() => { if (value) setCursor(fromKey(value)); }, [value]); + function openPanel() { + const r = triggerRef.current.getBoundingClientRect(); + setCursor(fromKey(value)); + setPos({ top:r.bottom+4, left:r.left, anchorTop:r.top, anchorBottom:r.bottom, ready:false }); + setOpen(true); + } + useLayoutEffect(() => { + if (!open || !pos || pos.ready || !panelRef.current) return; + const r = panelRef.current.getBoundingClientRect(), margin = 8; + let top = pos.top; + if (top+r.height > window.innerHeight-margin) top = pos.anchorTop-r.height-4 >= margin ? pos.anchorTop-r.height-4 : Math.max(margin,window.innerHeight-r.height-margin); + const left = Math.max(margin, Math.min(pos.left,window.innerWidth-r.width-margin)); + setPos((p) => ({...p,top,left,ready:true})); + }, [open,pos]); + useEffect(() => { + if (!open) return undefined; + const outside = (e) => { if (!triggerRef.current?.contains(e.target) && !panelRef.current?.contains(e.target)) setOpen(false); }; + const keydown = (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setOpen(false); triggerRef.current?.focus(); } else if (e.key === 'Tab' && panelRef.current?.contains(document.activeElement)) e.stopPropagation(); }; + const scroll = (e) => { if (!panelRef.current?.contains(e.target)) setOpen(false); }; + document.addEventListener('mousedown',outside); + document.addEventListener('keydown',keydown,true); + window.addEventListener('scroll',scroll,true); + window.addEventListener('resize',scroll); + return () => { document.removeEventListener('mousedown',outside); document.removeEventListener('keydown',keydown,true); window.removeEventListener('scroll',scroll,true); window.removeEventListener('resize',scroll); }; + }, [open]); + useEffect(() => { if (open && pos?.ready) panelRef.current?.querySelector('button.selected')?.focus(); }, [open,pos?.ready]); + const first = new Date(cursor.getFullYear(),cursor.getMonth(),1); + const offset = (first.getDay()+6)%7; + const count = new Date(cursor.getFullYear(),cursor.getMonth()+1,0).getDate(); + const days = Array.from({length:Math.ceil((offset+count)/7)*7},(_,i) => i-offset+1); + const weekday = Array.from({length:7},(_,i) => new Date(2024,0,1+i).toLocaleDateString(lang,{weekday:'short'})); + const today = new Date(Date.now()-5*3600000).toISOString().slice(0,10); + return <> + + {open && pos && createPortal(
+
{cursor.toLocaleDateString(lang,{month:'long',year:'numeric'})}
+
{weekday.map((day,i)=>{day})}{days.map((day,i) => day<1||day>count ? : )}
+
,document.body)} + ; +} diff --git a/frontend/src/components/NavigationView.jsx b/frontend/src/components/NavigationView.jsx index bea4064..c56acb7 100644 --- a/frontend/src/components/NavigationView.jsx +++ b/frontend/src/components/NavigationView.jsx @@ -1,7 +1,7 @@ import { NavLink } from 'react-router-dom'; import { useUserDrafts } from '../hooks/useDraftForm.js'; import { DraftMark } from './NavigationGuard.jsx'; -import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound, Monitor } from 'lucide-react'; +import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound, Monitor, CalendarDays } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useUi } from '../context/ui-context.jsx'; import { useAuth } from '../context/auth-context.jsx'; @@ -14,6 +14,7 @@ import { hasModule } from '../utils/modules.js'; const ITEMS = [ { to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true }, { to: '/proyectos', module: 'projects', icon: FolderKanban, labelKey: 'nav_projects' }, + { to: '/calendario', module: 'calendar', icon: CalendarDays, labelKey: 'nav_calendar' }, { to: '/tareas-recurrentes', module: 'recurring_tasks', icon: Repeat, labelKey: 'nav_recurring_tasks' }, { to: '/notas', module: 'notes', icon: StickyNote, labelKey: 'nav_notes' }, { to: '/contrasenas', module: 'vault', icon: KeyRound, labelKey: 'nav_passwords' }, diff --git a/frontend/src/hooks/useDraftForm.js b/frontend/src/hooks/useDraftForm.js index ced9e57..8a3d580 100644 --- a/frontend/src/hooks/useDraftForm.js +++ b/frontend/src/hooks/useDraftForm.js @@ -6,7 +6,9 @@ import { sanitizeDraftValues, fingerprint, setActiveEditor, draftAllowedFields, } from '../utils/drafts.js'; -const same = (a, b) => String(a ?? '') === String(b ?? ''); +const same = (a, b) => (a && typeof a === 'object') || (b && typeof b === 'object') + ? JSON.stringify(a ?? null) === JSON.stringify(b ?? null) + : String(a ?? '') === String(b ?? ''); /** * Estado de un formulario con borrador persistente (ver utils/drafts.js). diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 55e889f..6f65c8e 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -1,5 +1,19 @@ export const messages = { 'es-EC': { + nav_calendar: 'Calendario', + calendar_date: 'Fecha', + notification_meeting_created: 'Nueva reunión', notification_meeting_updated: 'Reunión modificada', notification_meeting_cancelled: 'Reunión cancelada', notification_meeting_occurrence_cancelled: 'Ocurrencia cancelada', notification_meeting_reminder: 'Recordatorio de reunión', notification_meeting_rsvp_accepted: 'Aceptó la reunión', notification_meeting_rsvp_declined: 'Rechazó la reunión', notification_meeting_rsvp_tentative: 'Respondió quizás', + calendar_today: 'Hoy', calendar_previous: 'Anterior', calendar_next: 'Siguiente', + calendar_month: 'Mes', calendar_week: 'Semana', calendar_day: 'Día', calendar_agenda: 'Agenda', + calendar_new: 'Nueva reunión', calendar_open: 'Abrir', calendar_show_due: 'Mostrar vencimientos de actividades', calendar_empty: 'No hay eventos en este período', + calendar_title: 'Título', calendar_description: 'Descripción', calendar_start: 'Inicio', calendar_end: 'Fin', calendar_all_day: 'Todo el día', + calendar_location: 'Ubicación', calendar_video_url: 'Enlace de videollamada', calendar_project: 'Proyecto', calendar_organizer: 'Organizador', calendar_reminder: 'Recordatorio', calendar_none: 'Ninguno', calendar_one_day: 'Un día antes', calendar_minutes: 'minutos', + calendar_participants: 'Participantes internos', calendar_external: 'Correos externos', calendar_add: 'Agregar', calendar_invalid_email: 'Correo inválido o límite de 50 participantes', + calendar_recurrence: 'Repetición', calendar_daily: 'Diaria', calendar_weekly: 'Semanal', calendar_monthly: 'Mensual', calendar_interval: 'Cada', calendar_count: 'Número de ocurrencias', calendar_until: 'Hasta la fecha', + calendar_mon: 'Lun', calendar_tue: 'Mar', calendar_wed: 'Mié', calendar_thu: 'Jue', calendar_fri: 'Vie', calendar_sat: 'Sáb', calendar_sun: 'Dom', + calendar_rsvp_pending: 'Pendiente', calendar_rsvp_accepted: 'Aceptar', calendar_rsvp_declined: 'Rechazar', calendar_rsvp_tentative: 'Quizás', + calendar_conflicts: 'Hay participantes con otra reunión a la misma hora', calendar_save_anyway: 'Confirmar y guardar', + calendar_cancel: 'Cancelar reunión', calendar_cancel_occurrence: 'Cancelar esta ocurrencia', calendar_cancel_confirm: 'Se cancelará la serie y se avisará a los participantes.', calendar_cancel_occurrence_confirm: 'Se cancelará esta ocurrencia y se avisará a los participantes.', calendar_cancelled: 'Reunión cancelada', appName: 'LinkedCorp Proyectos', nav_dashboard: 'Panel', user_modules: 'Módulos', @@ -611,6 +625,20 @@ export const messages = { notifications_mark_all_read: 'Marcar todas como leidas', }, 'en-US': { + nav_calendar: 'Calendar', + calendar_date: 'Date', + notification_meeting_created: 'New meeting', notification_meeting_updated: 'Meeting updated', notification_meeting_cancelled: 'Meeting cancelled', notification_meeting_occurrence_cancelled: 'Occurrence cancelled', notification_meeting_reminder: 'Meeting reminder', notification_meeting_rsvp_accepted: 'Accepted meeting', notification_meeting_rsvp_declined: 'Declined meeting', notification_meeting_rsvp_tentative: 'Replied maybe', + calendar_today: 'Today', calendar_previous: 'Previous', calendar_next: 'Next', + calendar_month: 'Month', calendar_week: 'Week', calendar_day: 'Day', calendar_agenda: 'Agenda', + calendar_new: 'New meeting', calendar_open: 'Open', calendar_show_due: 'Show activity due dates', calendar_empty: 'No events in this period', + calendar_title: 'Title', calendar_description: 'Description', calendar_start: 'Start', calendar_end: 'End', calendar_all_day: 'All day', + calendar_location: 'Location', calendar_video_url: 'Video call URL', calendar_project: 'Project', calendar_organizer: 'Organizer', calendar_reminder: 'Reminder', calendar_none: 'None', calendar_one_day: 'One day before', calendar_minutes: 'minutes', + calendar_participants: 'Internal participants', calendar_external: 'External emails', calendar_add: 'Add', calendar_invalid_email: 'Invalid email or 50 participant limit', + calendar_recurrence: 'Recurrence', calendar_daily: 'Daily', calendar_weekly: 'Weekly', calendar_monthly: 'Monthly', calendar_interval: 'Every', calendar_count: 'Occurrences', calendar_until: 'Until date', + calendar_mon: 'Mon', calendar_tue: 'Tue', calendar_wed: 'Wed', calendar_thu: 'Thu', calendar_fri: 'Fri', calendar_sat: 'Sat', calendar_sun: 'Sun', + calendar_rsvp_pending: 'Pending', calendar_rsvp_accepted: 'Accept', calendar_rsvp_declined: 'Decline', calendar_rsvp_tentative: 'Maybe', + calendar_conflicts: 'Some participants have another meeting at this time', calendar_save_anyway: 'Confirm and save', + calendar_cancel: 'Cancel meeting', calendar_cancel_occurrence: 'Cancel this occurrence', calendar_cancel_confirm: 'The series will be cancelled and participants notified.', calendar_cancel_occurrence_confirm: 'This occurrence will be cancelled and participants notified.', calendar_cancelled: 'Meeting cancelled', appName: 'LinkedCorp Projects', nav_dashboard: 'Dashboard', user_modules: 'Modules', diff --git a/frontend/src/pages/Calendario/CalendarioPage.jsx b/frontend/src/pages/Calendario/CalendarioPage.jsx new file mode 100644 index 0000000..ba2b137 --- /dev/null +++ b/frontend/src/pages/Calendario/CalendarioPage.jsx @@ -0,0 +1,203 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { CalendarDays, ChevronLeft, ChevronRight, Plus, Pencil, Trash2, XCircle } from 'lucide-react'; +import { useUi } from '../../context/ui-context.jsx'; +import { useAuth } from '../../context/auth-context.jsx'; +import { useAsync, useAction } from '../../hooks/useAsync.js'; +import { useDraftForm, useReopenDraft } from '../../hooks/useDraftForm.js'; +import { useGuardedClose } from '../../components/NavigationGuard.jsx'; +import { DraftNotice } from '../../components/DraftNotice.jsx'; +import { Button } from '../../components/Button.jsx'; +import { Field, FieldRow } from '../../components/Field.jsx'; +import { ComboBox } from '../../components/ComboBox.jsx'; +import { DatePicker } from '../../components/DatePicker.jsx'; +import { DetailPopup } from '../../components/DetailPopup.jsx'; +import { InfoBar } from '../../components/InfoBar.jsx'; +import { FlyoutItem, FlyoutSeparator } from '../../components/Flyout.jsx'; +import { listProjectOptions } from '../../services/projects.js'; +import { listUsers } from '../../services/users.js'; +import { listMeetings, listMeetingActivities, getMeeting, createMeeting, updateMeeting, cancelMeeting, cancelMeetingOccurrence, respondMeeting } from '../../services/meetings.js'; +import './calendar.css'; + +const dateKey = (d) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; +const ecuadorNow = () => { + const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone:'America/Guayaquil', year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', hourCycle:'h23' }).formatToParts(new Date()).map((p) => [p.type,p.value])); + return new Date(Number(parts.year), Number(parts.month)-1, Number(parts.day), Number(parts.hour), Number(parts.minute)); +}; +const dateTime = (d) => `${dateKey(d)} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:00`; +const shift = (d, days) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + days); +const plusHour = (s) => { const d = new Date(s.replace(' ', 'T')); d.setHours(d.getHours()+1); return dateTime(d); }; +const blank = (start = nextSlot()) => ({ title: '', description: '', startsAt: start, endsAt: plusHour(start), allDay: false, location: '', videoUrl: '', projectId: '', reminderMinutes: '', participantUserIds: [], externalEmails: [], recurrence: null }); +function toForm(row) { return { title: row.title, description: row.description || '', startsAt: row.starts_at, endsAt: row.ends_at, allDay: !!row.all_day, location: row.location || '', videoUrl: row.video_url || '', projectId: row.project_id || '', reminderMinutes: row.reminder_minutes ?? '', participantUserIds: row.participants.map((p) => p.user_id), externalEmails: row.external_emails, recurrence: row.recurrence_json ? JSON.parse(row.recurrence_json) : null, editVersion: row.edit_version }; } +const hours = Array.from({length:24},(_,i) => ({value:String(i).padStart(2,'0'),label:String(i).padStart(2,'0')})); +const minutes = Array.from({length:12},(_,i) => ({value:String(i*5).padStart(2,'0'),label:String(i*5).padStart(2,'0')})); +// Hora por defecto de una reunion nueva: siguiente media hora (10:31 -> 11:00, 10:10 -> 10:30). +const nextSlot = () => { const d = ecuadorNow(); d.setMinutes(d.getMinutes() < 30 ? 30 : 60, 0, 0); return dateTime(d); }; +const readable = (s, lang, allDay) => new Date(s.replace(' ', 'T')).toLocaleString(lang, allDay ? { dateStyle: 'full' } : { dateStyle: 'full', timeStyle: 'short' }); +function DateTimeEditor({ value, onChange, allDay, label, isEnd = false }) { + const date = value.slice(0,10), hour = value.slice(11,13), minute = value.slice(14,16); + const setDate = (next) => onChange(`${next} ${allDay ? (isEnd ? '23:59' : '00:00') : `${hour}:${minute}`}:00`); + const setTime = (h,m) => onChange(`${date} ${h}:${m}:00`); + return
{!allDay && <> setTime(h,minute)} placeholder={`${label} HH`} />: setTime(hour,m)} placeholder={`${label} MM`} />}
; +} +function bounds(cursor, view) { + let first, last; + if (view === 'month') { first = new Date(cursor.getFullYear(), cursor.getMonth(), 1); last = new Date(cursor.getFullYear(), cursor.getMonth()+1, 1); } + else if (view === 'week') { first = shift(cursor, -((cursor.getDay()+6)%7)); last = shift(first, 7); } + else if (view === 'day') { first = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate()); last = shift(first,1); } + else { first = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate()); last = shift(first,30); } + return { from: `${dateKey(first)} 00:00:00`, to: `${dateKey(last)} 00:00:00`, first, last }; +} + +export default function CalendarioPage() { + const { t, lang, openDialog, pushToast } = useUi(); + const { user } = useAuth(); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const runAction = useAction(); + const guardedClose = useGuardedClose(); + const [view, setView] = useState(() => { try { return localStorage.getItem('plc_calendar_view') || 'month'; } catch { return 'month'; } }); + const [cursor, setCursor] = useState(ecuadorNow); + const [showDue, setShowDue] = useState(true); + const [selected, setSelected] = useState(null); + const [creating, setCreating] = useState(false); + const [editing, setEditing] = useState(false); + const [newStart, setNewStart] = useState(null); + const [warning, setWarning] = useState([]); + const [externalInput, setExternalInput] = useState(''); + const [userChoice, setUserChoice] = useState(''); + const [context, setContext] = useState(null); + const { from, to, first, last } = useMemo(() => bounds(cursor, view), [cursor, view]); + const { data: meetings, reload } = useAsync(() => listMeetings(from, to), [from, to]); + const { data: activities } = useAsync(() => showDue ? listMeetingActivities(from, to) : Promise.resolve([]), [from, to, showDue]); + const { data: users } = useAsync(listUsers, []); + const { data: projects } = useAsync(listProjectOptions, []); + useReopenDraft({ newFormId:'meeting-new', editFormId:'meeting-edit', items:meetings, openNew: () => openNew(), openEdit: (item) => { void openMeeting(item, true); } }); + const initial = creating ? blank(newStart || nextSlot()) : selected ? toForm(selected) : blank(); + const draft = useDraftForm({ formId: creating ? 'meeting-new' : 'meeting-edit', entityId: creating ? null : selected?.id, initial, label: creating ? t('calendar_new') : `${t('nav_calendar')}: ${selected?.title || ''}`, enabled: creating || editing, onDiscard: () => setEditing(false) }); + const v = draft.values; + const canManage = selected && !selected.cancelled_at && (selected.organizer_id === user?.id || ['admin','superadmin'].includes(user?.roleCode)); + const canRsvp = !selected?.cancelled_at && selected?.participants?.some((p) => p.user_id === user?.id); + + useEffect(() => { try { localStorage.setItem('plc_calendar_view', view); } catch { /* almacenamiento privado */ } }, [view]); + useEffect(() => { + const id = Number(searchParams.get('meeting')); + if (!id) return; + getMeeting(id).then((row) => { setSelected(row); setCreating(false); setEditing(false); }).catch(() => {}); + setSearchParams((prev) => { const next = new URLSearchParams(prev); next.delete('meeting'); return next; }, { replace: true }); + }, [searchParams, setSearchParams]); + useEffect(() => { + if (!context) return undefined; + const onEscape = (e) => { if (e.key === 'Escape') setContext(null); }; + document.addEventListener('keydown', onEscape); + return () => document.removeEventListener('keydown', onEscape); + }, [context]); + + function openNew(day) { setNewStart(day || nextSlot()); setCreating(true); setEditing(true); setSelected(null); setWarning([]); } + async function openMeeting(item, edit = false) { try { const row = await getMeeting(item.id); setSelected(row); setCreating(false); setEditing(edit); setWarning([]); } catch (err) { pushToast({tone:'error',message:err.message}); } } + function close() { void guardedClose((creating || editing) ? draft : null, () => { setCreating(false); setEditing(false); setSelected(null); }); } + function changeView(next) { setView(next); } + function navigatePeriod(delta) { + if (view === 'month') setCursor(new Date(cursor.getFullYear(), cursor.getMonth()+delta, 1)); + else setCursor(shift(cursor, delta * (view === 'week' ? 7 : view === 'agenda' ? 30 : 1))); + } + async function submit(confirmConflicts = false) { + try { await runAction(async () => { + const payload = { ...v, participantUserIds: v.participantUserIds.map(Number), externalEmails: v.externalEmails, projectId: v.projectId || null, reminderMinutes: v.reminderMinutes || null, confirmConflicts }; + const result = creating ? await createMeeting(payload) : await updateMeeting(selected.id, payload); + if (result.requiresConfirmation) { setWarning(result.warnings); return; } + draft.clear(); setCreating(false); setEditing(false); setSelected(null); setWarning([]); await reload(); + pushToast({ tone: 'success', message: t('common_saved') }); + }); } catch { /* useAction ya muestra el error */ } + } + function save() { if (!creating && !draft.isDirty) return; const go = () => submit(false); if (creating) void go(); else void draft.guardSave(go, { fetchCurrent: async () => toForm(await getMeeting(selected.id)) }); } + function confirmSaveWithConflict() { const go = () => submit(true); if (creating) void go(); else void draft.guardSave(go); } + function confirmCancel(occurrence = false, item = selected) { + openDialog({ tone: 'warning', title: t('calendar_cancel'), message: occurrence ? t('calendar_cancel_occurrence_confirm') : t('calendar_cancel_confirm'), actions: [ + { label: t('common_cancel'), variant: 'standard' }, + { label: t('calendar_cancel'), variant: 'danger', onClick: () => runAction(async () => { + if (occurrence) await cancelMeetingOccurrence(item.id, item.occurrence_start || item.starts_at); + else await cancelMeeting(item.id); + draft.discard(); setSelected(null); setEditing(false); await reload(); pushToast({ tone: 'success', message: t('calendar_cancelled') }); + }).catch(() => {}) }, + ] }); + } + async function rsvp(status) { try { await runAction(async () => { setSelected(await respondMeeting(selected.id, status)); pushToast({ tone: 'success', message: t('common_saved') }); }); } catch { /* diálogo visible */ } } + function addExternal() { + const email = externalInput.trim().toLowerCase(); + if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,63}$/i.test(email) || email.length > 160 || v.externalEmails.length + v.participantUserIds.length >= 50) { pushToast({ tone:'error', message:t('calendar_invalid_email') }); return; } + draft.setField('externalEmails', [...new Set([...v.externalEmails, email])]); setExternalInput(''); setWarning([]); + } + const userOptions = (users || []).filter((u) => u.is_active && u.status === 'active' && u.id !== user?.id && !v.participantUserIds.includes(u.id)).map((u) => ({ value: u.id, label: `${u.name} (${u.email})` })); + const projectOptions = [{ value: '', label: t('calendar_none') }, ...(Array.isArray(projects) ? projects : projects?.items || []).map((p) => ({ value: p.id, label: p.name }))]; + const allItems = [...(meetings || []).map((m) => ({ ...m, kind: 'meeting', day: m.occurrence_start.slice(0,10) })), ...(showDue ? (activities || []).map((a) => ({ ...a, kind: 'activity', day: a.due_date })) : [])].sort((a,b) => (a.day || '').localeCompare(b.day || '')); + const byDay = allItems.reduce((map, item) => { (map[item.day] ||= []).push(item); return map; }, {}); + const days = view === 'month' ? Array.from({ length: Math.ceil((((first.getDay()+6)%7) + (last-first)/86400000)/7)*7 }, (_,i) => shift(first, i - ((first.getDay()+6)%7))) : Array.from({ length: (last-first)/86400000 }, (_,i) => shift(first,i)); + const weekLabels = [t('calendar_mon'),t('calendar_tue'),t('calendar_wed'),t('calendar_thu'),t('calendar_fri'),t('calendar_sat'),t('calendar_sun')]; + + function itemButton(item) { return ; } + + return
+

{t('nav_calendar')}

+
+
({ value:x, label:t(`calendar_${x}`) }))} value={view} onChange={changeView} /> +
+ + {view === 'agenda' ?
{allItems.length ? allItems.map((item) =>
{item.day}{itemButton(item)}
) :

{t('calendar_empty')}

}
:
+ {view !== 'day' && weekLabels.map((label) =>
{label}
)} + {days.map((day) => { const key = dateKey(day); const muted = view === 'month' && day.getMonth() !== cursor.getMonth(); return
openNew(`${key} 09:00:00`)}> +
{view === 'day' ? day.toLocaleDateString(lang, { weekday:'long', day:'numeric', month:'long' }) : day.getDate()}
+ {(byDay[key] || []).map(itemButton)} + {view !== 'month' &&
{Array.from({length:11}, (_,i) => i+8).map((hour) => )}
} +
; })}
} + + {context &&
setContext(null)}>
e.stopPropagation()}> + { void openMeeting(context.item); setContext(null); }}>{t('calendar_open')} + {(context.item.organizer_id === user?.id || ['admin','superadmin'].includes(user?.roleCode)) && <> { void openMeeting(context.item,true); setContext(null); }}>{t('common_edit')}{context.item.recurrence_json && { confirmCancel(true,context.item); setContext(null); }}>{t('calendar_cancel_occurrence')}} { confirmCancel(false,context.item); setContext(null); }}>{t('common_delete')}} +
} + + + {warning.length > 0 && {t('calendar_conflicts')}: {warning.join(', ')}. } + {editing && } + {!editing && selected ?
+ {selected.cancelled_at && {t('calendar_cancelled')}} +

{t('calendar_start')}: {readable(selected.starts_at, lang, !!selected.all_day)}
{t('calendar_end')}: {readable(selected.ends_at, lang, !!selected.all_day)}

+ {!!selected.all_day &&

{t('calendar_all_day')}

} + {selected.description &&

{selected.description}

}{selected.location &&

{t('calendar_location')}: {selected.location}

} + {selected.video_url &&

{selected.video_url}

} + {selected.project_name &&

{t('calendar_project')}: {selected.project_name}

} + {!!selected.reminder_minutes &&

{t('calendar_reminder')}: {selected.reminder_minutes === 1440 ? t('calendar_one_day') : `${selected.reminder_minutes} ${t('calendar_minutes')}`}

} + {selected.recurrence_json &&

{t('calendar_recurrence')}: {t(`calendar_${JSON.parse(selected.recurrence_json).frequency}`)}

} +

{t('calendar_organizer')}: {selected.organizer_name}

+
{selected.participants.map((p) =>
{p.name} — {t(`calendar_rsvp_${p.rsvp}`)}
)}
+ {selected.external_emails.length > 0 &&
{selected.external_emails.map((e) =>
{e}
)}
} + {canRsvp &&
{['accepted','declined','tentative'].map((s) => )}
} + {canManage &&
} +
:
+ draft.setField('title',e.target.value)} /> +