/* ============================================================ AGENDA — Calendário (dia / semana / mês) ============================================================ */ function CalendarScreen(ctx) { const { terms, appts, services, team, clients, openNewAppt, openAppt, statusMap, settings, addAppt, toast } = ctx; const DB = window.DB; const [view, setView] = React.useState(() => { const saved = localStorage.getItem('ag-calendar-view'); return ['dia', 'semana', 'mes'].includes(saved) ? saved : (window.innerWidth <= 820 ? 'dia' : 'semana'); }); const [anchor, setAnchor] = React.useState(new Date(DB.today)); // data de referência const user = ctx.user || {}; const isPlainUser = user.role === 'user'; const isAdmin = user.role === 'admin'; const isPro = user.role === 'pro'; const userProId = user.proId ? Number(user.proId) : 0; const resolveCalendarFilter = () => { if (isPlainUser) return 'mine'; const requested = ctx.calPro && ctx.calPro !== 'default' ? ctx.calPro : (settings.calendar_default_scope || 'mine'); if (requested === 'mine' || requested === 'all') return requested; const specificMatch = String(requested).match(/^pro:(\d+)$/); const requestedProId = specificMatch ? Number(specificMatch[1]) : Number(requested); return requestedProId > 0 && team.some((p) => Number(p.id) === requestedProId && !p.archived) ? requestedProId : 'mine'; }; const [proFilter, setProFilter] = React.useState(resolveCalendarFilter); // pré-filtro via configuração ou "Ver agenda" const [clientFilter, setClientFilter] = React.useState('all'); const [openGroup, setOpenGroup] = React.useState(null); const [freeSlotsOpen, setFreeSlotsOpen] = React.useState(false); const [printOpen, setPrintOpen] = React.useState(false); const [draggingId, setDraggingId] = React.useState(null); const dragMovedRef = React.useRef(false); const dragOffsetYRef = React.useRef(0); // relógio vivo: a linha de "agora" anda sozinha (atualiza a cada 30s, sem recarregar) const [now, setNow] = React.useState(() => new Date()); React.useEffect(() => { localStorage.setItem('ag-calendar-view', view); }, [view]); React.useEffect(() => { const t = setInterval(() => setNow(new Date()), 30000); return () => clearInterval(t); }, []); React.useEffect(() => { setProFilter(resolveCalendarFilter()); }, [ctx.calPro, settings.calendar_default_scope, isPlainUser, team]); React.useEffect(() => { if (clientFilter !== 'all' && !clients.some((c) => Number(c.id) === Number(clientFilter))) setClientFilter('all'); }, [clients, clientFilter]); const readHour = (value, fallback) => { const m = String(value || '').match(/^(\d{1,2})(?::\d{2})?$/); const h = m ? Number(m[1]) : fallback; return Number.isFinite(h) ? Math.max(0, Math.min(23, h)) : fallback; }; const H_START = readHour(settings.calendar_start, 7); const rawEnd = readHour(settings.calendar_end, 21); const H_END = Math.max(H_START + 1, Math.min(24, rawEnd)); const DEFAULT_APPT_START = `${String(Math.min(Math.max(9, H_START), H_END - 1)).padStart(2, '0')}:00`; const PX = 64; // px/h const svc = (id) => services.find(s => s.id === id); const cli = (id) => clients.find(c => c.id === id); const pro = (id) => team.find(p => p.id === id); const personColor = (proId) => DB.colorValue((pro(proId) || {}).color); const personColorBg = (proId) => DB.colorBg((pro(proId) || {}).color); const eventColorStyle = (a, done = false) => ({ '--c': done ? 'var(--text-3)' : personColor(a._displayProId || a.proId), '--cbg': done ? 'var(--surface-2)' : personColorBg(a._displayProId || a.proId), }); const iso = DB.iso; // ISO local (sem deslocamento de fuso) const sameMonth = (d) => d.getMonth() === anchor.getMonth(); const apptHasPro = (a, proId) => Number(a.proId) === Number(proId) || (a.participants || []).some((p) => Number(p.proId) === Number(proId)); const proAvailabilityForDate = (p, date) => { if (!p || !date || !p.availability) return null; const d = new Date(date + 'T00:00'); return p.availability[String(d.getDay())] || null; }; const proWorksAt = (p, date, start, end) => { const row = proAvailabilityForDate(p, date); if (row) { if (!row.enabled) return false; const ps = window.timeToMin(row.start || p.start || '00:00'); const pe = window.timeToMin(row.end || p.end || '23:59'); const ls = row.lunchStart ? window.timeToMin(row.lunchStart) : -1; const le = row.lunchEnd ? window.timeToMin(row.lunchEnd) : -1; return start >= ps && end <= pe && !(le > ls && start < le && end > ls); } const ps = window.timeToMin(p.start || '00:00'); const pe = window.timeToMin(p.end || '23:59'); const hasLunch = !!(p.lunchStart && p.lunchEnd); const ls = hasLunch ? window.timeToMin(p.lunchStart) : -1; const le = hasLunch ? window.timeToMin(p.lunchEnd) : -1; return start >= ps && end <= pe && !(hasLunch && le > ls && start < le && end > ls); }; const apptIsMine = (a) => Number(a.createdByUserId) === Number(user.id) || (userProId > 0 && apptHasPro(a, userProId)); const filteredByPro = (list) => (isPlainUser || proFilter === 'mine') ? list.filter(apptIsMine) : (proFilter === 'all' ? list : list.filter(a => apptHasPro(a, proFilter))); const filtered = (list) => { const byPro = filteredByPro(list); return clientFilter === 'all' ? byPro : byPro.filter((a) => Number(a.clientId) === Number(clientFilter)); }; const newApptProId = () => { if (isPlainUser) return userProId || undefined; if (proFilter === 'mine') return userProId || undefined; return proFilter !== 'all' ? proFilter : undefined; }; const filterLabel = () => { if (isPlainUser || proFilter === 'mine') return 'Minha Agenda'; if (proFilter === 'all') return terms.pros; const selected = pro(proFilter); return selected ? selected.name.replace('Dra. ', '') : terms.pros; }; const clientFilterLabel = () => { if (clientFilter === 'all') return `${terms.clients} (todos)`; const selected = cli(Number(clientFilter)); return selected ? selected.name : `${terms.clients} (todos)`; }; const computeOverlaps = (list) => { const sorted = [...list].sort((a, b) => window.timeToMin(a.start) - window.timeToMin(b.start) || window.apptDur(b) - window.apptDur(a)); const cols = []; sorted.forEach(a => { const aStart = window.timeToMin(a.start); const aDur = a._groupDur || window.apptDur(a); const aEnd = aStart + aDur; let placed = false; for (let i = 0; i < cols.length; i++) { const last = cols[i][cols[i].length - 1]; const lastDur = last._groupDur || window.apptDur(last); const lastEnd = window.timeToMin(last.start) + lastDur; if (aStart >= lastEnd) { cols[i].push(a); a._col = i; placed = true; break; } } if (!placed) { a._col = cols.length; cols.push([a]); } }); // Agrupa por grupos de sobreposição let currentGroup = []; let groupEnd = 0; let maxColsInGroup = 0; sorted.forEach((a, index) => { const aStart = window.timeToMin(a.start); const aDur = a._groupDur || window.apptDur(a); const aEnd = aStart + aDur; if (aStart >= groupEnd && currentGroup.length > 0) { currentGroup.forEach(ev => ev._maxCol = maxColsInGroup); currentGroup = []; groupEnd = 0; maxColsInGroup = 0; } currentGroup.push(a); groupEnd = Math.max(groupEnd, aEnd); maxColsInGroup = Math.max(maxColsInGroup, a._col + 1); if (index === sorted.length - 1) { currentGroup.forEach(ev => ev._maxCol = maxColsInGroup); } }); return sorted; }; const buildWeekItems = (list) => { const makeGroupItem = (items) => { const group = [...items]; const sortedGroup = [...group].sort((a, b) => String(a.clientLabel || a.clientName || '').localeCompare(String(b.clientLabel || b.clientName || ''), 'pt-BR')); const minS = Math.min(...group.map(x => window.timeToMin(x.start))); const maxE = Math.max(...group.map(x => window.timeToMin(x.start) + window.apptDur(x))); return { ...sortedGroup[0], id: `group-${sortedGroup.map((item) => item.id).join('-')}`, _group: sortedGroup, start: window.minToTime(minS), _groupEnd: window.minToTime(maxE), _groupDur: maxE - minS }; }; const teamKey = (a) => { const ids = [a.proId, ...(a.participants || []).map((part) => part.proId)] .map(Number) .filter((id) => id > 0); return [...new Set(ids)].sort((x, y) => x - y).join(',') || `none-${a.id}`; }; const byTeam = new Map(); list.forEach((a) => { const key = teamKey(a); byTeam.set(key, [...(byTeam.get(key) || []), a]); }); const condensed = []; byTeam.forEach((teamItems) => { const sorted = [...teamItems].sort((a, b) => window.timeToMin(a.start) - window.timeToMin(b.start) || window.apptDur(b) - window.apptDur(a)); let cluster = []; let clusterEnd = -1; const flush = () => { if (!cluster.length) return; condensed.push(cluster.length >= 2 ? makeGroupItem(cluster) : cluster[0]); cluster = []; clusterEnd = -1; }; sorted.forEach((a) => { const start = window.timeToMin(a.start); const end = start + window.apptDur(a); if (cluster.length && start >= clusterEnd) flush(); cluster.push(a); clusterEnd = Math.max(clusterEnd, end); }); flush(); }); return computeOverlaps(condensed); }; // week days (domingo a sábado) const weekStart = (() => { const d = new Date(anchor); d.setDate(d.getDate() - d.getDay()); d.setHours(0, 0, 0, 0); return d; })(); const weekDays = Array.from({ length: 7 }, (_, i) => { const d = new Date(weekStart); d.setDate(d.getDate() + i); return d; }); const move = (dir) => { const d = new Date(anchor); if (view === 'dia') d.setDate(d.getDate() + dir); else if (view === 'semana') d.setDate(d.getDate() + dir * 7); else d.setMonth(d.getMonth() + dir); setAnchor(d); }; const nowMin = now.getHours() * 60 + now.getMinutes(); const isToday = (d) => iso(d) === iso(now); const title = view === 'mes' ? anchor.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }) : view === 'semana' ? `${weekDays[0].toLocaleDateString('pt-BR', { day: 'numeric', month: 'short' })} – ${weekDays[6].toLocaleDateString('pt-BR', { day: 'numeric', month: 'short' })}` : anchor.toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' }); const hours = Array.from({ length: H_END - H_START }, (_, i) => H_START + i); const apptById = (id) => appts.find(a => a.id === Number(id)); const canMoveAppt = (a) => !!(ctx.user && Number(a.createdByUserId) === Number(ctx.user.id) && !['cancelado', 'concluido', 'faltou'].includes(a.status)); const markDragHandled = () => { dragMovedRef.current = true; window.setTimeout(() => { dragMovedRef.current = false; }, 250); }; const onDragStart = (e, a) => { if (!canMoveAppt(a)) { e.preventDefault(); return; } e.stopPropagation(); setDraggingId(a.id); dragMovedRef.current = false; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(a.id)); const rect = e.currentTarget.getBoundingClientRect(); dragOffsetYRef.current = e.clientY - rect.top; }; const onDragEnd = () => setDraggingId(null); const moveAppt = async (a, nextDate, nextStart, nextProId) => { if (!a || !nextDate || !nextStart) return; if (!canMoveAppt(a)) { toast(`Somente quem criou este ${terms.appt.toLowerCase()} pode movê-lo.`, 'x'); return; } const dur = window.apptDur(a); const nextEnd = window.addMinutes(nextStart, dur); const nextPrimaryProId = nextProId || a.proId; if (a.date === nextDate && a.start === nextStart && window.apptEnd(a) === nextEnd && Number(a.proId) === Number(nextPrimaryProId)) return; try { await addAppt({ editId: a.id, clientId: a.clientId, serviceId: a.serviceId, proId: nextPrimaryProId, date: nextDate, start: nextStart, end: nextEnd, status: a.status, notes: a.notes, showNotification: a.showNotification, launchFinance: a.launchFinance, }); toast(`${terms.appt} movido!`, 'calendar'); } catch (e) { toast(e.message || `Não foi possível mover o ${terms.appt.toLowerCase()}.`, 'x'); } }; const startFromDrop = (e, a) => { const rect = e.currentTarget.getBoundingClientRect(); const dur = Math.max(15, window.apptDur(a)); const minStart = H_START * 60; const maxStart = Math.max(minStart, H_END * 60 - dur); const raw = minStart + Math.round(((e.clientY - dragOffsetYRef.current - rect.top) / PX) * 4) * 15; return window.minToTime(Math.max(minStart, Math.min(maxStart, raw))); }; const dropOnTimedGrid = async (e, d, targetProId) => { e.preventDefault(); e.stopPropagation(); const a = apptById(e.dataTransfer.getData('text/plain')); if (!a) return; markDragHandled(); setDraggingId(null); await moveAppt(a, iso(d), startFromDrop(e, a), targetProId); }; const dropOnMonthDay = async (e, d) => { e.preventDefault(); e.stopPropagation(); const a = apptById(e.dataTransfer.getData('text/plain')); if (!a) return; markDragHandled(); setDraggingId(null); await moveAppt(a, iso(d), a.start || DEFAULT_APPT_START); }; function EventBlock({ a, compact }) { const displayProId = a._displayProId || a.proId; const s = a.manualServiceName ? { name: a.manualServiceName, price: a.manualServicePrice || 0, color: 'violet' } : svc(a.serviceId), c = cli(a.clientId), p = pro(displayProId); const partCount = (a.participants || []).length; const partConfirmed = (a.participants || []).filter((x) => x.response === 'confirmado').length; if (!s) return null; const startMin = window.timeToMin(a.start); if (startMin < H_START * 60 || startMin >= H_END * 60) return null; const top = (startMin - H_START * 60) / 60 * PX; const durMin = a._groupDur || window.apptDur(a); const height = Math.max(26, durMin / 60 * PX - 4); const done = a.status === 'concluido' || a.status === 'faltou'; const group = Array.isArray(a._group) ? a._group : null; const maxCol = a._maxCol || 1; const col = a._col || 0; const width = `calc(${100 / maxCol}% - 2px)`; const left = `${(100 / maxCol) * col}%`; const shortName = (name) => String(name || '').replace('Dra. ', '').trim(); const teamNamesFor = (item) => { const ids = [item.proId, item._displayProId, ...(item.participants || []).map((part) => part.proId)] .map(Number) .filter((id) => id > 0); return [...new Set(ids)] .map((id) => pro(id)) .filter(Boolean) .map((itemPro) => shortName(itemPro.name)) .filter(Boolean) .join(', '); }; const clientNameFor = (item) => { const itemClient = cli(item.clientId); return shortName(item.clientLabel || item.clientName || (itemClient ? itemClient.name : '')); }; const proNameFor = (item, fallbackProId) => { const itemPro = pro(fallbackProId || item._displayProId || item.proId); return shortName(itemPro ? itemPro.name : ''); }; const peopleLabelFor = (item, fallbackProId) => [proNameFor(item, fallbackProId), clientNameFor(item)].filter(Boolean).join(' · '); const peopleLabel = peopleLabelFor(a, displayProId); const teamLabel = teamNamesFor(a) || proNameFor(a, displayProId) || terms.pro; const apptStatus = statusMap[a.status] || { label: a.status || 'Pendente', cls: 'amber' }; const eventTitle = [s.name, peopleLabel].filter(Boolean).join(' - '); if (group) { const sameService = group.every((item) => Number(item.serviceId) === Number(group[0].serviceId)); const groupTitle = `${group.length} ${terms.appt.toLowerCase()}s${sameService ? ` · ${s.name}` : ''}`; const groupPros = []; const addGroupPro = (proId) => { const pid = Number(proId); if (!pid || groupPros.some((item) => Number(item.id) === pid)) return; const found = pro(pid); if (found) groupPros.push(found); }; group.forEach((item) => { addGroupPro(item.proId); (item.participants || []).forEach((part) => addGroupPro(part.proId)); }); const groupColors = groupPros.map((item) => personColor(item.id)); const groupColorBar = groupColors.length ? `linear-gradient(90deg, ${groupColors.map((color, i) => `${color} ${Math.round(i / groupColors.length * 100)}% ${Math.round((i + 1) / groupColors.length * 100)}%`).join(', ')})` : personColor(displayProId); const groupProNames = groupPros.map((item) => String(item.name || '').replace('Dra. ', '')).filter(Boolean); const visibleProNames = groupProNames.slice(0, 3).join(', ') + (groupProNames.length > 3 ? ` +${groupProNames.length - 3}` : ''); const mixedPros = groupPros.length > 1; const sameStatus = group.every((item) => item.status === group[0].status); const statusCounts = group.reduce((counts, item) => { const status = item.status || 'pendente'; counts[status] = (counts[status] || 0) + 1; return counts; }, {}); const statusOrder = ['confirmado', 'pendente', 'concluido', 'faltou', 'cancelado']; const countStatusLabel = (status, count) => { const labels = { confirmado: ['confirmado', 'confirmados'], pendente: ['pendente', 'pendentes'], concluido: ['concluído', 'concluídos'], faltou: ['falta', 'faltas'], cancelado: ['cancelado', 'cancelados'], }; const pair = labels[status]; if (pair) return `${count} ${pair[count === 1 ? 0 : 1]}`; const fallback = (statusMap[status] || {}).label || status; return `${count} ${String(fallback).toLowerCase()}`; }; const mixedStatusLabel = [...statusOrder, ...Object.keys(statusCounts).filter((status) => !statusOrder.includes(status))] .filter((status) => statusCounts[status]) .map((status) => countStatusLabel(status, statusCounts[status])) .join(' · '); const mixedStatusClass = statusCounts.cancelado || statusCounts.faltou ? 'red' : (statusCounts.pendente ? 'amber' : (statusCounts.concluido ? 'blue' : 'green')); const groupedStatus = sameStatus ? (statusMap[group[0].status] || { label: group[0].status || 'Pendente', cls: 'amber' }) : { label: mixedStatusLabel, cls: mixedStatusClass }; const groupDone = group.every((item) => item.status === 'concluido' || item.status === 'faltou'); const groupHeight = Math.max(58, height); const groupStyle = groupDone ? { ...eventColorStyle(a, groupDone), '--group-colors': groupColorBar } : (mixedPros ? { '--c': 'var(--text-2)', '--cbg': 'var(--surface-2)', '--group-colors': groupColorBar } : { ...eventColorStyle(a, groupDone), '--group-colors': groupColorBar }); const addAtSameTime = (e) => { e.stopPropagation(); openNewAppt({ date: a.date, start: a.start, end: window.apptEnd(a), proId: newApptProId() || displayProId, serviceId: sameService ? a.serviceId : undefined }); }; const names = group.map((item) => { return peopleLabelFor(item) || 'Sem participante informado'; }).filter(Boolean); const visibleNames = names.slice(0, 4).join(', ') + (names.length > 4 ? ` +${names.length - 4}` : ''); return (
| Data | Horário | ${esc(terms.client)} | ${esc(terms.pro)} | ${esc(terms.service)} | Status | |
|---|---|---|---|---|---|---|
| Nenhum agendamento encontrado neste filtro. | ||||||