/* ============================================================ AGENDA — App (shell + roteamento + estado vindo da API) ============================================================ */ function InternalChatScreen() { return (
); } function MissingScreen({ title = 'Tela nao encontrada' }) { return (
); } function App() { const DB = window.DB; const API = window.API; const routes = ['dashboard', 'calendario', 'clientes', 'fornecedores', 'servicos', 'equipe', 'financeiro', 'chat', 'tarefas', 'historico', 'usuarios', 'mensalidade', 'config']; const [theme, setTheme] = React.useState(() => localStorage.getItem('ag-theme') || 'light'); const [accent, setAccent] = React.useState(() => localStorage.getItem('ag-accent') || 'indigo'); const [businessType, setBusinessTypeState] = React.useState(() => localStorage.getItem('ag-biz') || 'geral'); const [route, setRoute] = React.useState(() => { const saved = localStorage.getItem('ag-route'); return routes.includes(saved) ? saved : 'dashboard'; }); const [collapsed, setCollapsed] = React.useState(() => localStorage.getItem('ag-collapsed') === '1'); const [navOpen, setNavOpen] = React.useState(false); // gaveta da sidebar no mobile // botão do menu: no mobile abre/fecha a gaveta; no desktop recolhe a sidebar const toggleMenu = () => { if (window.innerWidth <= 820) { setCollapsed(false); setNavOpen((o) => !o); } else { setCollapsed((c) => !c); } }; // autenticação const [user, setUser] = React.useState(null); const [authChecked, setAuthChecked] = React.useState(false); const [authMsg, setAuthMsg] = React.useState(''); // aviso na tela de login (ex.: empresa suspensa) // carregamento inicial const [loading, setLoading] = React.useState(true); const [loadError, setLoadError] = React.useState(null); // dados (espelho do banco, atualizado a cada resposta da API) const [appts, setAppts] = React.useState([]); const [clients, setClients] = React.useState([]); const [companies, setCompanies] = React.useState([]); const [suppliers, setSuppliers] = React.useState([]); const [services, setServices] = React.useState([]); const [team, setTeam] = React.useState([]); const [tasks, setTasks] = React.useState([]); const [transactions, setTransactions] = React.useState([]); const [notifications, setNotifications] = React.useState([]); const [historyClientId, setHistoryClientId] = React.useState('all'); const [acknowledgedAppointmentIds, setAcknowledgedAppointmentIds] = React.useState([]); const [floatingNotifications, setFloatingNotifications] = React.useState([]); const floatingSeenRef = React.useRef(new Set((() => { try { return JSON.parse(sessionStorage.getItem('ag-floating-seen') || '[]'); } catch { return []; } })())); const dismissedFloatingRef = React.useRef(new Set()); const [settings, setSettings] = React.useState({}); const queueFloatingNotifications = React.useCallback((list, currentAppts = null) => { const todayStr = window.DB.iso(new Date()); const now = new Date(); const nowStr = String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0'); const allFresh = (list || []).filter((n) => { if (n.read || floatingSeenRef.current.has(n.id)) return false; if (n.apptId && currentAppts) { const appt = currentAppts.find(a => a.id === n.apptId); if (!appt) return false; if (appt.showNotification === false) return false; if (appt.date < todayStr) return false; if (appt.date === todayStr && appt.start && appt.start < nowStr) return false; } return true; }); if (!allFresh.length) return; allFresh.forEach((n) => floatingSeenRef.current.add(n.id)); sessionStorage.setItem('ag-floating-seen', JSON.stringify([...floatingSeenRef.current].slice(-200))); const fresh = allFresh.slice(0, 3); setFloatingNotifications((current) => [...current, ...fresh].slice(-3)); }, []); // navegação contextual const [calPro, setCalPro] = React.useState('default'); const [clientQ, setClientQ] = React.useState(''); const [clientPro, setClientPro] = React.useState('all'); const searchRef = React.useRef(null); const notifRef = React.useRef(null); const [calendarToolbarHost, setCalendarToolbarHost] = React.useState(null); const [calendarNewActionHost, setCalendarNewActionHost] = React.useState(null); // modais const [newAppt, setNewAppt] = React.useState(null); // prefill obj ou null const [openAppt, setOpenAppt] = React.useState(null); const [notifOpen, setNotifOpen] = React.useState(false); const [push, toastNode] = window.useToasts(); const safeUser = React.useCallback((payload) => ( payload && typeof payload === 'object' && payload.user && typeof payload.user === 'object' ? payload.user : null ), []); const trialDaysLeft = React.useMemo(() => { if (!user || !user.isTrial || !user.trialEndsAt) return null; const end = new Date(user.trialEndsAt + 'T00:00:00'); const now = new Date(); end.setHours(0,0,0,0); now.setHours(0,0,0,0); const diff = Math.ceil((end - now) / 86400000); return diff >= 0 ? diff : null; }, [user]); // carrega os dados da empresa logada (bootstrap) const load = React.useCallback(async () => { setLoading(true); setLoadError(null); try { const d = await API.bootstrap(); if (!d || typeof d !== 'object') throw new Error('Resposta invalida do servidor ao carregar os dados.'); const bootUser = safeUser(d); if (bootUser) setUser(bootUser); setTeam(Array.isArray(d.team) ? d.team : []); setServices(Array.isArray(d.services) ? d.services : []); setCompanies(Array.isArray(d.companies) ? d.companies : []); setSuppliers(Array.isArray(d.suppliers) ? d.suppliers : []); setClients(Array.isArray(d.clients) ? d.clients : []); setAppts(Array.isArray(d.appts) ? d.appts : []); setTasks(Array.isArray(d.tasks) ? d.tasks : []); setTransactions(Array.isArray(d.transactions) ? d.transactions : []); setNotifications(Array.isArray(d.notifications) ? d.notifications : []); setAcknowledgedAppointmentIds(Array.isArray(d.acknowledgedAppointmentIds) ? d.acknowledgedAppointmentIds : []); queueFloatingNotifications(Array.isArray(d.notifications) ? d.notifications : [], Array.isArray(d.appts) ? d.appts : []); setSettings(d.settings && typeof d.settings === 'object' ? d.settings : {}); if (d.settings && d.settings.business_type) setBusinessTypeState(d.settings.business_type); } catch (e) { if (e.status === 401) { setUser(null); } else { setLoadError(e.message); } } setLoading(false); }, [queueFloatingNotifications, safeUser]); // checa a sessão; se logado, carrega os dados; senão mostra o login const init = React.useCallback(async () => { setLoading(true); setLoadError(null); try { const me = await API.auth.me(); const current = safeUser(me); if (!current) { setUser(null); setAuthChecked(true); setLoading(false); return; } const slug = window.TENANT_SLUG || ''; if (slug && current.tenantSlug && current.tenantSlug !== slug) { // sessão ativa era de OUTRA empresa: encerra e mostra o login desta try { await API.auth.logout(); } catch { /* ignora */ } setUser(null); setAuthMsg('Você estava conectado em outra empresa. Entre com o acesso desta empresa.'); } else if (!slug && current.tenantSlug) { // raiz sem endereço, mas com sessão ativa: leva ao endereço da empresa window.location.replace((window.BASE_PATH || '/') + current.tenantSlug); return; } else { setUser(current); await load(); } } catch (e) { if (e.status === 401) { setUser(null); } else { setLoadError(e.message); } } setAuthChecked(true); setLoading(false); }, [load, safeUser]); React.useEffect(() => { init(); }, [init]); // login / logout (o login vale só para a empresa do endereço atual) const doLogin = async (login, pass, remember = false) => { setAuthMsg(''); const r = await API.auth.login(window.TENANT_SLUG || '', login, pass, remember); // erro propaga p/ a tela de login const logged = safeUser(r); if (!logged) throw new Error('Resposta invalida do servidor ao entrar.'); setLoading(true); setUser(logged); setBusinessTypeState(logged.businessType || 'geral'); await load(); }; const doLogout = async () => { try { await API.auth.logout(); } catch { /* ignora */ } setUser(null); setRoute('dashboard'); setNotifOpen(false); setFloatingNotifications([]); setNavOpen(false); }; // Sessão encerrada pelo servidor (empresa suspensa, usuário desativado, logout em outra aba…) // Derruba a tela na hora e volta ao login com um aviso. const sessionEnded = React.useCallback((msg) => { setAuthMsg(msg || 'Sua sessão foi encerrada. Entre novamente.'); setUser(null); setRoute('dashboard'); setNotifOpen(false); setFloatingNotifications([]); setNavOpen(false); }, []); const SESSION_BLOCKED = 'Acesso bloqueado: sua empresa foi suspensa ou a sessão expirou.'; // Qualquer 401 vindo de uma chamada de dados (não-login) encerra a sessão imediatamente. React.useEffect(() => { window.onAuthExpired = () => sessionEnded(SESSION_BLOCKED); return () => { window.onAuthExpired = null; }; }, [sessionEnded]); // Sincronização curta: novos agendamentos, respostas e notificações aparecem // nas duas pontas sem recarregar a página. React.useEffect(() => { if (!user) return; // além de detectar suspensão (401), atualiza os limites do plano caso o // acesso sênior os altere com o cliente já com o sistema aberto. const check = async () => { try { const [me, notifResult, apptResult] = await Promise.all([ API.auth.me(), API.notifications.list(), API.appts.list(), ]); const current = safeUser(me); if (!current) { const err = new Error('Sessao invalida.'); err.status = 401; throw err; } // Aplica na hora qualquer alteração que o admin faça neste usuário (papel, // nome, vínculo de profissional, limites do plano) — sem precisar deslogar // e logar de novo. Mantém a mesma referência se nada relevante mudou, para // não re-renderizar à toa a cada 30s. setUser((prev) => { if (!prev) return current; const keys = ['role', 'name', 'login', 'proId', 'maxUsers', 'maxPros', 'hasTeam', 'hasGroupMeetings', 'hasAppointmentEmail', 'hasFinance', 'hasInternalChat', 'showBillingMenu', 'loginUserCount', 'tenantName', 'businessType', 'isTrial', 'isExpired', 'planName', 'paidUntil', 'trialEndsAt']; return keys.some((k) => prev[k] !== current[k]) ? current : prev; }); if (notifResult && notifResult.notifications) { queueFloatingNotifications(notifResult.notifications, apptResult ? apptResult.appointments : null); setFloatingNotifications((items) => items.filter((item) => !dismissedFloatingRef.current.has(item.id) && notifResult.notifications.some((n) => n.id === item.id && !n.read))); const sig = (list) => list.map((n) => n.id + ':' + (n.read ? 1 : 0)).join(','); setNotifications((prev) => sig(prev) === sig(notifResult.notifications) ? prev : notifResult.notifications); setAcknowledgedAppointmentIds(notifResult.acknowledgedAppointmentIds || []); } if (apptResult && apptResult.appointments) { const sig = (list) => list.map((a) => [ a.id, a.date, a.start, window.apptEnd(a), a.serviceId, a.clientId || '', a.clientName || '', a.clientLabel || '', a.proId, a.status, a.notes || '', a.showNotification === false ? 0 : 1, (a.participants || []).map((p) => p.proId + '-' + p.response + '-' + (p.responseAt || '')).join('|'), ].join(':')).join(','); setAppts((prev) => sig(prev) === sig(apptResult.appointments) ? prev : apptResult.appointments); } } catch (e) { if (e.status === 401) sessionEnded(SESSION_BLOCKED); } }; const iv = setInterval(check, 5000); const onVis = () => { if (document.visibilityState === 'visible') check(); }; document.addEventListener('visibilitychange', onVis); return () => { clearInterval(iv); document.removeEventListener('visibilitychange', onVis); }; }, [user, sessionEnded, queueFloatingNotifications, safeUser]); React.useEffect(() => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('ag-theme', theme); }, [theme]); React.useEffect(() => { document.documentElement.setAttribute('data-accent', accent); localStorage.setItem('ag-accent', accent); }, [accent]); React.useEffect(() => { localStorage.setItem('ag-collapsed', collapsed ? '1' : '0'); }, [collapsed]); React.useEffect(() => { localStorage.setItem('ag-biz', businessType); }, [businessType]); React.useEffect(() => { localStorage.setItem('ag-route', route); }, [route]); React.useEffect(() => { if (user && user.role === 'user' && route !== 'calendario') { setRoute('calendario'); } else if (user && route === 'mensalidade' && user.showBillingMenu === false) { setRoute('dashboard'); } }, [user, route]); React.useEffect(() => { if (!notifOpen) return; const closeOutside = (e) => { if (notifRef.current && !notifRef.current.contains(e.target)) setNotifOpen(false); }; const closeEscape = (e) => { if (e.key === 'Escape') setNotifOpen(false); }; document.addEventListener('pointerdown', closeOutside); document.addEventListener('keydown', closeEscape); return () => { document.removeEventListener('pointerdown', closeOutside); document.removeEventListener('keydown', closeEscape); }; }, [notifOpen]); // ⌘K / Ctrl+K foca a busca React.useEffect(() => { const h = (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); if (searchRef.current) searchRef.current.focus(); } }; window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); }, []); /* ---- aplica ao estado local as alterações retornadas pela API ---- */ const apply = (res) => { if (!res) return; const replace = (list, item) => list.map((x) => (x.id === item.id ? item : x)); if (res.appointments) setAppts(res.appointments); if (res.appts) setAppts((a) => [...a, ...res.appts]); if (res.appt) setAppts((a) => a.some((x) => x.id === res.appt.id) ? replace(a, res.appt) : [...a, res.appt]); if (res.removedApptId) setAppts((a) => a.filter((x) => x.id !== res.removedApptId)); if (res.companies) setCompanies(res.companies); if (res.company) setCompanies((cs) => cs.some((c) => c.id === res.company.id) ? replace(cs, res.company) : [...cs, res.company].sort((a, b) => a.name.localeCompare(b.name))); if (res.removedCompanyId) setCompanies((cs) => cs.filter((c) => c.id !== res.removedCompanyId)); if (res.suppliers) setSuppliers(res.suppliers); if (res.supplier) setSuppliers((ss) => ss.some((s) => s.id === res.supplier.id) ? replace(ss, res.supplier) : [res.supplier, ...ss].sort((a, b) => a.name.localeCompare(b.name))); if (res.removedSupplierId) setSuppliers((ss) => ss.filter((s) => s.id !== res.removedSupplierId)); if (res.clients) setClients(res.clients); if (res.client) setClients((cs) => cs.some((c) => c.id === res.client.id) ? replace(cs, res.client) : [res.client, ...cs]); if (res.removedClientId) setClients((cs) => cs.filter((c) => c.id !== res.removedClientId)); if (res.service) setServices((ss) => ss.some((s) => s.id === res.service.id) ? replace(ss, res.service) : [...ss, res.service]); if (res.removedServiceId) setServices((ss) => ss.filter((s) => s.id !== res.removedServiceId)); if (res.pro) setTeam((ts) => ts.some((p) => p.id === res.pro.id) ? replace(ts, res.pro) : [...ts, res.pro]); if (res.removedProId) setTeam((ts) => ts.filter((p) => p.id !== res.removedProId)); if (res.task) setTasks((ts) => ts.some((t) => t.id === res.task.id) ? replace(ts, res.task) : [res.task, ...ts]); if (res.removedTaskId) setTasks((ts) => ts.filter((t) => t.id !== res.removedTaskId)); if (res.transactions) setTransactions((txs) => { const incoming = res.transactions || []; const ids = new Set(incoming.map((t) => t.id)); return [...incoming, ...txs.filter((t) => !ids.has(t.id))]; }); if (res.transaction) setTransactions((txs) => txs.some((t) => t.id === res.transaction.id) ? replace(txs, res.transaction) : [res.transaction, ...txs]); if (res.removedTxId) setTransactions((txs) => txs.filter((t) => t.id !== res.removedTxId)); if (res.removedTxIds) setTransactions((txs) => txs.filter((t) => !res.removedTxIds.includes(t.id))); if (res.notification) setNotifications((ns) => [res.notification, ...ns]); if (res.notifications) { setNotifications(res.notifications); setFloatingNotifications((items) => items.filter((item) => res.notifications.some((n) => n.id === item.id && !n.read))); } if (res.acknowledgedAppointmentIds) setAcknowledgedAppointmentIds(res.acknowledgedAppointmentIds); }; // envolve ações disparadas "soltas" na UI: erro vira toast const act = (fn) => async (...args) => { try { return await fn(...args); } catch (e) { push(e.message || 'Erro inesperado', 'x'); } }; /* ---- ações (todas via AJAX, sem reload) ---- */ // usadas por modais: propagam o erro para o modal decidir (ex.: manter aberto) const dispatchAppointmentEmail = (job) => { if (!job || !job.appointmentId) return; const type = job.type === 'cancellation' ? 'cancellation' : 'confirmation'; push(type === 'cancellation' ? 'Enviando e-mail de cancelamento...' : 'Enviando e-mail de confirmação...', 'info'); API.appts.sendEmail(job.appointmentId, type) .then((res) => { apply(res || {}); const info = (res && res.email) || {}; const sent = Number(info.sent || 0); const total = Number(info.total || 0); if (total <= 0) { push('Nenhum destinatário com e-mail para avisar.', 'info'); return; } const label = type === 'cancellation' ? 'cancelamento' : 'confirmação'; push(`Confirmação de envio de e-mail de ${label}: ${sent}/${total} enviado${sent === 1 ? '' : 's'}.`, sent > 0 ? 'check' : 'x'); }) .catch((e) => push(e.message || `${terms.appt} salvo, mas não foi possível enviar o e-mail.`, 'x')); }; const addAppt = async (f) => { const payload = { clientId: f.isGroup ? null : (f.clientId || null), clientName: f.isGroup ? '' : (f.clientName || ''), clientIds: f.clientIds || [], proId: f.proId, proIds: f.proIds || [], isGroup: !!f.isGroup, serviceId: f.serviceId, manualServiceName: f.manualServiceName || '', manualServicePrice: f.manualServicePrice === '' ? null : f.manualServicePrice, date: f.date, start: f.start, end: f.end, status: f.status, notes: f.notes, showNotification: f.showNotification !== false, launchFinance: f.launchFinance === true, financeDueDate: f.financeDueDate || '', repeat: f.repeat, repeatUntil: f.repeatUntil || '', repeatDays: f.repeatDays || {}, scope: f.editScope || 'single', quotaAction: f.quotaAction || 'included', addReplacementCredit: f.addReplacementCredit === true, }; if (!f.editId) { payload.sendEmail = f.sendEmail === true; payload.emailReminderMinutes = f.sendEmail === true ? Number(f.emailReminderMinutes || 1440) : 0; } if (!f.editId && f.repeat === 'mes') { payload.monthlyWeekendPolicy = f.monthlyWeekendPolicy || 'keep'; } const res = f.editId ? await API.appts.update(f.editId, payload) : await API.appts.create(payload); apply(res); if (res.emailDispatch) dispatchAppointmentEmail(res.emailDispatch); if (res.skipped > 0) push(res.skipped + ' repetições puladas por conflito de horário', 'info'); return res; }; const addClient = async (d) => { const res = await API.clients.create(d); apply(res); return res.client; }; const updateClient = async (id, d) => { apply(await API.clients.update(id, d)); }; const anonymizeClient = async (id) => { const res = await API.clients.anonymize(id); apply(res); return res.client; }; const addSupplier = async (d) => { const res = await API.suppliers.create(d); apply(res); return res.supplier; }; const updateSupplier = async (id, d) => { apply(await API.suppliers.update(id, d)); }; const deleteSupplier = async (id) => { apply(await API.suppliers.remove(id)); }; const addCompany = async (d) => { apply(await API.companies.create(d)); }; const updateCompany = async (id, d) => { apply(await API.companies.update(id, d)); }; const addService = async (d) => { const res = await API.services.create(d); apply(res); return res.service; }; const updateService = async (id, d) => { apply(await API.services.update(id, d)); }; const addPro = async (d) => { const res = await API.team.create(d); apply(res); if (d.createAcesso) setUser((u) => u ? { ...u, loginUserCount: (u.loginUserCount || 1) + 1 } : u); return res.pro; }; const updatePro = async (id, d) => { apply(await API.team.update(id, d)); if (d.createAcesso) setUser((u) => u ? { ...u, loginUserCount: (u.loginUserCount || 1) + 1 } : u); }; const addTransaction = async (d) => { apply(await API.transactions.create(d)); }; const updateTransaction = async (id, d) => { apply(await API.transactions.update(id, d)); }; // usadas inline: erro vira toast const setApptStatus = async (id, status, options = {}) => { const res = await API.appts.update(id, { status, ...options }); apply(res); if (res.emailDispatch) dispatchAppointmentEmail(res.emailDispatch); return res; }; const respondAppt = act(async (id, response, proId) => { apply(await API.appts.respond(id, response, proId)); }); const deleteAppt = async (id, deleteFinance = false) => { const res = await API.appts.remove(id, deleteFinance); apply(res); return res; }; const cancelClientFuture = async (clientId, seriesId = 0) => { const res = await API.appts.cancelFuture(clientId, seriesId); apply(res); return res; }; const manageApptSeries = async (id, mode) => { const res = await API.appts.seriesAction(id, mode); apply(res); return res; }; const manageApptOrphanFuture = async (id) => { const res = await API.appts.orphanFutureAction(id); apply(res); return res; }; const confirmAllToday = act(async () => { const r = await API.appts.confirmToday(); apply(r); push(r.updated > 0 ? r.updated + ' confirmados!' : 'Nenhum pendente hoje'); }); const deleteClient = act(async (id) => { apply(await API.clients.remove(id)); push(`${terms.client} excluído`); }); const deleteCompany = act(async (id) => { apply(await API.companies.remove(id)); push('Empresa excluída'); }); const deleteService = act(async (id) => { apply(await API.services.remove(id)); push(`${terms.service} excluído`); }); const duplicateService = act(async (s) => { apply(await API.services.create({ name: s.name + ' (cópia)', cat: s.cat, dur: s.dur, price: s.price, desc: s.desc, planFrequency: s.planFrequency, planPeriod: s.planPeriod, commissionType: s.commissionType, commissionValue: s.commissionValue, commissionOverrides: s.commissionOverrides || [] })); push(`${terms.service} duplicado!`); }); const deletePro = act(async (id) => { apply(await API.team.remove(id)); push(terms.pro + ' removido'); }); const addTask = act(async (data) => { apply(await API.tasks.create(typeof data === 'string' ? { text: data } : data)); }); const toggleTask = act(async (id, done) => { apply(await API.tasks.update(id, { done })); }); const deleteTask = act(async (id) => { apply(await API.tasks.remove(id)); }); const payTransaction = act(async (id, payment) => { const payload = typeof payment === 'string' ? { status: 'pago', method: payment } : { status: 'pago', ...(payment || {}) }; apply(await API.transactions.update(id, payload)); push('Pagamento registrado!'); }); const deleteTransaction = act(async (id) => { apply(await API.transactions.remove(id)); push('Lançamento excluído'); }); // notificações: otimista (UI responde na hora, API em segundo plano) const markNotifRead = (id) => { setNotifications((ns) => ns.map((n) => (n.id === id ? { ...n, read: true } : n))); setFloatingNotifications((items) => items.filter((item) => item.id !== id)); API.notifications.read(id).catch(() => {}); }; const markAllNotifs = () => { const pendingIds = [...pendingInvites, ...pendingMeetings].map((item) => item.apptId); setNotifications((ns) => ns.map((n) => ({ ...n, read: true }))); setAcknowledgedAppointmentIds((ids) => [...new Set([...ids, ...pendingIds])]); setFloatingNotifications([]); API.notifications.readAll(pendingIds) .then((res) => { if (res.notifications) setNotifications(res.notifications); if (res.acknowledgedAppointmentIds) setAcknowledgedAppointmentIds(res.acknowledgedAppointmentIds); }) .catch(() => {}); }; const dismissAppt = (apptId) => { setAcknowledgedAppointmentIds((ids) => [...new Set([...ids, apptId])]); API.notifications.readAll([apptId]) .then((res) => { if (res.acknowledgedAppointmentIds) setAcknowledgedAppointmentIds(res.acknowledgedAppointmentIds); }) .catch(() => {}); }; const deleteNotification = (id) => { setNotifications((ns) => ns.filter((n) => n.id !== id)); setFloatingNotifications((items) => items.filter((item) => item.id !== id)); API.notifications.remove(id).catch(() => {}); }; const openNotification = (notification) => { markNotifRead(notification.id); if (notification.apptId) { const appt = appts.find((item) => item.id === notification.apptId); if (appt) setOpenAppt(appt); } setNotifOpen(false); setFloatingNotifications((items) => items.filter((item) => item.id !== notification.id)); }; const closeFloatingNotification = (id) => { dismissedFloatingRef.current.add(id); setFloatingNotifications((items) => items.filter((item) => item.id !== id)); markNotifRead(id); }; // Ao abrir o sino: busca notificações + agenda na hora, para os pendentes // aparecerem imediatamente (sem esperar o ciclo de ~30s) e sempre coerentes. const refreshBell = async () => { try { const [n, a] = await Promise.all([API.notifications.list(), API.appts.list()]); if (n && n.notifications) setNotifications(n.notifications); if (n && n.acknowledgedAppointmentIds) setAcknowledgedAppointmentIds(n.acknowledgedAppointmentIds); if (a && a.appointments) setAppts(a.appointments); } catch { /* silencioso */ } }; const openNotif = () => { if (!notifOpen) refreshBell(); setNotifOpen((prev) => !prev); if (!notifOpen) setFloatingNotifications([]); }; const showNotifications = () => { refreshBell(); setNotifOpen(true); }; // confirma/recusa um convite direto pelo sino (sem abrir o agendamento) const respondInvite = async (apptId, response, notifId) => { try { apply(await API.appts.respond(apptId, response, user && user.proId)); // marca o convite como lido (apaga o ponto do sino) — pelo id recebido ou // localizando a notificação daquele compromisso. const nid = notifId || (notifications.find((n) => n.type === 'invite' && n.apptId === apptId) || {}).id; if (nid) markNotifRead(nid); push(response === 'confirmado' ? 'Presença confirmada' : 'Convite recusado'); } catch (e) { push(e.message || 'Erro ao responder o convite', 'x'); } }; const saveSetting = async (k, v) => { setSettings((s) => ({ ...s, [k]: v })); return API.settings.set(k, v); }; const saveClientCategories = async (categories) => { const value = JSON.stringify(categories || []); const res = await API.settings.set('client_categories', value); setSettings((current) => ({ ...current, client_categories: (res && res.value) || value })); return categories; }; const terms = DB.businessTypes[businessType]; const notifTodayISO = DB.iso(DB.today); const notifLimitISO = DB.iso(DB.addDays(5)); const notificationApptById = new Map(appts.map((a) => [a.id, a])); const notificationCutoff = Date.now() - 5 * 24 * 60 * 60 * 1000; const notificationIsRecent = (n) => { if (n.type === 'pending_read') return false; if (n.apptId) { const appt = notificationApptById.get(n.apptId); if (!appt) return false; if (appt.showNotification === false) return false; if (appt.date < notifTodayISO || appt.date > notifLimitISO) return false; if (appt.date === notifTodayISO && appt.start) { const now = new Date(); const nowStr = String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0'); if (appt.start < nowStr) return false; } return true; } const timestamp = new Date(String(n.createdAt || '').replace(' ', 'T')).getTime(); return Number.isFinite(timestamp) && timestamp >= notificationCutoff; }; const unread = notifications.filter((n) => !n.read && notificationIsRecent(n)).length; const pendingTasks = tasks.filter((t) => !t.done).length; // Pendentes derivados da agenda (fonte da verdade — sempre coerente, não some // ao marcar notificação como lida). Aparecem fixos no topo do sino. const todayISO = DB.iso(DB.today); // Evita que recorrências anuais inundem o sino com pendências distantes. const attentionUntilISO = DB.iso(DB.addDays(5)); const myProId = (user && user.proId) ? user.proId : 0; const apptLabel = (a) => { const c = clients.find((x) => x.id === a.clientId); const s = a.manualServiceName ? { name: a.manualServiceName, price: a.manualServicePrice || 0 } : services.find((x) => x.id === a.serviceId); const svcName = s ? s.name : terms.appt; const clientLabel = a.clientLabel || a.clientName || (c ? c.name : ''); return clientLabel ? (clientLabel + ' — ' + svcName) : svcName; }; const apptWhen = (a) => { const d = new Date(a.date + 'T00:00'); return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' }) + ' · ' + a.start; }; const upcomingAppt = (a) => { if (a.status === 'cancelado' || a.showNotification === false) return false; if (a.date < todayISO || a.date > attentionUntilISO) return false; if (a.date === todayISO && a.start) { const now = new Date(); const nowStr = String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0'); if (a.start < nowStr) return false; } return true; }; // convites que EU (profissional) preciso confirmar const allPendingInvites = myProId ? appts.filter((a) => upcomingAppt(a) && (a.participants || []).some((p) => p.proId === myProId && p.response === 'pendente')) .map((a) => ({ apptId: a.id, title: apptLabel(a), sub: apptWhen(a) })) : []; const acknowledgedSet = new Set(acknowledgedAppointmentIds); const pendingInvites = allPendingInvites.filter((item) => !acknowledgedSet.has(item.apptId)); // reuniões com outros participantes ainda não confirmados (visão de quem marcou) const pendingMeetings = appts .filter((a) => upcomingAppt(a) && a.createdByUserId === (user && user.id) && !allPendingInvites.some((pi) => pi.apptId === a.id)) .map((a) => ({ apptId: a.id, title: apptLabel(a), sub: apptWhen(a), names: (a.participants || []).filter((p) => p.response === 'pendente' && p.proId !== myProId).map((p) => p.name), })) .filter((it) => it.names.length > 0 && !acknowledgedSet.has(it.apptId)); const attentionKeys = new Set(); notifications.filter((n) => !n.read && notificationIsRecent(n)).forEach((n) => attentionKeys.add(n.apptId ? `appt-${n.apptId}` : `notif-${n.id}`)); pendingInvites.forEach((item) => attentionKeys.add(`appt-${item.apptId}`)); pendingMeetings.forEach((item) => attentionKeys.add(`appt-${item.apptId}`)); const attentionCount = attentionKeys.size; const attentionText = pendingInvites.length > 0 ? `${pendingInvites.length} compromisso${pendingInvites.length === 1 ? '' : 's'} aguardando sua confirmação` : pendingMeetings.length > 0 ? `${pendingMeetings.length} compromisso${pendingMeetings.length === 1 ? '' : 's'} aguardando respostas` : `${unread} notificação${unread === 1 ? '' : 'ões'} não lida${unread === 1 ? '' : 's'}`; // limites do plano definidos pelo acesso sênior (0 = ilimitado); vêm no objeto user const limits = { maxUsers: (user && (user.maxUsers ?? user.maxAdmins)) || 0, maxPros: (user && user.maxPros) || 0 }; const ctx = { user, logout: doLogout, limits, terms, appts, clients, companies, suppliers, services, team, tasks, transactions, notifications, settings, statusMap: DB.statusMap, money: DB.money, calPro, clientQ, clientPro, calendarToolbarHost, calendarNewActionHost, historyClientId, openNewAppt: (prefill = {}) => { if (user && (user.role === 'pro' || user.role === 'user') && user.proId) { const requestedPro = prefill.proId && team.some((p) => p.id === prefill.proId) ? prefill.proId : user.proId; setNewAppt({ ...prefill, proId: requestedPro }); } else { setNewAppt(prefill); } }, openAppt: (a) => setOpenAppt(a), toast: push, theme, setTheme, accent, setAccent, businessType, saveSetting, saveClientCategories, go: (r) => { const target = user && user.showBillingMenu === false && r === 'mensalidade' ? 'dashboard' : r; setCalPro(target === 'calendario' ? 'default' : 'all'); if (target === 'historico') setHistoryClientId('all'); setClientQ(''); setClientPro('all'); setRoute(user && user.role === 'user' ? 'calendario' : target); setNavOpen(false); }, openCalendarFor: (proId) => { setCalPro(user && (user.role === 'pro' || user.role === 'user') ? user.proId : proId); setRoute('calendario'); }, openClientsFor: (proId) => { setClientPro(proId); setRoute('clientes'); }, searchClients: (q) => { setClientQ(q); setClientPro('all'); setRoute('clientes'); }, openClientHistory: (clientId) => { setHistoryClientId(Number(clientId)); setRoute('historico'); setNavOpen(false); }, addAppt, setApptStatus, respondAppt, deleteAppt, cancelClientFuture, manageApptSeries, manageApptOrphanFuture, confirmAllToday, addClient, updateClient, anonymizeClient, deleteClient, addSupplier, updateSupplier, deleteSupplier, addCompany, updateCompany, deleteCompany, addService, updateService, deleteService, duplicateService, addPro, updatePro, deletePro, addTask, toggleTask, deleteTask, addTransaction, updateTransaction, payTransaction, deleteTransaction, markNotifRead, markAllNotifs, updateCurrentUser: (u) => setUser((prev) => ({ ...prev, ...u })), }; if (loadError) { return (

Não foi possível carregar

{loadError}
Tentar novamente
); } if (!authChecked || loading) { return (
Carregando sua agenda…
); } if (!user) { if (window.TENANT_SLUG === 'acesso-gratuito') { return ; } return ; } const nav = user.role === 'user' ? [ { group: 'Principal', items: [ { id: 'calendario', label: 'Calendário', icon: 'calendar' }, ]}, ] : [ { group: 'Principal', items: [ { id: 'dashboard', label: 'Dashboard', icon: 'dashboard' }, { id: 'calendario', label: 'Calendário', icon: 'calendar' }, ]}, { group: 'Gestão', items: [ { id: 'clientes', label: terms.clients, icon: 'users' }, ...(user.hasFinance && (user.hasTeam || user.role === 'admin') ? [{ id: 'fornecedores', label: 'Fornecedores', icon: 'building' }] : []), { id: 'servicos', label: terms.services, icon: 'tag' }, ...((user.hasTeam || user.role === 'admin') ? [{ id: 'equipe', label: terms.pros, icon: 'team' }] : []), ]}, { group: 'Operação', items: [ ...(user.hasFinance && user.isPrimaryAdmin ? [{ id: 'financeiro', label: 'Financeiro', icon: 'wallet' }] : []), ...(user.hasInternalChat ? [{ id: 'chat', label: 'Chat interno', icon: 'mail' }] : []), { id: 'tarefas', label: 'Tarefas', icon: 'check2', badge: pendingTasks }, { id: 'historico', label: 'Histórico', icon: 'history' }, ]}, ...(user.role === 'admin' && user.isPrimaryAdmin ? [{ group: 'Administração', items: [ { id: 'usuarios', label: 'Usuários', icon: 'team' }, ...(user.showBillingMenu !== false ? [{ id: 'mensalidade', label: 'Mensalidade', icon: 'wallet' }] : []), ]}] : []), ]; const titles = { dashboard: ['Dashboard', 'Visão geral do seu dia'], calendario: ['Calendário', 'Gerencie todos os ' + terms.appt.toLowerCase() + 's'], clientes: [terms.clients, 'Base de ' + terms.clients.toLowerCase()], fornecedores: ['Fornecedores', 'Cadastro e consulta de fornecedores'], servicos: [terms.services, 'Catálogo e preços'], equipe: [terms.pros, terms.pros + ' e disponibilidade'], financeiro: ['Financeiro', 'Receitas, despesas e pagamentos'], chat: ['Chat interno', 'Comunicação da equipe'], tarefas: ['Tarefas', 'Lembretes e pendências'], historico: ['Histórico', terms.appt + 's anteriores'], usuarios: ['Usuários', 'Acessos da sua empresa'], mensalidade: ['Mensalidade', 'Plano e pagamentos da sua empresa'], config: ['Configurações', 'Preferências'], }; const tenantPlanLabel = user.showBillingMenu === false ? 'Conta ativa' : (user.planName ? 'Plano ' + user.planName : (user.isTrial ? 'Período de teste' : 'Plano ativo')); const visibleRoute = route === 'mensalidade' && user.showBillingMenu === false ? 'dashboard' : route; const isCalendarRoute = visibleRoute === 'calendario'; const renderScreen = () => { const screens = { dashboard: window.Dashboard, calendario: window.CalendarScreen, clientes: window.ClientsScreen, fornecedores: window.SuppliersScreen, servicos: window.ServicesScreen, equipe: window.TeamScreen, financeiro: window.FinanceScreen, chat: InternalChatScreen, tarefas: window.TasksScreen, historico: window.HistoryScreen, usuarios: window.UsersScreen, mensalidade: window.BillingScreen, config: window.SettingsScreen, }; if (user && user.role === 'user' && route !== 'calendario') { return React.createElement(window.CalendarScreen, { key: 'calendario', ...ctx }); } // só admin principal acessa a gestão de usuários e a mensalidade if ((visibleRoute === 'usuarios' || visibleRoute === 'mensalidade') && (!user || user.role !== 'admin' || !user.isPrimaryAdmin)) { return React.createElement(window.Dashboard, { key: 'dashboard', ...ctx }); } // empresa sem equipe: admins ainda acessam para ajustar o próprio perfil/horário. if (visibleRoute === 'equipe' && user && !user.hasTeam && user.role !== 'admin') { return React.createElement(window.Dashboard, { key: 'dashboard', ...ctx }); } if (visibleRoute === 'financeiro' && user && (!user.hasFinance || !user.isPrimaryAdmin)) { return React.createElement(window.Dashboard, { key: 'dashboard', ...ctx }); } if (visibleRoute === 'fornecedores' && user && (!user.hasFinance || (!user.hasTeam && user.role !== 'admin'))) { return React.createElement(window.Dashboard, { key: 'dashboard', ...ctx }); } if (visibleRoute === 'chat' && user && !user.hasInternalChat) { return React.createElement(window.Dashboard, { key: 'dashboard', ...ctx }); } const Comp = screens[visibleRoute]; return Comp ? React.createElement(Comp, { key: visibleRoute, ...ctx }) : React.createElement(MissingScreen, { key: visibleRoute }); }; const [t1, t2] = titles[visibleRoute] || ['', '']; return (
{/* SIDEBAR */} {/* fundo da gaveta (mobile) */}
setNavOpen(false)} /> {/* MAIN */}
{isCalendarRoute ? (
) : (<>

{t1}

{t2}

{user.role !== 'user' &&
{ if (e.key === 'Enter' && e.target.value.trim()) { ctx.searchClients(e.target.value.trim()); e.target.value = ''; e.target.blur(); } }} /> ⌘K
} )}
{notifOpen && (
setNotifOpen(false)} />
)}
{isCalendarRoute ?
: ctx.openNewAppt({})}>Novo}
{user.isExpired && (
{user.showBillingMenu === false ? 'Acesso em modo leitura' : 'Seu período de teste expirou'} {user.showBillingMenu === false ? 'Apenas leitura permitida. Fale com o suporte para voltar a editar.' : 'Apenas leitura permitida. Assine um plano no menu Mensalidade para voltar a editar.'}
{user.role === 'admin' && user.isPrimaryAdmin && user.showBillingMenu !== false && ( )}
)} {!user.isExpired && user.showBillingMenu !== false && trialDaysLeft !== null && trialDaysLeft <= 10 && (
Período de teste quase no fim Seu acesso expira {trialDaysLeft === 0 ? 'hoje' : `em ${trialDaysLeft} dia${trialDaysLeft === 1 ? '' : 's'}`}. Assine um plano para não ser bloqueado.
)} {attentionCount > 0 && (
Atenção necessária {attentionText}
)}
{renderScreen()}
{/* MODAIS */} {newAppt !== null && setNewAppt(null)} />} {openAppt && a.id === openAppt.id) || openAppt} onClose={() => setOpenAppt(null)} />} {floatingNotifications.length > 0 && (
{floatingNotifications.map((notification) => (
openNotification(notification)}>
{notification.title} {notification.desc}
))}
)} {toastNode}
); } ReactDOM.createRoot(document.getElementById('root')).render();