feat: version 4.4.0 -- Calendario de reuniones

Menu Calendario (Mes/Semana/Dia/Agenda) para agendar reuniones con participantes
internos y externos, RSVP, vinculo a proyecto, vencimientos de actividades,
invitaciones iCalendar por correo, recurrencia con excepciones, recordatorios
in-app y aviso de choques. Migracion 034_meetings.sql.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-28 11:04:41 -05:00
parent a6cc41666e
commit 6066f27ba0
34 changed files with 986 additions and 16 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "4.3.0",
"version": "4.4.0",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -24,6 +24,7 @@ import ContrasenasPage from '../pages/Contrasenas/ContrasenasPage.jsx';
import EquiposPage from '../pages/Equipos/EquiposPage.jsx';
import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx';
import MiCuentaPage from '../pages/MiCuenta/MiCuentaPage.jsx';
import CalendarioPage from '../pages/Calendario/CalendarioPage.jsx';
// Router de datos (createBrowserRouter): necesario para useBlocker, que avisa al salir de una
// seccion con cambios sin guardar (ver components/NavigationGuard.jsx). Mismas rutas de antes.
@@ -40,6 +41,7 @@ const router = createBrowserRouter(createRoutesFromElements(
<Route index element={<DashboardPage />} />
<Route path="mi-cuenta" element={<MiCuentaPage />} />
<Route path="proyectos" element={<ModuleRoute module="projects"><ProyectosPage /></ModuleRoute>} />
<Route path="calendario" element={<ModuleRoute module="calendar"><CalendarioPage /></ModuleRoute>} />
<Route path="proyectos/:id" element={<ModuleRoute module="projects"><ProyectoDetallePage /></ModuleRoute>} />
<Route path="tareas-recurrentes" element={<ModuleRoute module="recurring_tasks"><RecurringTasksPage /></ModuleRoute>} />
<Route path="notas" element={<ModuleRoute module="notes"><NotasPage /></ModuleRoute>} />
+2 -1
View File
@@ -67,6 +67,7 @@ export default function AppShell() {
if (!notification.is_read) await markNotificationRead(notification.id);
await reloadNotifications();
if (notification.entity_type === 'project') navigate(`/proyectos/${notification.entity_id}`);
else if (notification.entity_type === 'meeting') navigate(`/calendario?meeting=${notification.entity_id}`);
else if (notification.entity_type === 'activity') navigate(`/proyectos`);
}
@@ -94,7 +95,7 @@ export default function AppShell() {
)}
{notifData?.items?.slice(0, 8).map((n) => (
<FlyoutItem key={n.id} onClick={() => handleNotificationClick(n)}>
<span style={{ fontWeight: n.is_read ? 400 : 700 }}>{n.message}</span>
<span style={{ fontWeight: n.is_read ? 400 : 700 }}>{n.type?.startsWith('meeting_') ? `${t(`notification_${n.type}`)}: ${n.message}` : n.message}</span>
</FlyoutItem>
))}
{notifData?.items?.length > 0 && (
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
const key = (d) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
const fromKey = (s) => { const [y,m,d] = String(s || '').split('-').map(Number); return Number.isFinite(y) && Number.isFinite(m) && Number.isFinite(d) ? new Date(y,m-1,d) : new Date(); };
export function DatePicker({ id, value, onChange, label }) {
const { lang, t } = useUi();
const triggerRef = useRef(null), panelRef = useRef(null);
const [open, setOpen] = useState(false);
const [cursor, setCursor] = useState(() => fromKey(value));
const [pos, setPos] = useState(null);
useEffect(() => { if (value) setCursor(fromKey(value)); }, [value]);
function openPanel() {
const r = triggerRef.current.getBoundingClientRect();
setCursor(fromKey(value));
setPos({ top:r.bottom+4, left:r.left, anchorTop:r.top, anchorBottom:r.bottom, ready:false });
setOpen(true);
}
useLayoutEffect(() => {
if (!open || !pos || pos.ready || !panelRef.current) return;
const r = panelRef.current.getBoundingClientRect(), margin = 8;
let top = pos.top;
if (top+r.height > window.innerHeight-margin) top = pos.anchorTop-r.height-4 >= margin ? pos.anchorTop-r.height-4 : Math.max(margin,window.innerHeight-r.height-margin);
const left = Math.max(margin, Math.min(pos.left,window.innerWidth-r.width-margin));
setPos((p) => ({...p,top,left,ready:true}));
}, [open,pos]);
useEffect(() => {
if (!open) return undefined;
const outside = (e) => { if (!triggerRef.current?.contains(e.target) && !panelRef.current?.contains(e.target)) setOpen(false); };
const keydown = (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setOpen(false); triggerRef.current?.focus(); } else if (e.key === 'Tab' && panelRef.current?.contains(document.activeElement)) e.stopPropagation(); };
const scroll = (e) => { if (!panelRef.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown',outside);
document.addEventListener('keydown',keydown,true);
window.addEventListener('scroll',scroll,true);
window.addEventListener('resize',scroll);
return () => { document.removeEventListener('mousedown',outside); document.removeEventListener('keydown',keydown,true); window.removeEventListener('scroll',scroll,true); window.removeEventListener('resize',scroll); };
}, [open]);
useEffect(() => { if (open && pos?.ready) panelRef.current?.querySelector('button.selected')?.focus(); }, [open,pos?.ready]);
const first = new Date(cursor.getFullYear(),cursor.getMonth(),1);
const offset = (first.getDay()+6)%7;
const count = new Date(cursor.getFullYear(),cursor.getMonth()+1,0).getDate();
const days = Array.from({length:Math.ceil((offset+count)/7)*7},(_,i) => i-offset+1);
const weekday = Array.from({length:7},(_,i) => new Date(2024,0,1+i).toLocaleDateString(lang,{weekday:'short'}));
const today = new Date(Date.now()-5*3600000).toISOString().slice(0,10);
return <>
<button type="button" ref={triggerRef} id={id} className="input datepicker-trigger" aria-label={label || t('calendar_date')} aria-haspopup="dialog" aria-expanded={open} onClick={() => open ? setOpen(false) : openPanel()}><span>{value || '—'}</span><CalendarDays size={16} /></button>
{open && pos && createPortal(<div ref={panelRef} className="datepicker-panel" role="dialog" aria-label={t('calendar_date')} style={{top:pos.top,left:pos.left,visibility:pos.ready?'visible':'hidden'}}>
<div className="datepicker-head"><button type="button" aria-label={t('calendar_previous')} onClick={() => setCursor(new Date(cursor.getFullYear(),cursor.getMonth()-1,1))}><ChevronLeft size={16} /></button><strong>{cursor.toLocaleDateString(lang,{month:'long',year:'numeric'})}</strong><button type="button" aria-label={t('calendar_next')} onClick={() => setCursor(new Date(cursor.getFullYear(),cursor.getMonth()+1,1))}><ChevronRight size={16} /></button></div>
<div className="datepicker-grid">{weekday.map((day,i)=><span key={i} className="datepicker-weekday">{day}</span>)}{days.map((day,i) => day<1||day>count ? <span key={i} /> : <button type="button" key={i} className={`${key(new Date(cursor.getFullYear(),cursor.getMonth(),day))===value?'selected':''} ${key(new Date(cursor.getFullYear(),cursor.getMonth(),day))===today?'today':''}`} aria-pressed={key(new Date(cursor.getFullYear(),cursor.getMonth(),day))===value} onClick={() => { onChange(key(new Date(cursor.getFullYear(),cursor.getMonth(),day))); setOpen(false); triggerRef.current?.focus(); }}>{day}</button>)}</div>
</div>,document.body)}
</>;
}
+2 -1
View File
@@ -1,7 +1,7 @@
import { NavLink } from 'react-router-dom';
import { useUserDrafts } from '../hooks/useDraftForm.js';
import { DraftMark } from './NavigationGuard.jsx';
import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound, Monitor } from 'lucide-react';
import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound, Monitor, CalendarDays } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useUi } from '../context/ui-context.jsx';
import { useAuth } from '../context/auth-context.jsx';
@@ -14,6 +14,7 @@ import { hasModule } from '../utils/modules.js';
const ITEMS = [
{ to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true },
{ to: '/proyectos', module: 'projects', icon: FolderKanban, labelKey: 'nav_projects' },
{ to: '/calendario', module: 'calendar', icon: CalendarDays, labelKey: 'nav_calendar' },
{ to: '/tareas-recurrentes', module: 'recurring_tasks', icon: Repeat, labelKey: 'nav_recurring_tasks' },
{ to: '/notas', module: 'notes', icon: StickyNote, labelKey: 'nav_notes' },
{ to: '/contrasenas', module: 'vault', icon: KeyRound, labelKey: 'nav_passwords' },
+3 -1
View File
@@ -6,7 +6,9 @@ import {
sanitizeDraftValues, fingerprint, setActiveEditor, draftAllowedFields,
} from '../utils/drafts.js';
const same = (a, b) => String(a ?? '') === String(b ?? '');
const same = (a, b) => (a && typeof a === 'object') || (b && typeof b === 'object')
? JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
: String(a ?? '') === String(b ?? '');
/**
* Estado de un formulario con borrador persistente (ver utils/drafts.js).
+28
View File
@@ -1,5 +1,19 @@
export const messages = {
'es-EC': {
nav_calendar: 'Calendario',
calendar_date: 'Fecha',
notification_meeting_created: 'Nueva reunión', notification_meeting_updated: 'Reunión modificada', notification_meeting_cancelled: 'Reunión cancelada', notification_meeting_occurrence_cancelled: 'Ocurrencia cancelada', notification_meeting_reminder: 'Recordatorio de reunión', notification_meeting_rsvp_accepted: 'Aceptó la reunión', notification_meeting_rsvp_declined: 'Rechazó la reunión', notification_meeting_rsvp_tentative: 'Respondió quizás',
calendar_today: 'Hoy', calendar_previous: 'Anterior', calendar_next: 'Siguiente',
calendar_month: 'Mes', calendar_week: 'Semana', calendar_day: 'Día', calendar_agenda: 'Agenda',
calendar_new: 'Nueva reunión', calendar_open: 'Abrir', calendar_show_due: 'Mostrar vencimientos de actividades', calendar_empty: 'No hay eventos en este período',
calendar_title: 'Título', calendar_description: 'Descripción', calendar_start: 'Inicio', calendar_end: 'Fin', calendar_all_day: 'Todo el día',
calendar_location: 'Ubicación', calendar_video_url: 'Enlace de videollamada', calendar_project: 'Proyecto', calendar_organizer: 'Organizador', calendar_reminder: 'Recordatorio', calendar_none: 'Ninguno', calendar_one_day: 'Un día antes', calendar_minutes: 'minutos',
calendar_participants: 'Participantes internos', calendar_external: 'Correos externos', calendar_add: 'Agregar', calendar_invalid_email: 'Correo inválido o límite de 50 participantes',
calendar_recurrence: 'Repetición', calendar_daily: 'Diaria', calendar_weekly: 'Semanal', calendar_monthly: 'Mensual', calendar_interval: 'Cada', calendar_count: 'Número de ocurrencias', calendar_until: 'Hasta la fecha',
calendar_mon: 'Lun', calendar_tue: 'Mar', calendar_wed: 'Mié', calendar_thu: 'Jue', calendar_fri: 'Vie', calendar_sat: 'Sáb', calendar_sun: 'Dom',
calendar_rsvp_pending: 'Pendiente', calendar_rsvp_accepted: 'Aceptar', calendar_rsvp_declined: 'Rechazar', calendar_rsvp_tentative: 'Quizás',
calendar_conflicts: 'Hay participantes con otra reunión a la misma hora', calendar_save_anyway: 'Confirmar y guardar',
calendar_cancel: 'Cancelar reunión', calendar_cancel_occurrence: 'Cancelar esta ocurrencia', calendar_cancel_confirm: 'Se cancelará la serie y se avisará a los participantes.', calendar_cancel_occurrence_confirm: 'Se cancelará esta ocurrencia y se avisará a los participantes.', calendar_cancelled: 'Reunión cancelada',
appName: 'LinkedCorp Proyectos',
nav_dashboard: 'Panel',
user_modules: 'Módulos',
@@ -611,6 +625,20 @@ export const messages = {
notifications_mark_all_read: 'Marcar todas como leidas',
},
'en-US': {
nav_calendar: 'Calendar',
calendar_date: 'Date',
notification_meeting_created: 'New meeting', notification_meeting_updated: 'Meeting updated', notification_meeting_cancelled: 'Meeting cancelled', notification_meeting_occurrence_cancelled: 'Occurrence cancelled', notification_meeting_reminder: 'Meeting reminder', notification_meeting_rsvp_accepted: 'Accepted meeting', notification_meeting_rsvp_declined: 'Declined meeting', notification_meeting_rsvp_tentative: 'Replied maybe',
calendar_today: 'Today', calendar_previous: 'Previous', calendar_next: 'Next',
calendar_month: 'Month', calendar_week: 'Week', calendar_day: 'Day', calendar_agenda: 'Agenda',
calendar_new: 'New meeting', calendar_open: 'Open', calendar_show_due: 'Show activity due dates', calendar_empty: 'No events in this period',
calendar_title: 'Title', calendar_description: 'Description', calendar_start: 'Start', calendar_end: 'End', calendar_all_day: 'All day',
calendar_location: 'Location', calendar_video_url: 'Video call URL', calendar_project: 'Project', calendar_organizer: 'Organizer', calendar_reminder: 'Reminder', calendar_none: 'None', calendar_one_day: 'One day before', calendar_minutes: 'minutes',
calendar_participants: 'Internal participants', calendar_external: 'External emails', calendar_add: 'Add', calendar_invalid_email: 'Invalid email or 50 participant limit',
calendar_recurrence: 'Recurrence', calendar_daily: 'Daily', calendar_weekly: 'Weekly', calendar_monthly: 'Monthly', calendar_interval: 'Every', calendar_count: 'Occurrences', calendar_until: 'Until date',
calendar_mon: 'Mon', calendar_tue: 'Tue', calendar_wed: 'Wed', calendar_thu: 'Thu', calendar_fri: 'Fri', calendar_sat: 'Sat', calendar_sun: 'Sun',
calendar_rsvp_pending: 'Pending', calendar_rsvp_accepted: 'Accept', calendar_rsvp_declined: 'Decline', calendar_rsvp_tentative: 'Maybe',
calendar_conflicts: 'Some participants have another meeting at this time', calendar_save_anyway: 'Confirm and save',
calendar_cancel: 'Cancel meeting', calendar_cancel_occurrence: 'Cancel this occurrence', calendar_cancel_confirm: 'The series will be cancelled and participants notified.', calendar_cancel_occurrence_confirm: 'This occurrence will be cancelled and participants notified.', calendar_cancelled: 'Meeting cancelled',
appName: 'LinkedCorp Projects',
nav_dashboard: 'Dashboard',
user_modules: 'Modules',
@@ -0,0 +1,203 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { CalendarDays, ChevronLeft, ChevronRight, Plus, Pencil, Trash2, XCircle } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { useDraftForm, useReopenDraft } from '../../hooks/useDraftForm.js';
import { useGuardedClose } from '../../components/NavigationGuard.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { DatePicker } from '../../components/DatePicker.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { InfoBar } from '../../components/InfoBar.jsx';
import { FlyoutItem, FlyoutSeparator } from '../../components/Flyout.jsx';
import { listProjectOptions } from '../../services/projects.js';
import { listUsers } from '../../services/users.js';
import { listMeetings, listMeetingActivities, getMeeting, createMeeting, updateMeeting, cancelMeeting, cancelMeetingOccurrence, respondMeeting } from '../../services/meetings.js';
import './calendar.css';
const dateKey = (d) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
const ecuadorNow = () => {
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone:'America/Guayaquil', year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', hourCycle:'h23' }).formatToParts(new Date()).map((p) => [p.type,p.value]));
return new Date(Number(parts.year), Number(parts.month)-1, Number(parts.day), Number(parts.hour), Number(parts.minute));
};
const dateTime = (d) => `${dateKey(d)} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:00`;
const shift = (d, days) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + days);
const plusHour = (s) => { const d = new Date(s.replace(' ', 'T')); d.setHours(d.getHours()+1); return dateTime(d); };
const blank = (start = nextSlot()) => ({ title: '', description: '', startsAt: start, endsAt: plusHour(start), allDay: false, location: '', videoUrl: '', projectId: '', reminderMinutes: '', participantUserIds: [], externalEmails: [], recurrence: null });
function toForm(row) { return { title: row.title, description: row.description || '', startsAt: row.starts_at, endsAt: row.ends_at, allDay: !!row.all_day, location: row.location || '', videoUrl: row.video_url || '', projectId: row.project_id || '', reminderMinutes: row.reminder_minutes ?? '', participantUserIds: row.participants.map((p) => p.user_id), externalEmails: row.external_emails, recurrence: row.recurrence_json ? JSON.parse(row.recurrence_json) : null, editVersion: row.edit_version }; }
const hours = Array.from({length:24},(_,i) => ({value:String(i).padStart(2,'0'),label:String(i).padStart(2,'0')}));
const minutes = Array.from({length:12},(_,i) => ({value:String(i*5).padStart(2,'0'),label:String(i*5).padStart(2,'0')}));
// Hora por defecto de una reunion nueva: siguiente media hora (10:31 -> 11:00, 10:10 -> 10:30).
const nextSlot = () => { const d = ecuadorNow(); d.setMinutes(d.getMinutes() < 30 ? 30 : 60, 0, 0); return dateTime(d); };
const readable = (s, lang, allDay) => new Date(s.replace(' ', 'T')).toLocaleString(lang, allDay ? { dateStyle: 'full' } : { dateStyle: 'full', timeStyle: 'short' });
function DateTimeEditor({ value, onChange, allDay, label, isEnd = false }) {
const date = value.slice(0,10), hour = value.slice(11,13), minute = value.slice(14,16);
const setDate = (next) => onChange(`${next} ${allDay ? (isEnd ? '23:59' : '00:00') : `${hour}:${minute}`}:00`);
const setTime = (h,m) => onChange(`${date} ${h}:${m}:00`);
return <div className="meeting-datetime"><DatePicker value={date} onChange={setDate} label={label} />{!allDay && <><ComboBox options={hours} value={hour} onChange={(h) => setTime(h,minute)} placeholder={`${label} HH`} /><span>:</span><ComboBox options={minutes} value={minute} onChange={(m) => setTime(hour,m)} placeholder={`${label} MM`} /></>}</div>;
}
function bounds(cursor, view) {
let first, last;
if (view === 'month') { first = new Date(cursor.getFullYear(), cursor.getMonth(), 1); last = new Date(cursor.getFullYear(), cursor.getMonth()+1, 1); }
else if (view === 'week') { first = shift(cursor, -((cursor.getDay()+6)%7)); last = shift(first, 7); }
else if (view === 'day') { first = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate()); last = shift(first,1); }
else { first = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate()); last = shift(first,30); }
return { from: `${dateKey(first)} 00:00:00`, to: `${dateKey(last)} 00:00:00`, first, last };
}
export default function CalendarioPage() {
const { t, lang, openDialog, pushToast } = useUi();
const { user } = useAuth();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const runAction = useAction();
const guardedClose = useGuardedClose();
const [view, setView] = useState(() => { try { return localStorage.getItem('plc_calendar_view') || 'month'; } catch { return 'month'; } });
const [cursor, setCursor] = useState(ecuadorNow);
const [showDue, setShowDue] = useState(true);
const [selected, setSelected] = useState(null);
const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState(false);
const [newStart, setNewStart] = useState(null);
const [warning, setWarning] = useState([]);
const [externalInput, setExternalInput] = useState('');
const [userChoice, setUserChoice] = useState('');
const [context, setContext] = useState(null);
const { from, to, first, last } = useMemo(() => bounds(cursor, view), [cursor, view]);
const { data: meetings, reload } = useAsync(() => listMeetings(from, to), [from, to]);
const { data: activities } = useAsync(() => showDue ? listMeetingActivities(from, to) : Promise.resolve([]), [from, to, showDue]);
const { data: users } = useAsync(listUsers, []);
const { data: projects } = useAsync(listProjectOptions, []);
useReopenDraft({ newFormId:'meeting-new', editFormId:'meeting-edit', items:meetings, openNew: () => openNew(), openEdit: (item) => { void openMeeting(item, true); } });
const initial = creating ? blank(newStart || nextSlot()) : selected ? toForm(selected) : blank();
const draft = useDraftForm({ formId: creating ? 'meeting-new' : 'meeting-edit', entityId: creating ? null : selected?.id, initial, label: creating ? t('calendar_new') : `${t('nav_calendar')}: ${selected?.title || ''}`, enabled: creating || editing, onDiscard: () => setEditing(false) });
const v = draft.values;
const canManage = selected && !selected.cancelled_at && (selected.organizer_id === user?.id || ['admin','superadmin'].includes(user?.roleCode));
const canRsvp = !selected?.cancelled_at && selected?.participants?.some((p) => p.user_id === user?.id);
useEffect(() => { try { localStorage.setItem('plc_calendar_view', view); } catch { /* almacenamiento privado */ } }, [view]);
useEffect(() => {
const id = Number(searchParams.get('meeting'));
if (!id) return;
getMeeting(id).then((row) => { setSelected(row); setCreating(false); setEditing(false); }).catch(() => {});
setSearchParams((prev) => { const next = new URLSearchParams(prev); next.delete('meeting'); return next; }, { replace: true });
}, [searchParams, setSearchParams]);
useEffect(() => {
if (!context) return undefined;
const onEscape = (e) => { if (e.key === 'Escape') setContext(null); };
document.addEventListener('keydown', onEscape);
return () => document.removeEventListener('keydown', onEscape);
}, [context]);
function openNew(day) { setNewStart(day || nextSlot()); setCreating(true); setEditing(true); setSelected(null); setWarning([]); }
async function openMeeting(item, edit = false) { try { const row = await getMeeting(item.id); setSelected(row); setCreating(false); setEditing(edit); setWarning([]); } catch (err) { pushToast({tone:'error',message:err.message}); } }
function close() { void guardedClose((creating || editing) ? draft : null, () => { setCreating(false); setEditing(false); setSelected(null); }); }
function changeView(next) { setView(next); }
function navigatePeriod(delta) {
if (view === 'month') setCursor(new Date(cursor.getFullYear(), cursor.getMonth()+delta, 1));
else setCursor(shift(cursor, delta * (view === 'week' ? 7 : view === 'agenda' ? 30 : 1)));
}
async function submit(confirmConflicts = false) {
try { await runAction(async () => {
const payload = { ...v, participantUserIds: v.participantUserIds.map(Number), externalEmails: v.externalEmails, projectId: v.projectId || null, reminderMinutes: v.reminderMinutes || null, confirmConflicts };
const result = creating ? await createMeeting(payload) : await updateMeeting(selected.id, payload);
if (result.requiresConfirmation) { setWarning(result.warnings); return; }
draft.clear(); setCreating(false); setEditing(false); setSelected(null); setWarning([]); await reload();
pushToast({ tone: 'success', message: t('common_saved') });
}); } catch { /* useAction ya muestra el error */ }
}
function save() { if (!creating && !draft.isDirty) return; const go = () => submit(false); if (creating) void go(); else void draft.guardSave(go, { fetchCurrent: async () => toForm(await getMeeting(selected.id)) }); }
function confirmSaveWithConflict() { const go = () => submit(true); if (creating) void go(); else void draft.guardSave(go); }
function confirmCancel(occurrence = false, item = selected) {
openDialog({ tone: 'warning', title: t('calendar_cancel'), message: occurrence ? t('calendar_cancel_occurrence_confirm') : t('calendar_cancel_confirm'), actions: [
{ label: t('common_cancel'), variant: 'standard' },
{ label: t('calendar_cancel'), variant: 'danger', onClick: () => runAction(async () => {
if (occurrence) await cancelMeetingOccurrence(item.id, item.occurrence_start || item.starts_at);
else await cancelMeeting(item.id);
draft.discard(); setSelected(null); setEditing(false); await reload(); pushToast({ tone: 'success', message: t('calendar_cancelled') });
}).catch(() => {}) },
] });
}
async function rsvp(status) { try { await runAction(async () => { setSelected(await respondMeeting(selected.id, status)); pushToast({ tone: 'success', message: t('common_saved') }); }); } catch { /* diálogo visible */ } }
function addExternal() {
const email = externalInput.trim().toLowerCase();
if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,63}$/i.test(email) || email.length > 160 || v.externalEmails.length + v.participantUserIds.length >= 50) { pushToast({ tone:'error', message:t('calendar_invalid_email') }); return; }
draft.setField('externalEmails', [...new Set([...v.externalEmails, email])]); setExternalInput(''); setWarning([]);
}
const userOptions = (users || []).filter((u) => u.is_active && u.status === 'active' && u.id !== user?.id && !v.participantUserIds.includes(u.id)).map((u) => ({ value: u.id, label: `${u.name} (${u.email})` }));
const projectOptions = [{ value: '', label: t('calendar_none') }, ...(Array.isArray(projects) ? projects : projects?.items || []).map((p) => ({ value: p.id, label: p.name }))];
const allItems = [...(meetings || []).map((m) => ({ ...m, kind: 'meeting', day: m.occurrence_start.slice(0,10) })), ...(showDue ? (activities || []).map((a) => ({ ...a, kind: 'activity', day: a.due_date })) : [])].sort((a,b) => (a.day || '').localeCompare(b.day || ''));
const byDay = allItems.reduce((map, item) => { (map[item.day] ||= []).push(item); return map; }, {});
const days = view === 'month' ? Array.from({ length: Math.ceil((((first.getDay()+6)%7) + (last-first)/86400000)/7)*7 }, (_,i) => shift(first, i - ((first.getDay()+6)%7))) : Array.from({ length: (last-first)/86400000 }, (_,i) => shift(first,i));
const weekLabels = [t('calendar_mon'),t('calendar_tue'),t('calendar_wed'),t('calendar_thu'),t('calendar_fri'),t('calendar_sat'),t('calendar_sun')];
function itemButton(item) { return <button key={`${item.kind}-${item.id}-${item.occurrence_start || ''}`} className={`calendar-entry ${item.kind}`} title={item.kind === 'meeting' ? item.title : item.name}
onClick={(e) => { e.stopPropagation(); if (item.kind === 'meeting') void openMeeting(item); else navigate(`/proyectos/${item.project_id}?tab=activities&activity=${item.id}`); }}
onContextMenu={(e) => { if (item.kind !== 'meeting') return; e.preventDefault(); setContext({ item, x: e.clientX, y: e.clientY }); }}
onKeyDown={(e) => { if (item.kind === 'meeting' && (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10'))) { e.preventDefault(); const r=e.currentTarget.getBoundingClientRect(); setContext({item,x:r.left,y:r.bottom}); } }}>
{item.kind === 'meeting' ? `${item.occurrence_start.slice(11,16)} ${item.title}` : `◆ ${item.name}`}
{item.kind === 'meeting' && <span className="calendar-entry-more" onClick={(e) => { e.stopPropagation(); setContext({ item, x: e.clientX, y: e.clientY }); }}>⋯</span>}
</button>; }
return <div className="meeting-calendar">
<div className="toolbar"><div className="toolbar-left"><h2 className="page-title mt-0"><CalendarDays size={22} /> {t('nav_calendar')}</h2>
<Button onClick={() => setCursor(ecuadorNow())}>{t('calendar_today')}</Button><Button icon={ChevronLeft} onClick={() => navigatePeriod(-1)} aria-label={t('calendar_previous')} /><Button icon={ChevronRight} onClick={() => navigatePeriod(1)} aria-label={t('calendar_next')} />
<strong>{first.toLocaleDateString(lang, { month: 'long', year: 'numeric' })}</strong></div>
<div className="toolbar-left"><ComboBox options={['month','week','day','agenda'].map((x) => ({ value:x, label:t(`calendar_${x}`) }))} value={view} onChange={changeView} />
<Button variant="accent" icon={Plus} onClick={() => openNew()}>{t('calendar_new')}</Button></div></div>
<label className="calendar-filter"><input type="checkbox" checked={showDue} onChange={(e) => setShowDue(e.target.checked)} /> {t('calendar_show_due')}</label>
{view === 'agenda' ? <div className="calendar-agenda">{allItems.length ? allItems.map((item) => <div key={`${item.kind}-${item.id}-${item.occurrence_start || ''}`} className="calendar-agenda-row"><strong>{item.day}</strong>{itemButton(item)}</div>) : <p className="text-muted">{t('calendar_empty')}</p>}</div> : <div className={`calendar-grid calendar-${view}`}>
{view !== 'day' && weekLabels.map((label) => <div key={label} className="calendar-dow">{label}</div>)}
{days.map((day) => { const key = dateKey(day); const muted = view === 'month' && day.getMonth() !== cursor.getMonth(); return <div key={key} className={`calendar-cell ${muted ? 'muted' : ''} ${key === dateKey(ecuadorNow()) ? 'today' : ''}`} onClick={() => openNew(`${key} 09:00:00`)}>
<div className="calendar-day-num">{view === 'day' ? day.toLocaleDateString(lang, { weekday:'long', day:'numeric', month:'long' }) : day.getDate()}</div>
{(byDay[key] || []).map(itemButton)}
{view !== 'month' && <div className="calendar-hours">{Array.from({length:11}, (_,i) => i+8).map((hour) => <button key={hour} onClick={(e) => { e.stopPropagation(); openNew(`${key} ${String(hour).padStart(2,'0')}:00:00`); }}>{String(hour).padStart(2,'0')}:00 <Plus size={12} /></button>)}</div>}
</div>; })}</div>}
{context && <div className="calendar-context-layer" onClick={() => setContext(null)}><div className="flyout-panel calendar-context" style={{ left:Math.min(context.x, window.innerWidth-220), top:Math.min(context.y, window.innerHeight-190) }} onClick={(e) => e.stopPropagation()}>
<FlyoutItem onClick={() => { void openMeeting(context.item); setContext(null); }}>{t('calendar_open')}</FlyoutItem>
{(context.item.organizer_id === user?.id || ['admin','superadmin'].includes(user?.roleCode)) && <><FlyoutItem icon={Pencil} onClick={() => { void openMeeting(context.item,true); setContext(null); }}>{t('common_edit')}</FlyoutItem><FlyoutSeparator />{context.item.recurrence_json && <FlyoutItem icon={XCircle} onClick={() => { confirmCancel(true,context.item); setContext(null); }}>{t('calendar_cancel_occurrence')}</FlyoutItem>}<FlyoutItem icon={Trash2} danger onClick={() => { confirmCancel(false,context.item); setContext(null); }}>{t('common_delete')}</FlyoutItem></>}
</div></div>}
<DetailPopup open={creating || !!selected} title={creating ? t('calendar_new') : selected?.title} onClose={close} width={760} height={680}>
{warning.length > 0 && <InfoBar tone="warning">{t('calendar_conflicts')}: {warning.join(', ')}. <Button size="sm" onClick={confirmSaveWithConflict}>{t('calendar_save_anyway')}</Button></InfoBar>}
{editing && <DraftNotice draft={draft} />}
{!editing && selected ? <div className="calendar-detail">
{selected.cancelled_at && <InfoBar tone="warning">{t('calendar_cancelled')}</InfoBar>}
<p><strong>{t('calendar_start')}:</strong> {readable(selected.starts_at, lang, !!selected.all_day)}<br /><strong>{t('calendar_end')}:</strong> {readable(selected.ends_at, lang, !!selected.all_day)}</p>
{!!selected.all_day && <p>{t('calendar_all_day')}</p>}
{selected.description && <p>{selected.description}</p>}{selected.location && <p>{t('calendar_location')}: {selected.location}</p>}
{selected.video_url && <p><a href={selected.video_url} target="_blank" rel="noreferrer">{selected.video_url}</a></p>}
{selected.project_name && <p>{t('calendar_project')}: {selected.project_name}</p>}
{!!selected.reminder_minutes && <p>{t('calendar_reminder')}: {selected.reminder_minutes === 1440 ? t('calendar_one_day') : `${selected.reminder_minutes} ${t('calendar_minutes')}`}</p>}
{selected.recurrence_json && <p>{t('calendar_recurrence')}: {t(`calendar_${JSON.parse(selected.recurrence_json).frequency}`)}</p>}
<p>{t('calendar_organizer')}: {selected.organizer_name}</p>
<Field label={t('calendar_participants')}><div>{selected.participants.map((p) => <div key={p.user_id}>{p.name} — {t(`calendar_rsvp_${p.rsvp}`)}</div>)}</div></Field>
{selected.external_emails.length > 0 && <Field label={t('calendar_external')}><div>{selected.external_emails.map((e) => <div key={e}>{e}</div>)}</div></Field>}
{canRsvp && <div className="toolbar-left">{['accepted','declined','tentative'].map((s) => <Button key={s} onClick={() => void rsvp(s)}>{t(`calendar_rsvp_${s}`)}</Button>)}</div>}
{canManage && <div className="toolbar-left" style={{ marginTop:16 }}><Button icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button icon={Trash2} variant="danger" onClick={() => confirmCancel()}>{t('common_delete')}</Button></div>}
</div> : <div className="calendar-form">
<Field label={t('calendar_title')} dirty={draft.isFieldDirty('title')}><input className="input" maxLength={160} value={v.title} onChange={(e) => draft.setField('title',e.target.value)} /></Field>
<Field label={t('calendar_description')} dirty={draft.isFieldDirty('description')}><textarea className="textarea" maxLength={5000} value={v.description} onChange={(e) => draft.setField('description',e.target.value)} /></Field>
<FieldRow>
<Field label={t('calendar_start')} dirty={draft.isFieldDirty('startsAt')}><DateTimeEditor value={v.startsAt} onChange={(x) => draft.setField('startsAt',x)} allDay={v.allDay} label={t('calendar_start')} /></Field>
<Field label={t('calendar_end')} dirty={draft.isFieldDirty('endsAt')}><DateTimeEditor value={v.endsAt} onChange={(x) => draft.setField('endsAt',x)} allDay={v.allDay} label={t('calendar_end')} isEnd /></Field>
</FieldRow>
<label className="calendar-filter"><input type="checkbox" checked={v.allDay} onChange={(e) => { const checked = e.target.checked; draft.setValues((old) => ({ ...old, allDay: checked, startsAt: checked ? `${old.startsAt.slice(0,10)} 00:00:00` : old.startsAt, endsAt: checked ? `${old.endsAt.slice(0,10)} 23:59:00` : old.endsAt })); }} /> {t('calendar_all_day')}</label>
<FieldRow><Field label={t('calendar_location')}><input className="input" maxLength={255} value={v.location} onChange={(e) => draft.setField('location',e.target.value)} /></Field><Field label={t('calendar_video_url')}><input className="input" type="url" maxLength={2048} value={v.videoUrl} onChange={(e) => draft.setField('videoUrl',e.target.value)} /></Field></FieldRow>
<FieldRow><Field label={t('calendar_project')}><ComboBox options={projectOptions} value={v.projectId} onChange={(x) => draft.setField('projectId',x)} /></Field><Field label={t('calendar_reminder')}><ComboBox options={[{value:'',label:t('calendar_none')}, ...[5,10,15,30,60,1440].map((n) => ({value:n,label:n===1440?t('calendar_one_day'):`${n} ${t('calendar_minutes')}`}))]} value={v.reminderMinutes} onChange={(x) => draft.setField('reminderMinutes',x)} /></Field></FieldRow>
<Field label={t('calendar_participants')}><><div className="toolbar-left"><ComboBox options={[{value:'',label:t('common_select')},...userOptions]} value={userChoice} onChange={setUserChoice} /><Button onClick={() => { if (userChoice) { draft.setField('participantUserIds',[...v.participantUserIds,Number(userChoice)]); setUserChoice(''); } }}>{t('calendar_add')}</Button></div>
<div className="calendar-tags">{v.participantUserIds.map((id) => <button key={id} onClick={() => draft.setField('participantUserIds',v.participantUserIds.filter((x) => x!==id))}>{users?.find((u) => u.id===id)?.name || id} ×</button>)}</div></></Field>
<Field label={t('calendar_external')}><><div className="toolbar-left"><input className="input" type="email" value={externalInput} onChange={(e) => setExternalInput(e.target.value)} onKeyDown={(e) => { if(e.key==='Enter'){ e.preventDefault(); addExternal(); } }} /><Button onClick={addExternal}>{t('calendar_add')}</Button></div><div className="calendar-tags">{v.externalEmails.map((e) => <button key={e} onClick={() => draft.setField('externalEmails',v.externalEmails.filter((x) => x!==e))}>{e} ×</button>)}</div></></Field>
<Field label={t('calendar_recurrence')}><ComboBox options={['none','daily','weekly','monthly'].map((x) => ({value:x,label:t(`calendar_${x}`)}))} value={v.recurrence?.frequency || 'none'} onChange={(x) => draft.setField('recurrence',x==='none'?null:{frequency:x,interval:1,weekdays:x==='weekly'?[(new Date(v.startsAt.replace(' ','T')).getDay()+6)%7]:[],count:10})} /></Field>
{v.recurrence && <><FieldRow><Field label={t('calendar_interval')}><input className="input" type="number" min="1" max="365" value={v.recurrence.interval} onChange={(e) => draft.setField('recurrence',{...v.recurrence,interval:Number(e.target.value)})} /></Field><Field label={t('calendar_count')}><input className="input" type="number" min="1" max="365" value={v.recurrence.count || ''} onChange={(e) => draft.setField('recurrence',{...v.recurrence,count:e.target.value?Number(e.target.value):null,until:null})} /></Field><Field label={t('calendar_until')}><input className="input" type="date" value={v.recurrence.until || ''} onChange={(e) => draft.setField('recurrence',{...v.recurrence,until:e.target.value||null,count:null})} /></Field></FieldRow>
{v.recurrence.frequency==='weekly' && <div className="calendar-weekdays">{weekLabels.map((label,index) => <label key={label}><input type="checkbox" checked={v.recurrence.weekdays.includes(index)} onChange={() => draft.setField('recurrence',{...v.recurrence,weekdays:v.recurrence.weekdays.includes(index)?v.recurrence.weekdays.filter((x)=>x!==index):[...v.recurrence.weekdays,index]})} /> {label}</label>)}</div>}</>}
<div className="toolbar-left" style={{ marginTop:18 }}><Button variant="accent" onClick={save} disabled={!v.title.trim() || (!creating && !draft.isDirty)}>{t('common_save')}</Button><Button onClick={() => { draft.discard(); if (creating) setCreating(false); else setEditing(false); }}>{t('common_cancel')}</Button></div>
</div>}
</DetailPopup>
</div>;
}
@@ -0,0 +1,27 @@
.meeting-calendar .page-title { display:flex; align-items:center; gap:8px; }
.meeting-calendar .calendar-filter { display:flex; align-items:center; gap:7px; margin:10px 0 16px; font-size:.82rem; }
.meeting-datetime { display:flex; align-items:center; gap:4px; }
.meeting-datetime .datepicker-trigger { flex:1; min-width:112px; }
.meeting-datetime .combobox { width:55px; flex:none; }
.meeting-datetime .combobox .input { padding:6px 4px; }
.meeting-calendar .calendar-grid { width:100%; }
.meeting-calendar .calendar-grid.calendar-day { grid-template-columns:1fr; }
.meeting-calendar .calendar-cell { min-height:110px; min-width:0; cursor:pointer; }
.meeting-calendar .calendar-day .calendar-cell { min-height:360px; }
.meeting-calendar .calendar-hours { display:flex; flex-direction:column; margin-top:6px; }
.meeting-calendar .calendar-hours button { border:0; border-top:1px solid rgb(var(--line)); background:transparent; color:rgb(var(--muted)); text-align:left; min-height:32px; display:flex; align-items:center; justify-content:space-between; cursor:pointer; font:inherit; }
.meeting-calendar .calendar-hours button:hover { background:rgb(var(--accent) / .10); color:rgb(var(--accent)); }
.meeting-calendar .calendar-entry { display:block; width:100%; border:0; border-radius:4px; padding:4px 6px; text-align:left; font:inherit; font-size:.72rem; cursor:pointer; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:rgb(var(--accent)); background:rgb(var(--accent) / .16); }
.meeting-calendar .calendar-entry.activity { color:rgb(var(--warning)); background:rgb(var(--warning) / .14); }
.meeting-calendar .calendar-entry:hover, .meeting-calendar .calendar-entry:focus-visible { outline:2px solid rgb(var(--accent)); }
.meeting-calendar .calendar-entry-more { float:right; padding:0 3px; }
.meeting-calendar .calendar-agenda-row { display:flex; gap:12px; align-items:center; padding:8px 4px; border-bottom:1px solid rgb(var(--line)); }
.meeting-calendar .calendar-agenda-row strong { min-width:105px; font-size:.78rem; }
.meeting-calendar .calendar-agenda-row .calendar-entry { flex:1; }
.calendar-context-layer { position:fixed; inset:0; z-index:101; }
.calendar-context { position:fixed; visibility:visible; min-width:190px; }
.calendar-tags { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.calendar-tags button { background:rgb(var(--accent) / .16); color:rgb(var(--body)); border:1px solid rgb(var(--line)); border-radius:4px; padding:3px 7px; cursor:pointer; }
.calendar-weekdays { display:flex; flex-wrap:wrap; gap:12px; margin:8px 0; font-size:.8rem; }
.calendar-detail p { line-height:1.5; }
@media (max-width:700px) { .meeting-calendar .calendar-grid { gap:2px; } .meeting-calendar .calendar-cell { min-height:80px; padding:3px; } .meeting-calendar .calendar-entry { font-size:.6rem; padding:2px; } .meeting-calendar .calendar-dow { font-size:.58rem; } }
@@ -4,6 +4,8 @@ import { useUi } from '../../context/ui-context.jsx';
import { useAsync } from '../../hooks/useAsync.js';
import { listActivitiesByProject } from '../../services/activities.js';
import { Button } from '../../components/Button.jsx';
import { listMeetings } from '../../services/meetings.js';
import { useNavigate } from 'react-router-dom';
const DOW = ['Lun', 'Mar', 'Mie', 'Jue', 'Vie', 'Sab', 'Dom'];
@@ -20,8 +22,13 @@ function buildGrid(year, month) {
export default function ProjectCalendarTab({ projectId }) {
const { t } = useUi();
const navigate = useNavigate();
const { data: activities } = useAsync(() => listActivitiesByProject(projectId), [projectId]);
const [cursor, setCursor] = useState(() => { const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), 1); });
const from = `${cursor.getFullYear()}-${String(cursor.getMonth()+1).padStart(2,'0')}-01 00:00:00`;
const nextMonth = new Date(cursor.getFullYear(), cursor.getMonth()+1, 1);
const to = `${nextMonth.getFullYear()}-${String(nextMonth.getMonth()+1).padStart(2,'0')}-01 00:00:00`;
const { data: meetings } = useAsync(() => listMeetings(from, to).catch(() => []), [from, to]);
const cells = useMemo(() => buildGrid(cursor.getFullYear(), cursor.getMonth()), [cursor]);
const todayStr = new Date().toISOString().slice(0, 10);
@@ -50,12 +57,14 @@ export default function ProjectCalendarTab({ projectId }) {
if (!date) return <div key={i} className="calendar-cell muted" />;
const key = date.toISOString().slice(0, 10);
const items = byDay[key] || [];
const meetingItems = (meetings || []).filter((m) => m.project_id === projectId && m.occurrence_start.slice(0,10) === key);
return (
<div key={i} className={`calendar-cell ${key === todayStr ? 'today' : ''}`}>
<div className="calendar-day-num">{date.getDate()}</div>
{items.slice(0, 3).map((a) => (
<div key={a.id} className="calendar-chip" title={a.name}>{a.is_milestone ? '◆ ' : ''}{a.name}</div>
<div key={a.id} className="calendar-chip" style={{ color:'rgb(var(--warning))', background:'rgb(var(--warning) / .14)' }} title={a.name}>{a.is_milestone ? '◆ ' : ''}{a.name}</div>
))}
{meetingItems.map((m) => <button key={`${m.id}-${m.occurrence_start}`} className="calendar-chip" style={{ border:0, textAlign:'left' }} onClick={() => navigate(`/calendario?meeting=${m.id}`)}>{m.title}</button>)}
{items.length > 3 && <div className="text-muted" style={{ fontSize: '.64rem' }}>+{items.length - 3}</div>}
</div>
);
+10
View File
@@ -0,0 +1,10 @@
import { api } from './api.js';
const query = (from, to) => `?${new URLSearchParams({ from, to })}`;
export const listMeetings = (from, to) => api.get(`/meetings${query(from, to)}`).then((result) => result.items);
export const listMeetingActivities = (from, to) => api.get(`/meetings/activities${query(from, to)}`);
export const getMeeting = (id) => api.get(`/meetings/${id}`);
export const createMeeting = (data) => api.post('/meetings', data);
export const updateMeeting = (id, data) => api.put(`/meetings/${id}`, data);
export const cancelMeeting = (id) => api.delete(`/meetings/${id}`);
export const cancelMeetingOccurrence = (id, start) => api.post(`/meetings/${id}/occurrences/${encodeURIComponent(start)}/cancel`);
export const respondMeeting = (id, status) => api.put(`/meetings/${id}/rsvp`, { status });
+11
View File
@@ -157,6 +157,17 @@ button { font-family: inherit; }
background: rgb(var(--surface)); border: 1px solid rgb(var(--line)); border-radius: var(--r-lg);
box-shadow: 0 8px 24px rgb(0 0 0 / .35); max-height: 240px; overflow-y: auto; padding: 6px;
}
.datepicker-trigger { display:flex; align-items:center; justify-content:space-between; cursor:pointer; }
.datepicker-panel { position:fixed; z-index:220; width:270px; padding:8px; border:1px solid rgb(var(--line)); border-radius:var(--r-lg); background:rgb(var(--surface)); box-shadow:0 8px 24px rgb(0 0 0 / .35); }
.datepicker-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:7px; font-size:.8rem; }
.datepicker-head button, .datepicker-grid button { border:0; background:transparent; color:rgb(var(--body)); cursor:pointer; border-radius:var(--r-sm); }
.datepicker-head button { padding:5px; }
.datepicker-grid { display:grid; grid-template-columns:repeat(7,1fr); gap:2px; text-align:center; }
.datepicker-grid button { min-height:30px; font-size:.75rem; }
.datepicker-grid button:hover, .datepicker-head button:hover { background:rgb(var(--accent) / .14); }
.datepicker-grid button.selected { background:rgb(var(--accent)); color:white; }
.datepicker-grid button.today:not(.selected) { outline:1px solid rgb(var(--accent)); }
.datepicker-weekday { font-size:.65rem; color:rgb(var(--muted)); }
.combobox-search { padding: 4px 6px 8px; }
.combobox-option {
display: block; width: 100%; text-align: left; border: 0; background: transparent; color: inherit;
+2
View File
@@ -14,6 +14,8 @@ const TTL_MS = 7 * 24 * 60 * 60 * 1000;
const SECRET_FIELD = /pass(word|wd)?|token|secret|^pin$|pin$/i;
const DRAFT_FIELDS = {
'project-new': ['projectTypeId', 'priorityId', 'statusId', 'requestingDepartmentId', 'responsibleDepartmentId', 'startDate', 'estimatedEndDate', 'riskLevel'],
'meeting-new': ['startsAt', 'endsAt', 'allDay', 'projectId', 'reminderMinutes', 'participantUserIds', 'recurrence'],
'meeting-edit': ['editVersion', 'startsAt', 'endsAt', 'allDay', 'projectId', 'reminderMinutes', 'participantUserIds', 'recurrence'],
'project-info': ['editVersion', 'projectTypeId', 'priorityId', 'statusId', 'requestingDepartmentId', 'responsibleDepartmentId', 'startDate', 'estimatedEndDate', 'riskLevel'],
'activity-new': ['responsibleUserId', 'priorityId', 'statusId', 'startDate', 'dueDate', 'isMilestone', 'estimatedHours'],
'activity-info': ['editVersion', 'responsibleUserId', 'priorityId', 'statusId', 'startDate', 'dueDate', 'isMilestone', 'estimatedHours', 'progressPct'],
+1
View File
@@ -1,6 +1,7 @@
// Espejo de backend/src/middleware/modules.js: el backend es quien bloquea, aqui solo se oculta.
export const MODULE_LABEL_KEYS = {
projects: 'nav_projects',
calendar: 'nav_calendar',
recurring_tasks: 'nav_recurring_tasks',
notes: 'nav_notes',
vault: 'nav_passwords',