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 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-28 11:04:41 -05:00
parent a6cc41666e
commit 6066f27ba0
34 changed files with 986 additions and 16 deletions
+24 -1
View File
@@ -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.
+9
View File
@@ -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 |
+9
View File
@@ -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
+14 -1
View File
@@ -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
+11 -1
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
4.3.0
4.4.0
@@ -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;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "4.3.0",
"version": "4.4.0",
"private": true,
"type": "module",
"main": "src/server.js",
+2
View File
@@ -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).
+12
View File
@@ -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();
}
+1 -1
View File
@@ -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) {
@@ -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)));
+40
View File
@@ -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';
}
@@ -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;
}
+133
View File
@@ -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 (m.recurrence_json IS NOT NULL OR m.ends_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<? AND a.deleted_at IS NULL AND p.deleted_at IS NULL AND (a.responsible_user_id=? ${admin ? 'OR 1=1' : 'OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id=p.id AND pm.user_id=?)'})
ORDER BY a.due_date,a.id LIMIT 1000`, admin ? [from, to, user.id] : [from, to, user.id, user.id]);
return rows;
}
export async function sendReminderOnce(id, start, recipientIds, title) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [[meeting]] = await conn.query('SELECT cancelled_at FROM meetings WHERE id=? FOR UPDATE', [id]);
const [[exception]] = await conn.query('SELECT 1 AS excluded FROM meeting_exceptions WHERE meeting_id=? AND occurrence_start=?', [id, start]);
if (!meeting || meeting.cancelled_at || exception) { await conn.rollback(); return false; }
const [r] = await conn.query('INSERT IGNORE INTO meeting_reminders (meeting_id,occurrence_start) VALUES (?,?)', [id, start]);
if (!r.affectedRows) { await conn.rollback(); return false; }
for (const userId of recipientIds) await notificationsRepo.create({ userId, type: 'meeting_reminder', message: title.slice(0,255), entityType: 'meeting', entityId: id }, conn);
await conn.commit();
return true;
} catch (err) { await conn.rollback(); throw err; } finally { conn.release(); }
}
+15
View File
@@ -0,0 +1,15 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js';
import { getMeetings, getActivities, getMeeting, postMeeting, putMeeting, deleteMeeting, postCancelOccurrence, putRsvp } from './controller.js';
const router = Router();
router.use(requireAuth, requireModule('calendar'));
router.get('/', getMeetings);
router.get('/activities', getActivities);
router.post('/', postMeeting);
router.get('/:id', getMeeting);
router.put('/:id', putMeeting);
router.delete('/:id', deleteMeeting);
router.post('/:id/occurrences/:date/cancel', postCancelOccurrence);
router.put('/:id/rsvp', putRsvp);
export default router;
+198
View File
@@ -0,0 +1,198 @@
import { randomUUID } from 'node:crypto';
import * as repo from './repository.js';
import * as projectsRepo from '../projects/repository.js';
import { canReadProject } from '../projects/service.js';
import { createNotification } from '../notifications/service.js';
import { sendMail } from '../../utils/mailer.js';
import { logAudit } from '../../utils/audit.js';
import { HttpError } from '../../utils/http.js';
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
import { expand, validateRecurrence, durationMinutes, guayaquilNow, addMinutes } from './recurrence.js';
import { buildIcs } from './ical.js';
const DATE = /^\d{4}-\d\d-\d\d \d\d:\d\d(?::\d\d)?$/;
const EMAIL = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,63}$/i;
const ROLES = ['admin', 'superadmin'];
const htmlEscape = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' })[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 = `<p><strong>${htmlEscape(row.title)}</strong></p><p>${htmlEscape(row.description).replace(/\n/g, '<br>')}</p><p>${htmlEscape(occurrenceStart || row.starts_at)} – ${htmlEscape(row.ends_at)} (America/Guayaquil)</p><p>${htmlEscape(row.location)}</p>${row.video_url ? `<p><a href="${htmlEscape(row.video_url)}">${htmlEscape(row.video_url)}</a></p>` : ''}`;
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;
}
@@ -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]
);
+2
View File
@@ -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();
+3 -3
View File
@@ -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);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "4.3.0",
"version": "4.4.0",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -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(
<Route index element={<DashboardPage />} />
<Route path="mi-cuenta" element={<MiCuentaPage />} />
<Route path="proyectos" element={<ModuleRoute module="projects"><ProyectosPage /></ModuleRoute>} />
<Route path="calendario" element={<ModuleRoute module="calendar"><CalendarioPage /></ModuleRoute>} />
<Route path="proyectos/:id" element={<ModuleRoute module="projects"><ProyectoDetallePage /></ModuleRoute>} />
<Route path="tareas-recurrentes" element={<ModuleRoute module="recurring_tasks"><RecurringTasksPage /></ModuleRoute>} />
<Route path="notas" element={<ModuleRoute module="notes"><NotasPage /></ModuleRoute>} />
+2 -1
View File
@@ -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) => (
<FlyoutItem key={n.id} onClick={() => handleNotificationClick(n)}>
<span style={{ fontWeight: n.is_read ? 400 : 700 }}>{n.message}</span>
<span style={{ fontWeight: n.is_read ? 400 : 700 }}>{n.type?.startsWith('meeting_') ? `${t(`notification_${n.type}`)}: ${n.message}` : n.message}</span>
</FlyoutItem>
))}
{notifData?.items?.length > 0 && (
+55
View File
@@ -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 <>
<button type="button" ref={triggerRef} id={id} className="input datepicker-trigger" aria-label={label || t('calendar_date')} aria-haspopup="dialog" aria-expanded={open} onClick={() => open ? setOpen(false) : openPanel()}><span>{value || '—'}</span><CalendarDays size={16} /></button>
{open && pos && createPortal(<div ref={panelRef} className="datepicker-panel" role="dialog" aria-label={t('calendar_date')} style={{top:pos.top,left:pos.left,visibility:pos.ready?'visible':'hidden'}}>
<div className="datepicker-head"><button type="button" aria-label={t('calendar_previous')} onClick={() => setCursor(new Date(cursor.getFullYear(),cursor.getMonth()-1,1))}><ChevronLeft size={16} /></button><strong>{cursor.toLocaleDateString(lang,{month:'long',year:'numeric'})}</strong><button type="button" aria-label={t('calendar_next')} onClick={() => setCursor(new Date(cursor.getFullYear(),cursor.getMonth()+1,1))}><ChevronRight size={16} /></button></div>
<div className="datepicker-grid">{weekday.map((day,i)=><span key={i} className="datepicker-weekday">{day}</span>)}{days.map((day,i) => day<1||day>count ? <span key={i} /> : <button type="button" key={i} className={`${key(new Date(cursor.getFullYear(),cursor.getMonth(),day))===value?'selected':''} ${key(new Date(cursor.getFullYear(),cursor.getMonth(),day))===today?'today':''}`} aria-pressed={key(new Date(cursor.getFullYear(),cursor.getMonth(),day))===value} onClick={() => { onChange(key(new Date(cursor.getFullYear(),cursor.getMonth(),day))); setOpen(false); triggerRef.current?.focus(); }}>{day}</button>)}</div>
</div>,document.body)}
</>;
}
+2 -1
View File
@@ -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' },
+3 -1
View File
@@ -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).
+28
View File
@@ -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',
@@ -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 <div className="meeting-datetime"><DatePicker value={date} onChange={setDate} label={label} />{!allDay && <><ComboBox options={hours} value={hour} onChange={(h) => setTime(h,minute)} placeholder={`${label} HH`} /><span>:</span><ComboBox options={minutes} value={minute} onChange={(m) => setTime(hour,m)} placeholder={`${label} MM`} /></>}</div>;
}
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 <button key={`${item.kind}-${item.id}-${item.occurrence_start || ''}`} className={`calendar-entry ${item.kind}`} title={item.kind === 'meeting' ? item.title : item.name}
onClick={(e) => { e.stopPropagation(); if (item.kind === 'meeting') void openMeeting(item); else navigate(`/proyectos/${item.project_id}?tab=activities&activity=${item.id}`); }}
onContextMenu={(e) => { if (item.kind !== 'meeting') return; e.preventDefault(); setContext({ item, x: e.clientX, y: e.clientY }); }}
onKeyDown={(e) => { if (item.kind === 'meeting' && (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10'))) { e.preventDefault(); const r=e.currentTarget.getBoundingClientRect(); setContext({item,x:r.left,y:r.bottom}); } }}>
{item.kind === 'meeting' ? `${item.occurrence_start.slice(11,16)} ${item.title}` : `◆ ${item.name}`}
{item.kind === 'meeting' && <span className="calendar-entry-more" onClick={(e) => { e.stopPropagation(); setContext({ item, x: e.clientX, y: e.clientY }); }}>⋯</span>}
</button>; }
return <div className="meeting-calendar">
<div className="toolbar"><div className="toolbar-left"><h2 className="page-title mt-0"><CalendarDays size={22} /> {t('nav_calendar')}</h2>
<Button onClick={() => setCursor(ecuadorNow())}>{t('calendar_today')}</Button><Button icon={ChevronLeft} onClick={() => navigatePeriod(-1)} aria-label={t('calendar_previous')} /><Button icon={ChevronRight} onClick={() => navigatePeriod(1)} aria-label={t('calendar_next')} />
<strong>{first.toLocaleDateString(lang, { month: 'long', year: 'numeric' })}</strong></div>
<div className="toolbar-left"><ComboBox options={['month','week','day','agenda'].map((x) => ({ value:x, label:t(`calendar_${x}`) }))} value={view} onChange={changeView} />
<Button variant="accent" icon={Plus} onClick={() => openNew()}>{t('calendar_new')}</Button></div></div>
<label className="calendar-filter"><input type="checkbox" checked={showDue} onChange={(e) => setShowDue(e.target.checked)} /> {t('calendar_show_due')}</label>
{view === 'agenda' ? <div className="calendar-agenda">{allItems.length ? allItems.map((item) => <div key={`${item.kind}-${item.id}-${item.occurrence_start || ''}`} className="calendar-agenda-row"><strong>{item.day}</strong>{itemButton(item)}</div>) : <p className="text-muted">{t('calendar_empty')}</p>}</div> : <div className={`calendar-grid calendar-${view}`}>
{view !== 'day' && weekLabels.map((label) => <div key={label} className="calendar-dow">{label}</div>)}
{days.map((day) => { const key = dateKey(day); const muted = view === 'month' && day.getMonth() !== cursor.getMonth(); return <div key={key} className={`calendar-cell ${muted ? 'muted' : ''} ${key === dateKey(ecuadorNow()) ? 'today' : ''}`} onClick={() => openNew(`${key} 09:00:00`)}>
<div className="calendar-day-num">{view === 'day' ? day.toLocaleDateString(lang, { weekday:'long', day:'numeric', month:'long' }) : day.getDate()}</div>
{(byDay[key] || []).map(itemButton)}
{view !== 'month' && <div className="calendar-hours">{Array.from({length:11}, (_,i) => i+8).map((hour) => <button key={hour} onClick={(e) => { e.stopPropagation(); openNew(`${key} ${String(hour).padStart(2,'0')}:00:00`); }}>{String(hour).padStart(2,'0')}:00 <Plus size={12} /></button>)}</div>}
</div>; })}</div>}
{context && <div className="calendar-context-layer" onClick={() => setContext(null)}><div className="flyout-panel calendar-context" style={{ left:Math.min(context.x, window.innerWidth-220), top:Math.min(context.y, window.innerHeight-190) }} onClick={(e) => e.stopPropagation()}>
<FlyoutItem onClick={() => { void openMeeting(context.item); setContext(null); }}>{t('calendar_open')}</FlyoutItem>
{(context.item.organizer_id === user?.id || ['admin','superadmin'].includes(user?.roleCode)) && <><FlyoutItem icon={Pencil} onClick={() => { void openMeeting(context.item,true); setContext(null); }}>{t('common_edit')}</FlyoutItem><FlyoutSeparator />{context.item.recurrence_json && <FlyoutItem icon={XCircle} onClick={() => { confirmCancel(true,context.item); setContext(null); }}>{t('calendar_cancel_occurrence')}</FlyoutItem>}<FlyoutItem icon={Trash2} danger onClick={() => { confirmCancel(false,context.item); setContext(null); }}>{t('common_delete')}</FlyoutItem></>}
</div></div>}
<DetailPopup open={creating || !!selected} title={creating ? t('calendar_new') : selected?.title} onClose={close} width={760} height={680}>
{warning.length > 0 && <InfoBar tone="warning">{t('calendar_conflicts')}: {warning.join(', ')}. <Button size="sm" onClick={confirmSaveWithConflict}>{t('calendar_save_anyway')}</Button></InfoBar>}
{editing && <DraftNotice draft={draft} />}
{!editing && selected ? <div className="calendar-detail">
{selected.cancelled_at && <InfoBar tone="warning">{t('calendar_cancelled')}</InfoBar>}
<p><strong>{t('calendar_start')}:</strong> {readable(selected.starts_at, lang, !!selected.all_day)}<br /><strong>{t('calendar_end')}:</strong> {readable(selected.ends_at, lang, !!selected.all_day)}</p>
{!!selected.all_day && <p>{t('calendar_all_day')}</p>}
{selected.description && <p>{selected.description}</p>}{selected.location && <p>{t('calendar_location')}: {selected.location}</p>}
{selected.video_url && <p><a href={selected.video_url} target="_blank" rel="noreferrer">{selected.video_url}</a></p>}
{selected.project_name && <p>{t('calendar_project')}: {selected.project_name}</p>}
{!!selected.reminder_minutes && <p>{t('calendar_reminder')}: {selected.reminder_minutes === 1440 ? t('calendar_one_day') : `${selected.reminder_minutes} ${t('calendar_minutes')}`}</p>}
{selected.recurrence_json && <p>{t('calendar_recurrence')}: {t(`calendar_${JSON.parse(selected.recurrence_json).frequency}`)}</p>}
<p>{t('calendar_organizer')}: {selected.organizer_name}</p>
<Field label={t('calendar_participants')}><div>{selected.participants.map((p) => <div key={p.user_id}>{p.name} — {t(`calendar_rsvp_${p.rsvp}`)}</div>)}</div></Field>
{selected.external_emails.length > 0 && <Field label={t('calendar_external')}><div>{selected.external_emails.map((e) => <div key={e}>{e}</div>)}</div></Field>}
{canRsvp && <div className="toolbar-left">{['accepted','declined','tentative'].map((s) => <Button key={s} onClick={() => void rsvp(s)}>{t(`calendar_rsvp_${s}`)}</Button>)}</div>}
{canManage && <div className="toolbar-left" style={{ marginTop:16 }}><Button icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button icon={Trash2} variant="danger" onClick={() => confirmCancel()}>{t('common_delete')}</Button></div>}
</div> : <div className="calendar-form">
<Field label={t('calendar_title')} dirty={draft.isFieldDirty('title')}><input className="input" maxLength={160} value={v.title} onChange={(e) => draft.setField('title',e.target.value)} /></Field>
<Field label={t('calendar_description')} dirty={draft.isFieldDirty('description')}><textarea className="textarea" maxLength={5000} value={v.description} onChange={(e) => draft.setField('description',e.target.value)} /></Field>
<FieldRow>
<Field label={t('calendar_start')} dirty={draft.isFieldDirty('startsAt')}><DateTimeEditor value={v.startsAt} onChange={(x) => draft.setField('startsAt',x)} allDay={v.allDay} label={t('calendar_start')} /></Field>
<Field label={t('calendar_end')} dirty={draft.isFieldDirty('endsAt')}><DateTimeEditor value={v.endsAt} onChange={(x) => draft.setField('endsAt',x)} allDay={v.allDay} label={t('calendar_end')} isEnd /></Field>
</FieldRow>
<label className="calendar-filter"><input type="checkbox" checked={v.allDay} onChange={(e) => { const checked = e.target.checked; draft.setValues((old) => ({ ...old, allDay: checked, startsAt: checked ? `${old.startsAt.slice(0,10)} 00:00:00` : old.startsAt, endsAt: checked ? `${old.endsAt.slice(0,10)} 23:59:00` : old.endsAt })); }} /> {t('calendar_all_day')}</label>
<FieldRow><Field label={t('calendar_location')}><input className="input" maxLength={255} value={v.location} onChange={(e) => draft.setField('location',e.target.value)} /></Field><Field label={t('calendar_video_url')}><input className="input" type="url" maxLength={2048} value={v.videoUrl} onChange={(e) => draft.setField('videoUrl',e.target.value)} /></Field></FieldRow>
<FieldRow><Field label={t('calendar_project')}><ComboBox options={projectOptions} value={v.projectId} onChange={(x) => draft.setField('projectId',x)} /></Field><Field label={t('calendar_reminder')}><ComboBox options={[{value:'',label:t('calendar_none')}, ...[5,10,15,30,60,1440].map((n) => ({value:n,label:n===1440?t('calendar_one_day'):`${n} ${t('calendar_minutes')}`}))]} value={v.reminderMinutes} onChange={(x) => draft.setField('reminderMinutes',x)} /></Field></FieldRow>
<Field label={t('calendar_participants')}><><div className="toolbar-left"><ComboBox options={[{value:'',label:t('common_select')},...userOptions]} value={userChoice} onChange={setUserChoice} /><Button onClick={() => { if (userChoice) { draft.setField('participantUserIds',[...v.participantUserIds,Number(userChoice)]); setUserChoice(''); } }}>{t('calendar_add')}</Button></div>
<div className="calendar-tags">{v.participantUserIds.map((id) => <button key={id} onClick={() => draft.setField('participantUserIds',v.participantUserIds.filter((x) => x!==id))}>{users?.find((u) => u.id===id)?.name || id} ×</button>)}</div></></Field>
<Field label={t('calendar_external')}><><div className="toolbar-left"><input className="input" type="email" value={externalInput} onChange={(e) => setExternalInput(e.target.value)} onKeyDown={(e) => { if(e.key==='Enter'){ e.preventDefault(); addExternal(); } }} /><Button onClick={addExternal}>{t('calendar_add')}</Button></div><div className="calendar-tags">{v.externalEmails.map((e) => <button key={e} onClick={() => draft.setField('externalEmails',v.externalEmails.filter((x) => x!==e))}>{e} ×</button>)}</div></></Field>
<Field label={t('calendar_recurrence')}><ComboBox options={['none','daily','weekly','monthly'].map((x) => ({value:x,label:t(`calendar_${x}`)}))} value={v.recurrence?.frequency || 'none'} onChange={(x) => draft.setField('recurrence',x==='none'?null:{frequency:x,interval:1,weekdays:x==='weekly'?[(new Date(v.startsAt.replace(' ','T')).getDay()+6)%7]:[],count:10})} /></Field>
{v.recurrence && <><FieldRow><Field label={t('calendar_interval')}><input className="input" type="number" min="1" max="365" value={v.recurrence.interval} onChange={(e) => draft.setField('recurrence',{...v.recurrence,interval:Number(e.target.value)})} /></Field><Field label={t('calendar_count')}><input className="input" type="number" min="1" max="365" value={v.recurrence.count || ''} onChange={(e) => draft.setField('recurrence',{...v.recurrence,count:e.target.value?Number(e.target.value):null,until:null})} /></Field><Field label={t('calendar_until')}><input className="input" type="date" value={v.recurrence.until || ''} onChange={(e) => draft.setField('recurrence',{...v.recurrence,until:e.target.value||null,count:null})} /></Field></FieldRow>
{v.recurrence.frequency==='weekly' && <div className="calendar-weekdays">{weekLabels.map((label,index) => <label key={label}><input type="checkbox" checked={v.recurrence.weekdays.includes(index)} onChange={() => draft.setField('recurrence',{...v.recurrence,weekdays:v.recurrence.weekdays.includes(index)?v.recurrence.weekdays.filter((x)=>x!==index):[...v.recurrence.weekdays,index]})} /> {label}</label>)}</div>}</>}
<div className="toolbar-left" style={{ marginTop:18 }}><Button variant="accent" onClick={save} disabled={!v.title.trim() || (!creating && !draft.isDirty)}>{t('common_save')}</Button><Button onClick={() => { draft.discard(); if (creating) setCreating(false); else setEditing(false); }}>{t('common_cancel')}</Button></div>
</div>}
</DetailPopup>
</div>;
}
@@ -0,0 +1,27 @@
.meeting-calendar .page-title { display:flex; align-items:center; gap:8px; }
.meeting-calendar .calendar-filter { display:flex; align-items:center; gap:7px; margin:10px 0 16px; font-size:.82rem; }
.meeting-datetime { display:flex; align-items:center; gap:4px; }
.meeting-datetime .datepicker-trigger { flex:1; min-width:112px; }
.meeting-datetime .combobox { width:55px; flex:none; }
.meeting-datetime .combobox .input { padding:6px 4px; }
.meeting-calendar .calendar-grid { width:100%; }
.meeting-calendar .calendar-grid.calendar-day { grid-template-columns:1fr; }
.meeting-calendar .calendar-cell { min-height:110px; min-width:0; cursor:pointer; }
.meeting-calendar .calendar-day .calendar-cell { min-height:360px; }
.meeting-calendar .calendar-hours { display:flex; flex-direction:column; margin-top:6px; }
.meeting-calendar .calendar-hours button { border:0; border-top:1px solid rgb(var(--line)); background:transparent; color:rgb(var(--muted)); text-align:left; min-height:32px; display:flex; align-items:center; justify-content:space-between; cursor:pointer; font:inherit; }
.meeting-calendar .calendar-hours button:hover { background:rgb(var(--accent) / .10); color:rgb(var(--accent)); }
.meeting-calendar .calendar-entry { display:block; width:100%; border:0; border-radius:4px; padding:4px 6px; text-align:left; font:inherit; font-size:.72rem; cursor:pointer; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:rgb(var(--accent)); background:rgb(var(--accent) / .16); }
.meeting-calendar .calendar-entry.activity { color:rgb(var(--warning)); background:rgb(var(--warning) / .14); }
.meeting-calendar .calendar-entry:hover, .meeting-calendar .calendar-entry:focus-visible { outline:2px solid rgb(var(--accent)); }
.meeting-calendar .calendar-entry-more { float:right; padding:0 3px; }
.meeting-calendar .calendar-agenda-row { display:flex; gap:12px; align-items:center; padding:8px 4px; border-bottom:1px solid rgb(var(--line)); }
.meeting-calendar .calendar-agenda-row strong { min-width:105px; font-size:.78rem; }
.meeting-calendar .calendar-agenda-row .calendar-entry { flex:1; }
.calendar-context-layer { position:fixed; inset:0; z-index:101; }
.calendar-context { position:fixed; visibility:visible; min-width:190px; }
.calendar-tags { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.calendar-tags button { background:rgb(var(--accent) / .16); color:rgb(var(--body)); border:1px solid rgb(var(--line)); border-radius:4px; padding:3px 7px; cursor:pointer; }
.calendar-weekdays { display:flex; flex-wrap:wrap; gap:12px; margin:8px 0; font-size:.8rem; }
.calendar-detail p { line-height:1.5; }
@media (max-width:700px) { .meeting-calendar .calendar-grid { gap:2px; } .meeting-calendar .calendar-cell { min-height:80px; padding:3px; } .meeting-calendar .calendar-entry { font-size:.6rem; padding:2px; } .meeting-calendar .calendar-dow { font-size:.58rem; } }
@@ -4,6 +4,8 @@ import { useUi } from '../../context/ui-context.jsx';
import { useAsync } from '../../hooks/useAsync.js';
import { listActivitiesByProject } from '../../services/activities.js';
import { Button } from '../../components/Button.jsx';
import { listMeetings } from '../../services/meetings.js';
import { useNavigate } from 'react-router-dom';
const DOW = ['Lun', 'Mar', 'Mie', 'Jue', 'Vie', 'Sab', 'Dom'];
@@ -20,8 +22,13 @@ function buildGrid(year, month) {
export default function ProjectCalendarTab({ projectId }) {
const { t } = useUi();
const navigate = useNavigate();
const { data: activities } = useAsync(() => listActivitiesByProject(projectId), [projectId]);
const [cursor, setCursor] = useState(() => { const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), 1); });
const from = `${cursor.getFullYear()}-${String(cursor.getMonth()+1).padStart(2,'0')}-01 00:00:00`;
const nextMonth = new Date(cursor.getFullYear(), cursor.getMonth()+1, 1);
const to = `${nextMonth.getFullYear()}-${String(nextMonth.getMonth()+1).padStart(2,'0')}-01 00:00:00`;
const { data: meetings } = useAsync(() => listMeetings(from, to).catch(() => []), [from, to]);
const cells = useMemo(() => buildGrid(cursor.getFullYear(), cursor.getMonth()), [cursor]);
const todayStr = new Date().toISOString().slice(0, 10);
@@ -50,12 +57,14 @@ export default function ProjectCalendarTab({ projectId }) {
if (!date) return <div key={i} className="calendar-cell muted" />;
const key = date.toISOString().slice(0, 10);
const items = byDay[key] || [];
const meetingItems = (meetings || []).filter((m) => m.project_id === projectId && m.occurrence_start.slice(0,10) === key);
return (
<div key={i} className={`calendar-cell ${key === todayStr ? 'today' : ''}`}>
<div className="calendar-day-num">{date.getDate()}</div>
{items.slice(0, 3).map((a) => (
<div key={a.id} className="calendar-chip" title={a.name}>{a.is_milestone ? '◆ ' : ''}{a.name}</div>
<div key={a.id} className="calendar-chip" style={{ color:'rgb(var(--warning))', background:'rgb(var(--warning) / .14)' }} title={a.name}>{a.is_milestone ? '◆ ' : ''}{a.name}</div>
))}
{meetingItems.map((m) => <button key={`${m.id}-${m.occurrence_start}`} className="calendar-chip" style={{ border:0, textAlign:'left' }} onClick={() => navigate(`/calendario?meeting=${m.id}`)}>{m.title}</button>)}
{items.length > 3 && <div className="text-muted" style={{ fontSize: '.64rem' }}>+{items.length - 3}</div>}
</div>
);
+10
View File
@@ -0,0 +1,10 @@
import { api } from './api.js';
const query = (from, to) => `?${new URLSearchParams({ from, to })}`;
export const listMeetings = (from, to) => api.get(`/meetings${query(from, to)}`).then((result) => result.items);
export const listMeetingActivities = (from, to) => api.get(`/meetings/activities${query(from, to)}`);
export const getMeeting = (id) => api.get(`/meetings/${id}`);
export const createMeeting = (data) => api.post('/meetings', data);
export const updateMeeting = (id, data) => api.put(`/meetings/${id}`, data);
export const cancelMeeting = (id) => api.delete(`/meetings/${id}`);
export const cancelMeetingOccurrence = (id, start) => api.post(`/meetings/${id}/occurrences/${encodeURIComponent(start)}/cancel`);
export const respondMeeting = (id, status) => api.put(`/meetings/${id}/rsvp`, { status });
+11
View File
@@ -157,6 +157,17 @@ button { font-family: inherit; }
background: rgb(var(--surface)); border: 1px solid rgb(var(--line)); border-radius: var(--r-lg);
box-shadow: 0 8px 24px rgb(0 0 0 / .35); max-height: 240px; overflow-y: auto; padding: 6px;
}
.datepicker-trigger { display:flex; align-items:center; justify-content:space-between; cursor:pointer; }
.datepicker-panel { position:fixed; z-index:220; width:270px; padding:8px; border:1px solid rgb(var(--line)); border-radius:var(--r-lg); background:rgb(var(--surface)); box-shadow:0 8px 24px rgb(0 0 0 / .35); }
.datepicker-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:7px; font-size:.8rem; }
.datepicker-head button, .datepicker-grid button { border:0; background:transparent; color:rgb(var(--body)); cursor:pointer; border-radius:var(--r-sm); }
.datepicker-head button { padding:5px; }
.datepicker-grid { display:grid; grid-template-columns:repeat(7,1fr); gap:2px; text-align:center; }
.datepicker-grid button { min-height:30px; font-size:.75rem; }
.datepicker-grid button:hover, .datepicker-head button:hover { background:rgb(var(--accent) / .14); }
.datepicker-grid button.selected { background:rgb(var(--accent)); color:white; }
.datepicker-grid button.today:not(.selected) { outline:1px solid rgb(var(--accent)); }
.datepicker-weekday { font-size:.65rem; color:rgb(var(--muted)); }
.combobox-search { padding: 4px 6px 8px; }
.combobox-option {
display: block; width: 100%; text-align: left; border: 0; background: transparent; color: inherit;
+2
View File
@@ -14,6 +14,8 @@ const TTL_MS = 7 * 24 * 60 * 60 * 1000;
const SECRET_FIELD = /pass(word|wd)?|token|secret|^pin$|pin$/i;
const DRAFT_FIELDS = {
'project-new': ['projectTypeId', 'priorityId', 'statusId', 'requestingDepartmentId', 'responsibleDepartmentId', 'startDate', 'estimatedEndDate', 'riskLevel'],
'meeting-new': ['startsAt', 'endsAt', 'allDay', 'projectId', 'reminderMinutes', 'participantUserIds', 'recurrence'],
'meeting-edit': ['editVersion', 'startsAt', 'endsAt', 'allDay', 'projectId', 'reminderMinutes', 'participantUserIds', 'recurrence'],
'project-info': ['editVersion', 'projectTypeId', 'priorityId', 'statusId', 'requestingDepartmentId', 'responsibleDepartmentId', 'startDate', 'estimatedEndDate', 'riskLevel'],
'activity-new': ['responsibleUserId', 'priorityId', 'statusId', 'startDate', 'dueDate', 'isMilestone', 'estimatedHours'],
'activity-info': ['editVersion', 'responsibleUserId', 'priorityId', 'statusId', 'startDate', 'dueDate', 'isMilestone', 'estimatedHours', 'progressPct'],
+1
View File
@@ -1,6 +1,7 @@
// Espejo de backend/src/middleware/modules.js: el backend es quien bloquea, aqui solo se oculta.
export const MODULE_LABEL_KEYS = {
projects: 'nav_projects',
calendar: 'nav_calendar',
recurring_tasks: 'nav_recurring_tasks',
notes: 'nav_notes',
vault: 'nav_passwords',