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 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-28 11:14:13 -05:00
parent 6066f27ba0
commit d562351242
18 changed files with 269 additions and 38 deletions
+14 -2
View File
@@ -36,10 +36,22 @@ Las reuniones siguen el mismo contrato (`PUT /api/meetings/:id`).
## Calendario de reuniones (v4.4.0) ## 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 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`); `{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). 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. - `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}`. - `POST /api/meetings`: `{title,description,startsAt,endsAt,allDay,location,videoUrl,projectId,reminderMinutes,participantUserIds,externalEmails,recurrence}`.
+16
View File
@@ -1,5 +1,21 @@
# Changelog - project.linkedcorp.net # 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 ## [4.4.0] - 2026-10-27
### Added ### Added
- Menú Calendario con vistas Mes, Semana, Día y Agenda; reuniones con participantes internos/externos, - Menú Calendario con vistas Mes, Semana, Día y Agenda; reuniones con participantes internos/externos,
+7 -1
View File
@@ -1,11 +1,17 @@
# ESTADO — project.linkedcorp.net # 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 + Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo +
autorregistro/aprobacion/SMTP. autorregistro/aprobacion/SMTP.
## Calendario de reuniones (v4.4.0, sin desplegar) ## 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 - 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. 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 - Migración `034_meetings.sql` **aplicada en dev** (2026-10-27), **no en producción**. Aplicarla
+14 -2
View File
@@ -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 - 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 obligatorio por fecha o cantidad. Cancelación puntual con EXDATE; DELETE cancela lógicamente
toda la serie. Invitaciones/cancelaciones iCalendar y recordatorios in-app. 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 personal (v4.5.0), igual para todos los roles** (un admin no ve lo de los demás):
Calendario del proyecto muestra sus actividades y reuniones vinculadas. 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 ### 8. Gantt
- Vista de solo lectura por proyecto: barras por actividad (inicio/fin/avance), sin edición directa - Vista de solo lectura por proyecto: barras por actividad (inicio/fin/avance), sin edición directa
+1 -1
View File
@@ -1 +1 @@
4.4.0 4.5.0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.4.0", "version": "4.5.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
@@ -2,6 +2,9 @@ import { asyncHandler, ok } from '../../utils/http.js';
import * as service from './service.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 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 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 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 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 putMeeting = asyncHandler(async (req,res) => ok(res, await service.update(Number(req.params.id), req.body || {}, req.currentUser)));
+25 -10
View File
@@ -32,12 +32,12 @@ export async function findParticipantConflicts(from, to, ids, excludeId = null)
return [...grouped.values()]; return [...grouped.values()];
} }
export async function findVisibleCandidates(from, to, user) { // Calendario personal: solo reuniones que el usuario organiza o a las que fue invitado, sea cual
const roles = ['superadmin', 'admin', 'it_manager']; // sea su rol. Con projectId (pestana Calendario del proyecto, acceso ya validado en el service)
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=?)))`; // devuelve todas las reuniones vinculadas a ese proyecto.
// Roles globales ven proyectos; las reuniones sin proyecto siguen privadas. export async function findVisibleCandidates(from, to, user, projectId = null) {
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 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 > ?) ${globalAccess} ORDER BY m.starts_at`, [to, from, user.id, user.id, ...(roles.includes(user.roleCode) || !user.modules.includes('projects') ? [] : [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; 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]); 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; 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) { 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,st.is_closed,a.created_by FROM activities a JOIN projects p ON p.id=a.project_id JOIN activity_statuses st ON st.id=a.status_id
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=?
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`, [from, to, 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 taskDue(from, to, user) {
const [rows] = await pool.query(`SELECT t.id,t.name,t.due_date,t.is_done,a.created_by,a.id AS activity_id,a.name AS activity_name,p.id AS project_id,p.name AS project_name
FROM tasks t JOIN activities a ON a.id=t.activity_id JOIN projects p ON p.id=a.project_id
WHERE t.due_date>=? AND t.due_date<? AND t.deleted_at IS NULL AND a.deleted_at IS NULL AND p.deleted_at IS NULL AND t.responsible_user_id=?
ORDER BY t.due_date,t.id LIMIT 1000`, [from, to, user.id]);
return rows;
}
export async function projectDates(from, to, user) {
const [rows] = await pool.query(`SELECT p.id,p.code,p.name,p.start_date,p.estimated_end_date,st.is_closed,p.owner_user_id FROM projects p JOIN project_statuses st ON st.id=p.status_id
WHERE p.deleted_at IS NULL AND (p.owner_user_id=? OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id=p.id AND pm.user_id=?))
AND ((p.start_date>=? AND p.start_date<?) OR (p.estimated_end_date>=? AND p.estimated_end_date<?))
ORDER BY p.id LIMIT 500`, [user.id, user.id, from, to, from, to]);
return rows; return rows;
} }
export async function sendReminderOnce(id, start, recipientIds, title) { export async function sendReminderOnce(id, start, recipientIds, title) {
+4 -1
View File
@@ -1,11 +1,14 @@
import { Router } from 'express'; import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js'; import { requireAuth } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js'; import { requireModule } from '../../middleware/modules.js';
import { getMeetings, getActivities, getMeeting, postMeeting, putMeeting, deleteMeeting, postCancelOccurrence, putRsvp } from './controller.js'; import { getMeetings, getActivities, getRecurring, getTasks, getProjects, getMeeting, postMeeting, putMeeting, deleteMeeting, postCancelOccurrence, putRsvp } from './controller.js';
const router = Router(); const router = Router();
router.use(requireAuth, requireModule('calendar')); router.use(requireAuth, requireModule('calendar'));
router.get('/', getMeetings); router.get('/', getMeetings);
router.get('/activities', getActivities); router.get('/activities', getActivities);
router.get('/recurring', getRecurring);
router.get('/tasks', getTasks);
router.get('/projects', getProjects);
router.post('/', postMeeting); router.post('/', postMeeting);
router.get('/:id', getMeeting); router.get('/:id', getMeeting);
router.put('/:id', putMeeting); router.put('/:id', putMeeting);
+64 -3
View File
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
import * as repo from './repository.js'; import * as repo from './repository.js';
import * as projectsRepo from '../projects/repository.js'; import * as projectsRepo from '../projects/repository.js';
import { canReadProject } from '../projects/service.js'; import { canReadProject } from '../projects/service.js';
import * as recurringRepo from '../recurring-tasks/repository.js';
import { createNotification } from '../notifications/service.js'; import { createNotification } from '../notifications/service.js';
import { sendMail } from '../../utils/mailer.js'; import { sendMail } from '../../utils/mailer.js';
import { logAudit } from '../../utils/audit.js'; import { logAudit } from '../../utils/audit.js';
@@ -106,16 +107,76 @@ export async function list(query, user) {
const [from, to] = range(query.from, query.to); const [from, to] = range(query.from, query.to);
const page = Number(query.page || 1), pageSize = Number(query.pageSize || 1500); 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'); 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); 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 exceptions = await repo.exceptionsFor(rows.map((r) => r.id));
const all = rows.flatMap((r) => expand({ ...r, exceptions: exceptions.get(r.id) || [] }, from, to)) 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); .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) { export async function activities(query, user) {
const [from, to] = range(query.from, query.to); const [from, to] = range(query.from, query.to);
if (!user.modules.includes('projects')) return []; 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) { export async function create(raw, user) {
const data = await normalize(raw, user); const data = await normalize(raw, user);
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "4.4.0", "version": "4.5.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2 -2
View File
@@ -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', 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_today: 'Hoy', calendar_previous: 'Anterior', calendar_next: 'Siguiente',
calendar_month: 'Mes', calendar_week: 'Semana', calendar_day: 'Día', calendar_agenda: 'Agenda', 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_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_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_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', 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_today: 'Today', calendar_previous: 'Previous', calendar_next: 'Next',
calendar_month: 'Month', calendar_week: 'Week', calendar_day: 'Day', calendar_agenda: 'Agenda', 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_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_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_participants: 'Internal participants', calendar_external: 'External emails', calendar_add: 'Add', calendar_invalid_email: 'Invalid email or 50 participant limit',
@@ -10,13 +10,15 @@ import { DraftNotice } from '../../components/DraftNotice.jsx';
import { Button } from '../../components/Button.jsx'; import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx'; import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx'; import { ComboBox } from '../../components/ComboBox.jsx';
import { CheckListDropdown } from '../../components/CheckListDropdown.jsx';
import { DatePicker } from '../../components/DatePicker.jsx'; import { DatePicker } from '../../components/DatePicker.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx'; import { DetailPopup } from '../../components/DetailPopup.jsx';
import { InfoBar } from '../../components/InfoBar.jsx'; import { InfoBar } from '../../components/InfoBar.jsx';
import { FlyoutItem, FlyoutSeparator } from '../../components/Flyout.jsx'; import { FlyoutItem, FlyoutSeparator } from '../../components/Flyout.jsx';
import { listProjectOptions } from '../../services/projects.js'; import { listProjectOptions } from '../../services/projects.js';
import { listUsers } from '../../services/users.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'; import './calendar.css';
const dateKey = (d) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; 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 }; 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() { export default function CalendarioPage() {
const { t, lang, openDialog, pushToast } = useUi(); const { t, lang, openDialog, pushToast } = useUi();
const { user } = useAuth(); const { user } = useAuth();
@@ -58,7 +62,12 @@ export default function CalendarioPage() {
const guardedClose = useGuardedClose(); const guardedClose = useGuardedClose();
const [view, setView] = useState(() => { try { return localStorage.getItem('plc_calendar_view') || 'month'; } catch { return 'month'; } }); const [view, setView] = useState(() => { try { return localStorage.getItem('plc_calendar_view') || 'month'; } catch { return 'month'; } });
const [cursor, setCursor] = useState(ecuadorNow); 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 [selected, setSelected] = useState(null);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [editing, setEditing] = 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 { from, to, first, last } = useMemo(() => bounds(cursor, view), [cursor, view]);
const { data: meetings, reload } = useAsync(() => listMeetings(from, to), [from, to]); 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: 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: users } = useAsync(listUsers, []);
const { data: projects } = useAsync(listProjectOptions, []); const { data: projects } = useAsync(listProjectOptions, []);
useReopenDraft({ newFormId:'meeting-new', editFormId:'meeting-edit', items:meetings, openNew: () => openNew(), openEdit: (item) => { void openMeeting(item, true); } }); 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); 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_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(() => { useEffect(() => {
const id = Number(searchParams.get('meeting')); const id = Number(searchParams.get('meeting'));
if (!id) return; 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 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 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 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 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')]; 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} const availableLayers = [['meeting', t('calendar_layer_meetings')], ...(canProjects ? [['project', t('calendar_layer_projects')], ['activity', t('calendar_layer_activities')], ['task', t('calendar_layer_tasks')]] : []), ...(canRecurring ? [['recurring', t('calendar_layer_recurring')]] : [])];
onClick={(e) => { e.stopPropagation(); if (item.kind === 'meeting') void openMeeting(item); else navigate(`/proyectos/${item.project_id}?tab=activities&activity=${item.id}`); }} const activeTypes = availableLayers.filter(([kind]) => layers[kind]).map(([kind]) => kind);
const allTypesOn = activeTypes.length === availableLayers.length;
const typeValues = allTypesOn ? ['all', ...activeTypes] : activeTypes;
// "Todo" marca o desmarca todos; para ver solo un tipo: desmarcar Todo y marcar ese tipo.
function changeTypes(next) {
const keys = Object.keys(ALL_LAYERS);
if (next.includes('all') && !typeValues.includes('all')) setLayers({ ...ALL_LAYERS });
else if (!next.includes('all') && typeValues.includes('all')) setLayers(Object.fromEntries(keys.map((k) => [k, false])));
else setLayers(Object.fromEntries(keys.map((k) => [k, next.includes(k)])));
}
// Tarea recurrente compartida (soy responsable, no la cree): Tareas recurrentes no me la muestra,
// asi que se ve aqui en solo lectura.
function openRecurring(item) {
if (item.can_open) { navigate(`/tareas-recurrentes?task=${item.id}`); return; }
openDialog({ tone: 'info', title: item.name, message: [`${t('calendar_due')}: ${readable(`${item.due_date} 00:00:00`, lang, true)}`, item.project_name && `${t('calendar_project')}: ${item.project_name}`, item.overdue && t('calendar_overdue')].filter(Boolean).join(' · '), actions: [{ label: t('common_close'), variant: 'accent' }] });
}
function itemButton(item) { return <button key={`${item.kind}-${item.id}-${item.occurrence_start || ''}`} className={`calendar-entry ${item.kind}${item.overdue ? ' overdue' : ''}${item.kind === 'recurring' && !item.is_next ? ' projected' : ''}${item.is_closed ? ' done' : ''}${item.shared ? ' shared' : ''}`} title={item.kind === 'meeting' ? item.title : `${item.name}${item.project_name ? ` · ${item.project_name}` : ''}${item.overdue ? ` (${t('calendar_overdue')})` : ''}`}
onClick={(e) => { e.stopPropagation(); if (item.kind === 'meeting') void openMeeting(item); else if (item.kind === 'recurring') openRecurring(item); else if (item.kind === 'project') navigate(`/proyectos/${item.id}`); else if (item.kind === 'task') navigate(`/proyectos/${item.project_id}?tab=activities&activity=${item.activity_id}`); 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 }); }} 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}); } }}> 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' ? `${item.occurrence_start.slice(11,16)} ${item.title}` : item.kind === 'recurring' ? `↻ ${item.name}` : item.kind === 'project' ? `${item.milestone === 'start' ? '▶' : '⚑'} ${item.code} ${item.name}` : item.kind === 'task' ? `☐ ${item.name}` : `◆ ${item.name}`}
{item.kind === 'meeting' && <span className="calendar-entry-more" onClick={(e) => { e.stopPropagation(); setContext({ item, x: e.clientX, y: e.clientY }); }}>⋯</span>} {item.kind === 'meeting' && <span className="calendar-entry-more" onClick={(e) => { e.stopPropagation(); setContext({ item, x: e.clientX, y: e.clientY }); }}>⋯</span>}
</button>; } </button>; }
@@ -149,7 +181,19 @@ export default function CalendarioPage() {
<strong>{first.toLocaleDateString(lang, { month: 'long', year: 'numeric' })}</strong></div> <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} /> <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> <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> <div className="calendar-filters">
<label className="calendar-filter-label">{t('calendar_filter_origin')}
<ComboBox options={['all', 'mine', 'shared'].map((x) => ({ value: x, label: t(`calendar_origin_${x}`) }))} value={origin} onChange={setOrigin} />
</label>
<label className="calendar-filter-label">{t('calendar_filter_types')}
<CheckListDropdown label={allTypesOn ? t('calendar_all_types') : activeTypes.length === 0 ? t('calendar_no_types') : activeTypes.length === 1 ? availableLayers.find(([k]) => k === activeTypes[0])[1] : `${activeTypes.length} / ${availableLayers.length}`} options={[{ value: 'all', label: t('calendar_all_types') }, ...availableLayers.map(([value, label]) => ({ value, label }))]} values={typeValues} onChange={changeTypes} width={240} />
</label>
</div>
<div className="calendar-legend" aria-label={t('calendar_legend')}>
{availableLayers.filter(([kind]) => layers[kind]).map(([kind, label]) => <span key={kind} className={`calendar-legend-item ${kind}`}><span className="calendar-swatch" />{label}</span>)}
<span className="calendar-legend-item overdue"><span className="calendar-swatch" />{t('calendar_overdue')}</span>
<span className="calendar-legend-item shared-sample"><span className="calendar-swatch" />{t('calendar_origin_shared')}</span>
</div>
{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 === '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>)} {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`)}> {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`)}>
@@ -25,3 +25,25 @@
.calendar-weekdays { display:flex; flex-wrap:wrap; gap:12px; margin:8px 0; font-size:.8rem; } .calendar-weekdays { display:flex; flex-wrap:wrap; gap:12px; margin:8px 0; font-size:.8rem; }
.calendar-detail p { line-height:1.5; } .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; } } @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)); }
@@ -4,10 +4,15 @@ import { useUi } from '../../context/ui-context.jsx';
import { useAsync } from '../../hooks/useAsync.js'; import { useAsync } from '../../hooks/useAsync.js';
import { listActivitiesByProject } from '../../services/activities.js'; import { listActivitiesByProject } from '../../services/activities.js';
import { Button } from '../../components/Button.jsx'; 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'; import { useNavigate } from 'react-router-dom';
const DOW = ['Lun', 'Mar', 'Mie', 'Jue', 'Vie', 'Sab', '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) { function buildGrid(year, month) {
const first = new Date(year, month, 1); const first = new Date(year, month, 1);
@@ -23,12 +28,15 @@ function buildGrid(year, month) {
export default function ProjectCalendarTab({ projectId }) { export default function ProjectCalendarTab({ projectId }) {
const { t } = useUi(); const { t } = useUi();
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth();
const canMeetings = hasModule(user, 'calendar'), canRecurring = hasModule(user, 'recurring_tasks');
const { data: activities } = useAsync(() => listActivitiesByProject(projectId), [projectId]); const { data: activities } = useAsync(() => listActivitiesByProject(projectId), [projectId]);
const [cursor, setCursor] = useState(() => { const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), 1); }); 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 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 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 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 cells = useMemo(() => buildGrid(cursor.getFullYear(), cursor.getMonth()), [cursor]);
const todayStr = new Date().toISOString().slice(0, 10); const todayStr = new Date().toISOString().slice(0, 10);
@@ -51,20 +59,28 @@ export default function ProjectCalendarTab({ projectId }) {
<div style={{ fontWeight: 700 }}>{cursor.toLocaleDateString('es-EC', { month: 'long', year: 'numeric' })}</div> <div style={{ fontWeight: 700 }}>{cursor.toLocaleDateString('es-EC', { month: 'long', year: 'numeric' })}</div>
<Button variant="standard" size="sm" icon={ChevronRight} onClick={() => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1))} /> <Button variant="standard" size="sm" icon={ChevronRight} onClick={() => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1))} />
</div> </div>
<div className="text-muted" style={{ display:'flex', flexWrap:'wrap', gap:'6px 16px', fontSize:'.78rem', margin:'4px 0 10px' }}>
<span><span style={swatch('warning')} />{t('calendar_layer_activities')}</span>
{canMeetings && <span><span style={swatch('accent')} />{t('calendar_layer_meetings')}</span>}
{canMeetings && canRecurring && <span><span style={swatch('success')} />{t('calendar_layer_recurring')}</span>}
{canMeetings && canRecurring && <span><span style={swatch('danger')} />{t('calendar_overdue')}</span>}
</div>
<div className="calendar-grid"> <div className="calendar-grid">
{DOW.map((d) => <div key={d} className="calendar-dow">{d}</div>)} {DOW.map((d) => <div key={d} className="calendar-dow">{d}</div>)}
{cells.map((date, i) => { {cells.map((date, i) => {
if (!date) return <div key={i} className="calendar-cell muted" />; if (!date) return <div key={i} className="calendar-cell muted" />;
const key = date.toISOString().slice(0, 10); const key = date.toISOString().slice(0, 10);
const items = byDay[key] || []; 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 ( return (
<div key={i} className={`calendar-cell ${key === todayStr ? 'today' : ''}`}> <div key={i} className={`calendar-cell ${key === todayStr ? 'today' : ''}`}>
<div className="calendar-day-num">{date.getDate()}</div> <div className="calendar-day-num">{date.getDate()}</div>
{items.slice(0, 3).map((a) => ( {items.slice(0, 3).map((a) => (
<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> <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>)} {meetingItems.map((m) => <button key={`${m.id}-${m.occurrence_start}`} className="calendar-chip" style={chip('accent')} title={m.title} onClick={() => navigate(`/calendario?meeting=${m.id}`)}>{m.occurrence_start.slice(11,16)} {m.title}</button>)}
{recurringItems.map((r) => <button key={`r-${r.id}-${r.due_date}`} className="calendar-chip" style={{ ...chip(r.overdue ? 'danger' : 'success'), opacity: r.is_next ? 1 : .72 }} title={r.name} onClick={() => navigate(`/tareas-recurrentes?task=${r.id}`)}>↻ {r.name}</button>)}
{items.length > 3 && <div className="text-muted" style={{ fontSize: '.64rem' }}>+{items.length - 3}</div>} {items.length > 3 && <div className="text-muted" style={{ fontSize: '.64rem' }}>+{items.length - 3}</div>}
</div> </div>
); );
@@ -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 { Plus, Save, Trash2, RotateCcw, Pencil } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx'; import { useUi } from '../../context/ui-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js'; import { useAsync, useAction } from '../../hooks/useAsync.js';
@@ -44,6 +45,7 @@ export default function RecurringTasksPage() {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
const [searchParams, setSearchParams] = useSearchParams();
// Borrador del popup (nueva o edicion de una existente). // Borrador del popup (nueva o edicion de una existente).
const draft = useDraftForm({ const draft = useDraftForm({
formId: selected ? 'recurring-edit' : 'recurring-new', entityId: selected?.id ?? null, 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') }; 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() { function startNew() {
setSelected(null); setSelected(null);
setEditing(true); setEditing(true);
+6 -2
View File
@@ -1,7 +1,11 @@
import { api } from './api.js'; import { api } from './api.js';
const query = (from, to) => `?${new URLSearchParams({ from, to })}`; const query = (from, to, extra = {}) => `?${new URLSearchParams({ from, to, ...extra })}`;
export const listMeetings = (from, to) => api.get(`/meetings${query(from, to)}`).then((result) => result.items); // 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 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 getMeeting = (id) => api.get(`/meetings/${id}`);
export const createMeeting = (data) => api.post('/meetings', data); export const createMeeting = (data) => api.post('/meetings', data);
export const updateMeeting = (id, data) => api.put(`/meetings/${id}`, data); export const updateMeeting = (id, data) => api.put(`/meetings/${id}`, data);
+6
View File
@@ -10,6 +10,8 @@
--success: 108 203 95; --success: 108 203 95;
--warning: 255 185 0; --warning: 255 185 0;
--danger: 255 105 97; --danger: 255 105 97;
--cal-project: 180 140 255;
--cal-task: 64 200 210;
--r-sm: 4px; --r-sm: 4px;
--r-lg: 8px; --r-lg: 8px;
@@ -28,6 +30,8 @@
--success: 16 124 16; --success: 16 124 16;
--warning: 157 93 0; --warning: 157 93 0;
--danger: 196 43 28; --danger: 196 43 28;
--cal-project: 120 70 200;
--cal-task: 0 120 130;
} }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
@@ -43,5 +47,7 @@
--success: 16 124 16; --success: 16 124 16;
--warning: 157 93 0; --warning: 157 93 0;
--danger: 196 43 28; --danger: 196 43 28;
--cal-project: 120 70 200;
--cal-task: 0 120 130;
} }
} }