// Tela: Calendário — semana atual + eventos do dia const { useState: useStateCal } = React; function NovoEventoModal({ onClose, onCreate, selectedDay }) { const [titulo, setTitulo] = useStateCal(''); const [hora, setHora] = useStateCal('09:00'); const [dur, setDur] = useStateCal(60); const [cor, setCor] = useStateCal('blue'); const toast = useToast(); const CORES = [ { id: 'blue', label: 'Azul', bg: 'var(--acc-blue)' }, { id: 'pink', label: 'Rosa', bg: 'var(--acc-pink)' }, { id: 'yellow', label: 'Amarelo', bg: 'var(--acc-yellow)' }, { id: 'mint', label: 'Verde', bg: 'var(--acc-mint)' }, { id: 'lav', label: 'Lavanda', bg: 'var(--acc-lav)' }, ]; const DURACOES = [30, 45, 60, 90, 120]; const valid = titulo.trim().length > 0; const submit = () => { if (!valid) return; const novo = { hora, dur, titulo: titulo.trim(), color: cor, pessoas: [0] }; if (onCreate) onCreate(novo); toast(`"${titulo.trim()}" agendado às ${hora} ✓`); onClose(); }; return (
e.stopPropagation()}>
Novo Evento
{selectedDay} · adicione ao calendário
{ICN.x}
setTitulo(e.target.value)} autoFocus onKeyDown={e => e.key === 'Enter' && submit()} />
setHora(e.target.value)} />
{CORES.map(c => (
); } const HOURS = ['08:00','09:00','10:00','11:00','12:00','13:00','14:00','15:00','16:00','17:00','18:00']; const DOWS = ['DOM','SEG','TER','QUA','QUI','SEX','SÁB']; const PX_PER_MIN = 1; // 60px/hour // May 2026 grid: starts on Friday (index 5) const MAY_2026 = (() => { const cells = []; for (let i = 0; i < 5; i++) cells.push(null); // padding before May 1 (Fri = index 5, so 5 empty cells Sun-Thu) for (let d = 1; d <= 31; d++) cells.push(d); while (cells.length % 7 !== 0) cells.push(null); const weeks = []; for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7)); return weeks; })(); const EVENT_DAYS = { 11: EVENTOS_DIA, 13: EVENTOS_DIA, 15: EVENTOS_DIA.slice(0,3), 16: EVENTOS_DIA.slice(2) }; /* ── Modal de detalhe de evento ──────────────────────────── */ function EventoDetailModal({ evento, onClose, onDelete }) { const { useState: useED } = React; const [titulo, setTitulo] = useED(evento.titulo); const toast = useToast(); const durLabel = evento.dur >= 60 ? `${Math.floor(evento.dur/60)}h${evento.dur%60 ? ' ' + (evento.dur%60) + 'min' : ''}` : `${evento.dur}min`; const save = () => { toast(`Evento atualizado ✓`); onClose(); }; const del = () => { if (onDelete) onDelete(); toast('Evento removido'); onClose(); }; return (
e.stopPropagation()} style={{width: 420}}>
Detalhes do evento
{evento.hora} · duração {durLabel}
{ICN.x}
setTitulo(e.target.value)} />
{evento.pessoas.slice(0,5).map((id, i) => (
{CONTATOS[id]?.nome?.split(' ')[0] || 'Participante'}
))}
); } function EventTimeline({ events, compact, onEventClick, nowTop, nowLabel }) { const _nowTop = nowTop ?? (5*60+42)*PX_PER_MIN; const _nowLabel = nowLabel ?? '13:42'; return (
{HOURS.map(h =>
{h}
)}
{_nowLabel}
{events.map((e,i) => { const [h,m] = e.hora.split(':').map(Number); const top = ((h-8)*60+m)*PX_PER_MIN; const height = e.dur*PX_PER_MIN; return (
onEventClick && onEventClick(e)}>
{e.hora} · {e.dur>=60?`${Math.floor(e.dur/60)}h${e.dur%60?' '+(e.dur%60)+'min':''}`:e.dur+'min'}
{e.pessoas.slice(0,3).map(id=>)} {e.pessoas.length>3&&+{e.pessoas.length-3}}
{!compact &&
{e.titulo}
} {compact && height > 40 &&
{e.titulo}
}
); })}
); } // ── Helpers de data reais ────────────────────────────────────────── const MONTHS_PT = ['Janeiro','Fevereiro','Março','Abril','Maio','Junho','Julho','Agosto','Setembro','Outubro','Novembro','Dezembro']; const MONTHS_ABBR = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez']; const calHeaders = () => { const t = localStorage.getItem('ezza_token'); return { 'Content-Type':'application/json', ...(t?{'Authorization':`Bearer ${t}`}:{}) }; }; const _pad2 = n => String(n).padStart(2,'0'); const dKey = d => `${d.getFullYear()}-${_pad2(d.getMonth()+1)}-${_pad2(d.getDate())}`; const addDaysCal = (d,n) => { const x = new Date(d); x.setDate(x.getDate()+n); return x; }; const startOfWeekSun = d => addDaysCal(d, -d.getDay()); function transformEvento(e) { const ini = new Date(String(e.inicio).replace(' ','T')); const fimRaw = e.fim ? new Date(String(e.fim).replace(' ','T')) : null; const dur = fimRaw ? Math.max(15, Math.round((fimRaw - ini)/60000)) : 60; return { id: e.id, titulo: e.titulo, hora: `${_pad2(ini.getHours())}:${_pad2(ini.getMinutes())}`, dur, color: e.cor || 'blue', pessoas: [], key: dKey(ini), inicio: ini }; } function WeekStrip({ week, selectedKey, onSelect, byKey }) { const todayKey = dKey(new Date()); return (
{week.map(d => { const k = dKey(d); const evs = byKey[k] || []; return (
onSelect(d)}>
{DOWS[d.getDay()]}
{d.getDate()}
{evs.slice(0,3).map((e,i)=>)}
); })}
); } function Calendario() { const _t0 = new Date(); _t0.setHours(0,0,0,0); const [selectedDate, setSelectedDate] = useStateCal(_t0); const [weekOffset, setWeekOffset] = useStateCal(0); const [monthOffset, setMonthOffset] = useStateCal(0); const [viewMode, setViewMode] = useStateCal('Semana'); const [showNovoEvento, setShowNovoEvento] = useStateCal(false); const [eventos, setEventos] = useStateCal([]); // brutos do banco const [selectedEvent, setSelectedEvent] = useStateCal(null); const [showJumpMenu, setShowJumpMenu] = useStateCal(false); const [now, setNow] = useStateCal(new Date()); const toast = useToast(); React.useEffect(() => { fetch('/api/eventos', { headers: calHeaders() }) .then(r => r.json()).then(d => { if (d.ok) setEventos(d.eventos); }).catch(() => {}); const t = setInterval(() => setNow(new Date()), 30000); return () => clearInterval(t); }, []); const nowH = now.getHours(), nowM = now.getMinutes(); const nowLabel = `${_pad2(nowH)}:${_pad2(nowM)}`; const nowTop = ((nowH - 8) * 60 + nowM) * PX_PER_MIN; // Eventos reais agrupados por dia (YYYY-MM-DD) const byKey = {}; eventos.map(transformEvento).forEach(e => { (byKey[e.key] = byKey[e.key] || []).push(e); }); Object.values(byKey).forEach(arr => arr.sort((a,b) => a.hora.localeCompare(b.hora))); const today0 = new Date(); today0.setHours(0,0,0,0); const todayKey = dKey(today0); const weekStart = addDaysCal(startOfWeekSun(today0), weekOffset*7); const week = Array.from({length:7}, (_,i) => addDaysCal(weekStart, i)); const selKey = dKey(selectedDate); const dayEvents = byKey[selKey] || []; const monthLabel = `${MONTHS_PT[week[3].getMonth()]} ${week[3].getFullYear()}`; const totalSemana = week.reduce((s,d) => s + (byKey[dKey(d)] || []).length, 0); // Mês exibido (visão Mês) const monthBase = new Date(today0.getFullYear(), today0.getMonth() + monthOffset, 1); const mY = monthBase.getFullYear(), mM = monthBase.getMonth(); const monthLabelM = `${MONTHS_PT[mM]} ${mY}`; // Próximos compromissos (de hoje em diante) const proximos = eventos.map(transformEvento) .filter(e => e.key >= todayKey) .sort((a,b) => (a.key + a.hora).localeCompare(b.key + b.hora)) .slice(0, 3); const handleCriarEvento = async (novo) => { const inicioStr = `${selKey} ${novo.hora}:00`; const fimD = new Date(`${selKey}T${novo.hora}:00`); fimD.setMinutes(fimD.getMinutes() + novo.dur); const fimStr = `${dKey(fimD)} ${_pad2(fimD.getHours())}:${_pad2(fimD.getMinutes())}:00`; try { const r = await fetch('/api/eventos', { method:'POST', headers: calHeaders(), body: JSON.stringify({ titulo: novo.titulo, inicio: inicioStr, fim: fimStr, tipo: 'reuniao', cor: novo.color }) }); const d = await r.json(); if (d.ok) setEventos(prev => [...prev, d.evento]); } catch (e) {} }; const handleDelete = async (ev) => { if (ev && ev.id) { try { await fetch(`/api/eventos/${ev.id}`, { method:'DELETE', headers: calHeaders() }); } catch (e) {} } setEventos(prev => prev.filter(e => e.id !== (ev && ev.id))); setSelectedEvent(null); }; const JUMP_OPTIONS = [ { label: 'Hoje', icon: ICN.cal, action: () => { setWeekOffset(0); setMonthOffset(0); setSelectedDate(today0); } }, { label: 'Semana anterior', icon: ICN.arrowLeft, action: () => setWeekOffset(w => w - 1) }, { label: 'Próxima semana', icon: ICN.arrowRight, action: () => setWeekOffset(w => w + 1) }, { label: 'Mês anterior', icon: ICN.arrowLeft, action: () => setMonthOffset(m => m - 1) }, { label: 'Próximo mês', icon: ICN.arrowRight, action: () => setMonthOffset(m => m + 1) }, ]; const JumpMenu = () => { const { useEffect: useJE, useRef: useJR } = React; const ref = useJR(); useJE(() => { const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setShowJumpMenu(false); }; document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h); }, []); return (
e.stopPropagation()}>
Navegar para
{JUMP_OPTIONS.map((o, i) => (
{ o.action(); setShowJumpMenu(false); toast(o.label); }}> {o.icon} {o.label}
))}
); }; const selDayLabel = `${selectedDate.getDate()} ${MONTHS_ABBR[selectedDate.getMonth()]}`; const NavBar = () => { const isMonth = viewMode === 'Mês'; const label = isMonth ? monthLabelM : monthLabel; const atToday = isMonth ? monthOffset === 0 : weekOffset === 0; return (
isMonth ? setMonthOffset(m=>m-1) : setWeekOffset(w=>w-1)}/> isMonth ? setMonthOffset(m=>m+1) : setWeekOffset(w=>w+1)}/>
{label}
{!atToday&&}
setShowNovoEvento(true)} onPeriod={() => setShowJumpMenu(j => !j)} /> {showJumpMenu && }
); }; const ViewTabs = () => (
{['Semana','Dia','Mês'].map(v=>( setViewMode(v)} style={{cursor:'default'}}>{v} ))} setShowNovoEvento(true)}/>
); const ProximosCard = () => (
Próximos compromissos
{proximos.map((e)=>(
setSelectedEvent(e)} style={{cursor:'default'}}>
{e.inicio.getDate()}
{MONTHS_ABBR[e.inicio.getMonth()]}
{e.titulo}
{e.hora} · {e.dur>=60?`${Math.floor(e.dur/60)}h${e.dur%60?' '+(e.dur%60)+'min':''}`:e.dur+'min'}
{ICN.arrowRight}
))} {proximos.length===0&&
Nenhum compromisso futuro.
}
); const AgendaList = () => (
{dayEvents.map((e)=>(
setSelectedEvent(e)} style={{cursor:'default'}}>
{e.titulo} {e.hora} · {e.dur>=60?`${Math.floor(e.dur/60)}h${e.dur%60?' '+(e.dur%60)+'min':''}`:e.dur+'min'}
))} {dayEvents.length===0&&
Nenhum evento neste dia.
}
); // ── Visão MÊS ────────────────────────────────────────────────── if (viewMode === 'Mês') { const first = new Date(mY, mM, 1); const startPad = first.getDay(); const dim = new Date(mY, mM+1, 0).getDate(); const cells = []; for (let i=0;i Calendário mensal} subtitle={`${monthLabelM} · visão geral`} right={} />
{DOWS.map(d=>
{d}
)}
{monthWeeks.map((wk,wi)=> wk.map((day,di)=>{ const k = day ? dKey(day) : null; const evs = k ? (byKey[k]||[]) : []; return (
day&&setSelectedDate(day)}> {day&&<> {day.getDate()} {evs.length>0&&(
{evs.slice(0,3).map((e,i)=>)}
)} }
); }) )}
{showNovoEvento && setShowNovoEvento(false)} onCreate={handleCriarEvento} />} {selectedEvent && setSelectedEvent(null)} onDelete={() => handleDelete(selectedEvent)} />} ); } // ── Visão DIA ─────────────────────────────────────────────────── if (viewMode === 'Dia') { return ( <> Calendário do dia} subtitle={`${selDayLabel} · ${dayEvents.length} evento${dayEvents.length!==1?'s':''}`} right={} />
{selDayLabel} — agenda
{dayEvents.length} eventos
{showNovoEvento && setShowNovoEvento(false)} onCreate={handleCriarEvento} />} {selectedEvent && setSelectedEvent(null)} onDelete={() => handleDelete(selectedEvent)} />} ); } // ── Visão SEMANA (padrão) ──────────────────────────────────────── return ( <> Calendário da semana} subtitle={`${monthLabel} · ${totalSemana} compromisso${totalSemana!==1?'s':''} esta semana`} right={} />
{week.map(d=>{ const k = dKey(d); const evs = byKey[k] || []; return (
{evs.slice(0,3).map((e,i)=>(
{e.titulo}
))} {evs.length===0&&
}
); })}
{selDayLabel} — detalhes
{selDayLabel}
{dayEvents.length} eventos
{showNovoEvento && setShowNovoEvento(false)} onCreate={handleCriarEvento} />} {selectedEvent && setSelectedEvent(null)} onDelete={() => handleDelete(selectedEvent)} />} ); } const CAL_STYLES = ` /* ── Jump menu (navegar para) ── */ .jump-menu { position: absolute; right: 0; top: calc(100% + 6px); background: white; border-radius: 16px; padding: 8px; box-shadow: 0 8px 32px -8px rgba(20,30,60,.22), 0 0 0 0.5px rgba(20,30,60,.08); z-index: 50; min-width: 190px; animation: fadein .15s ease; } .jump-menu-title { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-muted); padding: 4px 10px 8px; } .jump-menu-item { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 10px; cursor: default; font-size: 13px; font-weight: 500; transition: background .1s; } .jump-menu-item:hover { background: oklch(0.96 0.01 250); } html.theme-dark .jump-menu { background: oklch(0.24 0.012 255); } html.theme-dark .jump-menu-item:hover { background: oklch(0.30 0.012 255); } /* ── Lista de eventos no sidebar ── */ .ev-list { display: flex; flex-direction: column; gap: 6px; } .ev-list-item { display: flex; align-items: stretch; gap: 10px; background: oklch(0.975 0.008 250); border-radius: 12px; padding: 10px 12px; transition: background .12s, transform .1s; } .ev-list-item:hover { background: oklch(0.96 0.015 250); transform: translateX(2px); } .ev-list-stripe { width: 3px; border-radius: 99px; flex-shrink: 0; align-self: stretch; min-height: 36px; } .ev-stripe-blue { background: var(--acc-blue); } .ev-stripe-pink { background: var(--acc-pink); } .ev-stripe-yellow { background: var(--acc-yellow); } .ev-stripe-mint { background: var(--acc-mint); } .ev-stripe-lav { background: var(--acc-lav); } html.theme-dark .ev-list-item { background: oklch(0.28 0.012 255); } html.theme-dark .ev-list-item:hover { background: oklch(0.31 0.012 255); } .cal-grid { display: grid; grid-template-columns: 1.7fr 1fr; gap: 14px; align-items: start; } .week-strip { display: grid; grid-template-columns: repeat(7,1fr); gap: 8px; margin-bottom: 18px; } .wday { background: oklch(0.97 0.01 250/0.7); border-radius: 16px; padding: 10px 6px; text-align: center; cursor: default; transition: background .15s; } .wday:hover { background: oklch(0.94 0.02 250); } .wday-dow { font-size: 10px; font-weight: 600; color: var(--ink-muted); letter-spacing: 0.08em; } .wday-num { font-size: 22px; font-weight: 700; margin-top: 4px; letter-spacing: -0.02em; } .wday-dots { display: flex; gap: 3px; justify-content: center; margin-top: 4px; min-height: 6px; } .wdot { width: 5px; height: 5px; border-radius: 50%; } .wdot-blue { background: var(--acc-blue); } .wdot-pink { background: var(--acc-pink); } .wdot-yellow { background: var(--acc-yellow); } .wdot-mint { background: var(--acc-mint); } .wdot-lav { background: var(--acc-lav); } .wday-sel { background: var(--black) !important; color: white; } .wday-sel .wday-dow { color: rgba(255,255,255,.65); } .day-grid { display: grid; grid-template-columns: 64px 1fr; gap: 8px; position: relative; } .hours { display: flex; flex-direction: column; position: relative; } .hour-row { height: 60px; border-top: 1px dashed oklch(0.90 0.01 255); color: var(--ink-faint); font-size: 10px; padding-top: 2px; } .hour-row:last-child { border-bottom: 1px dashed oklch(0.90 0.01 255); } .events-col { position: relative; border-top: 1px dashed oklch(0.90 0.01 255); border-bottom: 1px dashed oklch(0.90 0.01 255); } .events-col::before { content:''; position:absolute; inset:0; background-image: repeating-linear-gradient(to bottom, transparent 0, transparent 59px, oklch(0.92 0.01 255) 59px, oklch(0.92 0.01 255) 60px); pointer-events:none; } .event { position:absolute; left:4px; right:4px; border-radius:12px; padding:8px 12px; box-shadow:0 6px 14px -10px rgba(20,30,60,.18); display:flex; flex-direction:column; gap:6px; overflow:hidden; z-index:1; cursor:default; transition: transform .15s; } .event:hover { transform: scale(1.01); } .event-blue { background:oklch(0.92 0.07 250); color:oklch(0.30 0.16 250); border-left:3px solid oklch(0.55 0.16 250); } .event-pink { background:oklch(0.92 0.07 18); color:oklch(0.36 0.15 18); border-left:3px solid oklch(0.62 0.18 25); } .event-yellow { background:oklch(0.95 0.10 95); color:oklch(0.34 0.13 80); border-left:3px solid oklch(0.74 0.15 75); } .event-mint { background:oklch(0.93 0.07 160); color:oklch(0.32 0.12 160); border-left:3px solid oklch(0.55 0.13 160); } .event-lav { background:oklch(0.93 0.06 295); color:oklch(0.34 0.13 295); border-left:3px solid oklch(0.58 0.15 295); } .event-meta { display:flex; justify-content:space-between; align-items:center; } .event-time { font-size:10.5px; font-weight:600; opacity:0.8; } .event-title { font-size:12.5px; font-weight:600; line-height:1.25; } .now-line { position:absolute; left:0; right:-8px; height:0; z-index:2; pointer-events:none; } .now-line .now-dot { position:absolute; right:-6px; top:-5px; width:10px; height:10px; border-radius:50%; background:var(--danger); border:2px solid white; } .now-line .now-time { position:absolute; left:0; top:-7px; font-size:9.5px; font-weight:700; color:var(--danger); background:white; padding:1px 4px; border-radius:4px; } .now-line-ev { position:absolute; left:0; right:0; height:2px; background:var(--danger); z-index:3; pointer-events:none; border-radius:2px; } .upc { display:flex; align-items:center; gap:12px; padding:6px 0; cursor:default; } .upc-date { width:48px; height:48px; border-radius:14px; text-align:center; padding:6px 0; flex-shrink:0; } .upc-blue { background:oklch(0.92 0.07 250); color:oklch(0.30 0.16 250); } .upc-pink { background:oklch(0.92 0.07 18); color:oklch(0.36 0.15 18); } .upc-yellow { background:oklch(0.95 0.10 95); color:oklch(0.34 0.13 80); } .upc-d { font-size:16px; font-weight:700; line-height:1; } .upc-m { font-size:10px; font-weight:600; margin-top:2px; opacity:0.75; text-transform:uppercase; } /* visão semana multi-col */ .week-multi-grid { display:grid; grid-template-columns:repeat(7,1fr); gap:6px; margin-bottom:4px; } .week-col-ev { min-height:48px; border-radius:10px; padding:4px; display:flex; flex-direction:column; gap:3px; background:oklch(0.98 0.005 250); } .week-col-sel { background:oklch(0.96 0.015 250); } .week-ev-pill { font-size:9.5px; font-weight:600; border-radius:6px; padding:3px 6px; line-height:1.2; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .week-ev-blue { background:oklch(0.92 0.07 250); color:oklch(0.30 0.16 250); } .week-ev-pink { background:oklch(0.92 0.07 18); color:oklch(0.36 0.15 18); } .week-ev-yellow { background:oklch(0.95 0.10 95); color:oklch(0.34 0.13 80); } .week-ev-mint { background:oklch(0.93 0.07 160); color:oklch(0.32 0.12 160); } .week-ev-lav { background:oklch(0.93 0.06 295); color:oklch(0.34 0.13 295); } .week-ev-empty { height:20px; } /* visão mês */ .month-dow-header { display:grid; grid-template-columns:repeat(7,1fr); gap:4px; margin-bottom:6px; } .month-dow { text-align:center; font-size:10px; font-weight:700; color:var(--ink-muted); letter-spacing:0.08em; padding:4px 0; } .month-grid { display:grid; grid-template-columns:repeat(7,1fr); gap:4px; } .month-cell { min-height:72px; border-radius:12px; padding:8px; background:oklch(0.98 0.005 250); cursor:default; transition:background .15s; display:flex; flex-direction:column; gap:4px; } .month-cell:hover:not(.month-cell-empty) { background:oklch(0.94 0.02 250); } .month-cell-empty { background:transparent; } .month-cell-today { background:oklch(0.95 0.02 250); border:1.5px solid var(--acc-blue); } .month-cell-sel { background:var(--black) !important; color:white; } .month-day-num { font-size:13px; font-weight:700; letter-spacing:-0.01em; } .month-cell-sel .month-day-num { color:white; } .month-dots { display:flex; gap:3px; flex-wrap:wrap; } `; window.Calendario = Calendario;