From 26f39a60f39ee2a94a6e18ac2ac5614e65620fbc Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Mon, 6 Jul 2026 17:08:49 +0200 Subject: [PATCH 1/6] feat(newwhats): endpoints de encaixe (analisar + mover) p/ adiantar consulta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /encaixe/analisar: ocupação do horário desejado (quem está nele) + horário livre mais cedo (flex-inclusivo) p/ oferecer/encaixar. - POST /encaixe/mover: remarca um agendamento (o A aceitou adiantar), com checagem de conflito no destino (findConflict ganha excludeId p/ ignorar o próprio). Co-Authored-By: Claude Opus 4.8 --- backend/newwhats/agenda-bridge.js | 115 +++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/backend/newwhats/agenda-bridge.js b/backend/newwhats/agenda-bridge.js index 5f91b02..3ffa389 100644 --- a/backend/newwhats/agenda-bridge.js +++ b/backend/newwhats/agenda-bridge.js @@ -99,7 +99,7 @@ async function janelasClinicaNoDia(pool, clinicaId, dateStr, dow, clinicHours) { // Overbooking: MESMA regra do server.js (agrupa o dentista por usuario_id/email, // para o mesmo profissional atuando em clínicas diferentes). Cópia da lógica — // se a regra do scoreodonto mudar, revisar aqui também. -async function findConflict(db, dentistaId, start, end) { +async function findConflict(db, dentistaId, start, end, excludeId = null) { if (!dentistaId || !start) return null; const { rows: dr } = await db.query('SELECT usuario_id, email FROM dentistas WHERE id = $1', [dentistaId]); let ids = [dentistaId]; @@ -117,8 +117,9 @@ async function findConflict(db, dentistaId, start, end) { WHERE dentistaid = ANY($1) AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar')) AND start_time < $3 AND COALESCE(end_time, start_time) > $2 + AND ($4::text IS NULL OR id <> $4) LIMIT 1`, - [ids, start, end || start]); + [ids, start, end || start, excludeId]); return rows[0] || null; } @@ -473,6 +474,116 @@ export function createAgendaBridge(pool) { } }); + // ── GET /encaixe/analisar ───────────────────────────────────────────────────── + // Fluxo de "adiantar consulta": dado um dia (e horário desejado), diz se o horário + // está OCUPADO (por quem) e qual o horário LIVRE mais cedo que dá para oferecer/ + // encaixar — incluindo a janela FLEX (zona cinza), pois na contenção a Secretária + // pode usá-la. Query: clinica_id, dentista_id?, data (YYYY-MM-DD), hora? (HH:MM). + router.get('/encaixe/analisar', async (req, res) => { + const clinicaId = String(req.query.clinica_id || ''); + const data = String(req.query.data || ''); + if (!clinicaId || !data) return res.status(400).json({ error: 'clinica_id e data obrigatórios' }); + const dentistaId = req.query.dentista_id ? String(req.query.dentista_id) : null; + const horaDesejada = req.query.hora ? String(req.query.hora).slice(0, 5) : null; + try { + const day = new Date(`${data}T00:00:00`); + const dow = day.getDay(); + const dParams = [clinicaId]; let dWhere = 'clinica_id = $1 AND ativo IS DISTINCT FROM 0'; + if (dentistaId) { dParams.push(dentistaId); dWhere += ` AND id = $${dParams.length}`; } + const { rows: dentistas } = await pool.query(`SELECT id, nome FROM dentistas WHERE ${dWhere}`, dParams); + if (!dentistas.length) return res.json({ mais_cedo: null, contestado: null }); + + const clinicHours = await carregarHorariosClinica(pool, clinicaId); + const { janelas: clinicWins, fechado } = await janelasClinicaNoDia(pool, clinicaId, data, dow, clinicHours); + if (fechado || !clinicWins.length) return res.json({ mais_cedo: null, contestado: null, motivo: 'fechado' }); + + // Jornada FLEX-inclusiva (usa hora_*_flex quando houver — a zona cinza entra no encaixe). + const { rows: horarios } = await pool.query( + `SELECT dentista_id, + COALESCE(hora_inicio_flex, hora_inicio) AS hora_inicio, + COALESCE(hora_fim_flex, hora_fim) AS hora_fim + FROM dentistas_horarios WHERE clinica_id = $1 AND ativo = 1 AND dia_semana = $2`, [clinicaId, dow]); + const hoursBy = new Map(); + for (const h of horarios) { const a = hoursBy.get(h.dentista_id) || []; a.push([hm(h.hora_inicio), hm(h.hora_fim)]); hoursBy.set(h.dentista_id, a); } + + const start0 = new Date(day); start0.setHours(0, 0, 0, 0); + const end0 = new Date(start0); end0.setDate(end0.getDate() + 1); + const { rows: busy } = await pool.query( + `SELECT id, dentistaid, pacientenome, pacientecelular, start_time, end_time FROM agendamentos + WHERE clinica_id=$1 AND start_time>=$2 AND start_time<$3 + AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))`, + [clinicaId, start0.toISOString(), end0.toISOString()]); + const busyBy = new Map(); + for (const bz of busy) { const a = busyBy.get(bz.dentistaid) || []; a.push(bz); busyBy.set(bz.dentistaid, a); } + + // Quem ocupa o horário desejado (direto, sem depender do grid). + let contestado = null; + if (horaDesejada) { + const alvo = new Date(`${data}T${horaDesejada}:00`); + const occ = busy.find((b) => new Date(b.start_time) <= alvo && new Date(b.end_time || b.start_time) > alvo) + || busy.find((b) => fmt(new Date(b.start_time)).slice(11, 16) === horaDesejada); + if (occ) { + const dn = dentistas.find((d) => d.id === occ.dentistaid); + contestado = { agendamento_id: occ.id, dentista_id: occ.dentistaid, dentista_nome: dn?.nome || null, + paciente_nome: occ.pacientenome || null, paciente_celular: occ.pacientecelular || null, + start: fmt(new Date(occ.start_time)), hora: fmt(new Date(occ.start_time)).slice(11, 16) }; + } + } + + // Slots LIVRES do dia (flex-inclusivo). + const now = new Date(); const dur = SLOT_MIN; const livres = []; + for (const dent of dentistas) { + const dw = (hoursBy.get(dent.id) && hoursBy.get(dent.id).length) ? hoursBy.get(dent.id) : clinicWins; + const windows = interseccao(clinicWins, dw); + const bs = busyBy.get(dent.id) || []; + for (const [ini, fim] of windows) { + const [hi, mi] = String(ini).split(':').map(Number); + const [hf, mf] = String(fim).split(':').map(Number); + let t = new Date(day); t.setHours(hi, mi || 0, 0, 0); + const limit = new Date(day); limit.setHours(hf, mf || 0, 0, 0); + while (t.getTime() + dur * 60000 <= limit.getTime()) { + const s = new Date(t), e = new Date(t.getTime() + dur * 60000); t = e; + if (s <= now) continue; + if (bs.some((b) => new Date(b.start_time) < e && new Date(b.end_time || b.start_time) > s)) continue; + livres.push({ dentista_id: dent.id, dentista_nome: dent.nome, start: fmt(s), end: fmt(e), hora: fmt(s).slice(11, 16) }); + } + } + } + livres.sort((a, b) => a.start.localeCompare(b.start)); + // mais_cedo = livre imediatamente ANTES do desejado (o mais perto por baixo); senão o 1º livre. + let mais_cedo = null; + if (horaDesejada) { + const antes = livres.filter((s) => s.hora < horaDesejada); + mais_cedo = antes.length ? antes[antes.length - 1] : (livres[0] || null); + } else { + mais_cedo = livres[0] || null; + } + res.json({ mais_cedo, contestado, total_livres: livres.length }); + } catch (e) { res.status(500).json({ error: e.message }); } + }); + + // ── POST /encaixe/mover ─────────────────────────────────────────────────────── + // Move um agendamento existente para um novo horário (o A aceitou adiantar). + // Body: { clinica_id, agendamento_id, start, end? }. + router.post('/encaixe/mover', async (req, res) => { + const b = req.body || {}; + if (!b.clinica_id || !b.agendamento_id || !b.start) return res.status(400).json({ error: 'clinica_id, agendamento_id e start obrigatórios' }); + const end = b.end || new Date(new Date(b.start).getTime() + SLOT_MIN * 60000).toISOString(); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { rows } = await client.query('SELECT id, dentistaid FROM agendamentos WHERE id=$1 AND clinica_id=$2 FOR UPDATE', [b.agendamento_id, b.clinica_id]); + const ag = rows[0]; + if (!ag) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Agendamento não encontrado.' }); } + const conflict = await findConflict(client, ag.dentistaid, b.start, end, b.agendamento_id); + if (conflict) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Horário de destino já ocupado.' }); } + await client.query('UPDATE agendamentos SET start_time=$1, end_time=$2, updated_at=NOW() WHERE id=$3', [b.start, end, b.agendamento_id]); + await client.query('COMMIT'); + res.json({ ok: true }); + } catch (e) { try { await client.query('ROLLBACK'); } catch { /* ignore */ } res.status(500).json({ error: e.message }); } + finally { client.release(); } + }); + // ════════ Cadastro de pacientes (Secretária super inteligente) ════════════════ const soDigitos = (v) => String(v || '').replace(/\D/g, ''); function idadeDe(dob) { From 4dea9ab28a1dfa9333ddf9dd7c5e895bbbdd6ab5 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Mon, 6 Jul 2026 17:24:16 +0200 Subject: [PATCH 2/6] =?UTF-8?q?fix(newwhats):=20papel=20do=20n=C3=BAmero?= =?UTF-8?q?=20no=20/wa-inbox=20p/=20usu=C3=A1rio=20sem=20conta=20no=20moto?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Os papéis (sec_numbers) são resolvidos no motor pela conta (x-nw-email). Um usuário que só existe no scoreodonto (ex.: recepcionista) não tem conta no motor, então buscar os papéis com o e-mail DELE retorna "conta não encontrada" → todos os números liberados apareciam como "Sem papel definido". Agora o papel de cada sessão liberada é buscado com o e-mail do DONO da sessão (cache por e-mail). Verificado: recep passa a ver o número como role=clinic ("Clínica / Recepção · Rui Cesar"). Co-Authored-By: Claude Opus 4.8 --- backend/newwhats/proxy.js | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/backend/newwhats/proxy.js b/backend/newwhats/proxy.js index 5f333c0..2c994dd 100644 --- a/backend/newwhats/proxy.js +++ b/backend/newwhats/proxy.js @@ -301,29 +301,39 @@ export function createAccountsRoute(pool) { }; try { - // Papéis: sec_numbers são globais no motor — um fetch, indexado por instance_id. - const numbers = await motorGet('/secretaria/numbers', email); - const roleOf = new Map(numbers.map((n) => [n.instance_id, n])); - const meta = (instanceId, fallbackName) => { - const n = roleOf.get(instanceId) || {}; + // Papéis (sec_numbers) são resolvidos no motor pela CONTA (x-nw-email). Um + // usuário sem conta no motor (ex.: recepcionista que só existe no scoreodonto) + // não tem números próprios — então o papel de cada número LIBERADO precisa ser + // buscado com o e-mail do DONO da sessão, não do usuário logado. Cache por e-mail. + const numbersCache = new Map(); + const numbersFor = async (asEmail) => { + if (!numbersCache.has(asEmail)) { + const nums = await motorGet('/secretaria/numbers', asEmail); + numbersCache.set(asEmail, new Map(nums.map((n) => [n.instance_id, n]))); + } + return numbersCache.get(asEmail); + }; + const metaFrom = (roleMap, instanceId, fallbackName) => { + const n = roleMap.get(instanceId) || {}; return { role: n.role || null, label: n.label || fallbackName || null, area: n.area || null, notes: n.notes || null }; }; const out = []; const seen = new Set(); - const push = (s, extra) => { + const pushWith = (s, roleMap, extra) => { if (!s || seen.has(s.id)) return; seen.add(s.id); out.push({ instanceId: s.id, phone: s.phone || null, name: s.name || null, status: s.status || null, avatar: s.avatar || null, - ...meta(s.id, s.name), ...extra, + ...metaFrom(roleMap, s.id, s.name), ...extra, }); }; - // 1) Sessões PRÓPRIAS (conta do próprio usuário no motor). + // 1) Sessões PRÓPRIAS (conta do próprio usuário no motor) — papel da própria conta. + const ownRoles = await numbersFor(email); for (const s of await motorGet('/sessions', email)) { - push(s, { clinicaId: null, ownerEmail: email, ownerNome: null, own: true, canInbox: true }); + pushWith(s, ownRoles, { clinicaId: null, ownerEmail: email, ownerNome: null, own: true, canInbox: true }); } // 2) Sessões LIBERADAS por outros donos (can_inbox por sessão). @@ -343,8 +353,9 @@ export function createAccountsRoute(pool) { const { rows } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [owner.ownerId]); ownerNome = rows[0]?.nome || null; } catch { /* opcional */ } + const ownerRoles = await numbersFor(owner.email); // papéis dos números vêm da conta do DONO for (const s of await motorGet('/sessions', owner.email)) { - if (allowed.has(s.id)) push(s, { clinicaId, ownerEmail: owner.email, ownerNome, own: false, canInbox: true }); + if (allowed.has(s.id)) pushWith(s, ownerRoles, { clinicaId, ownerEmail: owner.email, ownerNome, own: false, canInbox: true }); } } From 882af1645806778261f00e71647a88803a473480 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Mon, 6 Jul 2026 19:11:46 +0200 Subject: [PATCH 3/6] =?UTF-8?q?fix(dashboard):=20escopo=20por=20papel=20?= =?UTF-8?q?=E2=80=94=20dentista=20n=C3=A3o=20v=C3=AA=20agenda=20da=20cl?= =?UTF-8?q?=C3=ADnica=20nem=20financeiro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/dashboard/stats devolvia a agenda inteira da clínica (nomes de pacientes de todos os dentistas) + faturamento/despesas/growth no payload para QUALQUER membro, inclusive dentista. Pior: cache por clínica (dashboard:stats:) servia o mesmo JSON a todos os papéis. Agora: dentista vê só a própria agenda; financeiro só p/ admin/donoclinica/donoconsultorio/funcionario; cache por escopo (papel/dentista). Verificado: dentista recém-cadastrado (murilo) → agenda vazia + financeiro 0; dono → tudo. Co-Authored-By: Claude Opus 4.8 --- backend/server.js | 84 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 57 insertions(+), 27 deletions(-) diff --git a/backend/server.js b/backend/server.js index 429cecb..497e4c4 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1927,7 +1927,25 @@ app.get('/api/dashboard/stats', tenantGuard, async (req, res) => { try { const clinicaId = req.clinicaId; if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); - const cacheKey = `dashboard:stats:${clinicaId}`; + // Escopo por PAPEL: um dentista vê só a PRÓPRIA agenda; o financeiro só é + // devolvido a dono/admin/funcionário. Sem isso, um dentista recém-cadastrado + // via a agenda inteira da clínica + os financeiros no payload (mesmo com o + // front escondendo os cards). O cache passa a ser por ESCOPO (não só clínica). + let role = null, dentistaIds = []; + try { + const { rows } = await pool.query('SELECT role FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2 LIMIT 1', [req.authUser.userId, clinicaId]); + role = rows[0]?.role || null; + } catch { /* segue */ } + const isDentista = role === 'dentista'; + const verFinanceiro = ['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(role); + if (isDentista) { + try { + const { rows } = await pool.query('SELECT id FROM dentistas WHERE usuario_id = $1 AND clinica_id = $2', [req.authUser.userId, clinicaId]); + dentistaIds = rows.map((r) => r.id); + } catch { /* segue */ } + } + const scope = isDentista ? `dent:${dentistaIds.join('_') || 'none'}` : (verFinanceiro ? 'full' : 'basic'); + const cacheKey = `dashboard:stats:${clinicaId}:${scope}`; if (redis) { const cachedStats = await redis.get(cacheKey); if (cachedStats) { @@ -1937,43 +1955,55 @@ app.get('/api/dashboard/stats', tenantGuard, async (req, res) => { } const { rows: pacientes } = await pool.query('SELECT COUNT(*) as count FROM pacientes WHERE clinica_id = $1', [clinicaId]); - const { rows: agendamentos } = await pool.query('SELECT COUNT(*) as count FROM agendamentos WHERE clinica_id = $1', [clinicaId]); const { rows: leads } = await pool.query('SELECT COUNT(*) as count FROM leads WHERE clinica_id = $1', [clinicaId]); - const { rows: faturamento } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE (tipo = 'RECEITA' OR tipo IS NULL) AND clinica_id = $1", [clinicaId]); - const { rows: pendente } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = 'DESPESA' AND clinica_id = $1", [clinicaId]); - // Growth data (last 6 months) - const { rows: growth } = await pool.query(` - SELECT - TO_CHAR(datavencimento, 'YYYY-MM') as mes, - SUM(CASE WHEN tipo = 'RECEITA' OR tipo IS NULL THEN valor ELSE 0 END) as receita, - SUM(CASE WHEN tipo = 'DESPESA' THEN valor ELSE 0 END) as despesa - FROM financeiro - WHERE datavencimento >= CURRENT_DATE - INTERVAL '6 months' AND clinica_id = $1 - GROUP BY TO_CHAR(datavencimento, 'YYYY-MM') - ORDER BY mes ASC - `, [clinicaId]); + // Filtro de agenda: dentista → só os próprios agendamentos. + const agFilter = (col, params) => { + let w = `${col === 'a' ? 'a.' : ''}clinica_id = $1`; + if (isDentista) { + if (!dentistaIds.length) return { where: w + ' AND false', params }; + params.push(dentistaIds); + w += ` AND ${col === 'a' ? 'a.' : ''}dentistaid = ANY($${params.length})`; + } + return { where: w, params }; + }; + const agp = agFilter('', [clinicaId]); + const { rows: agendamentos } = await pool.query(`SELECT COUNT(*) as count FROM agendamentos WHERE ${agp.where}`, agp.params); - // Recent appointments with patient and dentist names + // Financeiro só p/ dono/admin/funcionário. + let faturamentoTotal = 0, despesasTotal = 0, growth = []; + if (verFinanceiro) { + const { rows: faturamento } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE (tipo = 'RECEITA' OR tipo IS NULL) AND clinica_id = $1", [clinicaId]); + const { rows: pendente } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = 'DESPESA' AND clinica_id = $1", [clinicaId]); + const { rows: g } = await pool.query(` + SELECT TO_CHAR(datavencimento, 'YYYY-MM') as mes, + SUM(CASE WHEN tipo = 'RECEITA' OR tipo IS NULL THEN valor ELSE 0 END) as receita, + SUM(CASE WHEN tipo = 'DESPESA' THEN valor ELSE 0 END) as despesa + FROM financeiro + WHERE datavencimento >= CURRENT_DATE - INTERVAL '6 months' AND clinica_id = $1 + GROUP BY TO_CHAR(datavencimento, 'YYYY-MM') ORDER BY mes ASC`, [clinicaId]); + faturamentoTotal = parseFloat(faturamento[0].total); + despesasTotal = parseFloat(pendente[0].total); + growth = g; + } + + // Agenda recente com os mesmos limites por papel. + const rec = agFilter('a', [clinicaId]); const { rows: recent } = await pool.query(` - SELECT a.id, a.pacientenome, a.start_time, a.status, - d.nome as "dentistaNome" - FROM agendamentos a - LEFT JOIN dentistas d ON a.dentistaid = d.id - WHERE a.clinica_id = $1 - ORDER BY a.start_time DESC - LIMIT 5 - `, [clinicaId]); + SELECT a.id, a.pacientenome, a.start_time, a.status, d.nome as "dentistaNome" + FROM agendamentos a LEFT JOIN dentistas d ON a.dentistaid = d.id + WHERE ${rec.where} + ORDER BY a.start_time DESC LIMIT 5`, rec.params); const responseData = { stats: { totalPacientes: parseInt(pacientes[0].count), totalAgendamentos: parseInt(agendamentos[0].count), totalLeads: parseInt(leads[0].count), - faturamentoTotal: parseFloat(faturamento[0].total), - despesasTotal: parseFloat(pendente[0].total) + faturamentoTotal, + despesasTotal }, - growth: growth, + growth, recentAgendamentos: recent }; From ddcc639ab64cb1eca31c5466437b410b828be5d4 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Mon, 6 Jul 2026 19:22:04 +0200 Subject: [PATCH 4/6] =?UTF-8?q?feat(agenda):=20coluna=20"Agenda=20Cl=C3=AD?= =?UTF-8?q?nica"=20no=20desktop=20com=20a=20estrutura=20do=20mobile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As ações da coluna de controles no desktop eram só uma fileira de ícones sem rótulo (difícil saber o que era cada um). Agora o desktop herda a estrutura do mobile: botões empilhados com ícone + nome (Atividade da equipe, Pedidos pendentes, Configurações, Meus Horários, etc.), badges reposicionados junto ao ícone. Co-Authored-By: Claude Opus 4.8 --- frontend/views/AgendaView.tsx | 36 +++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/frontend/views/AgendaView.tsx b/frontend/views/AgendaView.tsx index 3467ed9..3599a70 100644 --- a/frontend/views/AgendaView.tsx +++ b/frontend/views/AgendaView.tsx @@ -658,60 +658,60 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar )} {/* Ações — mobile: empilhadas, ícone + nome, largura cheia; desktop: fileira de ícones */} -
+
{(currentRole !== 'paciente') && ( )} {(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && ( )} {['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(currentRole) && ( )} {(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && ( )} {souDentista && ( )} {souDentista && ( )} {(currentRole !== 'paciente' && !souDentista) && ( )} From f30b0ab732eba789654ea3903321f70dfeafa144 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Mon, 6 Jul 2026 19:34:02 +0200 Subject: [PATCH 5/6] feat(agenda): linha da meia-hora dividindo cada hora em 2 (estilo Google) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grade de 30min com rótulo só na hora (slotDuration 30min + slotLabelInterval 1h); a linha de :30 fica tracejada/fraca (fc-timegrid-slot-minor), distinta da linha cheia da hora — como na agenda do Google. Co-Authored-By: Claude Opus 4.8 --- frontend/views/AgendaView.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/views/AgendaView.tsx b/frontend/views/AgendaView.tsx index 3599a70..2956873 100644 --- a/frontend/views/AgendaView.tsx +++ b/frontend/views/AgendaView.tsx @@ -758,6 +758,8 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar buttonText={{ today: 'HOJE', month: 'MÊS', week: 'SEMANA', day: 'DIA' }} slotMinTime="08:00:00" slotMaxTime="19:00:00" + slotDuration="00:30:00" + slotLabelInterval="01:00:00" allDaySlot={false} events={events} selectable={true} @@ -902,8 +904,13 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar .fc .fc-col-header-cell-cushion { font-size: .65rem; padding: 2px; } .fc .fc-timegrid-slot-label-cushion, .fc .fc-timegrid-axis-cushion { font-size: .6rem; } .fc .fc-timegrid-event .fc-event-main { padding: 1px 2px; } - .fc-footer-toolbar.fc-toolbar { margin-top: .5rem; } } + /* Linha da MEIA-HORA (:30): divide a hora em 2, fraca/tracejada como no Google + (a linha cheia da hora vem do border padrão das células). */ + .fc .fc-timegrid-slot-minor { + border-top: 1px dashed #eef1f4 !important; + } + .fc-footer-toolbar.fc-toolbar { margin-top: .5rem; } `} Date: Mon, 6 Jul 2026 19:39:41 +0200 Subject: [PATCH 6/6] =?UTF-8?q?feat(wa-inbox):=20bot=C3=A3o=20liga/desliga?= =?UTF-8?q?=20da=20Secret=C3=A1ria=20por=20sess=C3=A3o=20na=20thin=20sideb?= =?UTF-8?q?ar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Botão Power na thin sidebar do /wa-inbox age SÓ na sessão/número aberto (verde=on, vermelho pulsante=off). Abre modal com textarea de motivo (obrigatório p/ desligar), histórico de quem ligou/desligou e aviso de rate-limit (1x/hora). secSessionPowerApi + SessaoSecretariaPower.tsx. Co-Authored-By: Claude Opus 4.8 --- .../components/SessaoSecretariaPower.tsx | 134 ++++++++++++++++++ .../views/newwhats/components/ThinSidebar.tsx | 3 + .../views/newwhats/services/secretariaApi.ts | 16 +++ 3 files changed, 153 insertions(+) create mode 100644 frontend/views/newwhats/components/SessaoSecretariaPower.tsx diff --git a/frontend/views/newwhats/components/SessaoSecretariaPower.tsx b/frontend/views/newwhats/components/SessaoSecretariaPower.tsx new file mode 100644 index 0000000..91ae9fa --- /dev/null +++ b/frontend/views/newwhats/components/SessaoSecretariaPower.tsx @@ -0,0 +1,134 @@ +// Liga/desliga a Secretária IA APENAS desta sessão (instância aberta no /wa-inbox). +// Rate-limit 1x/hora, motivo obrigatório ao desligar, histórico de quem apertou. +// Fica na thin sidebar; abre um modal com o textarea de motivo + histórico. +import React, { useState, useEffect, useCallback } from 'react'; +import { Power, X, Loader2, History, AlertTriangle } from 'lucide-react'; +import { secSessionPowerApi, SessionPower } from '../services/secretariaApi'; + +const userName = (() => { + try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}')?.nome || 'Usuário'; } + catch { return 'Usuário'; } +})(); + +const fmt = (iso?: string | null) => { + if (!iso) return ''; + try { return new Date(iso).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); } + catch { return ''; } +}; + +export const SessaoSecretariaPower: React.FC<{ instanceId: string | null }> = ({ instanceId }) => { + const [sp, setSp] = useState(null); + const [open, setOpen] = useState(false); + const [reason, setReason] = useState(''); + const [busy, setBusy] = useState(false); + const [erro, setErro] = useState(null); + + const carregar = useCallback(async () => { + if (!instanceId) return; + try { setSp(await secSessionPowerApi.get(instanceId)); } catch { /* silencioso */ } + }, [instanceId]); + + useEffect(() => { carregar(); }, [carregar]); + + if (!instanceId) return null; + const enabled = sp ? sp.enabled : true; + + const toggle = async () => { + if (!sp?.can_toggle) return; + const proximo = !enabled; + if (proximo === false && !reason.trim()) { setErro('Escreva o motivo para desligar.'); return; } + setBusy(true); setErro(null); + try { + await secSessionPowerApi.set(instanceId, proximo, reason.trim(), userName); + setReason(''); + await carregar(); + } catch (e: any) { + setErro(e?.message || 'Não foi possível alterar.'); + await carregar(); + } finally { setBusy(false); } + }; + + return ( + <> + + + {open && ( +
setOpen(false)}> +
e.stopPropagation()}> +
+
+
+
+

Secretária desta sessão

+

{enabled ? 'Ligada' : 'Desligada'} · só este número

+
+
+ +
+ + {erro &&
{erro}
} + +
+ {!enabled && sp?.reason && ( +
+ Desligada. Motivo: {sp.reason} + {sp.changed_by &&
por {sp.changed_by} · {fmt(sp.changed_at)}
} +
+ )} + + {/* Motivo (só ao desligar) */} + {enabled && ( +
+ +