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:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "4.4.0",
|
||||
"version": "4.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -5,7 +5,7 @@ export const messages = {
|
||||
notification_meeting_created: 'Nueva reunión', notification_meeting_updated: 'Reunión modificada', notification_meeting_cancelled: 'Reunión cancelada', notification_meeting_occurrence_cancelled: 'Ocurrencia cancelada', notification_meeting_reminder: 'Recordatorio de reunión', notification_meeting_rsvp_accepted: 'Aceptó la reunión', notification_meeting_rsvp_declined: 'Rechazó la reunión', notification_meeting_rsvp_tentative: 'Respondió quizás',
|
||||
calendar_today: 'Hoy', calendar_previous: 'Anterior', calendar_next: 'Siguiente',
|
||||
calendar_month: 'Mes', calendar_week: 'Semana', calendar_day: 'Día', calendar_agenda: 'Agenda',
|
||||
calendar_new: 'Nueva reunión', calendar_open: 'Abrir', calendar_show_due: 'Mostrar vencimientos de actividades', calendar_empty: 'No hay eventos en este período',
|
||||
calendar_new: 'Nueva reunión', calendar_open: 'Abrir', calendar_show_due: 'Mostrar vencimientos de actividades', calendar_legend: 'Capas del calendario', calendar_layer_meetings: 'Reuniones', calendar_layer_activities: 'Mis actividades', calendar_layer_recurring: 'Tareas recurrentes', calendar_overdue: 'Vencido', calendar_layer_projects: 'Proyectos', calendar_layer_tasks: 'Mis tareas', calendar_only: 'Solo', calendar_show_all: 'Ver todo', calendar_filter_origin: 'Ver', calendar_filter_types: 'Mostrar', calendar_all_types: 'Todo', calendar_no_types: 'Nada seleccionado', calendar_origin_all: 'Todo (mío y compartido)', calendar_origin_mine: 'Solo lo mío', calendar_origin_shared: 'Compartido conmigo', calendar_due: 'Vence', calendar_empty: 'No hay eventos en este período',
|
||||
calendar_title: 'Título', calendar_description: 'Descripción', calendar_start: 'Inicio', calendar_end: 'Fin', calendar_all_day: 'Todo el día',
|
||||
calendar_location: 'Ubicación', calendar_video_url: 'Enlace de videollamada', calendar_project: 'Proyecto', calendar_organizer: 'Organizador', calendar_reminder: 'Recordatorio', calendar_none: 'Ninguno', calendar_one_day: 'Un día antes', calendar_minutes: 'minutos',
|
||||
calendar_participants: 'Participantes internos', calendar_external: 'Correos externos', calendar_add: 'Agregar', calendar_invalid_email: 'Correo inválido o límite de 50 participantes',
|
||||
@@ -630,7 +630,7 @@ export const messages = {
|
||||
notification_meeting_created: 'New meeting', notification_meeting_updated: 'Meeting updated', notification_meeting_cancelled: 'Meeting cancelled', notification_meeting_occurrence_cancelled: 'Occurrence cancelled', notification_meeting_reminder: 'Meeting reminder', notification_meeting_rsvp_accepted: 'Accepted meeting', notification_meeting_rsvp_declined: 'Declined meeting', notification_meeting_rsvp_tentative: 'Replied maybe',
|
||||
calendar_today: 'Today', calendar_previous: 'Previous', calendar_next: 'Next',
|
||||
calendar_month: 'Month', calendar_week: 'Week', calendar_day: 'Day', calendar_agenda: 'Agenda',
|
||||
calendar_new: 'New meeting', calendar_open: 'Open', calendar_show_due: 'Show activity due dates', calendar_empty: 'No events in this period',
|
||||
calendar_new: 'New meeting', calendar_open: 'Open', calendar_show_due: 'Show activity due dates', calendar_legend: 'Calendar layers', calendar_layer_meetings: 'Meetings', calendar_layer_activities: 'My activities', calendar_layer_recurring: 'Recurring tasks', calendar_overdue: 'Overdue', calendar_layer_projects: 'Projects', calendar_layer_tasks: 'My tasks', calendar_only: 'Only', calendar_show_all: 'Show all', calendar_filter_origin: 'View', calendar_filter_types: 'Show', calendar_all_types: 'All', calendar_no_types: 'Nothing selected', calendar_origin_all: 'All (mine and shared)', calendar_origin_mine: 'Only mine', calendar_origin_shared: 'Shared with me', calendar_due: 'Due', calendar_empty: 'No events in this period',
|
||||
calendar_title: 'Title', calendar_description: 'Description', calendar_start: 'Start', calendar_end: 'End', calendar_all_day: 'All day',
|
||||
calendar_location: 'Location', calendar_video_url: 'Video call URL', calendar_project: 'Project', calendar_organizer: 'Organizer', calendar_reminder: 'Reminder', calendar_none: 'None', calendar_one_day: 'One day before', calendar_minutes: 'minutes',
|
||||
calendar_participants: 'Internal participants', calendar_external: 'External emails', calendar_add: 'Add', calendar_invalid_email: 'Invalid email or 50 participant limit',
|
||||
|
||||
@@ -10,13 +10,15 @@ import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import { Field, FieldRow } from '../../components/Field.jsx';
|
||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { CheckListDropdown } from '../../components/CheckListDropdown.jsx';
|
||||
import { DatePicker } from '../../components/DatePicker.jsx';
|
||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||
import { FlyoutItem, FlyoutSeparator } from '../../components/Flyout.jsx';
|
||||
import { listProjectOptions } from '../../services/projects.js';
|
||||
import { listUsers } from '../../services/users.js';
|
||||
import { listMeetings, listMeetingActivities, getMeeting, createMeeting, updateMeeting, cancelMeeting, cancelMeetingOccurrence, respondMeeting } from '../../services/meetings.js';
|
||||
import { hasModule } from '../../utils/modules.js';
|
||||
import { listMeetings, listMeetingActivities, listMeetingRecurring, listMeetingTasks, listMeetingProjects, getMeeting, createMeeting, updateMeeting, cancelMeeting, cancelMeetingOccurrence, respondMeeting } from '../../services/meetings.js';
|
||||
import './calendar.css';
|
||||
|
||||
const dateKey = (d) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||
@@ -49,6 +51,8 @@ function bounds(cursor, view) {
|
||||
return { from: `${dateKey(first)} 00:00:00`, to: `${dateKey(last)} 00:00:00`, first, last };
|
||||
}
|
||||
|
||||
const ALL_LAYERS = { meeting: true, project: true, activity: true, task: true, recurring: true };
|
||||
|
||||
export default function CalendarioPage() {
|
||||
const { t, lang, openDialog, pushToast } = useUi();
|
||||
const { user } = useAuth();
|
||||
@@ -58,7 +62,12 @@ export default function CalendarioPage() {
|
||||
const guardedClose = useGuardedClose();
|
||||
const [view, setView] = useState(() => { try { return localStorage.getItem('plc_calendar_view') || 'month'; } catch { return 'month'; } });
|
||||
const [cursor, setCursor] = useState(ecuadorNow);
|
||||
const [showDue, setShowDue] = useState(true);
|
||||
// Capas visibles del calendario (persistidas): reuniones, vencimientos de actividades y tareas recurrentes.
|
||||
const [layers, setLayers] = useState(() => { try { return { ...ALL_LAYERS, ...JSON.parse(localStorage.getItem('plc_calendar_layers') || '{}') }; } catch { return { ...ALL_LAYERS }; } });
|
||||
// Origen: todo, solo lo mio (lo creo/organizo yo) o solo lo compartido conmigo (lo creo otro y me incluye).
|
||||
const [origin, setOrigin] = useState(() => { try { return localStorage.getItem('plc_calendar_origin') || 'all'; } catch { return 'all'; } });
|
||||
const canProjects = hasModule(user, 'projects');
|
||||
const showDue = layers.activity && canProjects;
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -70,6 +79,10 @@ export default function CalendarioPage() {
|
||||
const { from, to, first, last } = useMemo(() => bounds(cursor, view), [cursor, view]);
|
||||
const { data: meetings, reload } = useAsync(() => listMeetings(from, to), [from, to]);
|
||||
const { data: activities } = useAsync(() => showDue ? listMeetingActivities(from, to) : Promise.resolve([]), [from, to, showDue]);
|
||||
const canRecurring = hasModule(user, 'recurring_tasks');
|
||||
const { data: recurring } = useAsync(() => layers.recurring && canRecurring ? listMeetingRecurring(from, to) : Promise.resolve([]), [from, to, layers.recurring, canRecurring]);
|
||||
const { data: projectDates } = useAsync(() => layers.project && canProjects ? listMeetingProjects(from, to) : Promise.resolve([]), [from, to, layers.project, canProjects]);
|
||||
const { data: myTasks } = useAsync(() => layers.task && canProjects ? listMeetingTasks(from, to) : Promise.resolve([]), [from, to, layers.task, canProjects]);
|
||||
const { data: users } = useAsync(listUsers, []);
|
||||
const { data: projects } = useAsync(listProjectOptions, []);
|
||||
useReopenDraft({ newFormId:'meeting-new', editFormId:'meeting-edit', items:meetings, openNew: () => openNew(), openEdit: (item) => { void openMeeting(item, true); } });
|
||||
@@ -80,6 +93,8 @@ export default function CalendarioPage() {
|
||||
const canRsvp = !selected?.cancelled_at && selected?.participants?.some((p) => p.user_id === user?.id);
|
||||
|
||||
useEffect(() => { try { localStorage.setItem('plc_calendar_view', view); } catch { /* almacenamiento privado */ } }, [view]);
|
||||
useEffect(() => { try { localStorage.setItem('plc_calendar_layers', JSON.stringify(layers)); } catch { /* almacenamiento privado */ } }, [layers]);
|
||||
useEffect(() => { try { localStorage.setItem('plc_calendar_origin', origin); } catch { /* almacenamiento privado */ } }, [origin]);
|
||||
useEffect(() => {
|
||||
const id = Number(searchParams.get('meeting'));
|
||||
if (!id) return;
|
||||
@@ -130,16 +145,33 @@ export default function CalendarioPage() {
|
||||
}
|
||||
const userOptions = (users || []).filter((u) => u.is_active && u.status === 'active' && u.id !== user?.id && !v.participantUserIds.includes(u.id)).map((u) => ({ value: u.id, label: `${u.name} (${u.email})` }));
|
||||
const projectOptions = [{ value: '', label: t('calendar_none') }, ...(Array.isArray(projects) ? projects : projects?.items || []).map((p) => ({ value: p.id, label: p.name }))];
|
||||
const allItems = [...(meetings || []).map((m) => ({ ...m, kind: 'meeting', day: m.occurrence_start.slice(0,10) })), ...(showDue ? (activities || []).map((a) => ({ ...a, kind: 'activity', day: a.due_date })) : [])].sort((a,b) => (a.day || '').localeCompare(b.day || ''));
|
||||
const allItems = [...(layers.meeting ? meetings || [] : []).map((m) => ({ ...m, kind: 'meeting', day: m.occurrence_start.slice(0,10) })), ...(showDue ? (activities || []).map((a) => ({ ...a, kind: 'activity', day: a.due_date })) : []), ...(layers.recurring && canRecurring ? (recurring || []).map((r) => ({ ...r, kind: 'recurring', day: r.due_date, occurrence_start: r.due_date })) : []), ...(layers.project && canProjects ? (projectDates || []).map((p) => ({ ...p, kind: 'project', day: p.due_date, occurrence_start: `${p.milestone}-${p.due_date}` })) : []), ...(layers.task && canProjects ? (myTasks || []).map((x) => ({ ...x, kind: 'task', day: x.due_date, occurrence_start: x.due_date })) : [])].filter((item) => origin === 'all' || (origin === 'shared' ? item.shared : !item.shared)).sort((a,b) => (a.day || '').localeCompare(b.day || ''));
|
||||
const byDay = allItems.reduce((map, item) => { (map[item.day] ||= []).push(item); return map; }, {});
|
||||
const days = view === 'month' ? Array.from({ length: Math.ceil((((first.getDay()+6)%7) + (last-first)/86400000)/7)*7 }, (_,i) => shift(first, i - ((first.getDay()+6)%7))) : Array.from({ length: (last-first)/86400000 }, (_,i) => shift(first,i));
|
||||
const weekLabels = [t('calendar_mon'),t('calendar_tue'),t('calendar_wed'),t('calendar_thu'),t('calendar_fri'),t('calendar_sat'),t('calendar_sun')];
|
||||
|
||||
function itemButton(item) { return <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}`); }}
|
||||
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')]] : [])];
|
||||
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 }); }}
|
||||
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>}
|
||||
</button>; }
|
||||
|
||||
@@ -149,7 +181,19 @@ export default function CalendarioPage() {
|
||||
<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>
|
||||
<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 !== '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`)}>
|
||||
|
||||
@@ -25,3 +25,25 @@
|
||||
.calendar-weekdays { display:flex; flex-wrap:wrap; gap:12px; margin:8px 0; font-size:.8rem; }
|
||||
.calendar-detail p { line-height:1.5; }
|
||||
@media (max-width:700px) { .meeting-calendar .calendar-grid { gap:2px; } .meeting-calendar .calendar-cell { min-height:80px; padding:3px; } .meeting-calendar .calendar-entry { font-size:.6rem; padding:2px; } .meeting-calendar .calendar-dow { font-size:.58rem; } }
|
||||
.meeting-calendar .calendar-entry.project { color:rgb(var(--cal-project)); background:rgb(var(--cal-project) / .14); }
|
||||
.meeting-calendar .calendar-entry.task { color:rgb(var(--cal-task)); background:rgb(var(--cal-task) / .14); }
|
||||
.meeting-calendar .calendar-legend-item.project .calendar-swatch { background:rgb(var(--cal-project)); }
|
||||
.meeting-calendar .calendar-legend-item.task .calendar-swatch { background:rgb(var(--cal-task)); }
|
||||
/* Colores por tipo de evento (leyenda = mismos colores que las entradas). */
|
||||
.meeting-calendar .calendar-entry.recurring { color:rgb(var(--success)); background:rgb(var(--success) / .14); }
|
||||
.meeting-calendar .calendar-entry.projected { opacity:.72; border:1px dashed rgb(var(--success) / .55); }
|
||||
.meeting-calendar .calendar-entry.done { opacity:.55; text-decoration:line-through; }
|
||||
.meeting-calendar .calendar-entry.overdue { color:rgb(var(--danger)); background:rgb(var(--danger) / .14); border:1px solid rgb(var(--danger) / .5); }
|
||||
.meeting-calendar .calendar-legend { display:flex; flex-wrap:wrap; align-items:center; gap:8px 18px; margin:10px 0 16px; font-size:.82rem; }
|
||||
.meeting-calendar .calendar-legend-item { display:inline-flex; align-items:center; gap:6px; cursor:pointer; }
|
||||
.meeting-calendar .calendar-legend-item.overdue { cursor:default; color:rgb(var(--muted)); }
|
||||
.meeting-calendar .calendar-swatch { width:12px; height:12px; border-radius:3px; background:rgb(var(--accent)); }
|
||||
.meeting-calendar .calendar-legend-item.activity .calendar-swatch { background:rgb(var(--warning)); }
|
||||
.meeting-calendar .calendar-legend-item.recurring .calendar-swatch { background:rgb(var(--success)); }
|
||||
.meeting-calendar .calendar-legend-item.overdue .calendar-swatch { background:rgb(var(--danger)); }
|
||||
.meeting-calendar .calendar-filters { display:flex; flex-wrap:wrap; align-items:flex-end; gap:10px 18px; margin:10px 0 6px; }
|
||||
.meeting-calendar .calendar-filter-label { display:flex; flex-direction:column; gap:4px; font-size:.72rem; color:rgb(var(--muted)); min-width:200px; }
|
||||
/* Compartido conmigo: franja a la izquierda del color del tipo. */
|
||||
.meeting-calendar .calendar-entry.shared { box-shadow:inset 3px 0 0 currentColor; padding-left:8px; }
|
||||
.meeting-calendar .calendar-legend-item.shared-sample { cursor:default; color:rgb(var(--muted)); }
|
||||
.meeting-calendar .calendar-legend-item.shared-sample .calendar-swatch { background:rgb(var(--muted) / .25); box-shadow:inset 3px 0 0 rgb(var(--body)); }
|
||||
|
||||
@@ -4,10 +4,15 @@ import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAsync } from '../../hooks/useAsync.js';
|
||||
import { listActivitiesByProject } from '../../services/activities.js';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import { listMeetings } from '../../services/meetings.js';
|
||||
import { listMeetings, listMeetingRecurring } from '../../services/meetings.js';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
import { hasModule } from '../../utils/modules.js';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const DOW = ['Lun', 'Mar', 'Mie', 'Jue', 'Vie', 'Sab', 'Dom'];
|
||||
// Mismos colores que el menu Calendario: reunion = accent, actividad = warning, recurrente = success.
|
||||
const chip = (tone) => ({ color: `rgb(var(--${tone}))`, background: `rgb(var(--${tone}) / .14)`, border: 0, textAlign: 'left', cursor: 'pointer', width: '100%' });
|
||||
const swatch = (tone) => ({ display: 'inline-block', width: 12, height: 12, borderRadius: 3, background: `rgb(var(--${tone}))`, marginRight: 6, verticalAlign: '-1px' });
|
||||
|
||||
function buildGrid(year, month) {
|
||||
const first = new Date(year, month, 1);
|
||||
@@ -23,12 +28,15 @@ function buildGrid(year, month) {
|
||||
export default function ProjectCalendarTab({ projectId }) {
|
||||
const { t } = useUi();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const canMeetings = hasModule(user, 'calendar'), canRecurring = hasModule(user, 'recurring_tasks');
|
||||
const { data: activities } = useAsync(() => listActivitiesByProject(projectId), [projectId]);
|
||||
const [cursor, setCursor] = useState(() => { const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), 1); });
|
||||
const from = `${cursor.getFullYear()}-${String(cursor.getMonth()+1).padStart(2,'0')}-01 00:00:00`;
|
||||
const nextMonth = new Date(cursor.getFullYear(), cursor.getMonth()+1, 1);
|
||||
const to = `${nextMonth.getFullYear()}-${String(nextMonth.getMonth()+1).padStart(2,'0')}-01 00:00:00`;
|
||||
const { data: meetings } = useAsync(() => listMeetings(from, to).catch(() => []), [from, to]);
|
||||
const { data: meetings } = useAsync(() => canMeetings ? listMeetings(from, to, projectId).catch(() => []) : Promise.resolve([]), [from, to, projectId, canMeetings]);
|
||||
const { data: recurring } = useAsync(() => canMeetings && canRecurring ? listMeetingRecurring(from, to).catch(() => []) : Promise.resolve([]), [from, to, canMeetings, canRecurring]);
|
||||
|
||||
const cells = useMemo(() => buildGrid(cursor.getFullYear(), cursor.getMonth()), [cursor]);
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
@@ -51,20 +59,28 @@ export default function ProjectCalendarTab({ projectId }) {
|
||||
<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))} />
|
||||
</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">
|
||||
{DOW.map((d) => <div key={d} className="calendar-dow">{d}</div>)}
|
||||
{cells.map((date, i) => {
|
||||
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);
|
||||
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 (
|
||||
<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" 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>}
|
||||
</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 { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||
@@ -44,6 +45,7 @@ export default function RecurringTasksPage() {
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Borrador del popup (nueva o edicion de una existente).
|
||||
const draft = useDraftForm({
|
||||
formId: selected ? 'recurring-edit' : 'recurring-new', entityId: selected?.id ?? null,
|
||||
@@ -68,6 +70,15 @@ export default function RecurringTasksPage() {
|
||||
];
|
||||
const unitLabel = { days: t('recurring_task_unit_days'), months: t('recurring_task_unit_months'), years: t('recurring_task_unit_years') };
|
||||
|
||||
// Llegada desde el Calendario (?task=id): abre esa tarea como un clic en su fila.
|
||||
useEffect(() => {
|
||||
const id = Number(searchParams.get('task'));
|
||||
if (!id || !items) return;
|
||||
const found = items.find((r) => r.id === id);
|
||||
if (found) startEdit(found);
|
||||
setSearchParams((prev) => { const next = new URLSearchParams(prev); next.delete('task'); return next; }, { replace: true });
|
||||
}, [searchParams, items]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function startNew() {
|
||||
setSelected(null);
|
||||
setEditing(true);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { api } from './api.js';
|
||||
const query = (from, to) => `?${new URLSearchParams({ from, to })}`;
|
||||
export const listMeetings = (from, to) => api.get(`/meetings${query(from, to)}`).then((result) => result.items);
|
||||
const query = (from, to, extra = {}) => `?${new URLSearchParams({ from, to, ...extra })}`;
|
||||
// Sin projectId: calendario personal (organizo o me invitaron). Con projectId: reuniones de ese proyecto.
|
||||
export const listMeetings = (from, to, projectId) => api.get(`/meetings${query(from, to, projectId ? { projectId } : {})}`).then((result) => result.items);
|
||||
export const listMeetingTasks = (from, to) => api.get(`/meetings/tasks${query(from, to)}`);
|
||||
export const listMeetingProjects = (from, to) => api.get(`/meetings/projects${query(from, to)}`);
|
||||
export const listMeetingActivities = (from, to) => api.get(`/meetings/activities${query(from, to)}`);
|
||||
export const listMeetingRecurring = (from, to) => api.get(`/meetings/recurring${query(from, to)}`);
|
||||
export const getMeeting = (id) => api.get(`/meetings/${id}`);
|
||||
export const createMeeting = (data) => api.post('/meetings', data);
|
||||
export const updateMeeting = (id, data) => api.put(`/meetings/${id}`, data);
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
--success: 108 203 95;
|
||||
--warning: 255 185 0;
|
||||
--danger: 255 105 97;
|
||||
--cal-project: 180 140 255;
|
||||
--cal-task: 64 200 210;
|
||||
|
||||
--r-sm: 4px;
|
||||
--r-lg: 8px;
|
||||
@@ -28,6 +30,8 @@
|
||||
--success: 16 124 16;
|
||||
--warning: 157 93 0;
|
||||
--danger: 196 43 28;
|
||||
--cal-project: 120 70 200;
|
||||
--cal-task: 0 120 130;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
@@ -43,5 +47,7 @@
|
||||
--success: 16 124 16;
|
||||
--warning: 157 93 0;
|
||||
--danger: 196 43 28;
|
||||
--cal-project: 120 70 200;
|
||||
--cal-task: 0 120 130;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user