/* ============================================================ AGENDA — App shell + modais compartilhados ============================================================ */ const { useState: uS, useEffect: uE } = React; /* ---------- NOVO / EDITAR AGENDAMENTO ---------- */ function NewApptModal({ ctx, prefill, onClose }) { const { terms, clients, services, team, appts, transactions, toast, user } = ctx; const DB = window.DB; const editing = !!prefill.editId; const isPlainUser = !!(user && user.role === 'user'); const canManage = !!(user && (user.role === 'admin' || user.role === 'pro')); const forcedProId = isPlainUser && user.proId ? Number(user.proId) : 0; // pessoa vinculada ao cliente, se houver vínculo e ela ainda estiver cadastrada na área do segmento const linkedProId = (clientId) => { const c = clients.find(x => x.id === clientId); return c && c.proId && team.some(p => p.id === c.proId && !p.archived) ? c.proId : null; }; const hasPrefill = (key) => prefill[key] !== undefined && prefill[key] !== null && prefill[key] !== ''; const proStart = (proId) => { const p = team.find(x => x.id === proId); return (p && p.start) || '09:00'; }; const initClientId = hasPrefill('clientId') ? Number(prefill.clientId) : ''; const initClientName = hasPrefill('clientName') ? String(prefill.clientName) : ''; const linkedPlanFor = (clientId) => { if (editing || terms.id !== 'pilates' || !clientId) return null; const client = clients.find((item) => Number(item.id) === Number(clientId)); if (!client || !client.planId) return null; return services.find((service) => Number(service.id) === Number(client.planId)) || null; }; const initLinkedPlan = !hasPrefill('serviceId') ? linkedPlanFor(initClientId) : null; const initServiceId = hasPrefill('serviceId') ? Number(prefill.serviceId) : (initLinkedPlan ? Number(initLinkedPlan.id) : ''); const initService = services.find(x => x.id === initServiceId); const shouldLaunchServiceFinance = (service, clientId) => { if (!service || Number(service.price) <= 0) return false; const client = clients.find((item) => Number(item.id) === Number(clientId)); const coveredByMonthlyBilling = terms.id === 'pilates' && client && client.monthlyBillingEnabled && Number(client.planId) === Number(service.id); return !coveredByMonthlyBilling; }; const weeklyPlanLimitFor = (service, clientId) => { if (!service) return 0; const client = clients.find((item) => Number(item.id) === Number(clientId)); const usesClientOverride = !!(client && Number(client.planId) === Number(service.id)); const period = usesClientOverride && client.planPeriod ? client.planPeriod : service.planPeriod; const frequency = usesClientOverride && Number(client.planFrequency) > 0 ? Number(client.planFrequency) : Number(service.planFrequency); return period === 'semana' && frequency > 0 ? Math.min(7, Math.max(1, frequency)) : 0; }; const existingFinanceTx = editing ? (transactions || []).find((t) => Number(t.apptId) === Number(prefill.editId)) : null; const hasFinanceTx = !!existingFinanceTx; const initLaunchFinance = hasFinanceTx || (hasPrefill('launchFinance') ? prefill.launchFinance === true : (editing ? hasFinanceTx : shouldLaunchServiceFinance(initService, initClientId))); const initProId = forcedProId || (hasPrefill('proId') ? Number(prefill.proId) : (initClientId ? (linkedProId(initClientId) || '') : '')); const initStart = hasPrefill('start') ? prefill.start : (initProId ? proStart(initProId) : ''); const initDate = prefill.date || DB.iso(DB.today); const serviceDur = (serviceId) => { const s = services.find(x => x.id === Number(serviceId)); return s ? Math.max(5, Number(s.dur) || 60) : 60; }; const defaultEnd = (start, serviceId = initServiceId) => start ? window.addMinutes(start, serviceDur(serviceId)) : ''; const initialWeeklyPlanLimit = !editing ? weeklyPlanLimitFor(initService, initClientId) : 0; const initialWeekday = new Date(initDate + 'T12:00').getDay(); const initialRepeatDays = initialWeeklyPlanLimit > 0 ? { [initialWeekday]: { start: initStart || '09:00', end: (hasPrefill('end') ? prefill.end : defaultEnd(initStart || '09:00', initServiceId)) } } : {}; const [f, setF] = uS({ clientId: initClientId, clientName: initClientName, proId: initProId, isGroup: false, clientIds: [], proIds: initProId ? [initProId] : [], serviceId: initServiceId, manualServiceName: hasPrefill('manualServiceName') ? String(prefill.manualServiceName) : '', manualServicePrice: hasPrefill('manualServicePrice') ? prefill.manualServicePrice : '', date: initDate, start: initStart, end: hasPrefill('end') ? prefill.end : defaultEnd(initStart, initServiceId), repeat: initialWeeklyPlanLimit > 0 ? 'sem' : 'nao', repeatUntil: '', repeatDays: initialRepeatDays, monthlyWeekendPolicy: hasPrefill('monthlyWeekendPolicy') ? String(prefill.monthlyWeekendPolicy) : 'keep', notes: prefill.notes || '', notify: false, sendEmail: false, emailReminderMinutes: '1440', showNotification: hasPrefill('showNotification') ? prefill.showNotification !== false : true, launchFinance: initLaunchFinance, financeDueDate: (existingFinanceTx && (existingFinanceTx.dueDate || existingFinanceTx.date)) || DB.iso(DB.today), status: editing ? (prefill.status === 'pendente' ? 'pendente' : 'confirmado') : 'pendente', quotaAction: hasPrefill('quotaAction') ? String(prefill.quotaAction) : 'included', }); React.useEffect(() => { if (!forcedProId) return; setF((prev) => ( prev.proId === forcedProId && prev.proIds.length === 1 && prev.proIds[0] === forcedProId && !prev.isGroup ? prev : { ...prev, proId: forcedProId, proIds: [forcedProId], isGroup: false } )); }, [forcedProId]); const [saving, setSaving] = uS(false); const [quickAdd, setQuickAdd] = uS(null); const [quotaPrompt, setQuotaPrompt] = uS(null); const set = (k, num) => (e) => setF({ ...f, [k]: num ? (e.target.value ? Number(e.target.value) : '') : e.target.value }); // Trocar o cliente leva junto a pessoa e, no Pilates, o plano vinculados. const withSelectedClientDefaults = (prev, clientId) => { const previousClient = clients.find((item) => Number(item.id) === Number(prev.clientId)); const previousPlanWasSelected = !!(terms.id === 'pilates' && previousClient && Number(previousClient.planId) > 0 && Number(previousClient.planId) === Number(prev.serviceId)); const clientName = clientId ? '' : prev.clientName; const lp = clientId ? linkedProId(clientId) : null; const plan = linkedPlanFor(clientId); let next = { ...prev, clientId, clientName }; if (plan) { const serviceId = Number(plan.id); const end = prev.start ? defaultEnd(prev.start, serviceId) : prev.end; const weeklyPlanLimit = weeklyPlanLimitFor(plan, clientId); const weekday = prev.date ? new Date(prev.date + 'T12:00').getDay() : new Date().getDay(); next = { ...next, serviceId, manualServiceName: '', manualServicePrice: '', end, launchFinance: shouldLaunchServiceFinance(plan, clientId), ...(weeklyPlanLimit > 0 ? { repeat: 'sem', repeatDays: { [weekday]: { start: prev.start || '09:00', end: end || defaultEnd(prev.start || '09:00', serviceId) } }, } : {}), }; } else if (clientId && previousPlanWasSelected) { next = { ...next, serviceId: '', end: prev.start ? window.addMinutes(prev.start, 60) : prev.end, launchFinance: false, repeat: 'nao', repeatDays: {}, }; } if (forcedProId) return { ...next, proId: forcedProId, proIds: [forcedProId] }; if (!lp || (prev.proId && prev.proId !== lp)) return next; return { ...next, proId: lp }; }; const setClient = (e) => { const clientId = e.target.value ? Number(e.target.value) : ''; setF((prev) => withSelectedClientDefaults(prev, clientId)); }; const setClientName = (e) => { setF((prev) => ({ ...prev, clientName: e.target.value, clientId: '', proId: forcedProId || prev.proId, proIds: forcedProId ? [forcedProId] : prev.proIds })); }; const addGroupClient = (e) => { const cid = e.target.value ? Number(e.target.value) : ''; if (cid && !f.clientIds.includes(cid)) { setF({ ...f, clientIds: [...f.clientIds, cid] }); } }; const removeGroupClient = (cid) => { setF({ ...f, clientIds: f.clientIds.filter(id => id !== cid) }); }; const setPro = (e) => { if (forcedProId) return; const proId = e.target.value ? Number(e.target.value) : ''; // A escolha do instrutor não deve alterar o horário já informado. setF((prev) => ({ ...prev, proId })); }; const setService = (e) => { const serviceId = e.target.value ? Number(e.target.value) : ''; const selected = services.find(x => x.id === serviceId); const hasPrice = !!(selected && Number(selected.price) > 0); setF((prev) => { const end = prev.start ? defaultEnd(prev.start, serviceId) : prev.end; const weeklyPlanLimit = !editing ? weeklyPlanLimitFor(selected, prev.clientId) : 0; const weekday = prev.date ? new Date(prev.date + 'T12:00').getDay() : new Date().getDay(); return { ...prev, serviceId, manualServiceName: serviceId ? '' : prev.manualServiceName, manualServicePrice: serviceId ? '' : prev.manualServicePrice, end, launchFinance: hasPrice ? (editing ? prev.launchFinance : shouldLaunchServiceFinance(selected, prev.clientId)) : false, ...(weeklyPlanLimit > 0 ? { repeat: 'sem', repeatDays: { [weekday]: { start: prev.start || '09:00', end: end || defaultEnd(prev.start || '09:00', serviceId) } }, } : {}), }; }); }; const setStart = (e) => { const start = e.target.value; setF((prev) => ({ ...prev, start, end: start ? defaultEnd(start, prev.serviceId) : '' })); }; const setEnd = (e) => { setF((prev) => ({ ...prev, end: e.target.value })); }; const svc = services.find(s => s.id === f.serviceId) || null; const pro = team.find(p => p.id === f.proId) || null; const cli = clients.find(c => c.id === f.clientId) || null; const weeklyPlanLimit = !editing ? weeklyPlanLimitFor(svc, f.clientId) : 0; const apptIsFeminine = ['Aula', 'Consulta', 'Sessão'].includes(terms.appt); React.useEffect(() => { if (weeklyPlanLimit <= 0 || f.repeat !== 'sem') return; const selectedDays = Object.keys(f.repeatDays || {}); if (selectedDays.length <= weeklyPlanLimit) return; const limitedDays = selectedDays.slice(0, weeklyPlanLimit).reduce((result, key) => { result[key] = f.repeatDays[key]; return result; }, {}); setF((prev) => ({ ...prev, repeatDays: limitedDays })); }, [weeklyPlanLimit, f.repeat]); const responsibleTeam = forcedProId ? team.filter((p) => Number(p.id) === forcedProId && !p.archived) : team.filter((p) => !p.archived); const duration = f.start && f.end ? window.timeToMin(f.end) - window.timeToMin(f.start) : 0; 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 hStart = readHour(ctx.settings.calendar_start, 7); const hEnd = Math.max(hStart + 1, Math.min(24, readHour(ctx.settings.calendar_end, 21))); const slotCapacity = ctx.businessType === 'pilates' ? Math.max(1, Math.min(99, Number(ctx.settings.appointment_slot_capacity) || 1)) : 1; const slotInterval = Math.max(5, Math.min(240, Number(ctx.settings.appointment_slot_interval) || 15)); const slotGap = ctx.businessType === 'pilates' ? Math.max(0, Math.min(240, Number(ctx.settings.appointment_slot_gap) || 0)) : 0; const slotStepFor = (dur) => ctx.businessType === 'pilates' ? Math.max(slotInterval, dur + slotGap) : slotInterval; const selectedProIds = f.isGroup ? f.proIds : (f.proId ? [f.proId] : []); const emailClients = (f.isGroup ? clients.filter((c) => f.clientIds.includes(c.id)) : (cli ? [cli] : [])).filter((c) => c && String(c.email || '').trim()); const emailPros = selectedProIds .map((pid) => team.find((p) => Number(p.id) === Number(pid))) .filter((p) => p && String(p.email || '').trim()); const hasEmailRecipients = emailClients.length > 0 || emailPros.length > 0; uE(() => { if (editing || !canManage || !(user && user.hasAppointmentEmail)) return; setF((current) => { if (current.sendEmail === hasEmailRecipients) return current; return { ...current, sendEmail: hasEmailRecipients }; }); }, [editing, canManage, !!(user && user.hasAppointmentEmail), hasEmailRecipients]); const emailRecipientLabel = hasEmailRecipients ? 'Enviar link de confirmação por E-mail' : 'Confirmação por E-mail indisponível sem e-mail cadastrado'; const reminderOptions = [ { value: '0', label: 'Somente envio inicial' }, { value: '120', label: 'Lembrar 2h antes' }, { value: '720', label: 'Lembrar 12h antes' }, { value: '1440', label: 'Lembrar 24h antes' }, { value: '2880', label: 'Lembrar 2 dias antes' }, { value: '4320', label: 'Lembrar 3 dias antes' }, ]; const monthlyWeekendOptions = [ { value: 'keep', label: 'Manter no dia mesmo se cair no fim de semana' }, { value: 'previous_business_day', label: 'Antecipar para o dia util anterior' }, { value: 'next_business_day', label: 'Adiar para o proximo dia util' }, ]; const hasManualService = String(f.manualServiceName || '').trim() !== ''; const effectiveServiceId = f.serviceId || (isPlainUser && services[0] ? services[0].id : ''); const availabilityForDate = (p, date) => { if (!p || !date || !p.availability) return null; const d = new Date(date + 'T00:00'); const row = p.availability[String(d.getDay())]; return row || null; }; const worksAt = (p, date, start, end) => { const row = availabilityForDate(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 workBoundsFor = (p, date) => { const row = availabilityForDate(p, date); if (row && !row.enabled) return null; const start = window.timeToMin((row && row.start) || p.start || '00:00'); const end = window.timeToMin((row && row.end) || p.end || '23:59'); return { start, end }; }; const apptHasPro = (a, proId) => a.proId === proId || (a.participants || []).some((p) => p.proId === proId); const hasBusy = (proId, start, end) => (appts || []).filter((a) => { if (a.date !== f.date || a.status === 'cancelado' || !apptHasPro(a, proId)) return false; if (editing && a.id === prefill.editId) return false; const aStart = window.timeToMin(a.start); const aEnd = window.timeToMin(window.apptEnd(a)); return aStart < end && aEnd > start; }).length >= slotCapacity; const commonFreeSlots = (() => { if (!f.date || !effectiveServiceId || selectedProIds.length === 0) return []; const dur = Math.max(15, duration > 0 ? duration : serviceDur(effectiveServiceId)); const step = slotStepFor(dur); const bounds = selectedProIds.map((pid) => { const p = team.find((x) => x.id === pid); return p ? workBoundsFor(p, f.date) : null; }); if (bounds.some((b) => !b)) return []; const startMinute = Math.max(hStart * 60, ...bounds.map((b) => b.start)); const endMinute = Math.min(hEnd * 60, ...bounds.map((b) => b.end)); const slots = []; for (let m = startMinute; m + dur <= endMinute; m += step) { const end = m + dur; const ok = selectedProIds.every((pid) => { const p = team.find((x) => x.id === pid); if (!p) return false; return worksAt(p, f.date, m, end) && !hasBusy(pid, m, end); }); if (ok) slots.push({ start: window.minToTime(m), end: window.minToTime(end) }); } return slots; })(); const hasClientInfo = f.isGroup || !!f.clientId || String(f.clientName || '').trim() !== ''; const valid = !!((effectiveServiceId || hasManualService) && hasClientInfo && (!f.isGroup ? !!f.proId : f.proIds.length > 0) && f.date && f.start && f.end && duration > 0); const generatedTimes = []; const timeStep = effectiveServiceId ? slotStepFor(Math.max(15, serviceDur(effectiveServiceId))) : slotInterval; for (let m = hStart * 60; m < hEnd * 60; m += timeStep) generatedTimes.push(window.minToTime(m)); const repeatDayValues = Object.values(f.repeatDays || {}); const sortTimes = (values) => [...new Set(values.filter((value) => /^\d{2}:\d{2}$/.test(String(value || ''))))] .sort((a, b) => window.timeToMin(a) - window.timeToMin(b)); const times = sortTimes([...generatedTimes, f.start, ...repeatDayValues.map((day) => day.start)]); const endTimes = sortTimes([ ...times, ...(hEnd < 24 ? [window.minToTime(hEnd * 60)] : []), f.end, ...repeatDayValues.map((day) => day.end), ]); const ini = (n) => n.split(' ').map(x => x[0]).slice(0, 2).join('').toUpperCase(); const visibleTeam = team.filter((p) => !p.systemIdentity && !p.archived); const maxPros = (ctx.limits && ctx.limits.maxPros) || 0; const maxUsers = (ctx.limits && ctx.limits.maxUsers) || 0; const proLimitReached = maxPros > 0 && visibleTeam.length >= maxPros; const canAddPro = !!(user && user.hasTeam) && !proLimitReached && !isPlainUser; const canQuickAddClient = !isPlainUser; const accessLimitReached = maxUsers > 0 && ((user && user.loginUserCount) || 1) >= maxUsers; const manualPrice = Number(f.manualServicePrice) || 0; const linkedPlanSelected = !!(terms.id === 'pilates' && cli && Number(cli.planId) > 0 && Number(cli.planId) === Number(f.serviceId)); const serviceCoveredByMonthlyBilling = linkedPlanSelected && !!cli.monthlyBillingEnabled; const canLaunchFinance = !!(((svc && Number(svc.price) > 0) || (hasManualService && manualPrice > 0)) && user && user.hasFinance); const launchFinanceOn = hasFinanceTx || !!f.launchFinance; const saveQuickClient = async (data) => { try { const created = await ctx.addClient(data); if (!created) return; setF((prev) => prev.isGroup ? { ...prev, clientIds: prev.clientIds.includes(created.id) ? prev.clientIds : [...prev.clientIds, created.id] } : withSelectedClientDefaults(prev, created.id)); toast(`${terms.client} cadastrado e selecionado!`); setQuickAdd(null); } catch (e) { toast(e.message, 'x'); } }; const saveQuickService = async (data) => { try { const created = await ctx.addService(data); if (!created) return; setF((prev) => ({ ...prev, serviceId: created.id, manualServiceName: '', manualServicePrice: '', end: prev.start ? window.addMinutes(prev.start, Math.max(5, Number(created.dur) || 30)) : prev.end, launchFinance: Number(created.price) > 0 })); toast(`${terms.service} cadastrado e selecionado!`); setQuickAdd(null); } catch (e) { toast(e.message, 'x'); } }; const saveQuickPro = async (data) => { try { const created = await ctx.addPro(data); if (!created) return; setF((prev) => prev.isGroup ? { ...prev, proIds: prev.proIds.includes(created.id) ? prev.proIds : [...prev.proIds, created.id] } : { ...prev, proId: created.id }); toast(`${terms.pro} cadastrado e selecionado!`); setQuickAdd(null); } catch (e) { toast(e.message, 'x'); } }; const handleRepeatChange = (e) => { const repeat = e.target.value; if (repeat === 'sem' && f.date) { // pré-seleciona automaticamente o dia da semana atual com os horários preenchidos const w = new Date(f.date + 'T12:00').getDay(); setF({ ...f, repeat, repeatDays: { ...f.repeatDays, [w]: { start: f.start || '', end: f.end || '' } } }); } else { setF({ ...f, repeat }); } }; const monthEndIso = (monthValue) => { const [year, month] = String(monthValue || '').split('-').map(Number); if (!year || !month) return ''; return new Date(year, month, 0).toISOString().slice(0, 10); }; const planQuotaInfo = () => { if (ctx.businessType !== 'pilates') return null; if (!cli || !cli.planId || !svc || !f.date) return null; const plan = services.find((service) => Number(service.id) === Number(cli.planId)); const limit = Math.max(0, Number(cli.planFrequency || (plan && plan.planFrequency)) || 0); if (!plan || limit <= 0 || (!svc.planFrequency && Number(svc.id) !== Number(cli.planId))) return null; const period = cli.planPeriod || plan.planPeriod || 'semana'; const target = new Date(f.date + 'T12:00'); let start; let end; if (period === 'mes') { const dueDay = Math.max(1, Math.min(31, Number(cli.planDueDay) || 1)); const anchor = (year, month) => new Date(year, month, Math.min(dueDay, new Date(year, month + 1, 0).getDate()), 12, 0, 0); start = anchor(target.getFullYear(), target.getMonth()); if (target < start) start = anchor(target.getFullYear(), target.getMonth() - 1); const next = anchor(start.getFullYear(), start.getMonth() + 1); end = new Date(next); end.setDate(end.getDate() - 1); } else { start = new Date(target); start.setDate(target.getDate() - ((target.getDay() + 6) % 7)); end = new Date(start); end.setDate(start.getDate() + 6); } const startISO = DB.iso(start); const endISO = DB.iso(end); const count = (appts || []).filter((a) => { if (Number(a.clientId) !== Number(cli.id) || a.status === 'cancelado' || a.quotaAction === 'extra' || a.quotaAction === 'replacement') return false; if (editing && Number(a.id) === Number(prefill.editId)) return false; const appointmentService = services.find((service) => Number(service.id) === Number(a.serviceId)); return appointmentService && (Number(appointmentService.planFrequency) > 0 || Number(appointmentService.id) === Number(cli.planId)) && a.date >= startISO && a.date <= endISO; }).length; const extraPrice = Number(cli.extraClassPrice) > 0 ? Number(cli.extraClassPrice) : (Number(plan.price) > 0 ? Number(plan.price) : Number(svc.price) || 0); return { count, limit, period, startISO, endISO, extraPrice, credits: Math.max(0, Number(cli.replacementCredits) || 0), planName: plan.name }; }; const save = async (quotaChoice = '') => { const selectedQuotaAction = typeof quotaChoice === 'string' && ['extra', 'replacement'].includes(quotaChoice) ? quotaChoice : ''; if (saving) return; if (launchFinanceOn && !f.financeDueDate) { toast('Informe a data para receber.', 'info'); return; } if (!valid) { toast(!hasClientInfo ? `Informe ${terms.client.toLowerCase()} para este ${terms.appt.toLowerCase()}.` : (duration <= 0 && f.start && f.end ? 'O horário final deve ser maior que o início.' : `Selecione ${terms.pro.toLowerCase()} e horários.`), 'info'); return; } const quota = !f.isGroup && f.quotaAction === 'included' ? planQuotaInfo() : null; if (quota && quota.count >= quota.limit && !selectedQuotaAction) { if (f.repeat !== 'nao') { toast('O limite do plano foi atingido. Reposições e aulas extras devem ser agendadas individualmente.', 'info'); return; } setQuotaPrompt(quota); return; } setQuotaPrompt(null); setSaving(true); const finalRepeatUntil = (f.repeat === 'mes' && f.repeatUntil && f.repeatUntil.length === 7) ? monthEndIso(f.repeatUntil) : f.repeatUntil; try { let chosenService = svc; let serviceIdToSave = effectiveServiceId; if (!serviceIdToSave && hasManualService) { chosenService = { name: String(f.manualServiceName).trim(), dur: Math.max(5, duration || 30), price: manualPrice, }; } await ctx.addAppt({ ...f, quotaAction: selectedQuotaAction || f.quotaAction || 'included', sendEmail: canManage && user.hasAppointmentEmail && hasEmailRecipients && f.sendEmail, serviceId: serviceIdToSave, repeatUntil: finalRepeatUntil, editId: prefill.editId, editScope: prefill.editScope || 'single' }); const fem = ['Aula', 'Consulta', 'Sessão'].includes(terms.appt); const art = fem ? 'a' : 'o'; toast(editing ? `${terms.appt} atualizad${art}!` : `${terms.appt} criad${art}!`, 'calendar'); if (f.notify && !editing && cli && cli.phone) { const when = new Date(f.date + 'T00:00').toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' }); const article = ctx.businessType === 'pilates' ? 'Sua' : 'Seu'; window.waOpen(cli.phone, `Olá, ${cli.name.split(' ')[0]}! ${article} ${terms.appt.toLowerCase()} de ${chosenService.name} está marcado para ${when}, das ${f.start} às ${f.end}, com ${pro.name}. Podemos confirmar?`); } onClose(); } catch (e) { toast(e.message, 'x'); // ex.: conflito de horário — mantém o modal aberto } setSaving(false); }; return ( <> quickAdd ? setQuickAdd(null) : onClose()} footer={<>
Agendado por Você ({(user && (user.name || user.login)) || 'você'})
Cancelar {saving ? <> Salvando... : (editing ? 'Salvar alterações' : 'Confirmar ' + terms.appt.toLowerCase())} }> {/* destaque: para quem / com quem */}
{!editing && !isPlainUser && user.hasGroupMeetings && ( )}
{f.isGroup ? (
{clients.filter(c => !f.clientIds.includes(c.id)).map(c => )} {canQuickAddClient && }
{f.clientIds.length > 0 && (
{f.clientIds.map(cid => { const c = clients.find(x => x.id === cid); return (
{c ? c.name.split(' ')[0] : '...'}
removeGroupClient(cid)}>
); })}
)}
) : isPlainUser ? (
) : (
{clients.map(c => )} {canQuickAddClient && }
{!f.clientId && }
)}
{f.isGroup ? (
{ const pid = Number(e.target.value); if (pid && !f.proIds.includes(pid)) setF({ ...f, proIds: [...f.proIds, pid] }); }} style={{ flex: 1 }}> {responsibleTeam.filter(p => !f.proIds.includes(p.id)).map(p => )} {canAddPro && }
{f.proIds.length > 0 && (
{f.proIds.map(pid => { const p = team.find(x => x.id === pid); return (
{p ? p.name.split(' ')[0] : '...'}
setF({ ...f, proIds: f.proIds.filter(x => x !== pid) })}>
); })}
)}
) : (
{responsibleTeam.map(p => )} {canAddPro && }
)} {!f.isGroup && (() => { const lp = linkedProId(f.clientId); if (!lp || !cli) return null; const lpro = team.find(p => p.id === lp); if (!lpro) return null; return lp === f.proId ?
{terms.pro} vinculado a {cli.name.split(' ')[0]}
:
{cli.name.split(' ')[0]} tem vínculo com {lpro.name}
; })()}
{services.map(s => )} {!isPlainUser && }
{linkedPlanSelected &&
Plano vinculado a {cli.name} selecionado.
}
{!f.serviceId && !isPlainUser && <> setF((prev) => ({ ...prev, manualServicePrice: value, launchFinance: Number(value) > 0 && !editing }))} />
Uso somente neste agendamento; não será incluído no cadastro de compromissos.
} {canLaunchFinance && (
{launchFinanceOn &&
{hasFinanceTx ? 'O lançamento já foi criado no financeiro.' : 'Será lançado como A receber nesta data.'}
} {serviceCoveredByMonthlyBilling && !launchFinanceOn &&
Esta aula já faz parte da mensalidade automática; o lançamento individual ficou desativado por padrão.
}
)}
{times.map(t => )} {endTimes.map(t => )}
{selectedProIds.length > 1 && f.serviceId && (
{'Hor\u00e1rios livres em comum'}
{selectedProIds.length} participante{selectedProIds.length > 1 ? 's' : ''}
{commonFreeSlots.length ? (
{commonFreeSlots.map((slot) => ( ))}
) : (
{'Nenhum hor\u00e1rio livre encontrado neste dia para todos os selecionados.'}
)}
)} {!editing && (
{f.repeat !== 'nao' &&
Em branco, os {terms.appt.toLowerCase()}s serão criados por 1 ano.
} {f.repeat === 'mes' && ( {monthlyWeekendOptions.map((opt) => )}
Quando o mês não tiver esse dia, o sistema usa o último dia válido e depois aplica esta regra.
)}
)} {f.repeat === 'sem' && (
0 ? 4 : 10 }}>Dias e horários {apptIsFeminine ? 'das próximas' : 'dos próximos'} {terms.appt.toLowerCase()}s
{weeklyPlanLimit > 0 &&
Este plano permite {weeklyPlanLimit} {weeklyPlanLimit === 1 ? 'dia' : 'dias'} por semana.
}
{['Domingo','Segunda','Terça','Quarta','Quinta','Sexta','Sábado'].map((dayName, w) => { const repeatDays = f.repeatDays || {}; const isSelected = !!repeatDays[w]; const limitReached = weeklyPlanLimit > 0 && Object.keys(repeatDays).length >= weeklyPlanLimit; const isDisabled = !isSelected && limitReached; return (
{ if (!isSelected) { if (isDisabled) { toast(`Este plano permite somente ${weeklyPlanLimit} ${weeklyPlanLimit === 1 ? 'dia' : 'dias'} por semana.`, 'info'); return; } setF({ ...f, repeatDays: { ...repeatDays, [w]: { start: f.start || '09:00', end: f.end || '10:00' } } }); } else { const copy = { ...repeatDays }; delete copy[w]; setF({ ...f, repeatDays: copy }); } }} style={{ padding: '6px 14px', borderRadius: 20, cursor: isDisabled ? 'not-allowed' : 'pointer', fontSize: 13, fontWeight: 600, background: isSelected ? 'var(--primary)' : 'var(--surface-3)', color: isSelected ? '#fff' : 'var(--text-1)', border: isSelected ? '1px solid var(--primary)' : '1px solid var(--border)', transition: 'all 0.2s', userSelect: 'none', opacity: isDisabled ? .5 : 1 }}> {dayName.substring(0, 3)}
); })}
{Object.keys(f.repeatDays || {}).length > 0 && (
{Object.keys(f.repeatDays || {}).map((wStr) => { const w = Number(wStr); const rd = (f.repeatDays || {})[w]; return (
{['Domingo','Segunda','Terça','Quarta','Quinta','Sexta','Sábado'][w]} { const st = e.target.value; const ed = st ? defaultEnd(st, effectiveServiceId) : rd.end; setF({ ...f, repeatDays: { ...f.repeatDays, [w]: { start: st, end: ed } } }); }}> {times.map(t => )} até { setF({ ...f, repeatDays: { ...f.repeatDays, [w]: { ...rd, end: e.target.value } } }); }}> {endTimes.map(t => )}
); })}
)}
A partir do dia seguinte {apptIsFeminine ? 'à primeira' : 'ao primeiro'} {terms.appt.toLowerCase()}, os dias selecionados gerarão repetições semanais nos horários definidos, até a data final ou por 1 ano se ela ficar em branco.
)} {editing && }
{/* resumo + lembrete */}
{valid ? `${new Date(f.date + 'T00:00').toLocaleDateString('pt-BR', { weekday: 'short', day: 'numeric', month: 'short' })} · ${f.start}–${f.end} (${duration} min)` : (!hasClientInfo ? `Informe ${terms.client.toLowerCase()}` : `Selecione ${terms.pro.toLowerCase()} e horários`)}
{( )} {!editing && !isPlainUser && ( )} {!editing && canManage && user.hasAppointmentEmail && (
✉️ {emailRecipientLabel} hasEmailRecipients && setF({ ...f, sendEmail: !f.sendEmail, emailReminderMinutes: f.emailReminderMinutes || '1440' })} disabled={!hasEmailRecipients} />
)} {!editing && canManage && user.hasAppointmentEmail && hasEmailRecipients && f.sendEmail && ( {reminderOptions.map((opt) => )} )}
{quotaPrompt && !saving && setQuotaPrompt(null)} footer={<> setQuotaPrompt(null)}>Voltar {quotaPrompt.credits > 0 && save('replacement')}>Usar 1 crédito} save('extra')}> {saving ? <> Salvando... : `Aula extra · ${DB.money(quotaPrompt.extraPrice)}`} }>
{quotaPrompt.planName}
Período: {new Date(quotaPrompt.startISO + 'T12:00').toLocaleDateString('pt-BR')} até {new Date(quotaPrompt.endISO + 'T12:00').toLocaleDateString('pt-BR')}.
{quotaPrompt.credits > 0 ?
Há {quotaPrompt.credits} crédito{quotaPrompt.credits === 1 ? '' : 's'} de reposição disponível{quotaPrompt.credits === 1 ? '' : 'is'}.
:
Não há crédito de reposição. A nova aula será registrada como extra pelo valor mostrado.
}
} {quickAdd === 'client' && setQuickAdd(null)} onSave={saveQuickClient} />} {quickAdd === 'pro' && setQuickAdd(null)} onSave={saveQuickPro} />} {quickAdd === 'service' && setQuickAdd(null)} onSave={saveQuickService} />} ); } /* ---------- DETALHE DO AGENDAMENTO ---------- */ function ApptDrawer({ ctx, appt, onClose }) { const { terms, clients, services, team, transactions, statusMap, toast, user } = ctx; const DB = window.DB; const [rescheduleScopeOpen, setRescheduleScopeOpen] = React.useState(false); const [seriesActionOpen, setSeriesActionOpen] = React.useState(false); const [cancelConfirmOpen, setCancelConfirmOpen] = React.useState(false); const [addReplacementCredit, setAddReplacementCredit] = React.useState(true); const [absenceConfirmOpen, setAbsenceConfirmOpen] = React.useState(false); const [statusBusy, setStatusBusy] = React.useState(''); const c = clients.find(x => x.id === appt.clientId); const s = appt.manualServiceName ? { name: appt.manualServiceName, price: appt.manualServicePrice || 0, color: 'violet' } : services.find(x => x.id === appt.serviceId); const p = team.find(x => x.id === appt.proId); const clientPlan = c && services.find(x => Number(x.id) === Number(c.planId)); const canGrantReplacementCredit = !!(ctx.businessType === 'pilates' && c && Number(c.planId) > 0 && Number(c.planFrequency || (clientPlan && clientPlan.planFrequency)) > 0 && appt.quotaAction !== 'extra' && (Number(appt.serviceId) === Number(c.planId) || Number(s && s.planFrequency) > 0)); const clientColor = c ? (DB.colors[Number(c.id) % DB.colors.length] || DB.personColorFallback) : DB.personColorFallback; const typedClientName = String(appt.clientLabel || appt.clientName || (c ? c.name : '')).trim(); const st = statusMap[appt.status]; if (!s || !p) { return null; } const ini = (n) => n.split(' ').map(x => x[0]).slice(0, 2).join('').toUpperCase(); const cancellationEmailRecipients = Math.max(0, Number(appt.emailNotifiedRecipientCount) || 0); const setStatus = async (status, msg, opts = {}) => { if (statusBusy) return; if (status === 'cancelado' && !opts.confirmed) { setCancelConfirmOpen(true); return; } if (status === 'faltou' && !opts.confirmed) { setAbsenceConfirmOpen(true); return; } if (status === 'cancelado') setCancelConfirmOpen(false); if (status === 'faltou') setAbsenceConfirmOpen(false); setStatusBusy(status); try { await ctx.setApptStatus(appt.id, status, status === 'cancelado' ? { addReplacementCredit: !!(canGrantReplacementCredit && opts.addReplacementCredit) } : {}); toast(status === 'cancelado' && canGrantReplacementCredit && opts.addReplacementCredit ? `${msg} · 1 crédito de reposição adicionado` : msg); onClose(); } catch (e) { toast(e.message || 'Erro ao atualizar.', 'x'); setStatusBusy(''); } }; const setStatusInline = async (status, msg) => { if (statusBusy) return; setStatusBusy(status); try { await ctx.setApptStatus(appt.id, status); toast(msg); } catch (e) { toast(e.message || 'Erro ao atualizar.', 'x'); } setStatusBusy(''); }; const myParticipant = user && user.proId ? (appt.participants || []).find((part) => Number(part.proId) === Number(user.proId)) : null; const isCreator = !!(user && Number(appt.createdByUserId) === Number(user.id)); const isPlainUser = !!(user && user.role === 'user'); const canManage = !!(user && (user.role === 'admin' || (user.role === 'pro' && isCreator))); const canConfirmClient = !!(canManage || (user && user.role === 'pro' && user.proId && Number(appt.proId) === Number(user.proId))); const canDelete = !!(user && (canManage || (isPlainUser && isCreator))); const canManageSeries = !!(appt.seriesId && canDelete); const canManageOrphanFuture = !!(!appt.seriesId && appt.createdAt && canDelete); const participantCanRespond = myParticipant && !['cancelado', 'concluido', 'faltou'].includes(appt.status); const isFutureAppointment = appt.date > DB.iso(DB.today); const responseLabel = { pendente: ['Pendente', 'amber'], confirmado: ['Confirmou', 'green'], recusado: ['Recusou', 'red'] }; const headBadge = myParticipant && !isCreator && myParticipant.response === 'confirmado' ? { label: 'Presen\u00e7a confirmada', cls: 'green' } : myParticipant && !isCreator && myParticipant.response === 'recusado' ? { label: 'Presen\u00e7a recusada', cls: 'red' } : st; const respond = async (response) => { await ctx.respondAppt(appt.id, response, user && user.proId); toast(response === 'confirmado' ? 'Presen\u00e7a confirmada' : 'Presen\u00e7a recusada'); onClose(); }; const respondFor = async (part, response) => { await ctx.respondAppt(appt.id, response, part.proId); toast(response === 'confirmado' ? `${part.name} confirmado` : `${part.name} recusou`); }; const remove = async () => { if (!window.confirm('Excluir este registro? Essa ação não pode ser desfeita.')) return; const hasLinkedFinance = !!appt.hasFinanceTransaction || (transactions || []).some((tx) => Number(tx.apptId) === Number(appt.id)); const deleteFinance = hasLinkedFinance && window.confirm('Este agendamento possui um lançamento no financeiro.\n\nClique em OK para excluir também o lançamento financeiro.\nClique em Cancelar para manter o lançamento financeiro.\n\nO agendamento será excluído em ambos os casos.'); try { const result = await ctx.deleteAppt(appt.id, deleteFinance); const fem = ['Aula', 'Consulta', 'Sessão'].includes(terms.appt); const financeMessage = deleteFinance ? ' · lançamento financeiro excluído' : (result && result.financeTransactionKept ? ' · lançamento financeiro mantido' : ''); toast(terms.appt + (fem ? ' excluída' : ' excluído') + financeMessage); onClose(); } catch (e) { toast(e.message, 'x'); } }; const manageSeries = async (mode) => { if (statusBusy) return; setStatusBusy(mode === 'delete' ? 'delete_future' : 'end_series'); try { const res = await ctx.manageApptSeries(appt.id, mode); const count = Number(res && res.updated ? res.updated : 0); if (mode === 'delete') { toast(count > 0 ? `${count} ${count === 1 ? 'ocorrência excluída da série' : 'ocorrências excluídas da série'}.` : 'Recorrência encerrada. Não havia ocorrências para excluir.'); } else { toast(count > 0 ? `${count} ${count === 1 ? 'ocorrência futura cancelada' : 'ocorrências futuras canceladas'}.` : 'Recorrência encerrada. Não havia futuras para cancelar.'); } setSeriesActionOpen(false); onClose(); } catch (e) { toast(e.message || 'Erro ao atualizar a recorrência.', 'x'); setStatusBusy(''); } }; const manageOrphanFuture = async () => { if (statusBusy) return; setStatusBusy('orphan_future'); try { const res = await ctx.manageApptOrphanFuture(appt.id); const count = Number(res && res.updated ? res.updated : 0); toast(count > 0 ? `${count} ${count === 1 ? terms.appt.toLowerCase() + ' excluido do lote' : terms.appt.toLowerCase() + 's excluidos do lote'}.` : `Nenhum ${terms.appt.toLowerCase()} futuro encontrado neste lote.`); setSeriesActionOpen(false); onClose(); } catch (e) { toast(e.message || 'Erro ao excluir o lote.', 'x'); setStatusBusy(''); } }; const reschedule = (scope = 'single') => { setRescheduleScopeOpen(false); onClose(); ctx.openNewAppt({ editId: appt.id, editScope: scope, clientId: appt.clientId, clientName: appt.clientLabel || appt.clientName, serviceId: appt.serviceId, manualServiceName: appt.manualServiceName, manualServicePrice: appt.manualServicePrice, proId: appt.proId, date: appt.date, start: appt.start, end: window.apptEnd(appt), status: appt.status, notes: appt.notes, showNotification: appt.showNotification, launchFinance: appt.launchFinance, quotaAction: appt.quotaAction }); }; return ( <> rescheduleScopeOpen ? setRescheduleScopeOpen(false) : (seriesActionOpen ? setSeriesActionOpen(false) : (absenceConfirmOpen ? setAbsenceConfirmOpen(false) : onClose()))} head={
{headBadge.label}#{appt.id}
} footer={!canManage ? null : appt.status === 'concluido' || appt.status === 'cancelado' || appt.status === 'faltou' ? ( setStatus('confirmado', 'Reagendado')}>{statusBusy === 'confirmado' ? <> Salvando... : 'Reabrir'} ) : (<> {appt.status === 'confirmado' && isCreator && !isFutureAppointment && setStatus('concluido', 'Marcado como concluído')}>Concluir} {!isFutureAppointment && setAbsenceConfirmOpen(true)}>{statusBusy === 'faltou' ? <> Salvando... : 'Registrar falta'}} setCancelConfirmOpen(true)}>{statusBusy === 'cancelado' ? <> Cancelando... : 'Cancelar'} )}>
{appt.start} – {window.apptEnd(appt)}
{new Date(appt.date + 'T00:00').toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' })}
{terms.service}
{s.name}
{s.dur} min
{appt.quotaAction === 'extra' && Aula extra · {DB.money(appt.extraClassPrice || s.price || 0)}} {appt.quotaAction === 'replacement' && Reposição com crédito} {appt.replacementCreditGranted && Gerou crédito de reposição}
{s.price > 0 ? DB.money0(s.price) : 'Interno'}
{terms.client}
{c ? c.name : (typedClientName || `Sem ${terms.client.toLowerCase()} informado`)}
{c ? ([c.companyName, c.companyRole || c.phone || 'Sem telefone'].filter(Boolean).join(' · ')) : (typedClientName ? 'Informado manualmente' : 'Compromisso interno ou avulso')}
{c && {appt.status === 'confirmado' ? 'Confirmado' : (appt.status === 'pendente' ? 'Pendente' : st.label)}} {c && canConfirmClient && appt.status === 'pendente' && setStatusInline('confirmado', `${terms.client} confirmado`)}>Confirmar} {c && c.phone && }
{terms.pro} {(appt.participants || []).length > 0 && <> {'Participantes \u00b7 '}{(appt.participants || []).filter((x) => x.response === 'confirmado').length}/{(appt.participants || []).length} confirmaram
{(appt.participants || []).map((part) => { const meta = responseLabel[part.response] || responseLabel.pendente; const responseDate = part.responseAt ? new Date(String(part.responseAt).replace(' ', 'T')) : null; const responseWhen = responseDate && !Number.isNaN(responseDate.getTime()) ? responseDate.toLocaleDateString('pt-BR') + ' às ' + responseDate.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : ''; return (
{part.name}
{part.role || terms.pro}
{responseWhen && part.response !== 'pendente' &&
{meta[0]} em {responseWhen}
}
{meta[0]} {participantCanRespond && Number(part.proId) === Number(user && user.proId) && part.response === 'pendente' && ( <> respond('confirmado')}>Confirmar respond('recusado')}>Recusar )} {participantCanRespond && Number(part.proId) === Number(user && user.proId) && part.response === 'recusado' && ( respond('confirmado')}>Confirmar )} {participantCanRespond && Number(part.proId) === Number(user && user.proId) && part.response === 'confirmado' && !isCreator && ( respond('recusado')}>Recusar )} {canManage && !(participantCanRespond && Number(part.proId) === Number(user && user.proId)) && !['cancelado', 'concluido', 'faltou'].includes(appt.status) && part.response !== 'confirmado' && ( respondFor(part, 'confirmado')}>Confirmar )}
); })}
} {appt.notes && <>Observações
{appt.notes}
} Registro
Agendado por {appt.createdBy || 'Secretaria'}
{appt.createdAt &&
Criado há {DB.timeAgo(appt.createdAt)}
}
{(canManage || canDelete) &&
{canManage && appt.seriesId ? setRescheduleScopeOpen(true) : reschedule('single')}>Remarcar} {(canManageSeries || canManageOrphanFuture) && setSeriesActionOpen(true)}>Recorrência} {canDelete && Excluir}
}
{cancelConfirmOpen && !statusBusy && setCancelConfirmOpen(false)} footer={<> setCancelConfirmOpen(false)}>Manter {terms.appt.toLowerCase()} setStatus('cancelado', `${terms.appt} cancelado`, { confirmed: true, addReplacementCredit })}> {statusBusy === 'cancelado' ? <> Cancelando... : `Cancelar ${terms.appt.toLowerCase()}`} }>
Tem certeza que deseja cancelar este {terms.appt.toLowerCase()}?
O horário será marcado como cancelado e deixará de contar como {terms.appt.toLowerCase()} ativo.
{s.name}
{new Date(appt.date + 'T00:00').toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' })} · {appt.start} - {window.apptEnd(appt)}
{(c || typedClientName) &&
{c ? c.name : typedClientName}
}
{cancellationEmailRecipients > 0 && (
O cancelamento será enviado para {cancellationEmailRecipients} destinatário{cancellationEmailRecipients === 1 ? '' : 's'} que já recebeu confirmação ou lembrete por e-mail.
)} {canGrantReplacementCredit && }
} {absenceConfirmOpen && !statusBusy && setAbsenceConfirmOpen(false)} footer={<> setAbsenceConfirmOpen(false)}>Voltar setStatus('faltou', 'Falta registrada', { confirmed: true })}> {statusBusy === 'faltou' ? <> Salvando... : 'Marcar como faltou'} }>
A pessoa não compareceu?
O status ficará como faltou, sem contar como {terms.appt.toLowerCase()} concluído. Se precisar, você pode reabrir depois.
{s.name}
{new Date(appt.date + 'T00:00').toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' })} · {appt.start} - {window.apptEnd(appt)}
{(c || typedClientName) &&
{c ? c.name : typedClientName}
}
} {rescheduleScopeOpen && setRescheduleScopeOpen(false)} footer={<>
setRescheduleScopeOpen(false)}>Voltar}>
{(appt.participants || []).length <= 1 && }
} {seriesActionOpen && !statusBusy && setSeriesActionOpen(false)} footer={<>
setSeriesActionOpen(false)}>Voltar}>
{canManageSeries && } {canManageSeries && } {canManageOrphanFuture && }
} ); } /* ---------- PAINEL DE NOTIFICAÇÕES ---------- */ function NotifPanel({ notifications, appts = [], pendingInvites = [], pendingMeetings = [], onRead, onMarkRead, onReadAll, onRespond, onDismissAppt, onClose }) { const DB = window.DB; const [showOlder, setShowOlder] = React.useState(false); const iconMap = { check: 'check', calendar: 'calendar', x: 'x', card: 'card', bell: 'bell' }; const colorMap = { confirm: 'var(--success)', new: 'var(--info)', cancel: 'var(--danger)', pay: 'var(--ev-cyan)', remind: 'var(--warning)', invite: 'var(--primary)', pending_read: 'var(--warning)' }; const hasPending = pendingInvites.length > 0 || pendingMeetings.length > 0; const todayISO = window.DB.iso(window.DB.today); const limitISO = window.DB.iso(window.DB.addDays(5)); const cutoff = Date.now() - 5 * 24 * 60 * 60 * 1000; const apptById = new Map((appts || []).map((a) => [a.id, a])); const isRecent = (n) => { if (n.type === 'pending_read') return false; if (n.apptId) { const appt = apptById.get(n.apptId); if (!appt) return false; if (appt.showNotification === false) return false; if (appt.date < todayISO || appt.date > limitISO) return false; if (appt.date === todayISO && 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 >= cutoff; }; const recentNotifications = notifications.filter((n) => !n.read && isRecent(n)); const olderCount = notifications.length - recentNotifications.length; const visibleNotifications = showOlder ? notifications : recentNotifications; return (
e.stopPropagation()}>
Notificações
{/* Pendentes fixos no topo — derivados da agenda, não somem ao marcar lido */} {hasPending && (
Confirmações pendentes
{/* convites que EU preciso confirmar (com ação) */} {pendingInvites.map((it) => (
{it.title}
{it.sub}
onRespond(it.apptId, 'confirmado')}>Confirmar onRespond(it.apptId, 'recusado')}>Recusar
))} {/* quem ainda não confirmou (visão de quem marcou) */} {pendingMeetings.map((it) => (
{it.title}
{it.sub} · aguardando
{it.names.map((nm, i) => {nm})}
))}
)} {visibleNotifications.length === 0 && !hasPending &&
{olderCount > 0 ? 'Nenhuma notificação nos próximos 5 dias.' : 'Nenhuma notificação.'}
} {visibleNotifications.map(n => (
onRead(n)}>
{n.title}
{n.desc}
{DB.timeAgo(n.createdAt)} {!n.read && onMarkRead && ( )}
))} {olderCount > 0 && ( )}
); } /* ---------- TROCAR TIPO DE NEGÓCIO ---------- */ function BusinessModal({ current, onSelect, onClose }) { const types = Object.values(window.DB.businessTypes); return (
{types.map(t => ( ))}
); } /* ---------- SETTINGS ---------- */ function SettingsScreen(ctx) { const { theme, setTheme, accent, setAccent, businessType, settings, saveSetting, team, user } = ctx; const calendarStart = settings.calendar_start || '07:00'; const calendarEnd = settings.calendar_end || '21:00'; const slotCapacity = settings.appointment_slot_capacity || '1'; const slotInterval = settings.appointment_slot_interval || '15'; const slotGap = settings.appointment_slot_gap || '0'; const activeTeam = (team || []).filter((p) => !p.archived); const savedCalendarScope = String(settings.calendar_default_scope || 'mine'); const calendarDefaultScope = savedCalendarScope === 'mine' || savedCalendarScope === 'all' || activeTeam.some((p) => savedCalendarScope === `pro:${p.id}`) ? savedCalendarScope : 'mine'; const themes = [ { id: 'indigo', name: 'Índigo', color: '#3a5bd9' }, { id: 'esmeralda', name: 'Esmeralda', color: '#0f9d77' }, { id: 'vinho', name: 'Vinho', color: '#c23a5e' }, { id: 'ambar', name: 'Âmbar', color: '#c97a16' }, ]; const notifPrefs = [ ['notif_whatsapp', 'Confirmações automáticas por WhatsApp'], ['notif_remind24', 'Lembrete 24h antes'], ['notif_daily_email', 'Resumo diário por e-mail'], ]; return (

Aparência

Tema escuro
Reduz o brilho em ambientes com pouca luz
setTheme(theme === 'dark' ? 'light' : 'dark')} />

Temas

Cor de destaque do sistema — vale para botões, menus, calendário e gráficos
{themes.map(t => ( ))}

Negócio

Tipo de negócio
{window.DB.businessTypes[businessType].name}
Definido pelo provedor

Calendário

saveSetting('calendar_default_scope', e.target.value)} disabled={user && user.role === 'user'}> {(!user || user.role !== 'user') && } {(!user || user.role !== 'user') && activeTeam.map((p) => )}
Usuários comuns continuam vendo somente a própria agenda, independentemente desta preferência.
saveSetting('calendar_start', e.target.value)} /> saveSetting('calendar_end', e.target.value)} /> saveSetting('appointment_slot_interval', String(Math.max(5, Math.min(240, Number(e.target.value) || 15))))} /> {businessType !== 'pilates' &&
Define de quantos em quantos minutos aparecem as opcoes e horarios livres.
}
{businessType === 'pilates' && ( saveSetting('appointment_slot_gap', String(Math.max(0, Math.min(240, Number(e.target.value) || 0))))} /> )} {businessType === 'pilates' && ( saveSetting('appointment_slot_capacity', String(Math.max(1, Math.min(99, Number(e.target.value) || 1))))} /> )}

Notificações

{notifPrefs.map(([key, label]) => (
{label}
Em breve
))}
); } Object.assign(window, { NewApptModal, ApptDrawer, NotifPanel, BusinessModal, SettingsScreen });