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:
@@ -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)}
|
||||
</>;
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user