From d5623512421cb35605c7646bd57358d48ecdf187 Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Wed, 28 Oct 2026 11:14:13 -0500 Subject: [PATCH] feat: version 4.5.0 -- Calendario personal con filtros y capas por tipo Calendario personal para todos los roles: reuniones que organizo o me invitaron, proyectos donde soy dueno/miembro, actividades y tareas donde soy responsable y tareas recurrentes creadas o compartidas conmigo (solo lectura si no soy el creador). Filtros Ver (Todo / Solo lo mio / Compartido conmigo) y Mostrar (lista de tipos con Todo); colores por tipo, vencidos en rojo, compartido marcado con franja. La pestana Calendario del proyecto muestra todas sus reuniones vinculadas. Co-Authored-By: Claude Opus 5.5 --- API.md | 16 ++++- CHANGELOG.md | 16 +++++ ESTADO.md | 8 ++- SPEC.md | 16 ++++- VERSION | 2 +- backend/package.json | 2 +- backend/src/modules/meetings/controller.js | 3 + backend/src/modules/meetings/repository.js | 35 +++++++--- backend/src/modules/meetings/routes.js | 5 +- backend/src/modules/meetings/service.js | 67 ++++++++++++++++++- frontend/package.json | 2 +- frontend/src/i18n/messages.js | 4 +- .../src/pages/Calendario/CalendarioPage.jsx | 58 ++++++++++++++-- frontend/src/pages/Calendario/calendar.css | 22 ++++++ .../pages/Proyectos/ProjectCalendarTab.jsx | 24 +++++-- .../RecurringTasks/RecurringTasksPage.jsx | 13 +++- frontend/src/services/meetings.js | 8 ++- frontend/src/styles/tokens.css | 6 ++ 18 files changed, 269 insertions(+), 38 deletions(-) diff --git a/API.md b/API.md index 3359bf8..a395c06 100644 --- a/API.md +++ b/API.md @@ -36,10 +36,22 @@ 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 +civil `America/Guayaquil` (UTC-5). `GET /api/meetings?from=&to=&page=&pageSize=[&projectId=]` (sin `projectId`: solo reuniones que el +usuario organiza o a las que fue invitado, cualquier rol; con `projectId`: todas las del proyecto si +puede leerlo, si no 404) 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/activities?from=&to=` devuelve hasta 1000 vencimientos de actividades donde el +usuario es responsable (con `is_closed` y `overdue`). +`GET /api/meetings/tasks?from=&to=`: tareas donde el usuario es responsable (`activity_id`, `project_id`, +`is_closed`, `overdue`). `GET /api/meetings/projects?from=&to=`: proyectos donde es dueño o miembro, +una fila por fecha en rango (`milestone`: `start` | `end`). Ambas requieren módulo `projects`. +`GET /api/meetings/recurring?from=&to=` (requiere también el módulo `recurring_tasks`) devuelve los +próximos vencimientos de las tareas recurrentes activas que el usuario creó o de las que es responsable, calculados al vuelo +desde `next_due_date` con la misma regla que `advanceCycle` y cortados en `end_date`. Cada fila trae +`due_date`, `is_next` (el ciclo pendiente real), `overdue` y `can_open` (si puede abrirla en Tareas recurrentes). + +Todas las filas de calendario traen `shared` (lo creó/organiza otra persona y me incluye). - `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}`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0267bb2..d87ca1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog - project.linkedcorp.net +## [4.5.0] - 2026-10-28 +### Added +- Calendario: capa de tareas recurrentes con sus próximos vencimientos (el pendiente real y las + repeticiones futuras proyectadas), también en la pestaña Calendario del proyecto. +- Leyenda de colores con interruptor por capa (reuniones, vencimientos de actividades, tareas + recurrentes), persistida; vencidos en rojo y actividades cerradas tachadas. +- Tareas recurrentes acepta `?task=id` para abrir una tarea desde el calendario. +- Capas Proyectos (inicio/fin estimado) y Mis tareas. +- Filtros persistidos: **Ver** (Todo / Solo lo mío / Compartido conmigo) y **Mostrar** (lista con + casillas por tipo con opción Todo). Lo compartido conmigo se marca con una franja. +### Changed +- El calendario es personal para todos los roles: reuniones que organizo o me invitaron, y + actividades/tareas/recurrentes donde soy responsable (antes un admin veía las de todos). + Las tareas recurrentes compartidas (soy responsable) se ven en solo lectura. +- La pestaña Calendario del proyecto muestra todas las reuniones vinculadas al proyecto. + ## [4.4.0] - 2026-10-27 ### Added - Menú Calendario con vistas Mes, Semana, Día y Agenda; reuniones con participantes internos/externos, diff --git a/ESTADO.md b/ESTADO.md index 9b6eaeb..5eb6d1d 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -1,11 +1,17 @@ # ESTADO — project.linkedcorp.net -Ultima actualizacion: 2026-10-27 (v4.4.0 en desarrollo, sin desplegar) +Ultima actualizacion: 2026-10-28 (v4.5.0 en Git, 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) +- v4.5.0: calendario personal para todos los roles (ver SPEC §7), capas Proyectos/Actividades/ + Tareas/Recurrentes/Reuniones con colores, filtros Ver (mío/compartido) y Mostrar (tipos); recurrentes compartidas en solo + lectura. Probado con un usuario temporal (ya borrado): lo compartido aparece a ambos y lo ajeno + no. Sin migración nueva. Probado en dev con tareas de prueba (fin de mes 31→30, corte por + `end_date`, vencida en rojo), ya borradas. + - 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 diff --git a/SPEC.md b/SPEC.md index 8a65805..05ce537 100644 --- a/SPEC.md +++ b/SPEC.md @@ -75,8 +75,20 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa - 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. +- **Calendario personal (v4.5.0), igual para todos los roles** (un admin no ve lo de los demás): + reuniones que organizo o a las que me invitaron; proyectos donde soy dueño o miembro (inicio y + fin estimado); actividades y tareas donde soy responsable; tareas recurrentes que creé o de las + que soy responsable. Lo compartido aparece en el calendario de ambos: participantes de una + reunión y responsable de una tarea recurrente (este la ve en solo lectura, porque el menú Tareas + recurrentes solo la muestra al creador/admin). +- Filtros persistidos: **Ver** (Todo / Solo lo mío / Compartido conmigo) y **Mostrar** (lista con + casillas: Todo, Reuniones, Proyectos, Mis actividades, Mis tareas, Tareas recurrentes). Compartido + conmigo = lo creó u organiza otra persona y me incluye (reunión: organizador; proyecto: dueño; + actividad y recurrente: creador; tarea: creador de su actividad, porque tasks no guarda creador). +- Cada tipo con color propio y leyenda; vencido en rojo, cerrado tachado, compartido con franja + a la izquierda. +- La pestaña Calendario del proyecto muestra todas sus actividades y todas las reuniones + vinculadas al proyecto (requiere acceso de lectura al proyecto). ### 8. Gantt - Vista de solo lectura por proyecto: barras por actividad (inicio/fin/avance), sin edición directa diff --git a/VERSION b/VERSION index fdc6698..a84947d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.4.0 +4.5.0 diff --git a/backend/package.json b/backend/package.json index b2ae758..04656b7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "4.4.0", + "version": "4.5.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/meetings/controller.js b/backend/src/modules/meetings/controller.js index 4a4732a..325d647 100644 --- a/backend/src/modules/meetings/controller.js +++ b/backend/src/modules/meetings/controller.js @@ -2,6 +2,9 @@ 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 getTasks = asyncHandler(async (req,res) => ok(res, await service.tasks(req.query, req.currentUser))); +export const getProjects = asyncHandler(async (req,res) => ok(res, await service.projects(req.query, req.currentUser))); +export const getRecurring = asyncHandler(async (req,res) => ok(res, await service.recurring(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))); diff --git a/backend/src/modules/meetings/repository.js b/backend/src/modules/meetings/repository.js index 5464d2b..913ed90 100644 --- a/backend/src/modules/meetings/repository.js +++ b/backend/src/modules/meetings/repository.js @@ -32,12 +32,12 @@ export async function findParticipantConflicts(from, to, ids, excludeId = null) 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])]); +// Calendario personal: solo reuniones que el usuario organiza o a las que fue invitado, sea cual +// sea su rol. Con projectId (pestana Calendario del proyecto, acceso ya validado en el service) +// devuelve todas las reuniones vinculadas a ese proyecto. +export async function findVisibleCandidates(from, to, user, projectId = null) { + const scope = projectId ? 'AND m.project_id=?' : 'AND (m.organizer_id=? OR EXISTS (SELECT 1 FROM meeting_participants mp WHERE mp.meeting_id=m.id AND mp.user_id=?))'; + 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 > ?) ${scope} ORDER BY m.starts_at`, [to, from, ...(projectId ? [projectId] : [user.id, user.id])]); return rows; } @@ -110,11 +110,26 @@ 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; } +// Capas personales del calendario: lo que tiene al usuario como responsable (actividades, tareas) +// y los proyectos donde es dueno o miembro. 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.due_date=? AND t.due_date=? AND p.start_date=? AND p.estimated_end_date 1500 || !Number.isSafeInteger((page-1)*pageSize)) throw new HttpError(400, 'Paginacion invalida'); - const rows = await repo.findVisibleCandidates(from, to, user); + let projectId = null; + if (query.projectId) { + projectId = Number(query.projectId); + const project = Number.isSafeInteger(projectId) && projectId > 0 && user.modules.includes('projects') ? await projectsRepo.findById(projectId) : null; + if (!project || !(await canReadProject(user, project))) throw new HttpError(404, 'Proyecto no encontrado'); + } + const rows = await repo.findVisibleCandidates(from, to, user, projectId); 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 }; + // shared = me la compartieron (organiza otra persona); el calendario permite filtrar por origen. + const items = all.slice((page-1)*pageSize, page*pageSize).map((m) => ({ ...m, shared: m.organizer_id !== user.id })); + return { items, 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); + const today = guayaquilNow().slice(0, 10); + return (await repo.activityDue(from.slice(0, 10), to.slice(0, 10), user)) + .map((a) => ({ ...a, is_closed: !!a.is_closed, overdue: !a.is_closed && String(a.due_date).slice(0, 10) < today, shared: a.created_by !== user.id })); +} +export async function tasks(query, user) { + const [from, to] = range(query.from, query.to); + if (!user.modules.includes('projects')) return []; + const today = guayaquilNow().slice(0, 10); + return (await repo.taskDue(from.slice(0, 10), to.slice(0, 10), user)) + .map((t) => ({ ...t, is_closed: !!t.is_done, overdue: !t.is_done && String(t.due_date).slice(0, 10) < today, shared: t.created_by !== user.id })); // tasks no guarda creador: se usa el de la actividad +} +// Un evento por fecha del proyecto (inicio y fin estimado) que caiga en el rango. +export async function projects(query, user) { + const [from, to] = range(query.from, query.to); + if (!user.modules.includes('projects')) return []; + const a = from.slice(0, 10), b = to.slice(0, 10), today = guayaquilNow().slice(0, 10); + const out = []; + for (const p of await repo.projectDates(a, b, user)) { + const start = p.start_date && String(p.start_date).slice(0, 10), end = p.estimated_end_date && String(p.estimated_end_date).slice(0, 10); + if (start && start >= a && start < b) out.push({ id: p.id, code: p.code, name: p.name, due_date: start, milestone: 'start', is_closed: !!p.is_closed, overdue: false, shared: p.owner_user_id !== user.id }); + if (end && end >= a && end < b) out.push({ id: p.id, code: p.code, name: p.name, due_date: end, milestone: 'end', is_closed: !!p.is_closed, overdue: !p.is_closed && end < today, shared: p.owner_user_id !== user.id }); + } + return out.sort((x, y) => x.due_date.localeCompare(y.due_date) || x.id - y.id); +} +// Proximos vencimientos de tareas recurrentes en el rango. Replica advanceCycle (DATE_ADD +// sucesivo de MariaDB: 31-ene + 1 mes = 28-feb y de ahi 28-mar), para que el calendario muestre +// las mismas fechas que tendra la tarea al ir completando ciclos. El primero es el pendiente real. +function addInterval(day, unit, value) { + const [y, m, d] = day.split('-').map(Number); + if (unit === 'days') return new Date(Date.UTC(y, m - 1, d + value)).toISOString().slice(0, 10); + const months = (m - 1) + (unit === 'years' ? value * 12 : value); + const ty = y + Math.floor(months / 12), tm = months % 12; + const last = new Date(Date.UTC(ty, tm + 1, 0)).getUTCDate(); + return new Date(Date.UTC(ty, tm, Math.min(d, last))).toISOString().slice(0, 10); +} +export async function recurring(query, user) { + const [from, to] = range(query.from, query.to); + if (!user.modules.includes('recurring_tasks')) return []; + const first = from.slice(0, 10), last = to.slice(0, 10), today = guayaquilNow().slice(0, 10); + const out = []; + // Personal: las que el usuario creo o de las que es responsable (se las compartieron), cualquier rol. + // can_open indica si puede abrirla en Tareas recurrentes (alli solo entra el creador o admin). + const mine = (await recurringRepo.findAll()).filter((t) => t.created_by === user.id || t.responsible_user_id === user.id); + for (const task of mine) { + if (!task.is_active || !task.next_due_date) continue; + const canOpen = task.created_by === user.id || ['superadmin', 'admin', 'it_manager'].includes(user.roleCode); + let day = String(task.next_due_date).slice(0, 10); + for (let n = 0; n < 400 && day < last && (!task.end_date || day <= String(task.end_date).slice(0, 10)); n++) { + if (day >= first) out.push({ id: task.id, name: task.name, project_id: task.project_id, project_name: task.project_name, responsible_name: task.responsible_name, can_open: canOpen, shared: task.created_by !== user.id, due_date: day, is_next: n === 0, overdue: n === 0 && day < today }); + day = addInterval(day, task.interval_unit, task.interval_value); + } + } + return out.sort((a, b) => a.due_date.localeCompare(b.due_date) || a.id - b.id); } export async function create(raw, user) { const data = await normalize(raw, user); diff --git a/frontend/package.json b/frontend/package.json index 55b3221..00ae21e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "4.4.0", + "version": "4.5.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 6f65c8e..0f11eca 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -5,7 +5,7 @@ export const messages = { 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_new: 'Nueva reunión', calendar_open: 'Abrir', calendar_show_due: 'Mostrar vencimientos de actividades', calendar_legend: 'Capas del calendario', calendar_layer_meetings: 'Reuniones', calendar_layer_activities: 'Mis actividades', calendar_layer_recurring: 'Tareas recurrentes', calendar_overdue: 'Vencido', calendar_layer_projects: 'Proyectos', calendar_layer_tasks: 'Mis tareas', calendar_only: 'Solo', calendar_show_all: 'Ver todo', calendar_filter_origin: 'Ver', calendar_filter_types: 'Mostrar', calendar_all_types: 'Todo', calendar_no_types: 'Nada seleccionado', calendar_origin_all: 'Todo (mío y compartido)', calendar_origin_mine: 'Solo lo mío', calendar_origin_shared: 'Compartido conmigo', calendar_due: 'Vence', 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', @@ -630,7 +630,7 @@ export const messages = { 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_new: 'New meeting', calendar_open: 'Open', calendar_show_due: 'Show activity due dates', calendar_legend: 'Calendar layers', calendar_layer_meetings: 'Meetings', calendar_layer_activities: 'My activities', calendar_layer_recurring: 'Recurring tasks', calendar_overdue: 'Overdue', calendar_layer_projects: 'Projects', calendar_layer_tasks: 'My tasks', calendar_only: 'Only', calendar_show_all: 'Show all', calendar_filter_origin: 'View', calendar_filter_types: 'Show', calendar_all_types: 'All', calendar_no_types: 'Nothing selected', calendar_origin_all: 'All (mine and shared)', calendar_origin_mine: 'Only mine', calendar_origin_shared: 'Shared with me', calendar_due: 'Due', 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', diff --git a/frontend/src/pages/Calendario/CalendarioPage.jsx b/frontend/src/pages/Calendario/CalendarioPage.jsx index ba2b137..07d6f55 100644 --- a/frontend/src/pages/Calendario/CalendarioPage.jsx +++ b/frontend/src/pages/Calendario/CalendarioPage.jsx @@ -10,13 +10,15 @@ 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 { CheckListDropdown } from '../../components/CheckListDropdown.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 { hasModule } from '../../utils/modules.js'; +import { listMeetings, listMeetingActivities, listMeetingRecurring, listMeetingTasks, listMeetingProjects, 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')}`; @@ -49,6 +51,8 @@ function bounds(cursor, view) { return { from: `${dateKey(first)} 00:00:00`, to: `${dateKey(last)} 00:00:00`, first, last }; } +const ALL_LAYERS = { meeting: true, project: true, activity: true, task: true, recurring: true }; + export default function CalendarioPage() { const { t, lang, openDialog, pushToast } = useUi(); const { user } = useAuth(); @@ -58,7 +62,12 @@ export default function CalendarioPage() { 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); + // Capas visibles del calendario (persistidas): reuniones, vencimientos de actividades y tareas recurrentes. + const [layers, setLayers] = useState(() => { try { return { ...ALL_LAYERS, ...JSON.parse(localStorage.getItem('plc_calendar_layers') || '{}') }; } catch { return { ...ALL_LAYERS }; } }); + // Origen: todo, solo lo mio (lo creo/organizo yo) o solo lo compartido conmigo (lo creo otro y me incluye). + const [origin, setOrigin] = useState(() => { try { return localStorage.getItem('plc_calendar_origin') || 'all'; } catch { return 'all'; } }); + const canProjects = hasModule(user, 'projects'); + const showDue = layers.activity && canProjects; const [selected, setSelected] = useState(null); const [creating, setCreating] = useState(false); const [editing, setEditing] = useState(false); @@ -70,6 +79,10 @@ export default function CalendarioPage() { 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 canRecurring = hasModule(user, 'recurring_tasks'); + const { data: recurring } = useAsync(() => layers.recurring && canRecurring ? listMeetingRecurring(from, to) : Promise.resolve([]), [from, to, layers.recurring, canRecurring]); + const { data: projectDates } = useAsync(() => layers.project && canProjects ? listMeetingProjects(from, to) : Promise.resolve([]), [from, to, layers.project, canProjects]); + const { data: myTasks } = useAsync(() => layers.task && canProjects ? listMeetingTasks(from, to) : Promise.resolve([]), [from, to, layers.task, canProjects]); 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); } }); @@ -80,6 +93,8 @@ export default function CalendarioPage() { 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(() => { try { localStorage.setItem('plc_calendar_layers', JSON.stringify(layers)); } catch { /* almacenamiento privado */ } }, [layers]); + useEffect(() => { try { localStorage.setItem('plc_calendar_origin', origin); } catch { /* almacenamiento privado */ } }, [origin]); useEffect(() => { const id = Number(searchParams.get('meeting')); if (!id) return; @@ -130,16 +145,33 @@ export default function CalendarioPage() { } 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 allItems = [...(layers.meeting ? meetings || [] : []).map((m) => ({ ...m, kind: 'meeting', day: m.occurrence_start.slice(0,10) })), ...(showDue ? (activities || []).map((a) => ({ ...a, kind: 'activity', day: a.due_date })) : []), ...(layers.recurring && canRecurring ? (recurring || []).map((r) => ({ ...r, kind: 'recurring', day: r.due_date, occurrence_start: r.due_date })) : []), ...(layers.project && canProjects ? (projectDates || []).map((p) => ({ ...p, kind: 'project', day: p.due_date, occurrence_start: `${p.milestone}-${p.due_date}` })) : []), ...(layers.task && canProjects ? (myTasks || []).map((x) => ({ ...x, kind: 'task', day: x.due_date, occurrence_start: x.due_date })) : [])].filter((item) => origin === 'all' || (origin === 'shared' ? item.shared : !item.shared)).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 ; } @@ -149,7 +181,19 @@ export default function CalendarioPage() { {first.toLocaleDateString(lang, { month: 'long', year: 'numeric' })}
({ value:x, label:t(`calendar_${x}`) }))} value={view} onChange={changeView} />
- +
+ + +
+
+ {availableLayers.filter(([kind]) => layers[kind]).map(([kind, label]) => {label})} + {t('calendar_overdue')} + {t('calendar_origin_shared')} +
{view === 'agenda' ?
{allItems.length ? allItems.map((item) =>
{item.day}{itemButton(item)}
) :

{t('calendar_empty')}

}
:
{view !== 'day' && weekLabels.map((label) =>
{label}
)} {days.map((day) => { const key = dateKey(day); const muted = view === 'month' && day.getMonth() !== cursor.getMonth(); return
openNew(`${key} 09:00:00`)}> diff --git a/frontend/src/pages/Calendario/calendar.css b/frontend/src/pages/Calendario/calendar.css index 31b06c5..705799c 100644 --- a/frontend/src/pages/Calendario/calendar.css +++ b/frontend/src/pages/Calendario/calendar.css @@ -25,3 +25,25 @@ .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; } } +.meeting-calendar .calendar-entry.project { color:rgb(var(--cal-project)); background:rgb(var(--cal-project) / .14); } +.meeting-calendar .calendar-entry.task { color:rgb(var(--cal-task)); background:rgb(var(--cal-task) / .14); } +.meeting-calendar .calendar-legend-item.project .calendar-swatch { background:rgb(var(--cal-project)); } +.meeting-calendar .calendar-legend-item.task .calendar-swatch { background:rgb(var(--cal-task)); } +/* Colores por tipo de evento (leyenda = mismos colores que las entradas). */ +.meeting-calendar .calendar-entry.recurring { color:rgb(var(--success)); background:rgb(var(--success) / .14); } +.meeting-calendar .calendar-entry.projected { opacity:.72; border:1px dashed rgb(var(--success) / .55); } +.meeting-calendar .calendar-entry.done { opacity:.55; text-decoration:line-through; } +.meeting-calendar .calendar-entry.overdue { color:rgb(var(--danger)); background:rgb(var(--danger) / .14); border:1px solid rgb(var(--danger) / .5); } +.meeting-calendar .calendar-legend { display:flex; flex-wrap:wrap; align-items:center; gap:8px 18px; margin:10px 0 16px; font-size:.82rem; } +.meeting-calendar .calendar-legend-item { display:inline-flex; align-items:center; gap:6px; cursor:pointer; } +.meeting-calendar .calendar-legend-item.overdue { cursor:default; color:rgb(var(--muted)); } +.meeting-calendar .calendar-swatch { width:12px; height:12px; border-radius:3px; background:rgb(var(--accent)); } +.meeting-calendar .calendar-legend-item.activity .calendar-swatch { background:rgb(var(--warning)); } +.meeting-calendar .calendar-legend-item.recurring .calendar-swatch { background:rgb(var(--success)); } +.meeting-calendar .calendar-legend-item.overdue .calendar-swatch { background:rgb(var(--danger)); } +.meeting-calendar .calendar-filters { display:flex; flex-wrap:wrap; align-items:flex-end; gap:10px 18px; margin:10px 0 6px; } +.meeting-calendar .calendar-filter-label { display:flex; flex-direction:column; gap:4px; font-size:.72rem; color:rgb(var(--muted)); min-width:200px; } +/* Compartido conmigo: franja a la izquierda del color del tipo. */ +.meeting-calendar .calendar-entry.shared { box-shadow:inset 3px 0 0 currentColor; padding-left:8px; } +.meeting-calendar .calendar-legend-item.shared-sample { cursor:default; color:rgb(var(--muted)); } +.meeting-calendar .calendar-legend-item.shared-sample .calendar-swatch { background:rgb(var(--muted) / .25); box-shadow:inset 3px 0 0 rgb(var(--body)); } diff --git a/frontend/src/pages/Proyectos/ProjectCalendarTab.jsx b/frontend/src/pages/Proyectos/ProjectCalendarTab.jsx index c2d4fb5..a40d0b9 100644 --- a/frontend/src/pages/Proyectos/ProjectCalendarTab.jsx +++ b/frontend/src/pages/Proyectos/ProjectCalendarTab.jsx @@ -4,10 +4,15 @@ 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 { listMeetings, listMeetingRecurring } from '../../services/meetings.js'; +import { useAuth } from '../../context/auth-context.jsx'; +import { hasModule } from '../../utils/modules.js'; import { useNavigate } from 'react-router-dom'; const DOW = ['Lun', 'Mar', 'Mie', 'Jue', 'Vie', 'Sab', 'Dom']; +// Mismos colores que el menu Calendario: reunion = accent, actividad = warning, recurrente = success. +const chip = (tone) => ({ color: `rgb(var(--${tone}))`, background: `rgb(var(--${tone}) / .14)`, border: 0, textAlign: 'left', cursor: 'pointer', width: '100%' }); +const swatch = (tone) => ({ display: 'inline-block', width: 12, height: 12, borderRadius: 3, background: `rgb(var(--${tone}))`, marginRight: 6, verticalAlign: '-1px' }); function buildGrid(year, month) { const first = new Date(year, month, 1); @@ -23,12 +28,15 @@ function buildGrid(year, month) { export default function ProjectCalendarTab({ projectId }) { const { t } = useUi(); const navigate = useNavigate(); + const { user } = useAuth(); + const canMeetings = hasModule(user, 'calendar'), canRecurring = hasModule(user, 'recurring_tasks'); 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 { data: meetings } = useAsync(() => canMeetings ? listMeetings(from, to, projectId).catch(() => []) : Promise.resolve([]), [from, to, projectId, canMeetings]); + const { data: recurring } = useAsync(() => canMeetings && canRecurring ? listMeetingRecurring(from, to).catch(() => []) : Promise.resolve([]), [from, to, canMeetings, canRecurring]); const cells = useMemo(() => buildGrid(cursor.getFullYear(), cursor.getMonth()), [cursor]); const todayStr = new Date().toISOString().slice(0, 10); @@ -51,20 +59,28 @@ export default function ProjectCalendarTab({ projectId }) {
{cursor.toLocaleDateString('es-EC', { month: 'long', year: 'numeric' })}
+
+ {t('calendar_layer_activities')} + {canMeetings && {t('calendar_layer_meetings')}} + {canMeetings && canRecurring && {t('calendar_layer_recurring')}} + {canMeetings && canRecurring && {t('calendar_overdue')}} +
{DOW.map((d) =>
{d}
)} {cells.map((date, i) => { if (!date) return
; 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); + const meetingItems = (meetings || []).filter((m) => m.occurrence_start.slice(0,10) === key); + const recurringItems = (recurring || []).filter((r) => r.project_id === projectId && r.due_date === key); return (
{date.getDate()}
{items.slice(0, 3).map((a) => (
{a.is_milestone ? '◆ ' : ''}{a.name}
))} - {meetingItems.map((m) => )} + {meetingItems.map((m) => )} + {recurringItems.map((r) => )} {items.length > 3 &&
+{items.length - 3}
}
); diff --git a/frontend/src/pages/RecurringTasks/RecurringTasksPage.jsx b/frontend/src/pages/RecurringTasks/RecurringTasksPage.jsx index e6a8198..8709a5c 100644 --- a/frontend/src/pages/RecurringTasks/RecurringTasksPage.jsx +++ b/frontend/src/pages/RecurringTasks/RecurringTasksPage.jsx @@ -1,4 +1,5 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { Plus, Save, Trash2, RotateCcw, Pencil } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; import { useAsync, useAction } from '../../hooks/useAsync.js'; @@ -44,6 +45,7 @@ export default function RecurringTasksPage() { const [editing, setEditing] = useState(false); const [selected, setSelected] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); // Borrador del popup (nueva o edicion de una existente). const draft = useDraftForm({ formId: selected ? 'recurring-edit' : 'recurring-new', entityId: selected?.id ?? null, @@ -68,6 +70,15 @@ export default function RecurringTasksPage() { ]; const unitLabel = { days: t('recurring_task_unit_days'), months: t('recurring_task_unit_months'), years: t('recurring_task_unit_years') }; + // Llegada desde el Calendario (?task=id): abre esa tarea como un clic en su fila. + useEffect(() => { + const id = Number(searchParams.get('task')); + if (!id || !items) return; + const found = items.find((r) => r.id === id); + if (found) startEdit(found); + setSearchParams((prev) => { const next = new URLSearchParams(prev); next.delete('task'); return next; }, { replace: true }); + }, [searchParams, items]); // eslint-disable-line react-hooks/exhaustive-deps + function startNew() { setSelected(null); setEditing(true); diff --git a/frontend/src/services/meetings.js b/frontend/src/services/meetings.js index e05139d..3c5858e 100644 --- a/frontend/src/services/meetings.js +++ b/frontend/src/services/meetings.js @@ -1,7 +1,11 @@ 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); +const query = (from, to, extra = {}) => `?${new URLSearchParams({ from, to, ...extra })}`; +// Sin projectId: calendario personal (organizo o me invitaron). Con projectId: reuniones de ese proyecto. +export const listMeetings = (from, to, projectId) => api.get(`/meetings${query(from, to, projectId ? { projectId } : {})}`).then((result) => result.items); +export const listMeetingTasks = (from, to) => api.get(`/meetings/tasks${query(from, to)}`); +export const listMeetingProjects = (from, to) => api.get(`/meetings/projects${query(from, to)}`); export const listMeetingActivities = (from, to) => api.get(`/meetings/activities${query(from, to)}`); +export const listMeetingRecurring = (from, to) => api.get(`/meetings/recurring${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); diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 1801ce4..cae74f8 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -10,6 +10,8 @@ --success: 108 203 95; --warning: 255 185 0; --danger: 255 105 97; + --cal-project: 180 140 255; + --cal-task: 64 200 210; --r-sm: 4px; --r-lg: 8px; @@ -28,6 +30,8 @@ --success: 16 124 16; --warning: 157 93 0; --danger: 196 43 28; + --cal-project: 120 70 200; + --cal-task: 0 120 130; } @media (prefers-color-scheme: light) { @@ -43,5 +47,7 @@ --success: 16 124 16; --warning: 157 93 0; --danger: 196 43 28; + --cal-project: 120 70 200; + --cal-task: 0 120 130; } }