From 3e64514b0887cea8a0cbaa1af98abdc6f661a8b5 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Tue, 16 Jun 2026 00:28:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(financeiro+salas):=20motor=20financeiro=20?= =?UTF-8?q?V1/V2,=20glosas,=20GTO=E2=86=92financeiro,=20contratos=20de=20c?= =?UTF-8?q?onv=C3=AAnio=20e=20redesenho=20de=20Salas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Financeiro V1 (Épicos 1–4): - FKs de origem no lançamento (paciente/profissional/procedimento/agendamento/contrato/realizado), soft delete e cron "Atrasado" - prontuário de procedimentos realizados (fotos antes/depois) + regra sem-comissão (garantia/reabertura do mesmo profissional) - normalizeFinanceiro: corrige CHECK capitalizado vs enforceUppercase (lançamentos não salvavam pela UI) - baixa de pagamento (pago_em), despesas (fornecedor/centro de custo/recorrente), competência por data de conclusão - relatório financeiro (período, paciente, profissional, forma, convênio, glosa) - orçamento Assinado -> contas a receber; contrato vigente -> cobrança recorrente; renovação automática de contrato Financeiro V2 (Comissão/Repasse): - regras de % (padrão/procedimento/override por profissional) + custo de laboratório - base selecionável (recebido/produção), fechamento persistido, pagar (gera despesa) + comprovante + estorno Glosas de convênio: por item de GTO, recurso/protocolo/prazo, recuperar/estornar, impacto no relatório GTO -> Financeiro: finalizar gera RECEITA (convênio a receber); LancarGTO passa a persistir; convênios reais (planos) Contratos: modelo "Atendimento a Convênios / Repasse (Dentista Credenciado)" + variáveis de convênio Salas (redesenho): perfil "Dono de Sala"; locação sempre ativa; menus MINHAS/ALUGAR SALAS; sala como workspace isolado (seletor agrupado Clínicas x Salas, tenantGuard por dono); financeiro de locação (reserva confirmada -> recebível); Painel da Sala (KPIs, agenda visual, recebíveis, relatório por sala) Docs: BACKLOG-FINANCEIRO-V1, AUDITORIA-FUNCIONAL-SCOREODONTO, CHECKLIST-DEPLOY-PRODUCAO Co-Authored-By: Claude Opus 4.8 --- backend/server.js | 2398 ++++++++++++++++- frontend/App.tsx | 37 +- frontend/components/AgendaDetailModal.tsx | 52 +- frontend/components/AgendaPresence.tsx | 2 +- frontend/components/AgendaSettingsModal.tsx | 18 +- frontend/components/AvaliacaoEditor.tsx | 16 +- frontend/components/ChangePasswordModal.tsx | 6 +- frontend/components/GoogleConnectButton.tsx | 12 +- frontend/components/Header.tsx | 16 +- frontend/components/NotificationCenter.tsx | 12 +- frontend/components/Sidebar.tsx | 92 +- frontend/contexts/ConfirmContext.tsx | 89 + frontend/index.tsx | 5 +- frontend/services/backend.ts | 310 ++- frontend/types.ts | 22 +- frontend/views/AdminGestaoTutoresView.tsx | 6 +- frontend/views/AdminViews.tsx | 25 +- frontend/views/AgendaView.tsx | 38 +- frontend/views/ComissoesView.tsx | 386 +++ frontend/views/ConfiguracoesView.tsx | 41 +- frontend/views/ContratosView.tsx | 729 +++-- frontend/views/CreateClinicView.tsx | 10 +- frontend/views/Dashboard.tsx | 20 +- frontend/views/DentistRegisterView.tsx | 30 +- frontend/views/DiretorioProfissionaisView.tsx | 6 +- frontend/views/FinanceiroView.tsx | 150 +- frontend/views/GestaoEquipeView.tsx | 88 +- frontend/views/GlosasView.tsx | 217 ++ frontend/views/LancarGTO.tsx | 91 +- frontend/views/LancarProcedimentoModal.tsx | 24 +- frontend/views/LandingPage.tsx | 72 +- frontend/views/LeadsView.tsx | 12 +- frontend/views/LoginView.tsx | 2 +- frontend/views/MeusTratamentos.tsx | 22 +- frontend/views/NotificationsView.tsx | 24 +- frontend/views/OnboardingChat.tsx | 25 +- .../{ContratoModal.tsx => OrcamentoModal.tsx} | 138 +- frontend/views/OrcamentosView.tsx | 333 +++ frontend/views/OrthoPhotoWorkspace.tsx | 45 +- frontend/views/OrthoReports.tsx | 6 +- frontend/views/OrthoView.tsx | 46 +- frontend/views/PatientsView.tsx | 95 +- frontend/views/PedidoExameModal.tsx | 14 +- frontend/views/PhotoAnnotator.tsx | 14 +- frontend/views/PluginsView.tsx | 15 +- frontend/views/ProcedimentosProntuario.tsx | 184 ++ frontend/views/ReceitaModal.tsx | 30 +- frontend/views/ReportsView.tsx | 267 +- frontend/views/RxConfigView.tsx | 26 +- frontend/views/SalaHomeView.tsx | 247 ++ frontend/views/SuperAdminView.tsx | 8 +- frontend/views/TutorCandidaturaView.tsx | 68 +- frontend/views/TutorPainelView.tsx | 18 +- frontend/views/TutoriaSection.tsx | 38 +- frontend/views/UpdateDbView.tsx | 4 +- frontend/views/WaitingInviteView.tsx | 16 +- .../views/plugins/ProfissionaisPlugin.tsx | 417 ++- frontend/views/plugins/SalasPlugin.tsx | 42 +- frontend/views/plugins/pluginRegistry.ts | 5 +- .../AUDITORIA-FUNCIONAL-SCOREODONTO.md | 165 ++ .../scoreodonto/BACKLOG-FINANCEIRO-V1.md | 156 ++ .../scoreodonto/CHECKLIST-DEPLOY-PRODUCAO.md | 61 + 62 files changed, 6528 insertions(+), 1035 deletions(-) create mode 100644 frontend/contexts/ConfirmContext.tsx create mode 100644 frontend/views/ComissoesView.tsx create mode 100644 frontend/views/GlosasView.tsx rename frontend/views/{ContratoModal.tsx => OrcamentoModal.tsx} (88%) create mode 100644 frontend/views/OrcamentosView.tsx create mode 100644 frontend/views/ProcedimentosProntuario.tsx create mode 100644 frontend/views/SalaHomeView.tsx create mode 100644 instrucoes_gerais/scoreodonto/AUDITORIA-FUNCIONAL-SCOREODONTO.md create mode 100644 instrucoes_gerais/scoreodonto/BACKLOG-FINANCEIRO-V1.md create mode 100644 instrucoes_gerais/scoreodonto/CHECKLIST-DEPLOY-PRODUCAO.md diff --git a/backend/server.js b/backend/server.js index 0c15c28..d01813a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -10,9 +10,27 @@ import { WebSocketServer } from 'ws'; import fs from 'fs'; import path from 'path'; import bcrypt from 'bcryptjs'; +import crypto from 'crypto'; const { Pool } = pg; const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026'; + +// URL assinada para imagens clínicas servidas em (sem header de auth). +// Token = "."; expira em 12h. Reemitido a cada listagem de fotos. +const MEDIA_TTL_MS = 12 * 60 * 60 * 1000; +function signMedia(id) { + const exp = Date.now() + MEDIA_TTL_MS; + const sig = crypto.createHmac('sha256', JWT_SECRET).update(`${id}.${exp}`).digest('hex').slice(0, 32); + return `${exp}.${sig}`; +} +function verifyMedia(id, token) { + if (!token || typeof token !== 'string' || !token.includes('.')) return false; + const i = token.indexOf('.'); + const exp = Number(token.slice(0, i)); + if (!exp || Date.now() > exp) return false; + const good = crypto.createHmac('sha256', JWT_SECRET).update(`${id}.${exp}`).digest('hex').slice(0, 32); + try { return crypto.timingSafeEqual(Buffer.from(token.slice(i + 1)), Buffer.from(good)); } catch { return false; } +} // Documentação fotográfica de ortodontia: uploads vão para o volume persistente // /app/media (montado de /home/deploy/scoreodonto-media), servidos via /api/orto/fotos/:id/raw. const MEDIA_ROOT = process.env.MEDIA_PATH || '/app/media'; @@ -168,6 +186,7 @@ async function tenantGuard(req, res, next) { return res.status(401).json({ success: false, message: 'TOKEN INVÁLIDO OU EXPIRADO. FAÇA LOGIN NOVAMENTE.' }); } const clinicaId = req.params.clinicaId || req.body?.clinica_id || req.query?.clinicaId || req.body?.clinicaId; + req.isSala = false; if (clinicaId) { try { const { rows } = await pool.query( @@ -175,8 +194,13 @@ async function tenantGuard(req, res, next) { [payload.userId, clinicaId] ); if (rows.length === 0) { - console.warn(`[TENANT VIOLATION] User ${payload.userId} -> clinica ${clinicaId}`); - return res.status(403).json({ success: false, message: 'ACESSO NEGADO: VOCÊ NÃO TEM VÍNCULO COM ESTA UNIDADE.' }); + // Workspace pode ser uma SALA própria (locação): valida pela posse, não por vínculo. + const { rows: sl } = await pool.query('SELECT id FROM salas WHERE id = $1 AND owner_usuario_id = $2', [clinicaId, payload.userId]); + if (sl.length === 0) { + console.warn(`[TENANT VIOLATION] User ${payload.userId} -> clinica ${clinicaId}`); + return res.status(403).json({ success: false, message: 'ACESSO NEGADO: VOCÊ NÃO TEM VÍNCULO COM ESTA UNIDADE.' }); + } + req.isSala = true; } } catch (err) { return res.status(500).json({ success: false, message: 'ERRO INTERNO DE AUTENTICAÇÃO.' }); @@ -356,6 +380,27 @@ function roleToArea(role) { return 'odontologia'; } +// Workspaces do usuário = clínicas (via vínculos) + salas que ele é dono (tipo='sala'). +// Cada sala é um workspace próprio, isolado da clínica (tem endereço próprio). +async function listarWorkspaces(userId, userRole) { + const { rows: clinicas } = await pool.query(` + SELECT v.clinica_id AS id, c.nome_fantasia AS nome, c.cor, v.role, + COALESCE(c.tipo, 'clinica') AS tipo, + v.funcao_id, f.nome AS funcao_nome, f.permissoes, f.nivel + FROM vinculos v + JOIN clinicas c ON v.clinica_id = c.id + LEFT JOIN funcoes f ON f.id = v.funcao_id + WHERE v.usuario_id = $1 + `, [userId]); + const { rows: salas } = await pool.query( + `SELECT id, nome, cidade, estado FROM salas WHERE owner_usuario_id = $1 ORDER BY nome`, [userId]); + const salaWs = salas.map(s => ({ + id: s.id, nome: s.nome, cor: '#0d9488', role: userRole || 'donosala', tipo: 'sala', + cidade: s.cidade, estado: s.estado, funcao_id: null, funcao_nome: null, permissoes: null, nivel: null, + })); + return [...clinicas, ...salaWs]; +} + // Catálogo padrão de Biomedicina Estética (especialidades + procedimentos com valor sugerido). const BIOMED_ESPECIALIDADES = [ 'BIOMEDICINA ESTÉTICA', 'HARMONIZAÇÃO OROFACIAL', 'ESTÉTICA FACIAL', 'ESTÉTICA CORPORAL', 'PROCEDIMENTOS INJETÁVEIS', @@ -401,7 +446,7 @@ async function seedBiomedicina(clinicaId) { app.post('/api/register', async (req, res) => { const { nome, email, senha, role, workspaceNome, especialidade } = req.body; if (!nome || !email || !senha) return res.status(400).json({ success: false, message: 'Nome, e-mail e senha são obrigatórios.' }); - const VALID_ROLES = ['donoconsultorio', 'donoclinica', 'dentista', 'biomedico', 'protetico', 'paciente']; + const VALID_ROLES = ['donoconsultorio', 'donoclinica', 'donosala', 'dentista', 'biomedico', 'protetico', 'paciente']; const userRole = VALID_ROLES.includes(role) ? role : 'donoclinica'; try { const existing = await pool.query('SELECT id FROM usuarios WHERE email = $1', [email.toLowerCase().trim()]); @@ -633,9 +678,10 @@ app.delete('/api/clinicas/:clinicaId/funcoes/:id', tenantGuard, async (req, res) }); // --- CLINICAS --- -app.put('/api/clinicas/:id/color', async (req, res) => { +app.put('/api/clinicas/:id/color', authGuard, async (req, res) => { const { cor } = req.body; - if (!cor) return res.status(400).json({ error: 'Campo cor obrigatório.' }); + if (!cor || !/^#[0-9a-fA-F]{6}$/.test(cor)) return res.status(400).json({ error: 'Cor inválida (use #RRGGBB).' }); + if (!await userHasVinculo(req.authUser?.userId, req.params.id)) return res.status(403).json({ error: 'Acesso negado: unidade de outra conta.' }); try { await pool.query('UPDATE clinicas SET cor = $1 WHERE id = $2', [cor, req.params.id]); res.json({ success: true }); @@ -712,14 +758,7 @@ app.post('/api/login', async (req, res) => { } const userRole = user.role || 'dentista'; const mustChangePassword = !!user.must_change_password; - const { rows: workspaces } = await pool.query(` - SELECT v.clinica_id as id, c.nome_fantasia as nome, c.cor, v.role, - v.funcao_id, f.nome AS funcao_nome, f.permissoes, f.nivel - FROM vinculos v - JOIN clinicas c ON v.clinica_id = c.id - LEFT JOIN funcoes f ON f.id = v.funcao_id - WHERE v.usuario_id = $1 - `, [user.id]); + const workspaces = await listarWorkspaces(user.id, userRole); const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { expiresIn: '12h' }); if (workspaces.length === 0) { console.log(`[LOGIN NO-WORKSPACE] User: ${user.email}, role: ${userRole}`); @@ -1319,27 +1358,38 @@ app.get('/api/auth/google/status', async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.delete('/api/auth/google/:ownerId', async (req, res) => { +app.delete('/api/auth/google/:ownerId', authGuard, async (req, res) => { try { - await pool.query('DELETE FROM google_tokens WHERE owner_id = $1', [req.params.ownerId]); + const owner = req.params.ownerId; + const uid = req.authUser?.userId; + // só o próprio usuário ou um membro da clínica dona pode desconectar a conta Google + if (owner !== uid && !await userHasVinculo(uid, owner)) return res.status(403).json({ error: 'Acesso negado.' }); + await pool.query('DELETE FROM google_tokens WHERE owner_id = $1', [owner]); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // --- SETTINGS --- -app.get('/api/settings', async (req, res) => { +app.get('/api/settings', tenantGuard, async (req, res) => { try { - // Escopo por workspace/clínica: cada clínica tem suas próprias configs - // (clinicEmail, etc.). 'main' é só o fallback legado/global. - const id = req.query.clinicaId || 'main'; + // Escopo por workspace/clínica (tenantGuard valida o vínculo). 'main' = legado/global. + const id = req.clinicaId || 'main'; + if (id === 'main') { + const { rows: u } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser?.userId]); + if (u[0]?.role !== 'superadmin') return res.json({}); // não superadmin não lê o global legado + } const { rows } = await pool.query('SELECT data FROM settings WHERE id = $1', [id]); res.json(rows[0] ? JSON.parse(rows[0].data) : {}); } catch (err) { res.status(500).json({ error: err.message }); } }); -app.post('/api/settings', async (req, res) => { +app.post('/api/settings', tenantGuard, async (req, res) => { try { - const id = req.query.clinicaId || 'main'; + const id = req.clinicaId || 'main'; + if (id === 'main') { + const { rows: u } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser?.userId]); + if (u[0]?.role !== 'superadmin') return res.status(403).json({ error: 'Acesso negado.' }); + } const data = JSON.stringify(req.body); await pool.query( `INSERT INTO settings (id, category, data) VALUES ($1, 'general', $2) @@ -1427,7 +1477,7 @@ const GOOGLE_EVENT_COLORS = { '1': '#7986cb', '2': '#33b679', '3': '#8e24aa', '4': '#e67c73', '5': '#f6bf26', '6': '#f4511e', '7': '#039be5', '8': '#616161', '9': '#3f51b5', '10': '#0b8043', '11': '#d50000' }; -app.get('/api/google/events', async (req, res) => { +app.get('/api/google/events', authGuard, async (req, res) => { try { const { clinicaId } = req.query; // Sem clínica definida não dá pra escopar → não vaza agenda de outras contas. @@ -1620,7 +1670,9 @@ const NOTIF_SCOPE = `(usuario_id = $1 OR usuario_id IS NULL) AND ($2::text IS NU app.get('/api/notificacoes', authGuard, async (req, res) => { try { const userId = req.authUser?.userId || null; - const clinicaId = req.query.clinicaId || null; + let clinicaId = req.query.clinicaId || null; + // Só aplica o escopo de clínica com vínculo; senão '' = apenas globais + pessoais (não vaza outras unidades). + if (!await userHasVinculo(userId, clinicaId)) clinicaId = ''; const { rows } = await pool.query( `SELECT * FROM notificacoes WHERE ${NOTIF_SCOPE} ORDER BY data DESC LIMIT 100`, [userId, clinicaId]); @@ -1631,7 +1683,8 @@ app.get('/api/notificacoes', authGuard, async (req, res) => { app.post('/api/notificacoes/read-all', authGuard, async (req, res) => { try { const userId = req.authUser?.userId || null; - const clinicaId = req.body?.clinicaId || req.query.clinicaId || null; + let clinicaId = req.body?.clinicaId || req.query.clinicaId || null; + if (!await userHasVinculo(userId, clinicaId)) clinicaId = ''; // sem vínculo → não marca de outras unidades await pool.query( `UPDATE notificacoes SET lida = 1 WHERE lida = 0 AND ${NOTIF_SCOPE}`, [userId, clinicaId]); @@ -1656,9 +1709,10 @@ app.put('/api/notificacoes/:id/read', authGuard, async (req, res) => { }); // --- PACIENTES --- -app.get('/api/pacientes', authGuard, async (req, res) => { +app.get('/api/pacientes', tenantGuard, async (req, res) => { try { - const { q, sort, clinicaId } = req.query; + const { q, sort } = req.query; + const clinicaId = req.clinicaId; if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); const orderMap = { az: 'p.nome ASC', za: 'p.nome DESC', convenio: 'p.convenio ASC NULLS LAST, p.nome ASC', recentes: 'p.nome ASC' }; const orderBy = orderMap[sort] || 'p.nome ASC'; @@ -1705,6 +1759,7 @@ app.post('/api/pacientes', authGuard, async (req, res) => { try { const clinicaId = req.body.clinica_id || req.query.clinicaId; if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório.' }); + if (!await userHasVinculo(req.authUser?.userId, clinicaId)) return res.status(403).json({ error: 'Acesso negado: sem vínculo com esta unidade.' }); const p = { ...req.body, clinica_id: clinicaId }; const ins = buildInsert('pacientes', p); await pool.query(ins.text, ins.values); @@ -1717,7 +1772,11 @@ app.post('/api/pacientes', authGuard, async (req, res) => { app.put('/api/pacientes/:id', authGuard, async (req, res) => { try { - const upd = buildUpdate('pacientes', req.body, 'id', req.params.id); + const { rows: pc } = await pool.query('SELECT clinica_id FROM pacientes WHERE id = $1', [req.params.id]); + if (!pc.length) return res.status(404).json({ error: 'Paciente não encontrado.' }); + if (!await userHasVinculo(req.authUser?.userId, pc[0].clinica_id)) return res.status(403).json({ error: 'Acesso negado: paciente de outra clínica.' }); + const body = { ...req.body }; delete body.clinica_id; // não permite mover de clínica + const upd = buildUpdate('pacientes', body, 'id', req.params.id); await pool.query(upd.text, upd.values); schedulePush('pacientes'); res.json({ success: true }); @@ -1763,7 +1822,7 @@ function sanitizeDentista(d) { return out; } -app.post('/api/dentistas', async (req, res) => { +app.post('/api/dentistas', tenantGuard, async (req, res) => { try { const d = req.body; let tempPassword = null, contaCriada = false; @@ -1791,9 +1850,14 @@ app.post('/api/dentistas', async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.put('/api/dentistas/reorder', async (req, res) => { +app.put('/api/dentistas/reorder', authGuard, async (req, res) => { const { orders } = req.body; // Array of {id, ordem} try { + const ids = (orders || []).map(o => o.id); + if (ids.length) { + const { rows } = await pool.query('SELECT DISTINCT clinica_id FROM dentistas WHERE id = ANY($1)', [ids]); + for (const r of rows) if (!await userHasVinculo(req.authUser?.userId, r.clinica_id)) return res.status(403).json({ error: 'Acesso negado: dentista de outra clínica.' }); + } await withTransaction(async (client) => { for (const item of orders) { await client.query('UPDATE dentistas SET ordem = $1 WHERE id = $2', [item.ordem, item.id]); @@ -1805,16 +1869,18 @@ app.put('/api/dentistas/reorder', async (req, res) => { } }); -app.put('/api/dentistas/:id', async (req, res) => { +app.put('/api/dentistas/:id', authGuard, async (req, res) => { try { + if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM dentistas WHERE id = $1', [req.params.id])) return; const upd = buildUpdate('dentistas', sanitizeDentista(req.body), 'id', req.params.id); if (upd) await pool.query(upd.text, upd.values); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); -app.delete('/api/dentistas/:id', async (req, res) => { +app.delete('/api/dentistas/:id', authGuard, async (req, res) => { try { + if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM dentistas WHERE id = $1', [req.params.id])) return; const { id } = req.params; await withTransaction(async (client) => { // 1. Remove Google Tokens associated with this dentist @@ -1834,8 +1900,9 @@ app.delete('/api/dentistas/:id', async (req, res) => { }); // --- GESTÃO DE HORÁRIOS --- -app.get('/api/dentistas/:dentistaId/horarios', async (req, res) => { +app.get('/api/dentistas/:dentistaId/horarios', authGuard, async (req, res) => { try { + if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM dentistas WHERE id = $1', [req.params.dentistaId])) return; const { dentistaId } = req.params; const { clinicaId } = req.query; let query = 'SELECT * FROM dentistas_horarios WHERE dentista_id = $1'; @@ -1850,8 +1917,9 @@ app.get('/api/dentistas/:dentistaId/horarios', async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.post('/api/dentistas/horarios', async (req, res) => { +app.post('/api/dentistas/horarios', authGuard, async (req, res) => { try { + if (!await assertCidScoped(req, res, req.body?.clinica_id)) return; const { id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo } = req.body; const horaId = id || `h_${Math.random().toString(36).substring(2, 9)}`; await pool.query(` @@ -1865,17 +1933,19 @@ app.post('/api/dentistas/horarios', async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.delete('/api/dentistas/horarios/:id', async (req, res) => { +app.delete('/api/dentistas/horarios/:id', authGuard, async (req, res) => { try { + if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM dentistas_horarios WHERE id = $1', [req.params.id])) return; await pool.query('DELETE FROM dentistas_horarios WHERE id = $1', [req.params.id]); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // --- RELATÓRIOS --- -app.get('/api/reports/productivity', async (req, res) => { +app.get('/api/reports/productivity', tenantGuard, async (req, res) => { try { - const { clinicaId, start, end } = req.query; + const clinicaId = req.clinicaId; + const { start, end } = req.query; if (!clinicaId) return res.status(400).json({ error: "Missing clinicaId" }); // Financial Totals @@ -1908,6 +1978,465 @@ app.get('/api/reports/productivity', async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); +// --- RELATÓRIO FINANCEIRO (H3.1 período / H3.2 por paciente e profissional) --- +app.get('/api/financeiro/relatorio', tenantGuard, async (req, res) => { + try { + const clinicaId = req.clinicaId; + if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const { inicio, fim, pacienteId, profissionalId } = req.query; + + // WHERE dinâmico: sempre escopado por clínica + soft delete; período por vencimento + const where = ['clinica_id = $1', 'deleted_at IS NULL']; + const params = [clinicaId]; + where.push(`datavencimento BETWEEN $${params.length + 1} AND $${params.length + 2}`); + params.push(inicio || '2000-01-01', fim || '2100-01-01'); + if (pacienteId) { params.push(pacienteId); where.push(`paciente_id = $${params.length}`); } + if (profissionalId) { params.push(profissionalId); where.push(`profissional_id = $${params.length}`); } + const W = where.join(' AND '); + + // Resumo por status/tipo (recebido = RECEITA Pago; a receber = RECEITA Pendente+Atrasado) + const { rows: resumo } = await pool.query(` + SELECT + COALESCE(SUM(CASE WHEN tipo='RECEITA' AND status='Pago' THEN valor ELSE 0 END),0) AS recebido, + COALESCE(SUM(CASE WHEN tipo='RECEITA' AND status='Pendente' THEN valor ELSE 0 END),0) AS a_receber, + COALESCE(SUM(CASE WHEN tipo='RECEITA' AND status='Atrasado' THEN valor ELSE 0 END),0) AS atrasado, + COALESCE(SUM(CASE WHEN tipo='DESPESA' AND status='Pago' THEN valor ELSE 0 END),0) AS despesa_paga, + COALESCE(SUM(CASE WHEN tipo='DESPESA' AND status<>'Pago' THEN valor ELSE 0 END),0) AS despesa_aberta, + COALESCE(SUM(CASE WHEN tipo='RECEITA' THEN COALESCE(valor_glosa,0) ELSE 0 END),0) AS glosa, + COUNT(*) AS total_lancamentos + FROM financeiro WHERE ${W} + `, params); + + // Série por mês (competência = vencimento) + const { rows: porMes } = await pool.query(` + SELECT to_char(date_trunc('month', datavencimento), 'YYYY-MM') AS mes, + COALESCE(SUM(CASE WHEN tipo='RECEITA' AND status='Pago' THEN valor ELSE 0 END),0) AS recebido, + COALESCE(SUM(CASE WHEN tipo='RECEITA' AND status<>'Pago' THEN valor ELSE 0 END),0) AS a_receber + FROM financeiro WHERE ${W} + GROUP BY 1 ORDER BY 1 + `, params); + + // Por forma de pagamento (somente recebido) + const { rows: porForma } = await pool.query(` + SELECT COALESCE(NULLIF(formapagamento,''),'—') AS forma, COALESCE(SUM(valor),0) AS total, COUNT(*) AS qtd + FROM financeiro WHERE ${W} AND tipo='RECEITA' AND status='Pago' + GROUP BY 1 ORDER BY total DESC + `, params); + + // Despesas por centro de custo / fornecedor (H3.3) + const { rows: porCategoria } = await pool.query(` + SELECT COALESCE(NULLIF(centro_custo,''),'Sem categoria') AS centro_custo, + COALESCE(SUM(valor),0) AS total, COUNT(*) AS qtd + FROM financeiro WHERE ${W} AND tipo='DESPESA' + GROUP BY 1 ORDER BY total DESC + `, params); + + // Receitas por convênio (GTO / planos) — cada condição do WHERE começa pelo nome da coluna, então prefixar com f. é seguro + const Wf = where.map(c => 'f.' + c).join(' AND '); + const { rows: porConvenio } = await pool.query(` + SELECT COALESCE(c.nome, f.convenio_id, 'Particular') AS convenio, + COALESCE(SUM(f.valor),0) AS total, + COALESCE(SUM(CASE WHEN f.status='Pago' THEN f.valor ELSE 0 END),0) AS recebido, + COALESCE(SUM(CASE WHEN f.status<>'Pago' THEN f.valor ELSE 0 END),0) AS a_receber, + COALESCE(SUM(f.valor_glosa),0) AS glosa, + COUNT(*) AS qtd + FROM financeiro f + LEFT JOIN planos c ON c.id = f.convenio_id + WHERE ${Wf} AND f.tipo='RECEITA' + GROUP BY 1 ORDER BY total DESC + `, params); + + // Detalhe (lista) — útil para H3.2 paciente/profissional + const { rows: lancamentos } = await pool.query(` + SELECT id, descricao, pacientenome, paciente_id, profissional_id, valor, tipo, status, + formapagamento, datavencimento, pago_em, origem, sem_comissao, fornecedor, centro_custo, recorrente + FROM financeiro WHERE ${W} + ORDER BY datavencimento DESC LIMIT 500 + `, params); + + const num = o => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, isNaN(Number(v)) ? v : Number(v)])); + const resumoNum = num(resumo[0]); + // Líquido a receber = pendentes + atrasados − glosa (convênio negou) + resumoNum.a_receber_liquido = Number(((resumoNum.a_receber + resumoNum.atrasado) - resumoNum.glosa).toFixed(2)); + res.json({ + resumo: resumoNum, + porMes: porMes.map(num), + porForma: porForma.map(num), + porCategoria: porCategoria.map(num), + porConvenio: porConvenio.map(num), + lancamentos: lancamentos.map(l => ({ ...l, valor: Number(l.valor || 0) })), + }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// ═══ GLOSAS DE CONVÊNIO ══════════════════════════════════════════════════════ +// Glosa = valor negado pelo convênio sobre um recebível. Acumula em financeiro.valor_glosa +// e mantém histórico em `glosas` (com recurso/recuperação). +app.get('/api/glosas', tenantGuard, async (req, res) => { + try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const { status } = req.query; + const params = [req.clinicaId]; + let cond = 'g.clinica_id = $1'; + if (status) { params.push(status); cond += ` AND g.status = $${params.length}`; } + const { rows } = await pool.query(` + SELECT g.*, f.descricao AS financeiro_descricao, f.pacientenome, f.valor AS financeiro_valor, + c.nome AS convenio_nome + FROM glosas g + JOIN financeiro f ON f.id = g.financeiro_id + LEFT JOIN planos c ON c.id = f.convenio_id + WHERE ${cond} ORDER BY g.criado_em DESC LIMIT 300 + `, params); + res.json(rows.map(r => ({ ...r, valor: Number(r.valor), financeiro_valor: Number(r.financeiro_valor || 0) }))); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Recebíveis de convênio elegíveis a glosa (GTO/convênio, ainda não totalmente glosados/pagos) +app.get('/api/glosas/recebiveis', tenantGuard, async (req, res) => { + try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const { rows } = await pool.query(` + SELECT f.id, f.descricao, f.pacientenome, f.valor, f.valor_glosa, f.status, f.datavencimento, c.nome AS convenio_nome + FROM financeiro f LEFT JOIN planos c ON c.id = f.convenio_id + WHERE f.clinica_id = $1 AND f.deleted_at IS NULL AND f.tipo = 'RECEITA' + AND (f.origem = 'gto' OR f.convenio_id IS NOT NULL) + AND COALESCE(f.valor_glosa,0) < f.valor + ORDER BY f.datavencimento DESC LIMIT 200 + `, [req.clinicaId]); + res.json(rows.map(r => ({ ...r, valor: Number(r.valor), valor_glosa: Number(r.valor_glosa || 0) }))); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Itens da GTO por trás de um recebível (para glosa por item) — com flag de já glosado +app.get('/api/glosas/itens/:financeiroId', tenantGuard, async (req, res) => { + try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + // confirma que o recebível é desta clínica + const fr = await pool.query('SELECT 1 FROM financeiro WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [req.params.financeiroId, req.clinicaId]); + if (!fr.rowCount) return res.status(404).json({ error: 'Recebível não encontrado.' }); + const { rows } = await pool.query(` + SELECT i.id, i.descricao, i.valortotal, + EXISTS (SELECT 1 FROM glosas g WHERE g.gto_item_id = i.id AND g.status <> 'recuperado') AS ja_glosado + FROM gto_builders b JOIN gto_items i ON i.builderid = b.id + WHERE b.financeiro_id = $1 ORDER BY i.descricao`, [req.params.financeiroId]); + res.json(rows.map(r => ({ ...r, valortotal: Number(r.valortotal || 0) }))); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Registra glosa sobre um recebível +app.post('/api/glosas', tenantGuard, async (req, res) => { + try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const { financeiro_id, motivo, gto_item_id, item_descricao, protocolo, prazo_recurso } = req.body; + const valor = Number(req.body.valor); + if (!financeiro_id || !(valor > 0)) return res.status(400).json({ error: 'Informe o recebível e um valor de glosa positivo.' }); + const { rows: fr } = await pool.query("SELECT valor, valor_glosa FROM financeiro WHERE id = $1 AND clinica_id = $2 AND deleted_at IS NULL", [financeiro_id, req.clinicaId]); + if (!fr.length) return res.status(404).json({ error: 'Recebível não encontrado nesta clínica.' }); + const restante = Number(fr[0].valor) - Number(fr[0].valor_glosa || 0); + if (valor > restante + 0.001) return res.status(400).json({ error: `Glosa (${valor.toFixed(2)}) excede o valor restante (${restante.toFixed(2)}).` }); + if (gto_item_id) { + const dup = await pool.query("SELECT 1 FROM glosas WHERE clinica_id=$1 AND gto_item_id=$2 AND status<>'recuperado'", [req.clinicaId, gto_item_id]); + if (dup.rowCount) return res.status(409).json({ error: 'Este item já possui glosa ativa.' }); + } + const id = `gl_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const nome = await actorNome(req.authUser?.userId); + await pool.query(`INSERT INTO glosas (id, clinica_id, financeiro_id, valor, motivo, status, gto_item_id, item_descricao, protocolo, prazo_recurso, criado_por, criado_por_nome) VALUES ($1,$2,$3,$4,$5,'glosado',$6,$7,$8,$9,$10,$11)`, + [id, req.clinicaId, financeiro_id, valor, motivo || null, gto_item_id || null, item_descricao || null, protocolo || null, prazo_recurso || null, req.authUser?.userId, nome]); + await pool.query('UPDATE financeiro SET valor_glosa = COALESCE(valor_glosa,0) + $1 WHERE id = $2', [valor, financeiro_id]); + await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'glosa', entidadeId: id, acao: 'registrou_glosa', actorId: req.authUser?.userId, actorNome: nome, detalhes: { financeiro_id, valor, motivo, gto_item_id } }); + schedulePush('financeiro'); + res.json({ success: true, id }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Abre recurso na glosa (em andamento) — registra protocolo + prazo, mantém valor_glosa +app.post('/api/glosas/:id/recorrer', tenantGuard, async (req, res) => { + try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const { protocolo, prazo_recurso } = req.body; + const { rows } = await pool.query('SELECT status FROM glosas WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]); + if (!rows.length) return res.status(404).json({ error: 'Glosa não encontrada.' }); + if (rows[0].status !== 'glosado') return res.status(400).json({ error: 'Só é possível abrir recurso de glosa em aberto.' }); + await pool.query("UPDATE glosas SET status='recurso', protocolo=COALESCE($1,protocolo), prazo_recurso=COALESCE($2,prazo_recurso) WHERE id=$3", [protocolo || null, prazo_recurso || null, req.params.id]); + await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'glosa', entidadeId: req.params.id, acao: 'abriu_recurso', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { protocolo, prazo_recurso } }); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Recurso ganho / glosa recuperada → devolve o valor ao recebível +app.post('/api/glosas/:id/recuperar', tenantGuard, async (req, res) => { + try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const { rows } = await pool.query('SELECT * FROM glosas WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]); + if (!rows.length) return res.status(404).json({ error: 'Glosa não encontrada.' }); + if (rows[0].status === 'recuperado') return res.status(400).json({ error: 'Glosa já recuperada.' }); + await pool.query("UPDATE glosas SET status = 'recuperado' WHERE id = $1", [req.params.id]); + await pool.query('UPDATE financeiro SET valor_glosa = GREATEST(0, COALESCE(valor_glosa,0) - $1) WHERE id = $2', [Number(rows[0].valor), rows[0].financeiro_id]); + await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'glosa', entidadeId: req.params.id, acao: 'recuperou_glosa', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { valor: Number(rows[0].valor) } }); + schedulePush('financeiro'); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Exclui glosa (correção) — devolve o valor se estava ativa +app.delete('/api/glosas/:id', tenantGuard, async (req, res) => { + try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const { rows } = await pool.query('SELECT * FROM glosas WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]); + if (!rows.length) return res.status(404).json({ error: 'Glosa não encontrada.' }); + if (rows[0].status !== 'recuperado') { // glosado/recurso ainda impactam o recebível + await pool.query('UPDATE financeiro SET valor_glosa = GREATEST(0, COALESCE(valor_glosa,0) - $1) WHERE id = $2', [Number(rows[0].valor), rows[0].financeiro_id]); + } + await pool.query('DELETE FROM glosas WHERE id = $1', [req.params.id]); + await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'glosa', entidadeId: req.params.id, acao: 'excluiu_glosa', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { valor: Number(rows[0].valor) } }); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// ═══ V2 COMISSÃO / REPASSE ═══════════════════════════════════════════════════ +// Percentual por procedimento (regra específica) com fallback p/ regra padrão (procedimento_id NULL). +// Base selecionável: 'recebido' (caixa, status=Pago via pago_em/vencimento) ou 'producao' (competência, vencimento). +async function computeComissoes(q, clinicaId, { inicio, fim, base = 'recebido', profissionalId } = {}) { + const params = [clinicaId, inicio || '2000-01-01', fim || '2100-01-01']; + let baseCond; + if (base === 'producao') { + // produção = competência pela data de conclusão do procedimento (fallback: vencimento) + baseCond = `COALESCE(f.data_competencia, f.datavencimento) BETWEEN $2 AND $3`; + } else { + baseCond = `f.status = 'Pago' AND COALESCE(f.pago_em::date, f.datavencimento) BETWEEN $2 AND $3`; + } + let profCond = ''; + if (profissionalId) { params.push(profissionalId); profCond = ` AND f.profissional_id = $${params.length}`; } + // Resolução do %: override do profissional > regra do procedimento > padrão > 0. + // Custo de lab (Gap 2): deduzido do valor antes da comissão; vem da regra do procedimento. + const { rows } = await q.query(` + SELECT f.id, f.descricao, f.profissional_id, d.nome AS profissional_nome, f.procedimento_id, + f.valor, f.status, f.datavencimento, f.pago_em, + COALESCE(rprof.percentual, rproc.percentual, rdef.percentual, 0) AS percentual, + COALESCE(rproc.custo_lab, 0) AS custo_lab + FROM financeiro f + LEFT JOIN comissao_regras rprof ON rprof.clinica_id = f.clinica_id AND rprof.profissional_id = f.profissional_id AND rprof.procedimento_id IS NULL + LEFT JOIN comissao_regras rproc ON rproc.clinica_id = f.clinica_id AND rproc.procedimento_id = f.procedimento_id AND rproc.profissional_id IS NULL + LEFT JOIN comissao_regras rdef ON rdef.clinica_id = f.clinica_id AND rdef.procedimento_id IS NULL AND rdef.profissional_id IS NULL + LEFT JOIN dentistas d ON d.id = f.profissional_id AND d.clinica_id = f.clinica_id + WHERE f.clinica_id = $1 AND f.deleted_at IS NULL AND f.tipo = 'RECEITA' + AND f.sem_comissao = false AND f.profissional_id IS NOT NULL + AND ${baseCond}${profCond} + ORDER BY d.nome NULLS LAST + `, params); + // Agrupa por profissional (mantém detalhe p/ snapshot/auditoria) + const mapa = new Map(); + for (const l of rows) { + const pct = Number(l.percentual) || 0; + const custoLab = Math.min(Number(l.custo_lab) || 0, Number(l.valor)); // lab não excede o valor + const baseLiquida = Number(l.valor) - custoLab; + const comissao = baseLiquida * pct / 100; + if (!mapa.has(l.profissional_id)) { + mapa.set(l.profissional_id, { profissional_id: l.profissional_id, profissional_nome: l.profissional_nome || '—', base_valor: 0, custo_lab_total: 0, comissao_valor: 0, qtd: 0, detalhe: [] }); + } + const g = mapa.get(l.profissional_id); + g.base_valor += Number(l.valor); + g.custo_lab_total += custoLab; + g.comissao_valor += comissao; + g.qtd += 1; + g.detalhe.push({ id: l.id, descricao: l.descricao, valor: Number(l.valor), custo_lab: Number(custoLab.toFixed(2)), percentual: pct, comissao: Number(comissao.toFixed(2)) }); + } + const grupos = [...mapa.values()].map(g => ({ + ...g, + base_valor: Number(g.base_valor.toFixed(2)), + custo_lab_total: Number(g.custo_lab_total.toFixed(2)), + comissao_valor: Number(g.comissao_valor.toFixed(2)), + percentual_medio: (g.base_valor - g.custo_lab_total) > 0 ? Number((g.comissao_valor / (g.base_valor - g.custo_lab_total) * 100).toFixed(2)) : 0, + })).sort((a, b) => b.comissao_valor - a.comissao_valor); + return { + base, inicio: inicio || null, fim: fim || null, + total_base: Number(grupos.reduce((s, g) => s + g.base_valor, 0).toFixed(2)), + total_custo_lab: Number(grupos.reduce((s, g) => s + g.custo_lab_total, 0).toFixed(2)), + total_comissao: Number(grupos.reduce((s, g) => s + g.comissao_valor, 0).toFixed(2)), + grupos, + }; +} + +// Regras de comissão (% por procedimento + padrão) +app.get('/api/comissoes/regras', tenantGuard, async (req, res) => { + try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const { rows } = await pool.query(` + SELECT r.id, r.procedimento_id, r.profissional_id, r.percentual, r.custo_lab, + p.nome AS procedimento_nome, d.nome AS profissional_nome + FROM comissao_regras r + LEFT JOIN procedimentos p ON p.id = r.procedimento_id + LEFT JOIN dentistas d ON d.id = r.profissional_id AND d.clinica_id = r.clinica_id + WHERE r.clinica_id = $1 + ORDER BY (r.procedimento_id IS NULL AND r.profissional_id IS NULL) DESC, d.nome NULLS LAST, p.nome NULLS FIRST + `, [req.clinicaId]); + res.json(rows.map(r => ({ ...r, percentual: Number(r.percentual), custo_lab: Number(r.custo_lab || 0) }))); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Upsert de regra (procedimento_id null = padrão da clínica) +app.put('/api/comissoes/regras', tenantGuard, async (req, res) => { + try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const procId = req.body.procedimento_id || null; + const profId = req.body.profissional_id || null; + if (procId && profId) return res.status(400).json({ error: 'Regra é por procedimento OU por profissional, não ambos.' }); + const pct = Number(req.body.percentual); + if (!(pct >= 0 && pct <= 100)) return res.status(400).json({ error: 'Percentual deve estar entre 0 e 100.' }); + const custoLab = Math.max(0, Number(req.body.custo_lab) || 0); + const id = `cr_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + await pool.query(` + INSERT INTO comissao_regras (id, clinica_id, procedimento_id, profissional_id, percentual, custo_lab) VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (clinica_id, COALESCE(procedimento_id, ''), COALESCE(profissional_id, '')) DO UPDATE SET percentual = EXCLUDED.percentual, custo_lab = EXCLUDED.custo_lab + `, [id, req.clinicaId, procId, profId, pct, custoLab]); + await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'comissao_regra', entidadeId: procId || profId || 'padrao', acao: 'definiu_percentual', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { procedimento_id: procId, profissional_id: profId, percentual: pct, custo_lab: custoLab } }); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.delete('/api/comissoes/regras/:id', tenantGuard, async (req, res) => { + try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const { rowCount } = await pool.query('DELETE FROM comissao_regras WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]); + if (!rowCount) return res.status(404).json({ error: 'Regra não encontrada nesta clínica.' }); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Relatório de comissão ao vivo (não persiste) +app.get('/api/comissoes/relatorio', tenantGuard, async (req, res) => { + try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const { inicio, fim, base, profissionalId } = req.query; + const out = await computeComissoes(pool, req.clinicaId, { inicio, fim, base, profissionalId }); + res.json(out); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Fecha repasse do período (snapshot imutável por profissional + auditoria) +app.post('/api/repasses/fechar', tenantGuard, async (req, res) => { + try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const { inicio, fim, base, profissionalId } = req.body; + if (!inicio || !fim) return res.status(400).json({ error: 'Informe início e fim do período.' }); + const out = await computeComissoes(pool, req.clinicaId, { inicio, fim, base, profissionalId }); + if (out.grupos.length === 0) return res.status(400).json({ error: 'Nenhuma comissão a fechar no período/filtro.' }); + const nome = await actorNome(req.authUser?.userId); + const criados = []; + for (const g of out.grupos) { + const id = `rp_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + await pool.query(` + INSERT INTO repasses (id, clinica_id, profissional_id, profissional_nome, inicio, fim, base, base_valor, custo_lab_total, comissao_valor, qtd_lancamentos, detalhes, status, criado_por, criado_por_nome) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'fechado',$13,$14) + `, [id, req.clinicaId, g.profissional_id, g.profissional_nome, inicio, fim, out.base, g.base_valor, g.custo_lab_total, g.comissao_valor, g.qtd, JSON.stringify(g.detalhe), req.authUser?.userId, nome]); + await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'repasse', entidadeId: id, acao: 'fechou_repasse', actorId: req.authUser?.userId, actorNome: nome, detalhes: { profissional: g.profissional_nome, base: out.base, periodo: `${inicio}..${fim}`, comissao: g.comissao_valor } }); + criados.push(id); + } + res.json({ success: true, criados: criados.length, total_comissao: out.total_comissao }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Lista fechamentos de repasse +app.get('/api/repasses', tenantGuard, async (req, res) => { + try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const { rows } = await pool.query(` + SELECT id, profissional_id, profissional_nome, inicio, fim, base, base_valor, custo_lab_total, comissao_valor, qtd_lancamentos, status, pago_em, financeiro_id, comprovante_filename, comprovante_nome, criado_por_nome, criado_em + FROM repasses WHERE clinica_id = $1 ORDER BY criado_em DESC LIMIT 300 + `, [req.clinicaId]); + res.json(rows.map(r => ({ + ...r, base_valor: Number(r.base_valor), custo_lab_total: Number(r.custo_lab_total || 0), comissao_valor: Number(r.comissao_valor), + comprovante_url: r.comprovante_filename ? `/api/repasses/comprovante/${r.id}/raw?t=${signMedia(r.id)}` : null, + }))); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Detalhe de um fechamento (lançamentos snapshot) +app.get('/api/repasses/:id', tenantGuard, async (req, res) => { + try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const { rows } = await pool.query('SELECT * FROM repasses WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]); + if (!rows.length) return res.status(404).json({ error: 'Repasse não encontrado.' }); + const r = rows[0]; + res.json({ ...r, base_valor: Number(r.base_valor), comissao_valor: Number(r.comissao_valor) }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Cancela um fechamento (não apaga — auditoria) +app.post('/api/repasses/:id/cancelar', tenantGuard, async (req, res) => { + try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const { rows } = await pool.query('SELECT profissional_nome, status, financeiro_id FROM repasses WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]); + if (!rows.length) return res.status(404).json({ error: 'Repasse não encontrado.' }); + if (rows[0].status === 'cancelado') return res.status(400).json({ error: 'Repasse já cancelado.' }); + // Se estava pago, estorna (soft-delete) a despesa gerada no financeiro + let estornou = null; + if (rows[0].status === 'pago' && rows[0].financeiro_id) { + await pool.query('UPDATE financeiro SET deleted_at = NOW() WHERE id = $1 AND clinica_id = $2 AND deleted_at IS NULL', [rows[0].financeiro_id, req.clinicaId]); + estornou = rows[0].financeiro_id; + } + await pool.query("UPDATE repasses SET status = 'cancelado', financeiro_id = NULL WHERE id = $1 AND clinica_id = $2", [req.params.id, req.clinicaId]); + await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'repasse', entidadeId: req.params.id, acao: 'cancelou_repasse', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { profissional: rows[0].profissional_nome, despesa_estornada: estornou } }); + if (estornou) schedulePush('financeiro'); + res.json({ success: true, despesa_estornada: estornou }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Marca o repasse como PAGO e lança a DESPESA correspondente no financeiro (Gap 3) +app.post('/api/repasses/:id/pagar', tenantGuard, async (req, res) => { + try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const { rows } = await pool.query('SELECT * FROM repasses WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]); + if (!rows.length) return res.status(404).json({ error: 'Repasse não encontrado.' }); + const rp = rows[0]; + if (rp.status !== 'fechado') return res.status(400).json({ error: `Só é possível pagar repasse fechado (atual: ${rp.status}).` }); + const pagoEm = req.body?.pago_em || new Date().toISOString(); + const forma = FIN_FORMA[(req.body?.formapagamento || '').toUpperCase()] || 'Pix'; + const finId = `fin_rp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const ymd = d => d instanceof Date ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` : String(d).slice(0, 10); + // Lançamento de DESPESA já pago (origem='repasse'); sem_comissao p/ não recomissionar + await pool.query(` + INSERT INTO financeiro (id, clinica_id, descricao, valor, tipo, status, formapagamento, datavencimento, pago_em, profissional_id, origem, centro_custo, fornecedor, sem_comissao) + VALUES ($1,$2,$3,$4,'DESPESA','Pago',$5,$6,$7,$8,'repasse','COMISSÕES',$9,true) + `, [finId, req.clinicaId, `REPASSE ${rp.profissional_nome} (${ymd(rp.inicio)} a ${ymd(rp.fim)})`, rp.comissao_valor, forma, ymd(pagoEm), pagoEm, rp.profissional_id, rp.profissional_nome]); + await pool.query("UPDATE repasses SET status = 'pago', pago_em = $1, financeiro_id = $2 WHERE id = $3 AND clinica_id = $4", [pagoEm, finId, req.params.id, req.clinicaId]); + await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'repasse', entidadeId: req.params.id, acao: 'pagou_repasse', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { profissional: rp.profissional_nome, valor: rp.comissao_valor, financeiro_id: finId } }); + schedulePush('financeiro'); + res.json({ success: true, financeiro_id: finId }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Anexa comprovante (imagem/PDF) ao repasse pago +app.post('/api/repasses/:id/comprovante', tenantGuard, ortoUpload.single('arquivo'), async (req, res) => { + try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + if (!req.file) return res.status(400).json({ error: 'Arquivo obrigatório.' }); + const mt = req.file.mimetype || ''; + if (!(mt.startsWith('image/') || mt === 'application/pdf')) return res.status(400).json({ error: 'Envie imagem ou PDF.' }); + const { rows } = await pool.query('SELECT status FROM repasses WHERE id=$1 AND clinica_id=$2', [req.params.id, req.clinicaId]); + if (!rows.length) return res.status(404).json({ error: 'Repasse não encontrado.' }); + const ext = mt === 'application/pdf' ? 'pdf' : ((req.file.originalname.split('.').pop() || 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '') || 'jpg'); + const rel = path.join('repasses', req.params.id, `comprovante_${Date.now()}.${ext}`); + await fs.promises.mkdir(path.join(MEDIA_ROOT, 'repasses', req.params.id), { recursive: true }); + await fs.promises.writeFile(path.join(MEDIA_ROOT, rel), req.file.buffer); + await pool.query('UPDATE repasses SET comprovante_filename=$1, comprovante_mimetype=$2, comprovante_nome=$3 WHERE id=$4 AND clinica_id=$5', + [rel, mt, req.file.originalname, req.params.id, req.clinicaId]); + await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'repasse', entidadeId: req.params.id, acao: 'anexou_comprovante', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { nome: req.file.originalname } }); + res.json({ success: true, url: `/api/repasses/comprovante/${req.params.id}/raw?t=${signMedia(req.params.id)}` }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// stream do comprovante (assinado, sem Authorization) +app.get('/api/repasses/comprovante/:id/raw', async (req, res) => { + try { + if (!verifyMedia(req.params.id, req.query.t)) return res.status(403).end(); + const { rows } = await pool.query('SELECT comprovante_filename, comprovante_mimetype FROM repasses WHERE id=$1', [req.params.id]); + if (!rows.length || !rows[0].comprovante_filename) return res.status(404).end(); + res.type(rows[0].comprovante_mimetype || 'application/octet-stream'); + res.sendFile(path.join(MEDIA_ROOT, rows[0].comprovante_filename), (err) => { if (err && !res.headersSent) res.status(404).end(); }); + } catch { res.status(404).end(); } +}); + // --- AGENDAMENTOS --- app.get('/api/agendamentos', tenantGuard, async (req, res) => { try { @@ -1962,6 +2491,8 @@ app.post('/api/agendamentos', authGuard, async (req, res) => { const actorNm = await actorNome(actor); const { start, end, ...rest } = req.body; const { dentistaId } = rest; + if (!rest.clinica_id) return res.status(400).json({ error: 'clinica_id obrigatório.' }); + if (!await userHasVinculo(actor, rest.clinica_id)) return res.status(403).json({ error: 'Acesso negado: sem vínculo com esta unidade.' }); const id = rest.id || `ag_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`; await withTransaction(async (client) => { @@ -2129,6 +2660,39 @@ async function loadAgendamentoScoped(req, res) { return a; } +// Anti-IDOR de escrita: o usuário tem vínculo com a clínica? (boolean, sem resposta HTTP) +async function userHasVinculo(userId, clinicaId) { + if (!userId || !clinicaId) return false; + const { rows } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2 LIMIT 1', [userId, clinicaId]); + return rows.length > 0; +} + +// Carrega um tratamento orto e valida o vínculo do usuário com a clínica dele (403 se não). +async function assertOrtoScoped(req, res) { + const { rows } = await pool.query('SELECT clinica_id FROM ortho_tratamento WHERE id = $1', [req.params.id]); + if (!rows.length) { res.status(404).json({ success: false, error: 'Tratamento não encontrado.' }); return null; } + if (!await userHasVinculo(req.authUser?.userId, rows[0].clinica_id)) { + res.status(403).json({ success: false, error: 'Acesso negado: tratamento de outra clínica.' }); return null; + } + return rows[0]; +} + +// Valida posse de um recurso por sua clínica. superadmin passa; recurso global (cid null) só superadmin. +async function assertCidScoped(req, res, cid) { + const { rows: u } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser?.userId]); + if (u[0]?.role === 'superadmin') return true; + if (!cid || !await userHasVinculo(req.authUser?.userId, cid)) { + res.status(403).json({ error: 'Acesso negado: recurso de outra clínica.' }); return false; + } + return true; +} +// Carrega o clinica_id de um recurso (sql deve retornar coluna "cid") e valida a posse. +async function assertRowScoped(req, res, sql, params) { + const { rows } = await pool.query(sql, params); + if (!rows.length) { res.status(404).json({ error: 'Recurso não encontrado.' }); return false; } + return await assertCidScoped(req, res, rows[0].cid); +} + // DELETE = cancelamento SOFT (status 'cancelado'). NUNCA remove a linha — preserva // histórico/autoria ("quem cancelou") e gera pendência "a reagendar". app.delete('/api/agendamentos/:id', authGuard, async (req, res) => { @@ -2286,7 +2850,7 @@ app.post('/api/agendamentos/:id/solicitar-avaliacao', authGuard, async (req, res // =================================================================== async function fotosDaAvaliacao(avaliacaoId) { const { rows } = await pool.query('SELECT id, dente, original_name, created_at FROM avaliacao_foto WHERE avaliacao_id = $1 ORDER BY created_at ASC', [avaliacaoId]); - return rows.map(f => ({ id: f.id, dente: f.dente, original_name: f.original_name, url: `/api/avaliacoes/fotos/${f.id}/raw` })); + return rows.map(f => ({ id: f.id, dente: f.dente, original_name: f.original_name, url: `/api/avaliacoes/fotos/${f.id}/raw?t=${signMedia(f.id)}` })); } // Lista avaliações de uma clínica (opcionalmente por status). Usado pela recepção/dono. @@ -2369,13 +2933,14 @@ app.post('/api/avaliacoes/:id/fotos', authGuard, ortoUpload.single('foto'), asyn await fs.promises.writeFile(path.join(MEDIA_ROOT, rel), req.file.buffer); const id = _oid('avf'); await pool.query('INSERT INTO avaliacao_foto (id, avaliacao_id, dente, filename, mimetype, original_name) VALUES ($1,$2,$3,$4,$5,$6)', [id, req.params.id, dente, rel, req.file.mimetype, req.file.originalname]); - res.json({ success: true, foto: { id, dente, url: `/api/avaliacoes/fotos/${id}/raw`, original_name: req.file.originalname } }); + res.json({ success: true, foto: { id, dente, url: `/api/avaliacoes/fotos/${id}/raw?t=${signMedia(id)}`, original_name: req.file.originalname } }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); // Stream público da foto (img tags não enviam Authorization). app.get('/api/avaliacoes/fotos/:id/raw', async (req, res) => { try { + if (!verifyMedia(req.params.id, req.query.t)) return res.status(403).end(); const { rows } = await pool.query('SELECT filename, mimetype FROM avaliacao_foto WHERE id = $1', [req.params.id]); if (!rows.length) return res.status(404).end(); res.type(rows[0].mimetype || 'image/jpeg'); @@ -2430,6 +2995,7 @@ app.post('/api/agenda/pendencias/:id/resolver', authGuard, async (req, res) => { // Contagem de faltas de um paciente. app.get('/api/pacientes/:id/faltas', authGuard, async (req, res) => { try { + if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM pacientes WHERE id = $1', [req.params.id])) return; const { rows } = await pool.query(`SELECT count(*)::int AS faltas FROM agendamentos WHERE pacienteid=$1 AND lower(status)='falta'`, [req.params.id]); res.json({ success: true, faltas: rows[0]?.faltas || 0 }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } @@ -2439,6 +3005,7 @@ app.get('/api/pacientes/:id/faltas', authGuard, async (req, res) => { // mas em grande número = atenção). Remarcações vêm do HISTÓRICO (audit_log 'remarcou'), durável. app.get('/api/pacientes/:id/score', authGuard, async (req, res) => { try { + if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM pacientes WHERE id = $1', [req.params.id])) return; const pid = req.params.id; const { rows: fr } = await pool.query(`SELECT count(*)::int AS faltas FROM agendamentos WHERE pacienteid=$1 AND lower(status)='falta'`, [pid]); const { rows: rr } = await pool.query( @@ -2570,6 +3137,7 @@ app.post('/api/agenda/importar-google', authGuard, async (req, res) => { // Grupo familiar do paciente (para chips compactos no modal de agenda). app.get('/api/pacientes/:id/familia', authGuard, async (req, res) => { try { + if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM pacientes WHERE id = $1', [req.params.id])) return; const { rows: pr } = await pool.query('SELECT grupofamiliarid FROM pacientes WHERE id = $1', [req.params.id]); const gid = pr[0]?.grupofamiliarid; if (!gid) return res.json({ success: true, grupo: null, membros: [] }); @@ -2582,10 +3150,11 @@ app.get('/api/pacientes/:id/familia', authGuard, async (req, res) => { // --- INTERESSES DE AGENDA (Fase 4) --- // Fila de interesse quando o horário do profissional está ocupado (não trava o slot). -app.post('/api/interesses', async (req, res) => { +app.post('/api/interesses', authGuard, async (req, res) => { try { const { dentistaId, profissionalUsuarioId, clinicaInteressadaId, solicitanteUsuarioId, start, end, tempoEsperaMin, observacoes } = req.body; if (!clinicaInteressadaId || !start) return res.status(400).json({ success: false, message: 'clinicaInteressadaId e start são obrigatórios.' }); + if (!await assertCidScoped(req, res, clinicaInteressadaId)) return; // identidade global do profissional let profUid = profissionalUsuarioId || null; @@ -2618,9 +3187,14 @@ app.post('/api/interesses', async (req, res) => { } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); -app.get('/api/interesses', async (req, res) => { +app.get('/api/interesses', authGuard, async (req, res) => { try { const { profissionalId, clinicaId } = req.query; + const uid = req.authUser?.userId; + // Escopo: a clínica (com vínculo) vê os seus; o profissional vê só os dele. + if (clinicaId) { if (!await userHasVinculo(uid, clinicaId)) return res.status(403).json({ error: 'Acesso negado a esta unidade.' }); } + else if (profissionalId) { if (profissionalId !== uid) return res.status(403).json({ error: 'Acesso negado.' }); } + else return res.status(400).json({ error: 'profissionalId ou clinicaId obrigatório.' }); // expira preguiçosamente os vencidos await pool.query(`UPDATE interesses_agenda SET status = 'expirado' WHERE status = 'aguardando' AND expira_em IS NOT NULL AND expira_em < NOW()`); let sql = `SELECT i.*, c.nome_fantasia AS clinica_nome @@ -2664,7 +3238,7 @@ app.post('/api/especialidades', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.put('/api/especialidades/reorder', async (req, res) => { +app.put('/api/especialidades/reorder', authGuard, async (req, res) => { const { orders } = req.body; // Array of {id, ordem} try { await withTransaction(async (client) => { @@ -2678,8 +3252,9 @@ app.put('/api/especialidades/reorder', async (req, res) => { } }); -app.put('/api/especialidades/:id', async (req, res) => { +app.put('/api/especialidades/:id', authGuard, async (req, res) => { try { + if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM especialidades WHERE id = $1', [req.params.id])) return; const upd = buildUpdate('especialidades', req.body, 'id', req.params.id); await pool.query(upd.text, upd.values); res.json({ success: true }); @@ -2719,8 +3294,9 @@ app.post('/api/planos', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.put('/api/planos/:id', async (req, res) => { +app.put('/api/planos/:id', authGuard, async (req, res) => { try { + if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM planos WHERE id = $1', [req.params.id])) return; const upd = buildUpdate('planos', req.body, 'id', req.params.id); await pool.query(upd.text, upd.values); res.json({ success: true }); @@ -2813,7 +3389,7 @@ app.post('/api/procedimentos', authGuard, async (req, res) => { } }); -app.put('/api/procedimentos/reorder', async (req, res) => { +app.put('/api/procedimentos/reorder', authGuard, async (req, res) => { const { orders } = req.body; // Array of {id, ordem} try { await withTransaction(async (client) => { @@ -2827,8 +3403,9 @@ app.put('/api/procedimentos/reorder', async (req, res) => { } }); -app.put('/api/procedimentos/:id', async (req, res) => { +app.put('/api/procedimentos/:id', authGuard, async (req, res) => { try { + if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM procedimentos WHERE id = $1', [req.params.id])) return; const { valoresPlanos, ...proc } = req.body; const updateData = { ...proc, exige_face: proc.exige_face ? 1 : 0 }; @@ -2860,28 +3437,56 @@ app.delete('/api/procedimentos/:id', tenantGuard, async (req, res) => { }); // --- FINANCEIRO --- +// Normaliza enums p/ as CHECK constraints (UI envia em MAIÚSCULO via enforceUppercase) +const FIN_STATUS = { PAGO: 'Pago', PENDENTE: 'Pendente', ATRASADO: 'Atrasado' }; +const FIN_FORMA = { PIX: 'Pix', 'CARTÃO': 'Cartão', CARTAO: 'Cartão', BOLETO: 'Boleto', DINHEIRO: 'Dinheiro' }; +function normalizeFinanceiro(body) { + const b = { ...body }; + if (typeof b.status === 'string') b.status = FIN_STATUS[b.status.toUpperCase()] || b.status; + if (typeof b.tipo === 'string') b.tipo = b.tipo.toUpperCase(); + if (typeof b.formapagamento === 'string') { + b.formapagamento = b.formapagamento.trim() === '' ? undefined : (FIN_FORMA[b.formapagamento.toUpperCase()] || b.formapagamento); + } + return b; +} + app.get('/api/financeiro', tenantGuard, async (req, res) => { try { const clinicaId = req.clinicaId; if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); - const { rows } = await pool.query('SELECT * FROM financeiro WHERE clinica_id = $1 ORDER BY datavencimento DESC', [clinicaId]); + const { rows } = await pool.query('SELECT * FROM financeiro WHERE clinica_id = $1 AND deleted_at IS NULL ORDER BY datavencimento DESC', [clinicaId]); res.json(rows.map(r => ({ ...r, valor: parseFloat(r.valor || 0) }))); } catch (err) { res.status(500).json({ error: err.message }); } }); -app.post('/api/financeiro', async (req, res) => { +app.post('/api/financeiro', tenantGuard, async (req, res) => { try { - const ins = buildInsert('financeiro', req.body); + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const ins = buildInsert('financeiro', { ...normalizeFinanceiro(req.body), clinica_id: req.clinicaId }); await pool.query(ins.text, ins.values); schedulePush('financeiro'); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); -app.put('/api/financeiro/:id', async (req, res) => { +app.put('/api/financeiro/:id', tenantGuard, async (req, res) => { try { - const upd = buildUpdate('financeiro', req.body, 'id', req.params.id); + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const own = await pool.query('SELECT * FROM financeiro WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]); + if (!own.rowCount) return res.status(404).json({ error: 'Lançamento não encontrado nesta unidade.' }); + const body = normalizeFinanceiro(req.body); delete body.clinica_id; + // H2.1 — baixa de pagamento: ao marcar Pago, carimba pago_em (se não informado) + const virouPago = body.status === 'Pago' && own.rows[0].status !== 'Pago'; + if (body.status === 'Pago' && !body.pago_em) body.pago_em = new Date().toISOString(); + const upd = buildUpdate('financeiro', body, 'id', req.params.id); await pool.query(upd.text, upd.values); + if (virouPago) { + await registrarAudit(pool, { + clinicaId: req.clinicaId, entidade: 'financeiro', entidadeId: req.params.id, acao: 'baixa_pagamento', + actorId: req.authUser?.userId, actorNome: req.authUser?.email, + detalhes: { valor: own.rows[0].valor, forma: body.formapagamento || own.rows[0].formapagamento, pago_em: body.pago_em }, + }); + } schedulePush('financeiro'); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } @@ -2891,27 +3496,131 @@ app.delete('/api/financeiro/:id', tenantGuard, async (req, res) => { try { const clinicaId = req.clinicaId; if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); - const { rowCount } = await pool.query('DELETE FROM financeiro WHERE id = $1 AND clinica_id = $2', [req.params.id, clinicaId]); + const { rowCount } = await pool.query('UPDATE financeiro SET deleted_at = NOW() WHERE id = $1 AND clinica_id = $2 AND deleted_at IS NULL', [req.params.id, clinicaId]); if (!rowCount) return res.status(404).json({ error: 'Lançamento não encontrado nesta clínica.' }); schedulePush('financeiro'); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); +// ═══ PRONTUÁRIO · Procedimentos realizados (reabertura/garantia sem comissão + fotos antes/depois) ═══ +// Regra de comissão: garantia => sem comissão; reabertura => sem comissão SE for o MESMO profissional do original. +app.post('/api/procedimentos-realizados', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const b = req.body || {}; + const tipo = ['novo', 'reabertura', 'garantia'].includes(b.tipo) ? b.tipo : 'novo'; + if (!b.paciente_id) return res.status(400).json({ error: 'paciente_id obrigatório.' }); + try { + let semComissao = false, origemId = b.origem_id || null; + if (tipo === 'reabertura' || tipo === 'garantia') { + if (!origemId) return res.status(400).json({ error: 'Reabertura/garantia exige o procedimento original (origem_id).' }); + const { rows: o } = await pool.query('SELECT profissional_id FROM procedimentos_realizados WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [origemId, req.clinicaId]); + if (!o.length) return res.status(404).json({ error: 'Procedimento original não encontrado.' }); + // garantia: sempre sem comissão. reabertura: sem comissão só se o MESMO profissional. + semComissao = (tipo === 'garantia') || (!!b.profissional_id && b.profissional_id === o[0].profissional_id); + } + const id = `pr_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + await pool.query( + `INSERT INTO procedimentos_realizados (id, clinica_id, paciente_id, paciente_nome, profissional_id, profissional_nome, procedimento_id, procedimento_nome, agendamento_id, tipo, origem_id, sem_comissao, valor, observacoes, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, + [id, req.clinicaId, b.paciente_id, b.paciente_nome || null, b.profissional_id || null, b.profissional_nome || null, + b.procedimento_id || null, b.procedimento_nome || null, b.agendamento_id || null, tipo, origemId, semComissao, + Number(b.valor) || 0, b.observacoes || null, req.authUser?.userId]); + // Lançamento financeiro opcional (só se houver valor) — herda sem_comissao para o futuro repasse (V2). + let lancamentoId = null; + if (Number(b.valor) > 0 && b.gerarLancamento !== false) { + lancamentoId = `fin_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const dataConclusao = b.data_conclusao || null; // competência = data em que o procedimento foi concluído + await pool.query( + `INSERT INTO financeiro (id, clinica_id, pacientenome, descricao, valor, datavencimento, data_competencia, status, tipo, paciente_id, profissional_id, procedimento_id, agendamento_id, realizado_id, origem, sem_comissao) + VALUES ($1,$2,$3,$4,$5,CURRENT_DATE, COALESCE($12::date, CURRENT_DATE),'Pendente','RECEITA',$6,$7,$8,$9,$10,'procedimento',$11)`, + [lancamentoId, req.clinicaId, b.paciente_nome || null, b.procedimento_nome || 'Procedimento', Number(b.valor), + b.paciente_id, b.profissional_id || null, b.procedimento_id || null, b.agendamento_id || null, id, semComissao, dataConclusao]); + } + await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'procedimento_realizado', entidadeId: id, acao: tipo === 'novo' ? 'registrou' : tipo, actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { semComissao, origemId } }); + res.json({ success: true, id, sem_comissao: semComissao, lancamento_id: lancamentoId }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.get('/api/procedimentos-realizados', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const pacienteId = req.query.pacienteId; + if (!pacienteId) return res.status(400).json({ error: 'pacienteId obrigatório.' }); + try { + const { rows } = await pool.query( + `SELECT r.*, + (SELECT COUNT(*) FROM procedimento_realizado_foto f WHERE f.realizado_id=r.id AND f.momento='antes') AS fotos_antes, + (SELECT COUNT(*) FROM procedimento_realizado_foto f WHERE f.realizado_id=r.id AND f.momento='depois') AS fotos_depois + FROM procedimentos_realizados r + WHERE r.clinica_id=$1 AND r.paciente_id=$2 AND r.deleted_at IS NULL + ORDER BY r.created_at DESC LIMIT 200`, [req.clinicaId, pacienteId]); + res.json(rows.map(r => ({ ...r, valor: parseFloat(r.valor || 0) }))); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.get('/api/procedimentos-realizados/:id', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + try { + const { rows } = await pool.query('SELECT * FROM procedimentos_realizados WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [req.params.id, req.clinicaId]); + if (!rows.length) return res.status(404).json({ error: 'Procedimento não encontrado.' }); + const { rows: fotos } = await pool.query('SELECT id, momento, original_name FROM procedimento_realizado_foto WHERE realizado_id=$1 ORDER BY momento, created_at', [req.params.id]); + res.json({ ...rows[0], valor: parseFloat(rows[0].valor || 0), fotos: fotos.map(f => ({ ...f, url: `/api/procedimentos-realizados/fotos/${f.id}/raw?t=${signMedia(f.id)}` })) }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.post('/api/procedimentos-realizados/:id/foto', tenantGuard, ortoUpload.single('foto'), async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const momento = req.body?.momento === 'depois' ? 'depois' : 'antes'; + if (!req.file) return res.status(400).json({ error: 'foto obrigatória.' }); + try { + const { rows } = await pool.query('SELECT id FROM procedimentos_realizados WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [req.params.id, req.clinicaId]); + if (!rows.length) return res.status(404).json({ error: 'Procedimento não encontrado.' }); + const ext = ((req.file.originalname.split('.').pop() || 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '')) || 'jpg'; + const rel = path.join('procedimentos', req.params.id, `${momento}_${Date.now()}.${ext}`); + await fs.promises.mkdir(path.join(MEDIA_ROOT, 'procedimentos', req.params.id), { recursive: true }); + await fs.promises.writeFile(path.join(MEDIA_ROOT, rel), req.file.buffer); + const fid = `prf_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + await pool.query('INSERT INTO procedimento_realizado_foto (id, realizado_id, clinica_id, momento, filename, mimetype, original_name) VALUES ($1,$2,$3,$4,$5,$6,$7)', + [fid, req.params.id, req.clinicaId, momento, rel, req.file.mimetype, req.file.originalname]); + res.json({ success: true, id: fid, momento, url: `/api/procedimentos-realizados/fotos/${fid}/raw?t=${signMedia(fid)}` }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// stream da imagem (img tag não envia Authorization → URL assinada) +app.get('/api/procedimentos-realizados/fotos/:id/raw', async (req, res) => { + try { + if (!verifyMedia(req.params.id, req.query.t)) return res.status(403).end(); + const { rows } = await pool.query('SELECT filename, mimetype FROM procedimento_realizado_foto WHERE id=$1', [req.params.id]); + if (!rows.length) return res.status(404).end(); + res.type(rows[0].mimetype || 'image/jpeg'); + res.sendFile(path.join(MEDIA_ROOT, rows[0].filename), (err) => { if (err && !res.headersSent) res.status(404).end(); }); + } catch { res.status(404).end(); } +}); + +app.delete('/api/procedimentos-realizados/:id', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + try { + const { rowCount } = await pool.query('UPDATE procedimentos_realizados SET deleted_at=NOW() WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [req.params.id, req.clinicaId]); + if (!rowCount) return res.status(404).json({ error: 'Procedimento não encontrado.' }); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + // --- CONTRATOS --- -app.get('/api/contratos', async (req, res) => { +app.get('/api/orcamentos', tenantGuard, async (req, res) => { try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); const { paciente_id, status } = req.query; - let where = 'WHERE 1=1'; - const vals = []; + const vals = [req.clinicaId]; + let where = 'WHERE c.clinica_id = $1'; if (paciente_id) { vals.push(paciente_id); where += ` AND c.paciente_id = $${vals.length}`; } if (status) { vals.push(status); where += ` AND c.status = $${vals.length}`; } - const { rows: contratos } = await pool.query( + const { rows: orcamentos } = await pool.query( `SELECT c.*, json_agg(ci.* ORDER BY ci.ordem) FILTER (WHERE ci.id IS NOT NULL) AS items - FROM contratos c - LEFT JOIN contrato_itens ci ON ci.contrato_id = c.id + FROM orcamentos c + LEFT JOIN orcamento_itens ci ON ci.orcamento_id = c.id ${where} GROUP BY c.id ORDER BY c.created_at DESC`, vals); - res.json(contratos.map(c => ({ + res.json(orcamentos.map(c => ({ ...c, valorTotal: parseFloat(c.valor_total || 0), entrada: parseFloat(c.entrada || 0), @@ -2928,7 +3637,7 @@ app.get('/api/contratos', async (req, res) => { createdAt: c.created_at, items: (c.items || []).map(i => ({ ...i, - contratoId: i.contrato_id, + orcamentoId: i.orcamento_id, procedimentoNome: i.procedimento_nome, valorUnitario: parseFloat(i.valor_unitario || 0), valorTotal: parseFloat(i.valor_total || 0), @@ -2937,17 +3646,19 @@ app.get('/api/contratos', async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.post('/api/contratos', async (req, res) => { +app.post('/api/orcamentos', tenantGuard, async (req, res) => { try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); const { items, ...contrato } = req.body; + contrato.clinica_id = req.clinicaId; // escopo forçado pela unidade do vínculo if (!contrato.id) contrato.id = Math.random().toString(36).substring(2, 12).toUpperCase(); await withTransaction(async (client) => { - const ins = buildInsert('contratos', contrato); + const ins = buildInsert('orcamentos', contrato); await client.query(ins.text, ins.values); if (items?.length) { for (const item of items) { if (!item.id) item.id = Math.random().toString(36).substring(2, 12); - const iIns = buildInsert('contrato_itens', { ...item, contrato_id: contrato.id }); + const iIns = buildInsert('orcamento_itens', { ...item, orcamento_id: contrato.id }); await client.query(iIns.text, iIns.values); } } @@ -2956,46 +3667,92 @@ app.post('/api/contratos', async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.put('/api/contratos/:id', async (req, res) => { +app.put('/api/orcamentos/:id', tenantGuard, async (req, res) => { try { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const own = await pool.query('SELECT financeiro_gerado FROM orcamentos WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]); + if (!own.rowCount) return res.status(404).json({ error: 'Contrato não encontrado nesta unidade.' }); const { items, ...contrato } = req.body; + delete contrato.clinica_id; // não permite mover de unidade await withTransaction(async (client) => { - const upd = buildUpdate('contratos', contrato, 'id', req.params.id); + const upd = buildUpdate('orcamentos', contrato, 'id', req.params.id); await client.query(upd.text, upd.values); - await client.query('DELETE FROM contrato_itens WHERE contrato_id = $1', [req.params.id]); + await client.query('DELETE FROM orcamento_itens WHERE orcamento_id = $1', [req.params.id]); if (items?.length) { for (const item of items) { if (!item.id) item.id = Math.random().toString(36).substring(2, 12); - const iIns = buildInsert('contrato_itens', { ...item, contrato_id: req.params.id }); + const iIns = buildInsert('orcamento_itens', { ...item, orcamento_id: req.params.id }); await client.query(iIns.text, iIns.values); } } }); - res.json({ success: true }); + + // H4.1 — Orçamento Assinado gera contas a receber (entrada + parcelas), idempotente + let lancamentos = 0; + const { rows: fr } = await pool.query('SELECT * FROM orcamentos WHERE id = $1', [req.params.id]); + const o = fr[0]; + if (o && o.status === 'Assinado' && !o.financeiro_gerado && Number(o.valor_total) > 0) { + lancamentos = await gerarFinanceiroOrcamento(o, req); + } + res.json({ success: true, lancamentos_gerados: lancamentos }); } catch (err) { res.status(500).json({ error: err.message }); } }); -app.delete('/api/contratos/:id', async (req, res) => { +// Gera os lançamentos RECEITA (entrada + N parcelas) de um orçamento assinado +async function gerarFinanceiroOrcamento(o, req) { + const total = Number(o.valor_total) || 0; + const entrada = Math.min(Number(o.entrada) || 0, total); + const parcelas = Math.max(1, parseInt(o.parcelas) || 1); + const restante = total - entrada; + const valorParcela = Math.round((restante / parcelas) * 100) / 100; + const base = o.data_inicio ? new Date(o.data_inicio) : new Date(); + const baseYmd = `${base.getFullYear()}-${String(base.getMonth() + 1).padStart(2, '0')}-${String(base.getDate()).padStart(2, '0')}`; + const lanc = []; + if (entrada > 0) lanc.push({ desc: `ENTRADA — ${o.titulo || 'Orçamento'}`, valor: entrada, offset: 0 }); + for (let i = 0; i < parcelas; i++) { + // último ajusta centavos p/ fechar o total + const v = i === parcelas - 1 ? Number((restante - valorParcela * (parcelas - 1)).toFixed(2)) : valorParcela; + lanc.push({ desc: `${o.titulo || 'Orçamento'} — ${i + 1}/${parcelas}`, valor: v, offset: entrada > 0 ? i + 1 : i }); + } + await withTransaction(async (client) => { + for (const l of lanc) { + if (l.valor <= 0) continue; + const id = `fin_orc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + await client.query(` + INSERT INTO financeiro (id, clinica_id, descricao, pacientenome, valor, tipo, status, datavencimento, paciente_id, profissional_id, contrato_id, origem) + VALUES ($1,$2,$3,$4,$5,'RECEITA','Pendente', ($6::date + ($7 || ' months')::interval), $8, $9, $10, 'orcamento') + `, [id, o.clinica_id, l.desc, o.paciente_nome, l.valor, baseYmd, String(l.offset), o.paciente_id, o.dentista_id, o.id]); + } + await client.query('UPDATE orcamentos SET financeiro_gerado = true WHERE id = $1', [o.id]); + }); + await registrarAudit(pool, { clinicaId: o.clinica_id, entidade: 'orcamento', entidadeId: o.id, acao: 'gerou_financeiro', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { total, entrada, parcelas, lancamentos: lanc.filter(l => l.valor > 0).length } }); + return lanc.filter(l => l.valor > 0).length; +} + +app.delete('/api/orcamentos/:id', tenantGuard, async (req, res) => { try { - await pool.query('DELETE FROM contrato_itens WHERE contrato_id = $1', [req.params.id]); - await pool.query('DELETE FROM contratos WHERE id = $1', [req.params.id]); + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const own = await pool.query('SELECT 1 FROM orcamentos WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]); + if (!own.rowCount) return res.status(404).json({ error: 'Contrato não encontrado nesta unidade.' }); + await pool.query('DELETE FROM orcamento_itens WHERE orcamento_id = $1', [req.params.id]); + await pool.query('DELETE FROM orcamentos WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // --- LEADS --- -app.get('/api/leads', authGuard, async (req, res) => { +app.get('/api/leads', tenantGuard, async (req, res) => { try { - const { clinicaId } = req.query; + const clinicaId = req.clinicaId; if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); const { rows } = await pool.query('SELECT * FROM leads WHERE clinica_id = $1 ORDER BY datacadastro DESC NULLS LAST', [clinicaId]); res.json(rows); } catch (err) { res.status(500).json({ error: err.message }); } }); -app.post('/api/leads', authGuard, async (req, res) => { +app.post('/api/leads', tenantGuard, async (req, res) => { try { - const clinicaId = req.body.clinica_id || req.query.clinicaId; + const clinicaId = req.clinicaId; if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório.' }); const ins = buildInsert('leads', { ...req.body, clinica_id: clinicaId }); await pool.query(ins.text, ins.values); @@ -3150,9 +3907,10 @@ app.get('/api/guias', tenantGuard, async (req, res) => { }); // --- GTO BUILDER (Lançar GTO) --- -app.post('/api/gto-builder', async (req, res) => { +app.post('/api/gto-builder', authGuard, async (req, res) => { try { const { pacienteId, dentistaId, credenciadaId } = req.body; + if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM pacientes WHERE id = $1', [pacienteId])) return; const id = Math.random().toString(36).substring(2, 9).toUpperCase(); await pool.query( "INSERT INTO gto_builders (id, pacienteid, dentistaid, credenciadaid, status, total) VALUES ($1, $2, $3, $4, 'RASCUNHO', 0)", @@ -3162,8 +3920,9 @@ app.post('/api/gto-builder', async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.get('/api/gto-builder/:id', async (req, res) => { +app.get('/api/gto-builder/:id', authGuard, async (req, res) => { try { + if (!await assertRowScoped(req, res, 'SELECT p.clinica_id AS cid FROM gto_builders g JOIN pacientes p ON p.id = g.pacienteid WHERE g.id = $1', [req.params.id])) return; const { rows: builder } = await pool.query('SELECT * FROM gto_builders WHERE id = $1', [req.params.id]); const { rows: items } = await pool.query('SELECT * FROM gto_items WHERE builderid = $1', [req.params.id]); const mappedItems = items.map(i => ({ @@ -3187,8 +3946,9 @@ app.get('/api/gto-builder/:id', async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.post('/api/gto-builder/:id/item', async (req, res) => { +app.post('/api/gto-builder/:id/item', authGuard, async (req, res) => { try { + if (!await assertRowScoped(req, res, 'SELECT p.clinica_id AS cid FROM gto_builders g JOIN pacientes p ON p.id = g.pacienteid WHERE g.id = $1', [req.params.id])) return; const itemId = Math.random().toString(36).substring(2, 9).toUpperCase(); const item = { ...req.body, id: itemId, builderId: req.params.id }; const ins = buildInsert('gto_items', item); @@ -3204,8 +3964,9 @@ app.post('/api/gto-builder/:id/item', async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.delete('/api/gto-builder/:id/item/:itemId', async (req, res) => { +app.delete('/api/gto-builder/:id/item/:itemId', authGuard, async (req, res) => { try { + if (!await assertRowScoped(req, res, 'SELECT p.clinica_id AS cid FROM gto_builders g JOIN pacientes p ON p.id = g.pacienteid WHERE g.id = $1', [req.params.id])) return; await pool.query('DELETE FROM gto_items WHERE id = $1', [req.params.itemId]); // Update total await pool.query( @@ -3216,14 +3977,34 @@ app.delete('/api/gto-builder/:id/item/:itemId', async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.put('/api/gto-builder/:id/finalizar', async (req, res) => { +app.put('/api/gto-builder/:id/finalizar', authGuard, async (req, res) => { try { + if (!await assertRowScoped(req, res, 'SELECT p.clinica_id AS cid FROM gto_builders g JOIN pacientes p ON p.id = g.pacienteid WHERE g.id = $1', [req.params.id])) return; + const { rows: br } = await pool.query( + `SELECT g.*, p.clinica_id, p.nome AS paciente_nome, c.nome AS convenio_nome + FROM gto_builders g JOIN pacientes p ON p.id = g.pacienteid + LEFT JOIN planos c ON c.id = g.credenciadaid WHERE g.id = $1`, + [req.params.id]); + const b = br[0]; + if (!b) return res.status(404).json({ error: 'GTO não encontrada.' }); + const jaFinalizada = b.status === 'FINALIZADA'; await pool.query("UPDATE gto_builders SET status = 'FINALIZADA' WHERE id = $1", [req.params.id]); - // Logic to migrate to guias_odontologicas would go here in a real app - // For now, we just mark as finished. - - res.json({ success: true }); + // GTO finalizada gera RECEITA no financeiro (convênio a receber em 30d) — idempotente + let financeiroId = b.financeiro_id || null; + const total = Number(b.total) || 0; + if (!jaFinalizada && total > 0 && !b.financeiro_id) { + financeiroId = `fin_gto_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const convLabel = b.convenio_nome || b.credenciadaid || 'convênio'; + await pool.query(` + INSERT INTO financeiro (id, clinica_id, descricao, pacientenome, valor, tipo, status, datavencimento, data_competencia, paciente_id, profissional_id, convenio_id, origem) + VALUES ($1,$2,$3,$4,$5,'RECEITA','Pendente', (CURRENT_DATE + INTERVAL '30 days'), CURRENT_DATE, $6, $7, $8, 'gto') + `, [financeiroId, b.clinica_id, `GTO ${b.id} — ${convLabel}`, b.paciente_nome, total, b.pacienteid, b.dentistaid, b.credenciadaid]); + await pool.query('UPDATE gto_builders SET financeiro_id = $1 WHERE id = $2', [financeiroId, b.id]); + await registrarAudit(pool, { clinicaId: b.clinica_id, entidade: 'gto', entidadeId: b.id, acao: 'finalizou_gto', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { total, financeiro_id: financeiroId } }); + schedulePush('financeiro'); + } + res.json({ success: true, financeiro_id: financeiroId }); } catch (err) { res.status(500).json({ error: err.message }); } }); @@ -3467,7 +4248,7 @@ async function generateIntelligentNotifications() { } // --- MODELOS DE RECEITA --- -app.get('/api/modelos-receita', async (req, res) => { +app.get('/api/modelos-receita', authGuard, async (req, res) => { try { const { especialidadeId } = req.query; let q = `SELECT m.*, COALESCE(json_agg(i ORDER BY i.ordem) FILTER (WHERE i.id IS NOT NULL), '[]') AS items @@ -3484,7 +4265,7 @@ app.get('/api/modelos-receita', async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.post('/api/modelos-receita', async (req, res) => { +app.post('/api/modelos-receita', authGuard, async (req, res) => { try { const { nome, especialidadeId, especialidadeNome, instrucoes, items = [] } = req.body; const id = `mr_${Date.now()}`; @@ -3503,8 +4284,9 @@ app.post('/api/modelos-receita', async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.put('/api/modelos-receita/:id', async (req, res) => { +app.put('/api/modelos-receita/:id', authGuard, async (req, res) => { try { + if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM modelos_receita WHERE id = $1', [req.params.id])) return; const { nome, especialidadeId, especialidadeNome, instrucoes, items = [] } = req.body; await pool.query( `UPDATE modelos_receita SET nome=$1, especialidade_id=$2, especialidade_nome=$3, instrucoes=$4 WHERE id=$5`, @@ -3522,8 +4304,9 @@ app.put('/api/modelos-receita/:id', async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.delete('/api/modelos-receita/:id', async (req, res) => { +app.delete('/api/modelos-receita/:id', authGuard, async (req, res) => { try { + if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM modelos_receita WHERE id = $1', [req.params.id])) return; await pool.query(`DELETE FROM items_receita WHERE modelo_id = $1`, [req.params.id]); await pool.query(`DELETE FROM modelos_receita WHERE id = $1`, [req.params.id]); res.json({ success: true }); @@ -3531,9 +4314,13 @@ app.delete('/api/modelos-receita/:id', async (req, res) => { }); // --- PEDIDOS DE EXAME --- -app.get('/api/pedidos-exame', async (req, res) => { +app.get('/api/pedidos-exame', authGuard, async (req, res) => { try { const { pacienteId } = req.query; + if (!pacienteId) return res.status(400).json({ error: 'pacienteId obrigatório.' }); + const { rows: pc } = await pool.query('SELECT clinica_id FROM pacientes WHERE id = $1', [pacienteId]); + if (!pc.length) return res.status(404).json({ error: 'Paciente não encontrado.' }); + if (!await userHasVinculo(req.authUser?.userId, pc[0].clinica_id)) return res.status(403).json({ error: 'Acesso negado: paciente de outra clínica.' }); let q = `SELECT p.*, COALESCE(json_agg(i ORDER BY i.ordem) FILTER (WHERE i.id IS NOT NULL), '[]') AS items FROM pedidos_exame p LEFT JOIN items_pedido_exame i ON i.pedido_id = p.id`; const params = []; @@ -3548,13 +4335,16 @@ app.get('/api/pedidos-exame', async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.post('/api/pedidos-exame', async (req, res) => { +app.post('/api/pedidos-exame', authGuard, async (req, res) => { try { const { pacienteId, pacienteNome, profissionalId, profissionalNome, data, observacoes, items = [] } = req.body; + const { rows: pc } = await pool.query('SELECT clinica_id FROM pacientes WHERE id = $1', [pacienteId]); + if (!pc.length) return res.status(404).json({ error: 'Paciente não encontrado.' }); + if (!await userHasVinculo(req.authUser?.userId, pc[0].clinica_id)) return res.status(403).json({ error: 'Acesso negado: paciente de outra clínica.' }); const id = `pe_${Date.now()}`; await pool.query( - `INSERT INTO pedidos_exame (id, paciente_id, paciente_nome, profissional_id, profissional_nome, data, observacoes) VALUES ($1,$2,$3,$4,$5,$6,$7)`, - [id, pacienteId || null, pacienteNome, profissionalId || null, profissionalNome || null, data, observacoes || null] + `INSERT INTO pedidos_exame (id, paciente_id, paciente_nome, profissional_id, profissional_nome, data, observacoes, clinica_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, + [id, pacienteId || null, pacienteNome, profissionalId || null, profissionalNome || null, data, observacoes || null, pc[0].clinica_id] ); for (let i = 0; i < items.length; i++) { const it = items[i]; @@ -3620,7 +4410,12 @@ async function runMigrations() { observacao TEXT, ordem INTEGER DEFAULT 0 )`, - `CREATE TABLE IF NOT EXISTS contratos ( + // Rename Contratos → Orçamentos (libera 'contratos' para o motor contratual futuro). + // Ordem importa: coluna antes da tabela; IF EXISTS torna idempotente em banco novo. + `ALTER TABLE IF EXISTS contrato_itens RENAME COLUMN contrato_id TO orcamento_id`, + `ALTER TABLE IF EXISTS contratos RENAME TO orcamentos`, + `ALTER TABLE IF EXISTS contrato_itens RENAME TO orcamento_itens`, + `CREATE TABLE IF NOT EXISTS orcamentos ( id TEXT PRIMARY KEY, clinica_id TEXT, paciente_id TEXT, @@ -3641,9 +4436,9 @@ async function runMigrations() { observacoes TEXT, created_at TIMESTAMP DEFAULT NOW() )`, - `CREATE TABLE IF NOT EXISTS contrato_itens ( + `CREATE TABLE IF NOT EXISTS orcamento_itens ( id TEXT PRIMARY KEY, - contrato_id TEXT, + orcamento_id TEXT, procedimento_nome TEXT NOT NULL, quantidade INTEGER DEFAULT 1, valor_unitario NUMERIC DEFAULT 0, @@ -3692,10 +4487,10 @@ async function runMigrations() { )`, // Vinculos: constraint role (inclui protetico — necessário p/ workspace pessoal) `ALTER TABLE vinculos DROP CONSTRAINT IF EXISTS vinculos_role_check`, - `ALTER TABLE vinculos ADD CONSTRAINT vinculos_role_check CHECK (role = ANY(ARRAY['paciente','dentista','biomedico','protetico','funcionario','donoclinica','donoconsultorio','admin']))`, + `ALTER TABLE vinculos ADD CONSTRAINT vinculos_role_check CHECK (role = ANY(ARRAY['paciente','dentista','biomedico','protetico','funcionario','donoclinica','donoconsultorio','donosala','admin']))`, // role na tabela usuarios também precisa aceitar 'biomedico' (constraint pré-existente do banco) `ALTER TABLE usuarios DROP CONSTRAINT IF EXISTS usuarios_role_check`, - `ALTER TABLE usuarios ADD CONSTRAINT usuarios_role_check CHECK (role = ANY(ARRAY['paciente','dentista','biomedico','protetico','funcionario','donoclinica','donoconsultorio','admin','superadmin']))`, + `ALTER TABLE usuarios ADD CONSTRAINT usuarios_role_check CHECK (role = ANY(ARRAY['paciente','dentista','biomedico','protetico','funcionario','donoclinica','donoconsultorio','donosala','admin','superadmin']))`, `ALTER TABLE google_tokens ADD COLUMN IF NOT EXISTS clinica_id TEXT`, `ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS clinica_id TEXT`, `ALTER TABLE leads ADD COLUMN IF NOT EXISTS clinica_id TEXT`, @@ -4044,6 +4839,8 @@ async function runMigrations() { `ALTER TABLE sala_reservas ADD COLUMN IF NOT EXISTS profissional_usuario_id TEXT`, `ALTER TABLE sala_reservas ADD COLUMN IF NOT EXISTS profissional_nome TEXT`, `CREATE INDEX IF NOT EXISTS idx_sala_reservas_clinica ON sala_reservas (clinica_id, inicio)`, + // Fatia 1b: reserva confirmada vira recebível de locação no financeiro + `ALTER TABLE sala_reservas ADD COLUMN IF NOT EXISTS financeiro_id TEXT`, // Ativação de plugin POR USUÁRIO (add-on por conta) — independe de clínica. // É a fonte da verdade do que cada pessoa habilitou (ex.: locacao-salas). `CREATE TABLE IF NOT EXISTS usuario_plugins ( @@ -4128,6 +4925,213 @@ async function runMigrations() { )`, `CREATE INDEX IF NOT EXISTS idx_propostas_prof ON propostas_profissional (profissional_id, status)`, `CREATE INDEX IF NOT EXISTS idx_propostas_clinica ON propostas_profissional (clinica_id, status)`, + // Modelos de proposta editáveis, por clínica e categoria. + `CREATE TABLE IF NOT EXISTS propostas_modelos ( + id TEXT PRIMARY KEY, + clinica_id TEXT NOT NULL, + categoria TEXT NOT NULL DEFAULT 'dentista', + titulo TEXT NOT NULL, + texto TEXT NOT NULL, + ordem INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_propostas_modelos_clinica ON propostas_modelos (clinica_id, categoria, ordem)`, + // ── MOTOR CONTRATUAL UNIVERSAL ─────────────────────────────────────── + `CREATE TABLE IF NOT EXISTS contrato_modelos ( + id TEXT PRIMARY KEY, + clinica_id TEXT, -- NULL = catálogo global padrão + nome TEXT NOT NULL, + categoria TEXT, + tipo_relacao TEXT, + corpo TEXT NOT NULL, -- texto com {{VARIÁVEIS}} + variaveis JSONB DEFAULT '[]', + vigencia_dias INTEGER, + editavel BOOLEAN DEFAULT true, + compartilhavel BOOLEAN DEFAULT false, + ordem INTEGER DEFAULT 0, + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_contrato_modelos_clinica ON contrato_modelos (clinica_id, ordem)`, + `CREATE TABLE IF NOT EXISTS contratos ( + id TEXT PRIMARY KEY, + clinica_origem_id TEXT, + modelo_id TEXT, + titulo TEXT NOT NULL, + tipo_relacao TEXT, + status TEXT DEFAULT 'rascunho', -- rascunho|aguardando_assinatura|assinado|vigente|encerrado|cancelado + corpo_renderizado TEXT, + valores JSONB DEFAULT '{}', + data_inicio DATE, + data_fim DATE, + renovacao TEXT DEFAULT 'manual', -- manual|automatica + aviso_dias INTEGER DEFAULT 30, + plugin_origem TEXT, + deleted_at TIMESTAMPTZ, + created_by TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_contratos_clinica ON contratos (clinica_origem_id, status)`, + `ALTER TABLE contratos ADD COLUMN IF NOT EXISTS vencimento_avisado BOOLEAN DEFAULT false`, + `CREATE TABLE IF NOT EXISTS contrato_partes ( + id TEXT PRIMARY KEY, + contrato_id TEXT NOT NULL, + papel TEXT NOT NULL, -- CONTRATANTE|CONTRATADO|LOCADOR|LOCATARIO|TESTEMUNHA + entidade_tipo TEXT, -- usuario|clinica|sala + entidade_id TEXT, + nome_snapshot TEXT, + doc_snapshot TEXT, + ordem INTEGER DEFAULT 0 + )`, + `CREATE INDEX IF NOT EXISTS idx_contrato_partes_contrato ON contrato_partes (contrato_id)`, + `CREATE TABLE IF NOT EXISTS contrato_assinaturas ( + id TEXT PRIMARY KEY, + contrato_id TEXT NOT NULL, + parte_id TEXT, + assinante_usuario_id TEXT, + tipo TEXT DEFAULT 'eletronica_simples', + status TEXT DEFAULT 'pendente', -- pendente|assinado|recusado + assinado_em TIMESTAMPTZ, + ip TEXT, + user_agent TEXT, + hash TEXT, + evidencias JSONB DEFAULT '{}' + )`, + `CREATE INDEX IF NOT EXISTS idx_contrato_assinaturas_contrato ON contrato_assinaturas (contrato_id)`, + `CREATE TABLE IF NOT EXISTS contrato_documentos ( + id TEXT PRIMARY KEY, + contrato_id TEXT NOT NULL, + tipo TEXT, -- original|assinado + filename TEXT, + versao INTEGER DEFAULT 1, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + // ── FINANCEIRO V1: chaves de ligação + flags (Épico 1) ─────────────── + `ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS paciente_id TEXT`, + `ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS profissional_id TEXT`, + `ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS procedimento_id TEXT`, + `ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS agendamento_id TEXT`, + `ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS contrato_id TEXT`, + `ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS realizado_id TEXT`, + `ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS origem TEXT DEFAULT 'manual'`, + `ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS sem_comissao BOOLEAN DEFAULT false`, + `ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS pago_em TIMESTAMPTZ`, + `ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS fornecedor TEXT`, + `ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS centro_custo TEXT`, + `ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS recorrente BOOLEAN DEFAULT false`, + `ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS convenio_id TEXT`, + // Competência por data de conclusão (produção) — fallback p/ vencimento quando nula + `ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS data_competencia DATE`, + // Índices p/ as queries novas do módulo financeiro + `CREATE INDEX IF NOT EXISTS idx_financeiro_contrato_comp ON financeiro (contrato_id, competencia) WHERE contrato_id IS NOT NULL`, + `CREATE INDEX IF NOT EXISTS idx_financeiro_convenio ON financeiro (clinica_id, convenio_id) WHERE convenio_id IS NOT NULL`, + `CREATE INDEX IF NOT EXISTS idx_glosas_financeiro ON glosas (financeiro_id)`, + `CREATE INDEX IF NOT EXISTS idx_glosas_item ON glosas (gto_item_id) WHERE gto_item_id IS NOT NULL`, + `CREATE INDEX IF NOT EXISTS idx_gto_builders_financeiro ON gto_builders (financeiro_id) WHERE financeiro_id IS NOT NULL`, + // Épico 4: orçamento Assinado gera contas a receber (idempotência) + `ALTER TABLE IF EXISTS orcamentos ADD COLUMN IF NOT EXISTS financeiro_gerado BOOLEAN DEFAULT false`, + // Glosa de convênio: acumulado no lançamento + histórico + `ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS valor_glosa NUMERIC DEFAULT 0`, + `CREATE TABLE IF NOT EXISTS glosas ( + id TEXT PRIMARY KEY, + clinica_id TEXT NOT NULL, + financeiro_id TEXT NOT NULL, + valor NUMERIC NOT NULL DEFAULT 0, + motivo TEXT, + status TEXT DEFAULT 'glosado', + criado_por TEXT, + criado_por_nome TEXT, + criado_em TIMESTAMPTZ DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_glosas_clinica ON glosas (clinica_id, status)`, + // Glosa por item de GTO + recurso/protocolo com prazo + `ALTER TABLE glosas ADD COLUMN IF NOT EXISTS gto_item_id TEXT`, + `ALTER TABLE glosas ADD COLUMN IF NOT EXISTS item_descricao TEXT`, + `ALTER TABLE glosas ADD COLUMN IF NOT EXISTS protocolo TEXT`, + `ALTER TABLE glosas ADD COLUMN IF NOT EXISTS prazo_recurso DATE`, + // H4.2: cobrança recorrente a partir do contrato vigente + `ALTER TABLE IF EXISTS contratos ADD COLUMN IF NOT EXISTS cobranca_automatica BOOLEAN DEFAULT false`, + `ALTER TABLE IF EXISTS contratos ADD COLUMN IF NOT EXISTS valor_recorrente NUMERIC`, + `ALTER TABLE IF EXISTS contratos ADD COLUMN IF NOT EXISTS periodicidade TEXT DEFAULT 'mensal'`, + `ALTER TABLE IF EXISTS contratos ADD COLUMN IF NOT EXISTS dia_cobranca INTEGER`, + `ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS competencia TEXT`, + // V2 Comissão/Repasse — regra de % por procedimento (procedimento_id NULL = padrão da clínica) + `CREATE TABLE IF NOT EXISTS comissao_regras ( + id TEXT PRIMARY KEY, + clinica_id TEXT NOT NULL, + procedimento_id TEXT, + percentual NUMERIC NOT NULL DEFAULT 0, + criado_em TIMESTAMPTZ DEFAULT NOW() + )`, + // Gap 1: override de % por profissional · Gap 2: custo de laboratório por procedimento + `ALTER TABLE comissao_regras ADD COLUMN IF NOT EXISTS profissional_id TEXT`, + `ALTER TABLE comissao_regras ADD COLUMN IF NOT EXISTS custo_lab NUMERIC DEFAULT 0`, + `DROP INDEX IF EXISTS uq_comissao_regra`, + `CREATE UNIQUE INDEX IF NOT EXISTS uq_comissao_regra2 ON comissao_regras (clinica_id, COALESCE(procedimento_id, ''), COALESCE(profissional_id, ''))`, + // Fechamentos de repasse (snapshot imutável + auditoria) + `CREATE TABLE IF NOT EXISTS repasses ( + id TEXT PRIMARY KEY, + clinica_id TEXT NOT NULL, + profissional_id TEXT, + profissional_nome TEXT, + inicio DATE, + fim DATE, + base TEXT, + base_valor NUMERIC DEFAULT 0, + comissao_valor NUMERIC DEFAULT 0, + qtd_lancamentos INTEGER DEFAULT 0, + detalhes JSONB DEFAULT '[]'::jsonb, + status TEXT DEFAULT 'fechado', + criado_por TEXT, + criado_por_nome TEXT, + criado_em TIMESTAMPTZ DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_repasses_clinica ON repasses (clinica_id, status)`, + // Gap 3: pagamento do repasse (gera DESPESA no financeiro) + `ALTER TABLE repasses ADD COLUMN IF NOT EXISTS pago_em TIMESTAMPTZ`, + `ALTER TABLE repasses ADD COLUMN IF NOT EXISTS financeiro_id TEXT`, + `ALTER TABLE repasses ADD COLUMN IF NOT EXISTS custo_lab_total NUMERIC DEFAULT 0`, + // GTO finalizada → lançamento no financeiro (vínculo p/ idempotência) + `ALTER TABLE IF EXISTS gto_builders ADD COLUMN IF NOT EXISTS financeiro_id TEXT`, + // Comprovante do pagamento do repasse (imagem ou PDF) + `ALTER TABLE repasses ADD COLUMN IF NOT EXISTS comprovante_filename TEXT`, + `ALTER TABLE repasses ADD COLUMN IF NOT EXISTS comprovante_mimetype TEXT`, + `ALTER TABLE repasses ADD COLUMN IF NOT EXISTS comprovante_nome TEXT`, + `ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ`, + `CREATE INDEX IF NOT EXISTS idx_financeiro_clinica ON financeiro (clinica_id, status, datavencimento)`, + `CREATE INDEX IF NOT EXISTS idx_financeiro_prof ON financeiro (clinica_id, profissional_id)`, + // ── PRONTUÁRIO: procedimentos realizados + reabertura/garantia ─────── + `CREATE TABLE IF NOT EXISTS procedimentos_realizados ( + id TEXT PRIMARY KEY, + clinica_id TEXT NOT NULL, + paciente_id TEXT, + paciente_nome TEXT, + profissional_id TEXT, + profissional_nome TEXT, + procedimento_id TEXT, + procedimento_nome TEXT, + agendamento_id TEXT, + tipo TEXT DEFAULT 'novo', -- novo | reabertura | garantia + origem_id TEXT, -- procedimento original (em reabertura/garantia) + sem_comissao BOOLEAN DEFAULT false, + valor NUMERIC DEFAULT 0, + observacoes TEXT, + created_by TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ + )`, + `CREATE INDEX IF NOT EXISTS idx_proc_realizados_pac ON procedimentos_realizados (clinica_id, paciente_id, created_at)`, + `CREATE TABLE IF NOT EXISTS procedimento_realizado_foto ( + id TEXT PRIMARY KEY, + realizado_id TEXT NOT NULL, + clinica_id TEXT, + momento TEXT, -- antes | depois + filename TEXT, + mimetype TEXT, + original_name TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_proc_foto_realizado ON procedimento_realizado_foto (realizado_id, momento)`, ]; for (const sql of migrations) { try { await pool.query(sql); } catch (e) { console.error('[MIGRATION]', e.message); } @@ -4185,7 +5189,7 @@ app.post('/api/usuarios/me/senha', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); -app.put('/api/usuarios/localidade', async (req, res) => { +app.put('/api/usuarios/localidade', authGuard, async (req, res) => { const authHeader = req.headers['authorization']; if (!authHeader?.startsWith('Bearer ')) return res.status(401).json({ success: false, message: 'Não autenticado.' }); let payload; @@ -4201,7 +5205,7 @@ app.put('/api/usuarios/localidade', async (req, res) => { } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); -app.get('/api/profissionais-disponiveis', async (req, res) => { +app.get('/api/profissionais-disponiveis', authGuard, async (req, res) => { const authHeader = req.headers['authorization']; if (!authHeader?.startsWith('Bearer ')) return res.status(401).json({ error: 'Não autenticado.' }); try { jwt.verify(authHeader.split(' ')[1], JWT_SECRET); } catch { return res.status(401).json({ error: 'Token inválido.' }); } @@ -4242,6 +5246,15 @@ async function notificarUsuario(usuarioId, titulo, mensagem, tipo = 'info') { // ── Plugins POR USUÁRIO (add-on por conta) ────────────────────────────────── // Fonte da verdade da ativação de plugins do usuário logado (ex.: locacao-salas). // Sem tenantGuard de propósito: independe de o usuário ter clínica. +// Lista de workspaces do usuário (clínicas + salas) — p/ refresh sem re-login (ex.: após criar uma sala). +app.get('/api/me/workspaces', authGuard, async (req, res) => { + try { + const { rows } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser.userId]); + const ws = await listarWorkspaces(req.authUser.userId, rows[0]?.role); + res.json({ success: true, workspaces: ws }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + app.get('/api/me/plugins', authGuard, async (req, res) => { try { const { rows } = await pool.query( @@ -4430,6 +5443,47 @@ app.get('/api/salas/minhas', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); +// Relatório por sala (faturamento de locação + ocupação) no período — escopo: dono. +app.get('/api/salas/relatorio', authGuard, async (req, res) => { + try { + const { inicio, fim } = req.query; + const i = inicio || '2000-01-01', f = fim || '2100-01-01'; + const { rows } = await pool.query(` + SELECT s.id, s.nome, s.cidade, s.estado, + COALESCE(fin.recebido,0) AS recebido, + COALESCE(fin.a_receber,0) AS a_receber, + COALESCE(res.qtd,0) AS reservas, + COALESCE(res.horas,0) AS horas + FROM salas s + LEFT JOIN ( + SELECT clinica_id AS sala_id, + SUM(CASE WHEN status='Pago' THEN valor ELSE 0 END) AS recebido, + SUM(CASE WHEN status<>'Pago' THEN valor ELSE 0 END) AS a_receber + FROM financeiro + WHERE origem='locacao' AND deleted_at IS NULL AND datavencimento BETWEEN $2 AND $3 + GROUP BY clinica_id + ) fin ON fin.sala_id = s.id + LEFT JOIN ( + SELECT sala_id, COUNT(*) AS qtd, SUM(EXTRACT(EPOCH FROM (fim - inicio))/3600.0) AS horas + FROM sala_reservas + WHERE status='confirmada' AND inicio BETWEEN $2 AND $3 + GROUP BY sala_id + ) res ON res.sala_id = s.id + WHERE s.owner_usuario_id = $1 + ORDER BY recebido DESC, s.nome + `, [req.authUser.userId, i, f]); + const num = o => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, (k === 'nome' || k === 'cidade' || k === 'estado' || k === 'id') ? v : Number(v)])); + const data = rows.map(num); + res.json({ + salas: data, + total_recebido: Number(data.reduce((s, r) => s + r.recebido, 0).toFixed(2)), + total_a_receber: Number(data.reduce((s, r) => s + r.a_receber, 0).toFixed(2)), + total_reservas: data.reduce((s, r) => s + r.reservas, 0), + total_horas: Number(data.reduce((s, r) => s + r.horas, 0).toFixed(1)), + }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + app.post('/api/salas', authGuard, async (req, res) => { try { const b = req.body || {}; @@ -4670,7 +5724,25 @@ app.put('/api/sala-reservas/:id', authGuard, async (req, res) => { if (!RESERVA_ATIVAS.includes(r.status)) return res.status(409).json({ error: `Reserva já está "${r.status}".` }); const novo = acao === 'confirmar' ? 'confirmada' : acao === 'recusar' ? 'recusada' : 'cancelada'; await pool.query(`UPDATE sala_reservas SET status = $1, respondida_em = NOW() WHERE id = $2`, [novo, req.params.id]); - await registrarAudit(pool, { clinicaId: null, entidade: 'sala_reserva', entidadeId: r.id, acao: novo, actorId: uid, actorNome: await actorNome(uid), detalhes: { salaId: r.sala_id } }); + // Fatia 1b — reserva confirmada gera RECEITA de locação no financeiro da SALA (workspace = sala_id) + let financeiroId = r.financeiro_id || null; + if (novo === 'confirmada' && !r.financeiro_id && Number(r.valor) > 0) { + financeiroId = `fin_loc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const quem = r.clinica_nome || r.solicitante_nome || r.profissional_nome || 'Locatário'; + const vencYmd = (r.inicio instanceof Date ? r.inicio.toISOString() : String(r.inicio)).slice(0, 10); + await pool.query(` + INSERT INTO financeiro (id, clinica_id, descricao, pacientenome, valor, tipo, status, datavencimento, data_competencia, profissional_id, origem) + VALUES ($1,$2,$3,$4,$5,'RECEITA','Pendente', $6::date, $6::date, $7, 'locacao') + `, [financeiroId, r.sala_id, `Locação ${r.sala_nome} — ${quem}`, quem, Number(r.valor), vencYmd, r.owner_usuario_id]); + await pool.query('UPDATE sala_reservas SET financeiro_id = $1 WHERE id = $2', [financeiroId, r.id]); + } + // Cancelar reserva confirmada → estorna (soft-delete) o recebível gerado + if (novo === 'cancelada' && r.financeiro_id) { + await pool.query('UPDATE financeiro SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL', [r.financeiro_id]); + await pool.query('UPDATE sala_reservas SET financeiro_id = NULL WHERE id = $1', [r.id]); + financeiroId = null; + } + await registrarAudit(pool, { clinicaId: r.sala_id, entidade: 'sala_reserva', entidadeId: r.id, acao: novo, actorId: uid, actorNome: await actorNome(uid), detalhes: { salaId: r.sala_id, financeiro_id: financeiroId } }); const destino = isOwner ? r.solicitante_id : r.owner_usuario_id; await notificarUsuario(destino, `Reserva ${novo}`, `A reserva da sala "${r.sala_nome}" (${new Date(r.inicio).toLocaleString('pt-BR')}) foi ${novo}.`, @@ -4713,15 +5785,38 @@ app.put('/api/profissionais/perfil', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); +// Especialidades disponíveis para o dropdown da busca — por área (slug) ou tipo (role). +// Une o catálogo global da área com as especialidades reais já declaradas pelos profissionais. +const AREA_TO_ROLE = { odontologia: 'dentista', biomedicina: 'biomedico', protese: 'protetico' }; +const ROLE_TO_AREA = { dentista: 'odontologia', biomedico: 'biomedicina', protetico: 'protese' }; +app.get('/api/profissionais/especialidades', authGuard, async (req, res) => { + let { area, tipo } = req.query; + if (!area && tipo && ROLE_TO_AREA[tipo]) area = ROLE_TO_AREA[tipo]; + const role = AREA_TO_ROLE[area]; + if (!area || !role) return res.json([]); // sem área/tipo definido → sem lista + try { + const cat = await pool.query( + `SELECT DISTINCT UPPER(nome) AS nome FROM especialidades + WHERE area = $1 AND clinica_id IS NULL AND nome IS NOT NULL AND nome <> ''`, [area]); + const real = await pool.query( + `SELECT DISTINCT UPPER(especialidade_dir) AS nome FROM usuarios + WHERE role = $1 AND disponivel_diretorio = true AND especialidade_dir IS NOT NULL AND especialidade_dir <> ''`, [role]); + const set = new Set([...cat.rows, ...real.rows].map(r => r.nome)); + if (area === 'biomedicina') BIOMED_ESPECIALIDADES.forEach(e => set.add(e.toUpperCase())); + res.json([...set].sort((a, b) => a.localeCompare(b, 'pt-BR'))); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + // Busca de profissionais (inclui biomédicos; supera o diretório antigo) app.get('/api/profissionais/marketplace', authGuard, async (req, res) => { - const { tipo, estado, cidade, especialidade, cep, raio, clinicaId, area } = req.query; + const { q, tipo, estado, cidade, especialidade, cep, raio, clinicaId, area } = req.query; const raioKm = Number(raio); // Origem = CEP digitado → clínica ativa → próprio usuário (mostra os mais próximos primeiro). const origin = await resolveOrigem(req, cep, clinicaId); let where = `WHERE disponivel_diretorio = true AND COALESCE(ativo, true) = true AND role = ANY($1::text[])`; const params = [tipo && MARKETPLACE_ROLES.includes(tipo) ? [tipo] : MARKETPLACE_ROLES]; + if (q && String(q).trim()) { params.push(`%${String(q).trim()}%`); where += ` AND (nome ILIKE $${params.length} OR especialidade_dir ILIKE $${params.length} OR bio_profissional ILIKE $${params.length})`; } if (estado) { params.push(String(estado).toUpperCase()); where += ` AND estado = $${params.length}`; } if (cidade) { params.push(`%${String(cidade).toUpperCase()}%`); where += ` AND cidade ILIKE $${params.length}`; } if (especialidade) { params.push(`%${String(especialidade).toUpperCase()}%`); where += ` AND especialidade_dir ILIKE $${params.length}`; } @@ -4817,6 +5912,1048 @@ app.put('/api/propostas/:id', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); +// ═══════════════════════════════════════════════════════════════════════════ +// MOTOR CONTRATUAL UNIVERSAL — Fatia 1: biblioteca de modelos + variáveis +// ═══════════════════════════════════════════════════════════════════════════ +const VARIAVEIS_CONTRATO = [ + { chave: '{{CONTRATANTE}}', desc: 'Nome da parte contratante' }, + { chave: '{{CONTRATADO}}', desc: 'Nome da parte contratada' }, + { chave: '{{CPF}}', desc: 'CPF da pessoa física' }, + { chave: '{{CNPJ}}', desc: 'CNPJ da pessoa jurídica' }, + { chave: '{{ENDERECO}}', desc: 'Endereço completo' }, + { chave: '{{CRO}}', desc: 'CRO do dentista' }, + { chave: '{{CRBM}}', desc: 'CRBM do biomédico' }, + { chave: '{{ESPECIALIDADE}}', desc: 'Especialidade profissional' }, + { chave: '{{SALA}}', desc: 'Identificação da sala' }, + { chave: '{{VALOR}}', desc: 'Valor do contrato' }, + { chave: '{{PERCENTUAL}}', desc: 'Percentual / comissão' }, + { chave: '{{DATA_INICIO}}', desc: 'Início da vigência' }, + { chave: '{{DATA_FIM}}', desc: 'Término da vigência' }, + { chave: '{{CIDADE}}', desc: 'Cidade do foro' }, + { chave: '{{DATA_HOJE}}', desc: 'Data de geração' }, + // Atendimento a convênios/planos (dentista credenciado) + { chave: '{{CONVENIOS}}', desc: 'Convênios/planos atendidos' }, + { chave: '{{PERCENTUAL_REPASSE}}', desc: 'Percentual de repasse sobre a produção de convênio' }, + { chave: '{{BASE_REPASSE}}', desc: 'Base do repasse (recebido/caixa ou produção)' }, + { chave: '{{PRAZO_REPASSE}}', desc: 'Prazo/dia do repasse ao profissional' }, + { chave: '{{RESPONSAVEL_GLOSA}}', desc: 'Responsável pelas glosas do convênio' }, + { chave: '{{CUSTO_LABORATORIO}}', desc: 'Tratamento do custo de laboratório/prótese' }, +]; + +function buildCorpoModelo(objeto) { + return `CLÁUSULA 1ª – DO OBJETO +${objeto} + +CLÁUSULA 2ª – DAS PARTES +CONTRATANTE: {{CONTRATANTE}} — {{CPF}} {{CNPJ}}, {{ENDERECO}}. +CONTRATADO: {{CONTRATADO}} — {{CRO}} {{CRBM}}, {{ESPECIALIDADE}}. + +CLÁUSULA 3ª – DA VIGÊNCIA +O presente contrato vigora de {{DATA_INICIO}} a {{DATA_FIM}}, podendo ser renovado conforme acordo entre as partes. + +CLÁUSULA 4ª – DO VALOR E PAGAMENTO +Pelos serviços/uso ora contratados será devido o valor de {{VALOR}} {{PERCENTUAL}}, nas condições acordadas entre as partes. + +CLÁUSULA 5ª – DAS OBRIGAÇÕES +As partes comprometem-se a cumprir fielmente as obrigações assumidas, com boa-fé e zelo profissional, observada a legislação aplicável e os conselhos de classe. + +CLÁUSULA 6ª – DA RESCISÃO +O contrato poderá ser rescindido por qualquer das partes mediante aviso prévio, respeitadas as obrigações pendentes até a data da rescisão. + +CLÁUSULA 7ª – DA PROTEÇÃO DE DADOS (LGPD) +As partes tratarão os dados pessoais em conformidade com a Lei nº 13.709/2018 (LGPD), restringindo o uso às finalidades deste contrato. + +CLÁUSULA 8ª – DO FORO +Fica eleito o foro da comarca de {{CIDADE}} para dirimir controvérsias oriundas deste contrato, com renúncia a qualquer outro. + +{{CIDADE}}, {{DATA_HOJE}}.`; +} + +const MODELOS_CONTRATO_DEFAULT = [ + { nome: 'Prestação de Serviços Odontológicos', categoria: 'servico', tipo_relacao: 'dentista-clinica', objeto: 'Prestação de serviços odontológicos pelo CONTRATADO em favor do CONTRATANTE, na especialidade {{ESPECIALIDADE}}.' }, + { nome: 'Prestação de Serviços Biomédicos', categoria: 'servico', tipo_relacao: 'biomedico-clinica', objeto: 'Prestação de serviços biomédicos/estéticos pelo CONTRATADO em favor do CONTRATANTE.' }, + { nome: 'Prestação de Serviços Protéticos', categoria: 'servico', tipo_relacao: 'protetico-clinica', objeto: 'Confecção de trabalhos protéticos pelo CONTRATADO em favor do CONTRATANTE.' }, + { nome: 'Locação de Sala por Hora', categoria: 'locacao', tipo_relacao: 'locacao-sala', objeto: 'Locação por hora da sala {{SALA}} pelo LOCADOR ao LOCATÁRIO.' }, + { nome: 'Locação de Sala por Período', categoria: 'locacao', tipo_relacao: 'locacao-sala', objeto: 'Locação por período (turno/diária) da sala {{SALA}}.' }, + { nome: 'Locação de Sala Mensal', categoria: 'locacao', tipo_relacao: 'locacao-sala', objeto: 'Locação mensal da sala {{SALA}}, com renovação automática salvo aviso prévio.' }, + { nome: 'Parceria Profissional', categoria: 'parceria', tipo_relacao: 'profissional-profissional', objeto: 'Parceria profissional entre as partes para atuação conjunta, com partilha de {{PERCENTUAL}}.' }, + { nome: 'Cooperação Profissional', categoria: 'parceria', tipo_relacao: 'profissional-profissional', objeto: 'Cooperação técnico-profissional entre as partes, sem vínculo empregatício.' }, + { nome: 'Encaminhamento de Pacientes', categoria: 'parceria', tipo_relacao: 'dentista-dentista', objeto: 'Encaminhamento de pacientes entre os profissionais, observada a ética profissional.' }, + { nome: 'Uso Compartilhado de Estrutura', categoria: 'locacao', tipo_relacao: 'estrutura', objeto: 'Uso compartilhado de estrutura e equipamentos entre as partes.' }, + { nome: 'Contrato de Produção', categoria: 'remuneracao', tipo_relacao: 'profissional-clinica', objeto: 'Remuneração por produção, conforme metas e valores acordados.' }, + { nome: 'Contrato por Comissão', categoria: 'remuneracao', tipo_relacao: 'profissional-clinica', objeto: 'Remuneração por comissão de {{PERCENTUAL}} sobre os procedimentos realizados.' }, + { nome: 'Contrato por Percentual', categoria: 'remuneracao', tipo_relacao: 'profissional-clinica', objeto: 'Repasse de {{PERCENTUAL}} ao CONTRATADO sobre o faturamento dos procedimentos.' }, + { nome: 'Contrato por Procedimento', categoria: 'remuneracao', tipo_relacao: 'profissional-clinica', objeto: 'Remuneração por procedimento realizado, conforme tabela acordada.' }, +]; + +// Modelos completos por especialidade (clínica/dentista ↔ paciente). Padrão de mercado; +// revisar com advogado antes do uso real. +const MODELOS_CONTRATO_ODONTO = [ + { + nome: 'Tratamento Ortodôntico', categoria: 'servico', tipo_relacao: 'dentista-paciente', + corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — TRATAMENTO ORTODÔNTICO + +Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro lado {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente e domiciliado(a) em {{ENDERECO}}, doravante CONTRATANTE, têm entre si justo e contratado o seguinte: + +CLÁUSULA 1ª – DO OBJETO +Constitui objeto deste contrato a prestação de serviços de tratamento ortodôntico ({{ESPECIALIDADE}}), compreendendo a instalação, o acompanhamento e a manutenção de aparelho ortodôntico, visando à correção do posicionamento dentário e/ou ósseo do(a) CONTRATANTE, conforme plano de tratamento previamente apresentado e aceito. + +CLÁUSULA 2ª – DAS MANUTENÇÕES PERIÓDICAS +O tratamento ortodôntico exige o comparecimento periódico do(a) CONTRATANTE às consultas de manutenção, em regra mensais, indispensáveis à sua evolução. O não comparecimento reiterado poderá prolongar o tratamento e/ou comprometer seu resultado, isentando o(a) CONTRATADO(A) de responsabilidade pelos atrasos daí decorrentes. + +CLÁUSULA 3ª – DAS OBRIGAÇÕES DO(A) CONTRATANTE +Obriga-se o(a) CONTRATANTE a: (a) manter rigorosa higiene bucal; (b) zelar pela conservação do aparelho, evitando alimentos duros e pegajosos; (c) seguir fielmente as orientações clínicas; (d) comparecer às manutenções agendadas; e (e) efetuar os pagamentos nos prazos acordados. + +CLÁUSULA 4ª – DA REPOSIÇÃO DE PEÇAS +O reparo ou a reposição de bráquetes, bandas, fios e demais acessórios danificados por mau uso, descuido ou inobservância das orientações correrá por conta exclusiva do(a) CONTRATANTE, mediante valor a ser informado. + +CLÁUSULA 5ª – DO PRAZO E DA DURAÇÃO +O tratamento terá início em {{DATA_INICIO}}, com previsão de término em {{DATA_FIM}}. A duração é estimada e poderá variar conforme a resposta biológica e a cooperação do(a) CONTRATANTE, não constituindo o prazo obrigação de resultado. + +CLÁUSULA 6ª – DA CONTENÇÃO +Concluída a fase ativa, poderá ser necessária a instalação e o uso de contenção, fixa ou removível, pelo período indicado pelo(a) CONTRATADO(A), a fim de estabilizar os resultados; eventuais custos, se não incluídos, serão informados em separado. + +CLÁUSULA 7ª – DO VALOR E DA FORMA DE PAGAMENTO +Pelos serviços ora contratados, o(a) CONTRATANTE pagará o valor de {{VALOR}}, nas condições acordadas entre as partes (entrada e parcelas mensais), sendo a manutenção mensal parte integrante do tratamento. + +CLÁUSULA 8ª – DA RESCISÃO +Em caso de desistência, serão devidos os valores correspondentes aos serviços já prestados (aparelhos instalados e manutenções realizadas), restituindo-se eventual saldo pago a maior no prazo de 30 (trinta) dias. + +CLÁUSULA 9ª – DA PROTEÇÃO DE DADOS (LGPD) +Os dados pessoais e de saúde do(a) CONTRATANTE serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD), utilizados exclusivamente para as finalidades deste contrato e do tratamento odontológico. + +CLÁUSULA 10ª – DO FORO +Fica eleito o foro da comarca de {{CIDADE}} para dirimir quaisquer controvérsias oriundas do presente contrato, com renúncia a qualquer outro. + +{{CIDADE}}, {{DATA_HOJE}}.`, + }, + { + nome: 'Implantodontia (Implante Dentário)', categoria: 'servico', tipo_relacao: 'dentista-paciente', + corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — IMPLANTODONTIA + +Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, ajustam o seguinte: + +CLÁUSULA 1ª – DO OBJETO +Constitui objeto deste contrato a prestação de serviços de implantodontia ({{ESPECIALIDADE}}), compreendendo a avaliação, o planejamento, a instalação de implante(s) dentário(s) e, quando previsto, a reabilitação protética sobre implante, conforme plano de tratamento aceito pelo(a) CONTRATANTE. + +CLÁUSULA 2ª – DAS FASES DO TRATAMENTO +O tratamento desenvolve-se em fases: (a) avaliação e exames de imagem; (b) fase cirúrgica de instalação do(s) implante(s); (c) período de osseointegração; e (d) fase protética, dependendo cada fase do sucesso da anterior. + +CLÁUSULA 3ª – DOS RISCOS E DO CONSENTIMENTO +O(a) CONTRATANTE declara estar ciente de que se trata de procedimento cirúrgico, sujeito a riscos inerentes (edema, hematoma, infecção, parestesia transitória, entre outros) e de que o sucesso da osseointegração depende de fatores biológicos individuais, não havendo garantia absoluta de resultado. + +CLÁUSULA 4ª – DAS CONDIÇÕES DE SAÚDE E EXAMES +Obriga-se o(a) CONTRATANTE a fornecer informações verídicas sobre seu estado de saúde e medicamentos em uso e a apresentar os exames solicitados, sob pena de comprometimento do tratamento e de isenção de responsabilidade do(a) CONTRATADO(A). + +CLÁUSULA 5ª – DOS CUIDADOS PÓS-OPERATÓRIOS +Obriga-se o(a) CONTRATANTE a seguir rigorosamente as orientações pós-operatórias, a manter higiene adequada, a abster-se do tabagismo no período indicado e a comparecer aos retornos, indispensáveis à osseointegração. + +CLÁUSULA 6ª – DA REINTERVENÇÃO +Na hipótese de não integração do implante por causa não imputável ao(à) CONTRATANTE, o(a) CONTRATADO(A) poderá proceder à reinstalação, observado novo período de cicatrização, nos termos acordados entre as partes. + +CLÁUSULA 7ª – DO VALOR E DO PAGAMENTO +Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições ajustadas, podendo os valores referentes a implante, componentes protéticos e prótese ser discriminados separadamente. + +CLÁUSULA 8ª – DO PRAZO +Os serviços terão início em {{DATA_INICIO}} e previsão de conclusão em {{DATA_FIM}}, prazo estimado em razão do período de osseointegração, que varia conforme a resposta individual. + +CLÁUSULA 9ª – DA RESCISÃO +Em caso de desistência após iniciada a fase cirúrgica, serão devidos os valores dos serviços e materiais já empregados (implantes, componentes e atos praticados). + +CLÁUSULA 10ª – DA PROTEÇÃO DE DADOS (LGPD) +Os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD), restritos às finalidades do tratamento. + +CLÁUSULA 11ª – DO FORO +Fica eleito o foro da comarca de {{CIDADE}}. + +{{CIDADE}}, {{DATA_HOJE}}.`, + }, + { + nome: 'Prótese Dentária', categoria: 'servico', tipo_relacao: 'dentista-paciente', + corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — PRÓTESE DENTÁRIA + +Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado: + +CLÁUSULA 1ª – DO OBJETO +Constitui objeto deste contrato a prestação de serviços de prótese dentária ({{ESPECIALIDADE}}), compreendendo o planejamento, as moldagens, a confecção e a instalação de prótese (fixa, removível ou total), conforme plano de tratamento aceito. + +CLÁUSULA 2ª – DAS ETAPAS +O serviço compreende: (a) moldagens e registros; (b) confecção laboratorial; (c) provas; e (d) instalação e ajustes. A confecção laboratorial pode demandar prazo próprio, por depender de terceiro (laboratório de prótese). + +CLÁUSULA 3ª – DAS OBRIGAÇÕES DO(A) CONTRATANTE +Obriga-se o(a) CONTRATANTE a comparecer às sessões de moldagem, prova e instalação, a seguir as orientações de uso e higienização e a comunicar qualquer desconforto para os devidos ajustes. + +CLÁUSULA 4ª – DOS AJUSTES E DA ADAPTAÇÃO +Após a instalação, poderão ser necessárias sessões de ajuste para adaptação da prótese, incluídas no objeto, dentro do prazo razoável de adaptação. + +CLÁUSULA 5ª – DA GARANTIA +A prótese possui garantia contra defeitos de confecção pelo prazo ajustado entre as partes, não abrangendo danos decorrentes de mau uso, acidentes, falta de higiene, alterações da estrutura bucal ou inobservância das orientações. + +CLÁUSULA 6ª – DA RESPONSABILIDADE POR DANOS +Danos à prótese causados por mau uso, queda, mordida em alimentos ou objetos inadequados ou falta de cuidado serão de responsabilidade do(a) CONTRATANTE, correndo por sua conta o reparo ou a substituição. + +CLÁUSULA 7ª – DO VALOR E DO PAGAMENTO +Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições ajustadas entre as partes. + +CLÁUSULA 8ª – DO PRAZO +Início em {{DATA_INICIO}} e previsão de entrega/instalação em {{DATA_FIM}}, podendo variar conforme o prazo laboratorial e as provas necessárias. + +CLÁUSULA 9ª – DA RESCISÃO +Em caso de desistência após iniciada a confecção, serão devidos os valores dos serviços e materiais já empregados (moldagens, peças laboratoriais e atos praticados). + +CLÁUSULA 10ª – DA PROTEÇÃO DE DADOS (LGPD) +Os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD). + +CLÁUSULA 11ª – DO FORO +Fica eleito o foro da comarca de {{CIDADE}}. + +{{CIDADE}}, {{DATA_HOJE}}.`, + }, + { + nome: 'Clínico Geral', categoria: 'servico', tipo_relacao: 'dentista-paciente', + corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — CLÍNICO GERAL + +Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado: + +CLÁUSULA 1ª – DO OBJETO +Constitui objeto deste contrato a prestação de serviços odontológicos de clínica geral ({{ESPECIALIDADE}}), compreendendo os procedimentos constantes do plano de tratamento previamente apresentado e aceito pelo(a) CONTRATANTE (entre os quais profilaxia, restaurações, exodontias simples e demais procedimentos clínicos). + +CLÁUSULA 2ª – DO PLANO DE TRATAMENTO +Os procedimentos, suas etapas e respectivos valores constam do plano de tratamento, que integra este contrato. Eventuais procedimentos não previstos, identificados no curso do atendimento, serão informados e orçados em separado, dependendo de nova anuência. + +CLÁUSULA 3ª – DAS OBRIGAÇÕES DO(A) CONTRATANTE +Obriga-se o(a) CONTRATANTE a manter higiene bucal adequada, a seguir as orientações clínicas, a comparecer às sessões e retornos agendados e a efetuar os pagamentos nos prazos acordados. + +CLÁUSULA 4ª – DOS RETORNOS E DA MANUTENÇÃO +A manutenção dos resultados depende de acompanhamento periódico e de cuidados do(a) CONTRATANTE; a inobservância das orientações ou a ausência aos retornos poderá comprometer o tratamento, isentando o(a) CONTRATADO(A) das consequências daí decorrentes. + +CLÁUSULA 5ª – DO VALOR E DO PAGAMENTO +Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes (entrada e parcelas), conforme o plano de tratamento. + +CLÁUSULA 6ª – DO PRAZO +Os serviços terão início em {{DATA_INICIO}} e previsão de conclusão em {{DATA_FIM}}, prazo estimado e sujeito à evolução clínica e à disponibilidade do(a) CONTRATANTE. + +CLÁUSULA 7ª – DA RESCISÃO +Em caso de desistência, serão devidos os valores correspondentes aos procedimentos já realizados, restituindo-se eventual saldo pago a maior no prazo de 30 (trinta) dias. + +CLÁUSULA 8ª – DA PROTEÇÃO DE DADOS (LGPD) +Os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD), restritos às finalidades do tratamento. + +CLÁUSULA 9ª – DO FORO +Fica eleito o foro da comarca de {{CIDADE}}. + +{{CIDADE}}, {{DATA_HOJE}}.`, + }, + { + nome: 'Periodontia', categoria: 'servico', tipo_relacao: 'dentista-paciente', + corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — PERIODONTIA + +Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado: + +CLÁUSULA 1ª – DO OBJETO +Constitui objeto deste contrato a prestação de serviços de periodontia ({{ESPECIALIDADE}}), compreendendo o tratamento das estruturas de suporte dos dentes — raspagem e alisamento radicular, controle de placa bacteriana e, quando indicadas, cirurgias periodontais — conforme plano de tratamento aceito. + +CLÁUSULA 2ª – DA HIGIENE COMO FATOR ESSENCIAL +O(a) CONTRATANTE declara estar ciente de que o sucesso do tratamento periodontal depende fundamentalmente do controle da higiene bucal e da remoção de fatores de risco (como tabagismo), comprometendo-se a seguir rigorosamente as orientações de higienização. + +CLÁUSULA 3ª – DAS FASES E DA MANUTENÇÃO +O tratamento desenvolve-se em fases (terapia básica, reavaliação e, se necessário, fase cirúrgica), seguidas de manutenção periodontal periódica, indispensável à estabilidade dos resultados. + +CLÁUSULA 4ª – DO PROGNÓSTICO +O(a) CONTRATANTE está ciente de que o prognóstico depende da resposta individual e da cooperação, não constituindo o tratamento obrigação de resultado, e de que a doença periodontal pode recidivar na ausência de manutenção e higiene adequadas. + +CLÁUSULA 5ª – DAS OBRIGAÇÕES DO(A) CONTRATANTE +Obriga-se o(a) CONTRATANTE a comparecer às sessões e às manutenções, a seguir as orientações clínicas e a efetuar os pagamentos nos prazos acordados. + +CLÁUSULA 6ª – DO VALOR E DO PAGAMENTO +Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes. + +CLÁUSULA 7ª – DO PRAZO +Início em {{DATA_INICIO}} e previsão de conclusão da fase ativa em {{DATA_FIM}}, prazo estimado e sujeito à resposta clínica. + +CLÁUSULA 8ª – DA RESCISÃO +Em caso de desistência, serão devidos os valores correspondentes aos procedimentos já realizados. + +CLÁUSULA 9ª – DA PROTEÇÃO DE DADOS (LGPD) +Os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD). + +CLÁUSULA 10ª – DO FORO +Fica eleito o foro da comarca de {{CIDADE}}. + +{{CIDADE}}, {{DATA_HOJE}}.`, + }, + { + nome: 'Endodontia (Tratamento de Canal)', categoria: 'servico', tipo_relacao: 'dentista-paciente', + corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — ENDODONTIA + +Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado: + +CLÁUSULA 1ª – DO OBJETO +Constitui objeto deste contrato a prestação de serviços de endodontia ({{ESPECIALIDADE}}), consistente no tratamento endodôntico (tratamento de canal) do(s) elemento(s) dentário(s) indicado(s), conforme plano de tratamento aceito. + +CLÁUSULA 2ª – DAS SESSÕES +O tratamento poderá ser realizado em uma ou mais sessões, conforme a complexidade do caso e a condição do(s) dente(s), obrigando-se o(a) CONTRATANTE a comparecer às sessões agendadas até sua conclusão. + +CLÁUSULA 3ª – DOS RISCOS E INTERCORRÊNCIAS +O(a) CONTRATANTE declara estar ciente das possíveis intercorrências inerentes ao procedimento (entre as quais dor pós-operatória, fratura de instrumento endodôntico, perfurações, anatomia complexa e necessidade eventual de retratamento ou de cirurgia paraendodôntica), não constituindo o tratamento obrigação de resultado. + +CLÁUSULA 4ª – DA RESTAURAÇÃO DEFINITIVA +O(a) CONTRATANTE está ciente de que, após o tratamento de canal, o dente deve receber restauração definitiva e/ou coroa protética em prazo adequado, sob pena de fratura ou perda do elemento, podendo tais procedimentos não estar incluídos neste objeto. + +CLÁUSULA 5ª – DAS OBRIGAÇÕES DO(A) CONTRATANTE +Obriga-se o(a) CONTRATANTE a seguir as orientações clínicas, a comunicar sintomas e a comparecer aos retornos, bem como a efetuar os pagamentos nos prazos acordados. + +CLÁUSULA 6ª – DO VALOR E DO PAGAMENTO +Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes. + +CLÁUSULA 7ª – DO PRAZO +Início em {{DATA_INICIO}} e previsão de conclusão em {{DATA_FIM}}. + +CLÁUSULA 8ª – DA RESCISÃO +Em caso de desistência após iniciado o tratamento, serão devidos os valores correspondentes aos procedimentos já realizados. + +CLÁUSULA 9ª – DA PROTEÇÃO DE DADOS (LGPD) +Os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD). + +CLÁUSULA 10ª – DO FORO +Fica eleito o foro da comarca de {{CIDADE}}. + +{{CIDADE}}, {{DATA_HOJE}}.`, + }, + { + nome: 'Harmonização Orofacial', categoria: 'servico', tipo_relacao: 'biomedico-paciente', + corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS — HARMONIZAÇÃO OROFACIAL / ESTÉTICA + +Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no respectivo conselho de classe sob o nº {{CRO}}{{CRBM}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado: + +CLÁUSULA 1ª – DO OBJETO +Constitui objeto deste contrato a prestação de serviços de harmonização orofacial / estética ({{ESPECIALIDADE}}), compreendendo os procedimentos constantes do plano previamente apresentado e aceito (entre os quais, conforme o caso, toxina botulínica, preenchimento com ácido hialurônico, bioestimuladores de colágeno e demais procedimentos injetáveis). + +CLÁUSULA 2ª – DA AVALIAÇÃO E DAS CONTRAINDICAÇÕES +A realização dos procedimentos pressupõe avaliação prévia. O(a) CONTRATANTE obriga-se a informar de forma verídica seu histórico de saúde, alergias, medicamentos em uso, gestação/amamentação e demais condições, declarando ciência de que há contraindicações que podem inviabilizar o procedimento. + +CLÁUSULA 3ª – DOS RISCOS E DO CONSENTIMENTO +O(a) CONTRATANTE declara estar ciente dos riscos inerentes (entre os quais edema, equimose, hematoma, assimetria, reação ao produto e, raramente, intercorrências vasculares), bem como de que o resultado estético é individual e subjetivo, não constituindo o serviço obrigação de resultado. + +CLÁUSULA 4ª – DO CARÁTER TEMPORÁRIO E DOS RETOQUES +O(a) CONTRATANTE está ciente de que os efeitos de parte dos procedimentos são temporários, com duração variável conforme o metabolismo individual, podendo demandar retoques ou novas aplicações, cujos custos, se não incluídos, serão informados em separado. + +CLÁUSULA 5ª – DOS CUIDADOS PÓS-PROCEDIMENTO +Obriga-se o(a) CONTRATANTE a seguir rigorosamente as orientações pós-procedimento e a comparecer aos retornos de avaliação, indispensáveis ao acompanhamento. + +CLÁUSULA 6ª – DO VALOR E DO PAGAMENTO +Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes. + +CLÁUSULA 7ª – DO PRAZO +Início em {{DATA_INICIO}} e previsão de conclusão do protocolo em {{DATA_FIM}}. + +CLÁUSULA 8ª – DA RESCISÃO +Em caso de desistência após iniciado o protocolo, serão devidos os valores dos serviços e materiais já empregados. + +CLÁUSULA 9ª – DA PROTEÇÃO DE DADOS (LGPD) +Os dados pessoais e de saúde, incluindo imagens, serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD), restritos às finalidades aqui previstas. + +CLÁUSULA 10ª – DO FORO +Fica eleito o foro da comarca de {{CIDADE}}. + +{{CIDADE}}, {{DATA_HOJE}}.`, + }, + { + nome: 'Odontopediatria', categoria: 'servico', tipo_relacao: 'dentista-paciente', + corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — ODONTOPEDIATRIA + +Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, na qualidade de responsável legal pelo(a) paciente menor de idade, doravante CONTRATANTE, têm justo e contratado: + +CLÁUSULA 1ª – DO OBJETO +Constitui objeto deste contrato a prestação de serviços de odontopediatria ({{ESPECIALIDADE}}) ao(à) paciente menor representado(a) pelo(a) CONTRATANTE, compreendendo os procedimentos constantes do plano de tratamento previamente apresentado e aceito (entre os quais prevenção, profilaxia, aplicação de flúor, restaurações e demais procedimentos pertinentes à faixa etária). + +CLÁUSULA 2ª – DA REPRESENTAÇÃO E DO CONSENTIMENTO +O(a) CONTRATANTE declara ser o(a) responsável legal pelo(a) paciente menor e consente com os procedimentos descritos no plano de tratamento, comprometendo-se a acompanhar e a autorizar as condutas necessárias. + +CLÁUSULA 3ª – DO MANEJO COMPORTAMENTAL +O(a) CONTRATANTE está ciente de que o atendimento infantil pode exigir técnicas de manejo do comportamento e que a colaboração da criança e da família é determinante para a evolução do tratamento. + +CLÁUSULA 4ª – DAS OBRIGAÇÕES DO(A) CONTRATANTE +Obriga-se o(a) CONTRATANTE a zelar pela higiene bucal e dieta do(a) paciente, a seguir as orientações clínicas, a comparecer às sessões e aos retornos preventivos e a efetuar os pagamentos nos prazos acordados. + +CLÁUSULA 5ª – DO VALOR E DO PAGAMENTO +Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes, conforme o plano de tratamento. + +CLÁUSULA 6ª – DO PRAZO +Início em {{DATA_INICIO}} e previsão de conclusão em {{DATA_FIM}}, prazo estimado e sujeito à evolução clínica e à colaboração do(a) paciente. + +CLÁUSULA 7ª – DA RESCISÃO +Em caso de desistência, serão devidos os valores correspondentes aos procedimentos já realizados. + +CLÁUSULA 8ª – DA PROTEÇÃO DE DADOS (LGPD) +Os dados pessoais e de saúde do(a) paciente serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD), restritos às finalidades do tratamento. + +CLÁUSULA 9ª – DO FORO +Fica eleito o foro da comarca de {{CIDADE}}. + +{{CIDADE}}, {{DATA_HOJE}}.`, + }, + { + nome: 'Cirurgia Oral', categoria: 'servico', tipo_relacao: 'dentista-paciente', + corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — CIRURGIA ORAL + +Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado: + +CLÁUSULA 1ª – DO OBJETO +Constitui objeto deste contrato a prestação de serviços de cirurgia oral ({{ESPECIALIDADE}}), compreendendo o(s) procedimento(s) cirúrgico(s) bucal(is) indicado(s) (entre os quais exodontias, remoção de dentes inclusos/retidos, frenectomias e demais procedimentos), conforme plano de tratamento aceito. + +CLÁUSULA 2ª – DA AVALIAÇÃO E DOS EXAMES +A realização do(s) procedimento(s) pressupõe avaliação e exames de imagem. Obriga-se o(a) CONTRATANTE a fornecer informações verídicas sobre seu estado de saúde e medicamentos em uso e a apresentar os exames solicitados. + +CLÁUSULA 3ª – DOS RISCOS E DO CONSENTIMENTO +O(a) CONTRATANTE declara estar ciente de que se trata de procedimento cirúrgico, sujeito a riscos inerentes (entre os quais edema, hematoma, sangramento, infecção, dor pós-operatória, parestesia transitória e, conforme o caso, comunicação buco-sinusal), não constituindo o ato obrigação de resultado. + +CLÁUSULA 4ª – DOS CUIDADOS PÓS-OPERATÓRIOS +Obriga-se o(a) CONTRATANTE a seguir rigorosamente as orientações pós-operatórias (repouso, medicação, dieta e higiene) e a comparecer aos retornos, indispensáveis à adequada cicatrização. + +CLÁUSULA 5ª – DAS OBRIGAÇÕES DO(A) CONTRATANTE +Obriga-se ainda o(a) CONTRATANTE a comunicar intercorrências e a efetuar os pagamentos nos prazos acordados. + +CLÁUSULA 6ª – DO VALOR E DO PAGAMENTO +Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes. + +CLÁUSULA 7ª – DO PRAZO +Início em {{DATA_INICIO}} e previsão de conclusão em {{DATA_FIM}}. + +CLÁUSULA 8ª – DA RESCISÃO +Em caso de desistência após iniciado o procedimento, serão devidos os valores dos serviços e materiais já empregados. + +CLÁUSULA 9ª – DA PROTEÇÃO DE DADOS (LGPD) +Os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD). + +CLÁUSULA 10ª – DO FORO +Fica eleito o foro da comarca de {{CIDADE}}. + +{{CIDADE}}, {{DATA_HOJE}}.`, + }, + { + nome: 'Clareamento Dental', categoria: 'servico', tipo_relacao: 'dentista-paciente', + corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — CLAREAMENTO DENTAL + +Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado: + +CLÁUSULA 1ª – DO OBJETO +Constitui objeto deste contrato a prestação de serviços de clareamento dental ({{ESPECIALIDADE}}), na modalidade de consultório, caseiro supervisionado ou combinada, conforme plano de tratamento aceito. + +CLÁUSULA 2ª – DO RESULTADO +O(a) CONTRATANTE declara estar ciente de que o resultado do clareamento é individual e variável, dependente das características dos dentes, e de que não há garantia de alcance de tonalidade específica, não constituindo o serviço obrigação de resultado. + +CLÁUSULA 3ª – DA SENSIBILIDADE E CONTRAINDICAÇÕES +O(a) CONTRATANTE está ciente da possibilidade de sensibilidade dentária transitória e de irritação gengival, bem como de que restaurações, próteses e manchas de origem não vital podem não responder ao clareamento, e de que existem contraindicações (gestação, idade, entre outras). + +CLÁUSULA 4ª – DA MANUTENÇÃO E DOS CUIDADOS +Os resultados podem regredir com o tempo, podendo demandar retoques. Obriga-se o(a) CONTRATANTE a seguir as orientações (dieta, restrição a alimentos pigmentados e tabagismo) e a utilizar corretamente os materiais entregues, quando for o caso. + +CLÁUSULA 5ª – DO VALOR E DO PAGAMENTO +Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes. + +CLÁUSULA 6ª – DO PRAZO +Início em {{DATA_INICIO}} e previsão de conclusão em {{DATA_FIM}}. + +CLÁUSULA 7ª – DA RESCISÃO +Em caso de desistência após iniciado o tratamento, serão devidos os valores dos serviços e materiais já empregados. + +CLÁUSULA 8ª – DA PROTEÇÃO DE DADOS (LGPD) +Os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD). + +CLÁUSULA 9ª – DO FORO +Fica eleito o foro da comarca de {{CIDADE}}. + +{{CIDADE}}, {{DATA_HOJE}}.`, + }, + { + nome: 'DTM e Dor Orofacial', categoria: 'servico', tipo_relacao: 'dentista-paciente', + corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — DTM E DOR OROFACIAL + +Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado: + +CLÁUSULA 1ª – DO OBJETO +Constitui objeto deste contrato a prestação de serviços de diagnóstico e tratamento de disfunção temporomandibular (DTM) e dor orofacial ({{ESPECIALIDADE}}), compreendendo, conforme o caso, avaliação, placa oclusal/miorrelaxante, orientações e acompanhamento, segundo plano de tratamento aceito. + +CLÁUSULA 2ª – DA NATUREZA MULTIFATORIAL +O(a) CONTRATANTE declara estar ciente de que a DTM possui caráter multifatorial e por vezes crônico, sendo o tratamento voltado ao controle dos sintomas e à melhora da função, sem garantia de cura, não constituindo obrigação de resultado. + +CLÁUSULA 3ª – DA COOPERAÇÃO E DOS HÁBITOS +O sucesso do tratamento depende da cooperação do(a) CONTRATANTE, inclusive quanto ao controle de hábitos parafuncionais (apertamento/bruxismo), ao uso correto da placa e ao seguimento das orientações. + +CLÁUSULA 4ª – DOS AJUSTES E DO ACOMPANHAMENTO +A placa, quando indicada, demanda ajustes periódicos e acompanhamento. Obriga-se o(a) CONTRATANTE a comparecer aos retornos e a comunicar a evolução dos sintomas. + +CLÁUSULA 5ª – DO VALOR E DO PAGAMENTO +Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes. + +CLÁUSULA 6ª – DO PRAZO +Início em {{DATA_INICIO}} e previsão de reavaliação/conclusão da fase de controle em {{DATA_FIM}}. + +CLÁUSULA 7ª – DA RESCISÃO +Em caso de desistência, serão devidos os valores correspondentes aos procedimentos e dispositivos já realizados/confeccionados. + +CLÁUSULA 8ª – DA PROTEÇÃO DE DADOS (LGPD) +Os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD). + +CLÁUSULA 9ª – DO FORO +Fica eleito o foro da comarca de {{CIDADE}}. + +{{CIDADE}}, {{DATA_HOJE}}.`, + }, + { + nome: 'Radiologia / Laudo Odontológico', categoria: 'servico', tipo_relacao: 'dentista-paciente', + corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — RADIOLOGIA E LAUDO + +Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado: + +CLÁUSULA 1ª – DO OBJETO +Constitui objeto deste contrato a prestação de serviços de radiologia odontológica e imaginologia ({{ESPECIALIDADE}}), compreendendo a realização do(s) exame(s) de imagem solicitado(s) (entre os quais radiografias, tomografia computadorizada de feixe cônico e documentação) e a emissão do respectivo laudo, quando aplicável. + +CLÁUSULA 2ª – DA ENTREGA E DO LAUDO +As imagens e o laudo serão disponibilizados ao(à) CONTRATANTE no prazo informado. O laudo restringe-se à análise das imagens obtidas, conforme a finalidade da solicitação, não substituindo a avaliação clínica do(a) profissional assistente. + +CLÁUSULA 3ª – DA RESPONSABILIDADE TÉCNICA E PROTEÇÃO RADIOLÓGICA +Os procedimentos observarão as normas de proteção radiológica e biossegurança aplicáveis. O(a) CONTRATANTE obriga-se a informar condições relevantes (como gestação) previamente à realização do exame. + +CLÁUSULA 4ª – DO VALOR E DO PAGAMENTO +Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes. + +CLÁUSULA 5ª – DO PRAZO +Realização em {{DATA_INICIO}} e entrega do laudo/imagens até {{DATA_FIM}}, conforme disponibilidade técnica. + +CLÁUSULA 6ª – DA PROTEÇÃO DE DADOS E DAS IMAGENS (LGPD) +As imagens e os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD), restritos às finalidades aqui previstas, sendo as imagens armazenadas pelo prazo legal aplicável. + +CLÁUSULA 7ª – DA RESCISÃO +Em caso de desistência após a realização do exame, será devido o valor do serviço prestado. + +CLÁUSULA 8ª – DO FORO +Fica eleito o foro da comarca de {{CIDADE}}. + +{{CIDADE}}, {{DATA_HOJE}}.`, + }, + { + nome: 'Cirurgia Ortognática', categoria: 'servico', tipo_relacao: 'dentista-paciente', + corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — CIRURGIA ORTOGNÁTICA + +Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado: + +CLÁUSULA 1ª – DO OBJETO +Constitui objeto deste contrato a prestação de serviços relacionados ao tratamento ortodôntico-cirúrgico ({{ESPECIALIDADE}}), compreendendo o planejamento, o preparo ortodôntico, a cirurgia ortognática e a finalização, com vistas à correção das deformidades dentofaciais, conforme plano de tratamento aceito. + +CLÁUSULA 2ª – DAS FASES E DA EQUIPE +O tratamento desenvolve-se em fases (preparo ortodôntico pré-cirúrgico, fase cirúrgica e ortodontia pós-cirúrgica) e pode envolver equipe multidisciplinar, dependendo cada fase do sucesso da anterior. + +CLÁUSULA 3ª – DOS RISCOS E DO CONSENTIMENTO +O(a) CONTRATANTE declara estar ciente de que se trata de cirurgia de grande porte, sujeita a riscos inerentes (entre os quais edema acentuado, sangramento, infecção, alterações de sensibilidade e necessidade de revisão), realizada, em regra, em ambiente hospitalar, não constituindo o ato obrigação de resultado. + +CLÁUSULA 4ª – DOS CUSTOS HOSPITALARES E DE TERCEIROS +Os custos de internação, ambiente hospitalar, equipe de anestesia, materiais e exames, quando não expressamente incluídos neste objeto, correrão por conta do(a) CONTRATANTE e serão orçados separadamente. + +CLÁUSULA 5ª – DOS EXAMES E CONDIÇÕES DE SAÚDE +Obriga-se o(a) CONTRATANTE a fornecer informações verídicas sobre sua saúde, a apresentar os exames pré-operatórios solicitados e a seguir as orientações pré e pós-operatórias. + +CLÁUSULA 6ª – DO VALOR E DO PAGAMENTO +Pelos serviços do(a) CONTRATADO(A), o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes. + +CLÁUSULA 7ª – DO PRAZO +Início em {{DATA_INICIO}} e previsão de conclusão em {{DATA_FIM}}, prazo estimado em razão das fases ortodônticas e do período de recuperação. + +CLÁUSULA 8ª – DA RESCISÃO +Em caso de desistência, serão devidos os valores correspondentes às fases e procedimentos já realizados. + +CLÁUSULA 9ª – DA PROTEÇÃO DE DADOS (LGPD) +Os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD). + +CLÁUSULA 10ª – DO FORO +Fica eleito o foro da comarca de {{CIDADE}}. + +{{CIDADE}}, {{DATA_HOJE}}.`, + }, + { + nome: 'Credenciamento Profissional', categoria: 'parceria', tipo_relacao: 'clinica-profissional', + corpo: `CONTRATO DE CREDENCIAMENTO PROFISSIONAL + +Por este instrumento particular, de um lado {{CONTRATANTE}}, inscrita no CNPJ sob o nº {{CNPJ}}, com sede em {{ENDERECO}}, doravante CONTRATANTE (unidade), e de outro {{CONTRATADO}}, inscrito(a) no respectivo conselho de classe sob o nº {{CRO}}{{CRBM}}, doravante CONTRATADO(A) (profissional), têm entre si justo e contratado: + +CLÁUSULA 1ª – DO OBJETO +Constitui objeto deste contrato o credenciamento do(a) CONTRATADO(A) para a prestação de serviços profissionais ({{ESPECIALIDADE}}) na estrutura da CONTRATANTE, sem vínculo empregatício, atuando com autonomia técnica e responsabilidade por seus atos profissionais. + +CLÁUSULA 2ª – DA NATUREZA DA RELAÇÃO +As partes declaram que o presente contrato não gera vínculo empregatício, inexistindo subordinação, habitualidade e demais requisitos da relação de emprego, atuando o(a) CONTRATADO(A) como profissional autônomo(a)/parceiro(a). + +CLÁUSULA 3ª – DAS OBRIGAÇÕES DO(A) CONTRATADO(A) +Obriga-se o(a) CONTRATADO(A) a manter regularidade perante o conselho de classe, a observar as normas técnicas e de biossegurança, a zelar pela documentação dos atendimentos e a cumprir a agenda acordada. + +CLÁUSULA 4ª – DAS OBRIGAÇÕES DA CONTRATANTE +Obriga-se a CONTRATANTE a disponibilizar a estrutura, os equipamentos e o agendamento necessários ao atendimento, nos termos acordados. + +CLÁUSULA 5ª – DO REPASSE / REMUNERAÇÃO +Pela prestação dos serviços, será devido ao(à) CONTRATADO(A) o repasse de {{PERCENTUAL}} sobre os procedimentos efetivamente realizados (ou o valor de {{VALOR}}, conforme ajustado), nas datas e condições acordadas entre as partes. + +CLÁUSULA 6ª – DA VIGÊNCIA E DA RENOVAÇÃO +O presente contrato vigora de {{DATA_INICIO}} a {{DATA_FIM}}, podendo ser renovado por igual período mediante acordo entre as partes. + +CLÁUSULA 7ª – DA RESCISÃO +O contrato poderá ser rescindido por qualquer das partes mediante aviso prévio, respeitadas as obrigações e os atendimentos em curso até a data da rescisão. + +CLÁUSULA 8ª – DA PROTEÇÃO DE DADOS (LGPD) +As partes tratarão os dados pessoais e de saúde dos pacientes em conformidade com a Lei nº 13.709/2018 (LGPD), restringindo o uso às finalidades do atendimento. + +CLÁUSULA 9ª – DO FORO +Fica eleito o foro da comarca de {{CIDADE}} para dirimir controvérsias oriundas deste contrato. + +{{CIDADE}}, {{DATA_HOJE}}.`, + }, + { + nome: 'Atendimento a Convênios / Repasse (Dentista Credenciado)', categoria: 'remuneracao', tipo_relacao: 'dentista-clinica', + corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS — ATENDIMENTO A CONVÊNIOS E REPASSE + +Por este instrumento particular, de um lado {{CONTRATANTE}}, inscrita no CNPJ sob o nº {{CNPJ}}, com sede em {{ENDERECO}}, doravante CONTRATANTE (clínica), e de outro {{CONTRATADO}}, cirurgião(ã)-dentista inscrito(a) no CRO sob o nº {{CRO}}, especialidade {{ESPECIALIDADE}}, doravante CONTRATADO(A) (profissional), têm entre si justo e contratado o seguinte: + +CLÁUSULA 1ª – DO OBJETO +O(A) CONTRATADO(A) prestará serviços odontológicos, nas dependências e sob a organização da CONTRATANTE, no atendimento a pacientes vinculados aos convênios e planos odontológicos: {{CONVENIOS}}, observadas as regras, tabelas e o credenciamento de cada operadora. + +CLÁUSULA 2ª – DAS GUIAS E DO FATURAMENTO +O(A) CONTRATADO(A) obriga-se a emitir e preencher corretamente as guias de tratamento odontológico (GTO) e demais documentos exigidos pelas operadoras, respondendo pela exatidão das informações clínicas e dos códigos lançados. O faturamento junto às operadoras será conduzido pela CONTRATANTE. + +CLÁUSULA 3ª – DO REPASSE +Pela produção realizada para convênios, o(a) CONTRATADO(A) fará jus ao repasse de {{PERCENTUAL_REPASSE}} sobre o valor dos procedimentos, calculado sobre a base de {{BASE_REPASSE}}. O repasse será efetuado {{PRAZO_REPASSE}}, mediante demonstrativo de produção emitido pela CONTRATANTE. + +CLÁUSULA 4ª – DO CUSTO DE LABORATÓRIO +Os custos de laboratório/prótese inerentes aos procedimentos serão tratados da seguinte forma: {{CUSTO_LABORATORIO}}, deduzidos da base de cálculo do repasse quando assim ajustado. + +CLÁUSULA 5ª – DAS GLOSAS +As glosas aplicadas pelas operadoras (negativa total ou parcial de pagamento) serão suportadas por {{RESPONSAVEL_GLOSA}}. Quando decorrentes de erro no preenchimento ou na execução atribuível ao(à) CONTRATADO(A), os valores glosados poderão ser deduzidos do respectivo repasse. A CONTRATANTE manterá registro das glosas e dos recursos interpostos, com seus protocolos e prazos. + +CLÁUSULA 6ª – DAS OBRIGAÇÕES DO(A) CONTRATADO(A) +Obriga-se o(a) CONTRATADO(A) a: (a) atender com zelo técnico e ético os pacientes dos convênios; (b) cumprir as normas e prazos das operadoras; (c) manter regular sua inscrição no CRO e eventuais credenciamentos; (d) registrar adequadamente os atendimentos no prontuário; e (e) observar a biossegurança. + +CLÁUSULA 7ª – DA AUSÊNCIA DE VÍNCULO EMPREGATÍCIO +O presente contrato é de natureza civil, não gerando vínculo empregatício, exercendo o(a) CONTRATADO(A) sua atividade com autonomia técnica, sem subordinação, responsabilizando-se por seus tributos e obrigações. + +CLÁUSULA 8ª – DA VIGÊNCIA E DA RESCISÃO +O contrato vigora de {{DATA_INICIO}} a {{DATA_FIM}}, podendo ser rescindido por qualquer das partes mediante aviso prévio de 30 (trinta) dias, ressalvado o pagamento da produção já realizada e dos repasses pendentes apurados até a data da rescisão. + +CLÁUSULA 9ª – DA PROTEÇÃO DE DADOS (LGPD) +As partes tratarão os dados pessoais e de saúde dos pacientes em conformidade com a Lei nº 13.709/2018 (LGPD), restringindo o uso às finalidades do atendimento e do faturamento junto às operadoras. + +CLÁUSULA 10ª – DO FORO +Fica eleito o foro da comarca de {{CIDADE}} para dirimir controvérsias oriundas deste contrato. + +{{CIDADE}}, {{DATA_HOJE}}.`, + }, +]; + +async function seedContratoModelos() { + try { + for (let i = 0; i < MODELOS_CONTRATO_DEFAULT.length; i++) { + const m = MODELOS_CONTRATO_DEFAULT[i]; + await pool.query( + `INSERT INTO contrato_modelos (id, clinica_id, nome, categoria, tipo_relacao, corpo, variaveis, editavel, ordem) + VALUES ($1, NULL, $2, $3, $4, $5, $6, false, $7) ON CONFLICT (id) DO NOTHING`, + [`cmod_def_${i}`, m.nome, m.categoria, m.tipo_relacao, buildCorpoModelo(m.objeto), JSON.stringify(VARIAVEIS_CONTRATO.map(v => v.chave)), i]); + } + for (let i = 0; i < MODELOS_CONTRATO_ODONTO.length; i++) { + const m = MODELOS_CONTRATO_ODONTO[i]; + await pool.query( + `INSERT INTO contrato_modelos (id, clinica_id, nome, categoria, tipo_relacao, corpo, variaveis, editavel, ordem) + VALUES ($1, NULL, $2, $3, $4, $5, $6, false, $7) ON CONFLICT (id) DO NOTHING`, + [`cmod_odo_${i}`, m.nome, m.categoria, m.tipo_relacao, m.corpo, JSON.stringify(VARIAVEIS_CONTRATO.map(v => v.chave)), 100 + i]); + } + console.log('[SEED] contrato_modelos OK'); + } catch (e) { console.error('[SEED contrato_modelos]', e.message); } +} + +const MODELO_CONTRATO_ROW = 'id, clinica_id, nome, categoria, tipo_relacao, corpo, variaveis, vigencia_dias, editavel, compartilhavel, ordem'; + +app.get('/api/contratos/variaveis', authGuard, async (_req, res) => res.json(VARIAVEIS_CONTRATO)); + +app.get('/api/contratos/modelos', tenantGuard, async (req, res) => { + const clinicaId = req.clinicaId || null; + try { + const { rows } = await pool.query( + `SELECT ${MODELO_CONTRATO_ROW}, (clinica_id IS NULL) AS global FROM contrato_modelos + WHERE deleted_at IS NULL AND (clinica_id IS NULL OR clinica_id = $1) + ORDER BY (clinica_id IS NULL) DESC, ordem, nome`, [clinicaId]); + res.json(rows); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.post('/api/contratos/modelos', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const { nome, categoria, tipo_relacao, corpo, vigencia_dias } = req.body || {}; + if (!nome?.trim() || !corpo?.trim()) return res.status(400).json({ error: 'Nome e corpo são obrigatórios.' }); + try { + const id = `cmod_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + await pool.query( + `INSERT INTO contrato_modelos (id, clinica_id, nome, categoria, tipo_relacao, corpo, variaveis, vigencia_dias, editavel, compartilhavel) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,true,$9)`, + [id, req.clinicaId, nome.trim(), categoria || null, tipo_relacao || null, corpo, JSON.stringify(VARIAVEIS_CONTRATO.map(v => v.chave)), vigencia_dias || null, req.body?.compartilhavel === true]); + res.json({ success: true, id }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.put('/api/contratos/modelos/:id', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const { nome, categoria, tipo_relacao, corpo, vigencia_dias } = req.body || {}; + if (!nome?.trim() || !corpo?.trim()) return res.status(400).json({ error: 'Nome e corpo são obrigatórios.' }); + try { + const { rowCount } = await pool.query( + `UPDATE contrato_modelos SET nome=$1, categoria=$2, tipo_relacao=$3, corpo=$4, vigencia_dias=$5 + WHERE id=$6 AND clinica_id=$7 AND deleted_at IS NULL`, + [nome.trim(), categoria || null, tipo_relacao || null, corpo, vigencia_dias || null, req.params.id, req.clinicaId]); + if (!rowCount) return res.status(404).json({ error: 'Modelo não encontrado nesta unidade (modelos do catálogo são read-only — duplique).' }); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.delete('/api/contratos/modelos/:id', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + try { + const { rowCount } = await pool.query( + `UPDATE contrato_modelos SET deleted_at=NOW() WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL`, + [req.params.id, req.clinicaId]); + if (!rowCount) return res.status(404).json({ error: 'Modelo não encontrado nesta unidade.' }); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.post('/api/contratos/modelos/:id/duplicar', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + try { + const { rows } = await pool.query( + `SELECT nome, categoria, tipo_relacao, corpo, variaveis, vigencia_dias FROM contrato_modelos + WHERE id=$1 AND deleted_at IS NULL AND (clinica_id IS NULL OR clinica_id=$2)`, [req.params.id, req.clinicaId]); + if (!rows.length) return res.status(404).json({ error: 'Modelo não encontrado.' }); + const m = rows[0]; + const id = `cmod_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + await pool.query( + `INSERT INTO contrato_modelos (id, clinica_id, nome, categoria, tipo_relacao, corpo, variaveis, vigencia_dias, editavel) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,true)`, + [id, req.clinicaId, `${m.nome} (cópia)`, m.categoria, m.tipo_relacao, m.corpo, JSON.stringify(m.variaveis || []), m.vigencia_dias]); + res.json({ success: true, id }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// ── Motor contratual · Fatia 2: gerador de instâncias ─────────────────────── +// Resolve {{VAR}} no corpo com os valores informados (mantém a variável se vazia). +function renderContratoCorpo(corpo, valores) { + const v = valores || {}; + return String(corpo || '').replace(/\{\{(\w+)\}\}/g, (m, k) => (v[k] != null && String(v[k]).trim() !== '') ? String(v[k]) : m); +} + +app.post('/api/contratos/instancias', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const b = req.body || {}; + if (!b.titulo?.trim()) return res.status(400).json({ error: 'Título obrigatório.' }); + try { + let corpoBase = b.corpo, tipoRel = b.tipo_relacao || null; + if (!corpoBase && b.modelo_id) { + const { rows } = await pool.query('SELECT corpo, tipo_relacao FROM contrato_modelos WHERE id=$1 AND deleted_at IS NULL AND (clinica_id IS NULL OR clinica_id=$2)', [b.modelo_id, req.clinicaId]); + corpoBase = rows[0]?.corpo || ''; + tipoRel = tipoRel || rows[0]?.tipo_relacao || null; + } + const corpoRend = renderContratoCorpo(corpoBase, b.valores); + const id = `ctr_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + await pool.query( + `INSERT INTO contratos (id, clinica_origem_id, modelo_id, titulo, tipo_relacao, status, corpo_renderizado, valores, data_inicio, data_fim, renovacao, aviso_dias, created_by, cobranca_automatica, valor_recorrente, periodicidade, dia_cobranca) + VALUES ($1,$2,$3,$4,$5,'rascunho',$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, + [id, req.clinicaId, b.modelo_id || null, b.titulo.trim(), tipoRel, corpoRend, JSON.stringify(b.valores || {}), b.data_inicio || null, b.data_fim || null, b.renovacao || 'manual', b.aviso_dias ?? 30, req.authUser?.userId, + !!b.cobranca_automatica, b.valor_recorrente != null ? Number(b.valor_recorrente) : null, b.periodicidade || 'mensal', b.dia_cobranca != null ? parseInt(b.dia_cobranca) : null]); + const partes = Array.isArray(b.partes) ? b.partes : []; + for (let i = 0; i < partes.length; i++) { + const pt = partes[i] || {}; + await pool.query('INSERT INTO contrato_partes (id, contrato_id, papel, entidade_tipo, entidade_id, nome_snapshot, doc_snapshot, ordem) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)', + [`cpt_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 5)}`, id, pt.papel || 'PARTE', pt.entidade_tipo || null, pt.entidade_id || null, pt.nome || null, pt.doc || null, i]); + } + await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'contrato', entidadeId: id, acao: 'gerou', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { titulo: b.titulo } }); + res.json({ success: true, id }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.get('/api/contratos/instancias', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + try { + const { rows } = await pool.query( + `SELECT id, titulo, tipo_relacao, status, data_inicio, data_fim, created_at, cobranca_automatica, valor_recorrente FROM contratos + WHERE deleted_at IS NULL AND clinica_origem_id=$1 ORDER BY created_at DESC LIMIT 200`, [req.clinicaId]); + res.json(rows); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.get('/api/contratos/instancias/:id', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + try { + const { rows } = await pool.query('SELECT * FROM contratos WHERE id=$1 AND deleted_at IS NULL AND clinica_origem_id=$2', [req.params.id, req.clinicaId]); + if (!rows.length) return res.status(404).json({ error: 'Contrato não encontrado.' }); + const { rows: partes } = await pool.query('SELECT * FROM contrato_partes WHERE contrato_id=$1 ORDER BY ordem', [req.params.id]); + const { rows: assinaturas } = await pool.query( + `SELECT a.id, a.parte_id, a.status, a.assinado_em, a.ip, a.assinante_usuario_id, u.nome AS assinante_nome + FROM contrato_assinaturas a LEFT JOIN usuarios u ON u.id = a.assinante_usuario_id + WHERE a.contrato_id=$1`, [req.params.id]); + res.json({ ...rows[0], partes, assinaturas }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.put('/api/contratos/instancias/:id', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const b = req.body || {}; + try { + const own = await pool.query('SELECT status FROM contratos WHERE id=$1 AND deleted_at IS NULL AND clinica_origem_id=$2', [req.params.id, req.clinicaId]); + if (!own.rowCount) return res.status(404).json({ error: 'Contrato não encontrado nesta unidade.' }); + if (own.rows[0].status !== 'rascunho') return res.status(409).json({ error: 'Só é possível editar contratos em rascunho.' }); + const corpoRend = b.corpo_renderizado != null ? b.corpo_renderizado : (b.corpo != null ? renderContratoCorpo(b.corpo, b.valores) : null); + await pool.query( + `UPDATE contratos SET titulo=COALESCE($1,titulo), corpo_renderizado=COALESCE($2,corpo_renderizado), + valores=COALESCE($3,valores), data_inicio=$4, data_fim=$5, + cobranca_automatica=COALESCE($8,cobranca_automatica), valor_recorrente=COALESCE($9,valor_recorrente), + periodicidade=COALESCE($10,periodicidade), dia_cobranca=COALESCE($11,dia_cobranca) + WHERE id=$6 AND clinica_origem_id=$7`, + [b.titulo || null, corpoRend, b.valores ? JSON.stringify(b.valores) : null, b.data_inicio || null, b.data_fim || null, req.params.id, req.clinicaId, + b.cobranca_automatica != null ? !!b.cobranca_automatica : null, b.valor_recorrente != null ? Number(b.valor_recorrente) : null, b.periodicidade || null, b.dia_cobranca != null ? parseInt(b.dia_cobranca) : null]); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// H4.2 — gera manualmente a cobrança recorrente da competência atual de um contrato vigente +app.post('/api/contratos/instancias/:id/gerar-cobranca', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + try { + const { rows } = await pool.query('SELECT * FROM contratos WHERE id=$1 AND deleted_at IS NULL AND clinica_origem_id=$2', [req.params.id, req.clinicaId]); + if (!rows.length) return res.status(404).json({ error: 'Contrato não encontrado.' }); + const c = rows[0]; + if (c.status !== 'vigente') return res.status(400).json({ error: 'Contrato precisa estar vigente para gerar cobrança.' }); + if (!(Number(c.valor_recorrente) > 0)) return res.status(400).json({ error: 'Defina um valor recorrente > 0.' }); + const created = await gerarCobrancaContratoCompetencia(c); + if (created) await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'contrato', entidadeId: c.id, acao: 'gerou_cobranca_recorrente', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { valor: Number(c.valor_recorrente) } }); + if (created) schedulePush('financeiro'); + res.json({ success: true, financeiro_id: created || null, ja_existia: !created }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.delete('/api/contratos/instancias/:id', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + try { + const { rowCount } = await pool.query('UPDATE contratos SET deleted_at=NOW() WHERE id=$1 AND clinica_origem_id=$2 AND deleted_at IS NULL', [req.params.id, req.clinicaId]); + if (!rowCount) return res.status(404).json({ error: 'Contrato não encontrado nesta unidade.' }); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Carrega um contrato do escopo da clínica (helper anti-IDOR). +async function loadContratoScoped(id, clinicaId) { + const { rows } = await pool.query('SELECT * FROM contratos WHERE id=$1 AND deleted_at IS NULL AND clinica_origem_id=$2', [id, clinicaId]); + return rows[0] || null; +} +const reqIp = (req) => (String(req.headers['x-forwarded-for'] || '').split(',')[0].trim()) || req.socket?.remoteAddress || null; + +// Enviar para assinatura: rascunho → aguardando_assinatura (cria assinaturas pendentes por parte). +app.post('/api/contratos/instancias/:id/enviar', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + try { + const c = await loadContratoScoped(req.params.id, req.clinicaId); + if (!c) return res.status(404).json({ error: 'Contrato não encontrado.' }); + if (c.status !== 'rascunho') return res.status(409).json({ error: `Contrato já está "${c.status}".` }); + const { rows: partes } = await pool.query('SELECT id FROM contrato_partes WHERE contrato_id=$1', [c.id]); + if (!partes.length) return res.status(400).json({ error: 'Adicione partes antes de enviar para assinatura.' }); + for (const p of partes) { + await pool.query( + `INSERT INTO contrato_assinaturas (id, contrato_id, parte_id, status) + SELECT $1,$2,$3,'pendente' WHERE NOT EXISTS (SELECT 1 FROM contrato_assinaturas WHERE contrato_id=$2 AND parte_id=$3)`, + [`csig_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, c.id, p.id]); + } + await pool.query("UPDATE contratos SET status='aguardando_assinatura' WHERE id=$1", [c.id]); + await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'contrato', entidadeId: c.id, acao: 'enviou para assinatura', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: {} }); + res.json({ success: true, status: 'aguardando_assinatura' }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Assinar uma parte (aceite eletrônico simples do usuário logado: ip/hash/user-agent). +app.post('/api/contratos/instancias/:id/assinar', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const parteId = req.body?.parteId; + if (!parteId) return res.status(400).json({ error: 'parteId obrigatório.' }); + try { + const c = await loadContratoScoped(req.params.id, req.clinicaId); + if (!c) return res.status(404).json({ error: 'Contrato não encontrado.' }); + if (c.status !== 'aguardando_assinatura') return res.status(409).json({ error: 'Envie o contrato para assinatura primeiro.' }); + const { rows: sig } = await pool.query('SELECT id, status FROM contrato_assinaturas WHERE contrato_id=$1 AND parte_id=$2', [c.id, parteId]); + if (!sig.length) return res.status(404).json({ error: 'Parte não encontrada.' }); + if (sig[0].status === 'assinado') return res.status(409).json({ error: 'Esta parte já assinou.' }); + const hash = crypto.createHash('sha256').update(String(c.corpo_renderizado || '')).digest('hex'); + await pool.query( + `UPDATE contrato_assinaturas SET status='assinado', assinado_em=NOW(), assinante_usuario_id=$1, ip=$2, user_agent=$3, hash=$4 + WHERE id=$5`, + [req.authUser?.userId, reqIp(req), String(req.headers['user-agent'] || '').slice(0, 300), hash, sig[0].id]); + // todas as partes assinaram? → assinado (ou vigente se já em vigência) + const { rows: cnt } = await pool.query( + `SELECT (SELECT COUNT(*) FROM contrato_partes WHERE contrato_id=$1) AS partes, + (SELECT COUNT(*) FROM contrato_assinaturas WHERE contrato_id=$1 AND status='assinado') AS assinadas`, [c.id]); + let novoStatus = c.status; + if (Number(cnt[0].partes) > 0 && Number(cnt[0].partes) === Number(cnt[0].assinadas)) { + const vigente = !c.data_inicio || new Date(c.data_inicio) <= new Date(); + novoStatus = vigente ? 'vigente' : 'assinado'; + await pool.query('UPDATE contratos SET status=$1 WHERE id=$2', [novoStatus, c.id]); + } + await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'contrato', entidadeId: c.id, acao: 'assinou', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { parteId } }); + res.json({ success: true, status: novoStatus }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.post('/api/contratos/instancias/:id/cancelar', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + try { + const { rowCount } = await pool.query( + "UPDATE contratos SET status='cancelado' WHERE id=$1 AND clinica_origem_id=$2 AND deleted_at IS NULL AND status NOT IN ('encerrado','cancelado')", + [req.params.id, req.clinicaId]); + if (!rowCount) return res.status(404).json({ error: 'Contrato não encontrado ou já finalizado.' }); + res.json({ success: true, status: 'cancelado' }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Cron: transições de vigência + alerta de vencimento (broadcast por clínica, uma vez). +// H4.2 — gera a cobrança (RECEITA) de UMA competência de um contrato; idempotente por (contrato_id, competencia). +async function gerarCobrancaContratoCompetencia(c, refDate = new Date()) { + const periodic = (c.periodicidade || 'mensal').toLowerCase(); + const ano = refDate.getFullYear(), mes = refDate.getMonth() + 1; + if (periodic === 'anual' && c.data_inicio) { + if (new Date(c.data_inicio).getMonth() + 1 !== mes) return false; // anual só no mês de aniversário + } + const competencia = `${ano}-${String(mes).padStart(2, '0')}`; + const ex = await pool.query("SELECT 1 FROM financeiro WHERE contrato_id=$1 AND competencia=$2 AND origem='contrato' AND deleted_at IS NULL", [c.id, competencia]); + if (ex.rowCount) return false; + const dia = Math.min(Math.max(1, parseInt(c.dia_cobranca) || 1), 28); // 28 evita data inválida + const venc = `${competencia}-${String(dia).padStart(2, '0')}`; + const id = `fin_ctr_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + await pool.query(` + INSERT INTO financeiro (id, clinica_id, descricao, valor, tipo, status, datavencimento, contrato_id, origem, competencia) + VALUES ($1,$2,$3,$4,'RECEITA','Pendente',$5,$6,'contrato',$7)`, + [id, c.clinica_origem_id, `${c.titulo || 'Contrato'} — ${String(mes).padStart(2, '0')}/${ano}`, Number(c.valor_recorrente), venc, c.id, competencia]); + return id; +} + +async function gerarCobrancasRecorrentes() { + const { rows } = await pool.query(` + SELECT id, clinica_origem_id, titulo, valor_recorrente, dia_cobranca, periodicidade, data_inicio + FROM contratos + WHERE deleted_at IS NULL AND status='vigente' AND cobranca_automatica=true + AND COALESCE(valor_recorrente,0) > 0 AND clinica_origem_id IS NOT NULL + AND (data_inicio IS NULL OR data_inicio <= CURRENT_DATE) + AND (data_fim IS NULL OR data_fim >= date_trunc('month', CURRENT_DATE))`); + let criados = 0; + for (const c of rows) { if (await gerarCobrancaContratoCompetencia(c)) criados++; } + if (criados) console.log(`[CRON contratos] ${criados} cobrança(s) recorrente(s) gerada(s)`); +} + +async function processarVigenciasContratos() { + try { + // Financeiro: marca lançamentos vencidos como Atrasado (Épico 1). + await pool.query(`UPDATE financeiro SET status='Atrasado' WHERE status='Pendente' AND deleted_at IS NULL AND datavencimento IS NOT NULL AND datavencimento < CURRENT_DATE`); + await pool.query(`UPDATE contratos SET status='vigente' WHERE status='assinado' AND deleted_at IS NULL AND (data_inicio IS NULL OR data_inicio <= CURRENT_DATE)`); + await pool.query(`UPDATE contratos SET status='encerrado' WHERE status='vigente' AND deleted_at IS NULL AND data_fim IS NOT NULL AND data_fim < CURRENT_DATE AND COALESCE(renovacao,'manual') <> 'automatica'`); + // Renovação automática: estende a vigência por uma nova duração igual à anterior (ou +12 meses se não houver início) + const renA = await pool.query(` + UPDATE contratos + SET data_inicio = data_fim + 1, + data_fim = data_fim + (data_fim - data_inicio) + 1, + vencimento_avisado = false + WHERE status='vigente' AND deleted_at IS NULL AND renovacao='automatica' + AND data_fim IS NOT NULL AND data_inicio IS NOT NULL AND data_fim > data_inicio AND data_fim < CURRENT_DATE + RETURNING id`); + const renB = await pool.query(` + UPDATE contratos + SET data_fim = (data_fim + INTERVAL '12 months')::date, vencimento_avisado = false + WHERE status='vigente' AND deleted_at IS NULL AND renovacao='automatica' + AND data_fim IS NOT NULL AND (data_inicio IS NULL OR data_fim <= data_inicio) AND data_fim < CURRENT_DATE + RETURNING id`); + if (renA.rowCount + renB.rowCount) console.log(`[CRON contratos] ${renA.rowCount + renB.rowCount} renovação(ões) automática(s)`); + const { rows } = await pool.query( + `SELECT id, clinica_origem_id, titulo, data_fim FROM contratos + WHERE deleted_at IS NULL AND vencimento_avisado = false AND status IN ('vigente','assinado') + AND data_fim IS NOT NULL AND data_fim >= CURRENT_DATE + AND data_fim <= CURRENT_DATE + ((COALESCE(aviso_dias,30))::text || ' days')::interval`); + for (const c of rows) { + if (c.clinica_origem_id) { + await pool.query( + `INSERT INTO notificacoes (id, titulo, mensagem, tipo, lida, data, clinica_id, enviado_por) + VALUES (gen_random_uuid(), $1, $2, 'info', 0, NOW(), $3, 'sistema')`, + ['Contrato vencendo', `O contrato "${c.titulo}" vence em ${new Date(c.data_fim).toLocaleDateString('pt-BR')}.`, c.clinica_origem_id]); + } + await pool.query('UPDATE contratos SET vencimento_avisado = true WHERE id=$1', [c.id]); + } + if (rows.length) console.log(`[CRON contratos] ${rows.length} aviso(s) de vencimento`); + await gerarCobrancasRecorrentes(); // H4.2 — cobrança recorrente de contratos vigentes + } catch (e) { console.error('[processarVigenciasContratos]', e.message); } +} + +// ── Modelos de proposta (editáveis, por clínica) ───────────────────────────── +const MODELO_CATS = ['dentista', 'biomedico']; +const MODELOS_PROPOSTA_DEFAULT = [ + { categoria: 'dentista', titulo: 'Parceria fixa semanal', texto: 'Olá, Dr(a). {prof}! Somos da {clinica} e buscamos um(a) dentista para atendimentos fixos semanais. Temos estrutura completa e fluxo de pacientes ativo. Podemos conversar sobre disponibilidade e valores?' }, + { categoria: 'dentista', titulo: 'Especialista por demanda', texto: 'Olá, Dr(a). {prof}! Temos demanda recorrente na sua especialidade e gostaríamos de contar com seu atendimento na {clinica}. Trabalhamos com agenda flexível e repasse atrativo. Tem interesse em conhecer a proposta?' }, + { categoria: 'dentista', titulo: 'Cobertura de agenda', texto: 'Olá, Dr(a). {prof}! Precisamos de um(a) profissional para ampliar horários e reduzir a fila de espera da {clinica}. Sua experiência seria muito bem-vinda. Podemos agendar uma conversa?' }, + { categoria: 'biomedico', titulo: 'Harmonização orofacial', texto: 'Olá, {prof}! A {clinica} está ampliando os serviços de harmonização orofacial e estética e gostaríamos de uma parceria com você. Oferecemos sala equipada e captação de clientes. Vamos conversar?' }, + { categoria: 'biomedico', titulo: 'Procedimentos estéticos', texto: 'Olá, {prof}! Buscamos biomédico(a) para procedimentos estéticos em dias específicos na {clinica}. Estrutura completa e divulgação inclusa. Tem interesse na proposta?' }, + { categoria: 'biomedico', titulo: 'Parceria por sessão', texto: 'Olá, {prof}! Temos demanda de pacientes para estética e gostaríamos de fechar uma parceria por sessão na {clinica}. Podemos alinhar agenda e valores?' }, +]; + +// Lista modelos da clínica; na primeira leitura semeia os padrões. +app.get('/api/propostas/modelos', tenantGuard, async (req, res) => { + const clinicaId = req.query.clinicaId; + if (!clinicaId) return res.status(400).json({ error: 'clinicaId é obrigatório.' }); + const sel = 'SELECT id, clinica_id, categoria, titulo, texto, ordem FROM propostas_modelos WHERE clinica_id = $1 ORDER BY categoria, ordem, created_at'; + try { + let { rows } = await pool.query(sel, [clinicaId]); + if (!rows.length) { + for (let i = 0; i < MODELOS_PROPOSTA_DEFAULT.length; i++) { + const m = MODELOS_PROPOSTA_DEFAULT[i]; + await pool.query('INSERT INTO propostas_modelos (id, clinica_id, categoria, titulo, texto, ordem) VALUES ($1,$2,$3,$4,$5,$6)', + [`pmod_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, clinicaId, m.categoria, m.titulo, m.texto, i]); + } + rows = (await pool.query(sel, [clinicaId])).rows; + } + res.json(rows); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.post('/api/propostas/modelos', tenantGuard, async (req, res) => { + const { clinicaId, titulo, texto } = req.body || {}; + const categoria = MODELO_CATS.includes(req.body?.categoria) ? req.body.categoria : 'dentista'; + if (!clinicaId || !titulo?.trim() || !texto?.trim()) return res.status(400).json({ error: 'clinicaId, título e texto são obrigatórios.' }); + try { + const { rows: c } = await pool.query('SELECT COALESCE(MAX(ordem),-1)+1 AS ordem FROM propostas_modelos WHERE clinica_id=$1 AND categoria=$2', [clinicaId, categoria]); + const id = `pmod_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + await pool.query('INSERT INTO propostas_modelos (id, clinica_id, categoria, titulo, texto, ordem) VALUES ($1,$2,$3,$4,$5,$6)', + [id, clinicaId, categoria, titulo.trim(), texto.trim(), c[0].ordem]); + res.json({ success: true, id }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.put('/api/propostas/modelos/:id', tenantGuard, async (req, res) => { + const { clinicaId, titulo, texto } = req.body || {}; + const categoria = MODELO_CATS.includes(req.body?.categoria) ? req.body.categoria : 'dentista'; + if (!clinicaId || !titulo?.trim() || !texto?.trim()) return res.status(400).json({ error: 'clinicaId, título e texto são obrigatórios.' }); + try { + const { rowCount } = await pool.query( + 'UPDATE propostas_modelos SET categoria=$1, titulo=$2, texto=$3 WHERE id=$4 AND clinica_id=$5', + [categoria, titulo.trim(), texto.trim(), req.params.id, clinicaId]); + if (!rowCount) return res.status(404).json({ error: 'Modelo não encontrado nesta unidade.' }); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.delete('/api/propostas/modelos/:id', tenantGuard, async (req, res) => { + const clinicaId = req.query.clinicaId; + if (!clinicaId) return res.status(400).json({ error: 'clinicaId é obrigatório.' }); + try { + const { rowCount } = await pool.query('DELETE FROM propostas_modelos WHERE id=$1 AND clinica_id=$2', [req.params.id, clinicaId]); + if (!rowCount) return res.status(404).json({ error: 'Modelo não encontrado nesta unidade.' }); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + // --- LIMPEZA DE DADOS LEGADOS (sem clinica_id) --- app.delete('/api/sync/limpar-legado', superadminGuard, async (req, res) => { try { @@ -5003,6 +7140,22 @@ async function superadminGuard(req, res, next) { } } +// Admin de plataforma (gestão de tutores/repasses): role admin OU superadmin. +async function adminGuard(req, res, next) { + const auth = req.headers['authorization']; + if (!auth?.startsWith('Bearer ')) return res.status(401).json({ error: 'Não autenticado.' }); + try { + const payload = jwt.verify(auth.split(' ')[1], JWT_SECRET); + const { rows } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [payload.userId]); + if (!rows[0] || !['admin', 'superadmin'].includes(rows[0].role)) return res.status(403).json({ error: 'Acesso negado.' }); + req.authUser = payload; + next(); + } catch (e) { + if (e.name === 'JsonWebTokenError' || e.name === 'TokenExpiredError') return res.status(401).json({ error: 'Token inválido.' }); + res.status(500).json({ error: e.message }); + } +} + app.get('/api/superadmin/stats', superadminGuard, async (req, res) => { try { const [usersRes, clinicasRes, loginsRes, semAssRes] = await Promise.all([ @@ -5412,12 +7565,11 @@ const TRAT_COLS = ['clinica_id', 'paciente_id', 'paciente_nome', 'data_nasciment const TRAT_JSON_COLS = new Set(['triagem', 'diagnostico', 'flags']); const tratVal = (col, v) => (TRAT_JSON_COLS.has(col) && v != null && typeof v !== 'string') ? JSON.stringify(v) : v; -app.get('/api/orto/tratamentos', authGuard, async (req, res) => { +app.get('/api/orto/tratamentos', tenantGuard, async (req, res) => { try { - const { clinicaId } = req.query; - const { rows: trats } = clinicaId - ? await pool.query('SELECT * FROM ortho_tratamento WHERE clinica_id = $1 ORDER BY created_at DESC', [clinicaId]) - : await pool.query('SELECT * FROM ortho_tratamento ORDER BY created_at DESC'); + const clinicaId = req.clinicaId; + if (!clinicaId) return res.status(400).json({ success: false, error: 'clinicaId obrigatório.' }); + const { rows: trats } = await pool.query('SELECT * FROM ortho_tratamento WHERE clinica_id = $1 ORDER BY created_at DESC', [clinicaId]); const byT = {}; if (trats.length) { const { rows: evos } = await pool.query('SELECT * FROM ortho_evolucao WHERE tratamento_id = ANY($1) ORDER BY data ASC, created_at ASC', [trats.map(t => t.id)]); @@ -5431,6 +7583,7 @@ app.post('/api/orto/tratamentos', authGuard, async (req, res) => { try { const b = req.body || {}; if (!b.paciente_nome) return res.status(400).json({ success: false, message: 'Nome do paciente é obrigatório.' }); + if (!await userHasVinculo(req.authUser?.userId, b.clinica_id)) return res.status(403).json({ success: false, error: 'Acesso negado: sem vínculo com esta unidade.' }); const id = _oid('otr'); const keys = TRAT_COLS.filter(c => b[c] !== undefined); const cols = ['id', ...keys]; @@ -5443,6 +7596,7 @@ app.post('/api/orto/tratamentos', authGuard, async (req, res) => { app.put('/api/orto/tratamentos/:id', authGuard, async (req, res) => { try { + if (!await assertOrtoScoped(req, res)) return; const b = req.body || {}; const keys = TRAT_COLS.filter(c => b[c] !== undefined); if (!keys.length) return res.json({ success: true }); @@ -5454,6 +7608,7 @@ app.put('/api/orto/tratamentos/:id', authGuard, async (req, res) => { app.post('/api/orto/tratamentos/:id/evolucoes', authGuard, async (req, res) => { try { + if (!await assertOrtoScoped(req, res)) return; const b = req.body || {}; const id = _oid('oev'); const data = b.data || new Date().toISOString().slice(0, 10); @@ -5470,6 +7625,7 @@ app.post('/api/orto/tratamentos/:id/evolucoes', authGuard, async (req, res) => { app.post('/api/orto/tratamentos/:id/finalizar', authGuard, async (req, res) => { try { + if (!await assertOrtoScoped(req, res)) return; const { tipo, superior, inferior, tempoUso } = req.body || {}; await pool.query(`UPDATE ortho_tratamento SET status = 'Finalizado', contencao_tipo = $1, contencao_superior = $2, contencao_inferior = $3, tempo_uso = $4 WHERE id = $5`, [tipo || null, superior ?? null, inferior ?? null, tempoUso || null, req.params.id]); @@ -5481,12 +7637,13 @@ app.post('/api/orto/tratamentos/:id/finalizar', authGuard, async (req, res) => { app.get('/api/orto/sessoes', authGuard, async (req, res) => { try { const { tratamentoId } = req.query; + if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM ortho_tratamento WHERE id = $1', [tratamentoId])) return; const { rows: sess } = await pool.query('SELECT id, tipo, rotulo, data_ref FROM ortho_photo_session WHERE tratamento_id = $1 ORDER BY data_ref ASC NULLS FIRST, created_at ASC', [tratamentoId]); const byS = {}; if (sess.length) { const ids = sess.map(s => s.id); const { rows: fotos } = await pool.query('SELECT id, sessao_id, slot, original_name FROM ortho_photo WHERE sessao_id = ANY($1)', [ids]); - for (const f of fotos) (byS[f.sessao_id] ||= []).push({ id: f.id, slot: f.slot, url: `/api/orto/fotos/${f.id}/raw`, original_name: f.original_name }); + for (const f of fotos) (byS[f.sessao_id] ||= []).push({ id: f.id, slot: f.slot, url: `/api/orto/fotos/${f.id}/raw?t=${signMedia(f.id)}`, original_name: f.original_name }); } res.json({ success: true, sessoes: sess.map(s => ({ id: s.id, tipo: s.tipo, rotulo: s.rotulo, data_ref: s.data_ref, fotos: byS[s.id] || [] })) }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } @@ -5497,6 +7654,7 @@ app.post('/api/orto/sessoes', authGuard, async (req, res) => { try { const { tratamento_id, tipo, clinica_id, rotulo, data_ref } = req.body; if (!tratamento_id || !tipo) return res.status(400).json({ success: false, message: 'tratamento_id e tipo são obrigatórios.' }); + if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM ortho_tratamento WHERE id = $1', [tratamento_id])) return; let { rows } = await pool.query('SELECT id, tipo, rotulo, data_ref FROM ortho_photo_session WHERE tratamento_id = $1 AND tipo = $2', [tratamento_id, tipo]); if (!rows.length) { const id = _oid('ops'); @@ -5505,7 +7663,7 @@ app.post('/api/orto/sessoes', authGuard, async (req, res) => { } const s = rows[0]; const { rows: fotos } = await pool.query('SELECT id, slot, original_name FROM ortho_photo WHERE sessao_id = $1', [s.id]); - res.json({ success: true, sessao: { id: s.id, tipo: s.tipo, rotulo: s.rotulo, data_ref: s.data_ref, fotos: fotos.map(f => ({ id: f.id, slot: f.slot, url: `/api/orto/fotos/${f.id}/raw`, original_name: f.original_name })) } }); + res.json({ success: true, sessao: { id: s.id, tipo: s.tipo, rotulo: s.rotulo, data_ref: s.data_ref, fotos: fotos.map(f => ({ id: f.id, slot: f.slot, url: `/api/orto/fotos/${f.id}/raw?t=${signMedia(f.id)}`, original_name: f.original_name })) } }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); @@ -5514,6 +7672,7 @@ app.post('/api/orto/fotos', authGuard, ortoUpload.single('foto'), async (req, re try { const { sessao_id, slot } = req.body; if (!sessao_id || !slot || !req.file) return res.status(400).json({ success: false, message: 'sessao_id, slot e foto são obrigatórios.' }); + if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM ortho_photo_session WHERE id = $1', [sessao_id])) return; const ext = ((req.file.originalname.split('.').pop() || 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '')) || 'jpg'; const dir = path.join(MEDIA_ROOT, 'orto', sessao_id); await fs.promises.mkdir(dir, { recursive: true }); @@ -5525,13 +7684,14 @@ app.post('/api/orto/fotos', authGuard, ortoUpload.single('foto'), async (req, re const rel = path.join('orto', sessao_id, `${slot}_${Date.now()}.${ext}`); await fs.promises.writeFile(path.join(MEDIA_ROOT, rel), req.file.buffer); await pool.query('INSERT INTO ortho_photo (id, sessao_id, slot, filename, mimetype, original_name) VALUES ($1,$2,$3,$4,$5,$6)', [id, sessao_id, slot, rel, req.file.mimetype, req.file.originalname]); - res.json({ success: true, foto: { id, slot, url: `/api/orto/fotos/${id}/raw`, original_name: req.file.originalname } }); + res.json({ success: true, foto: { id, slot, url: `/api/orto/fotos/${id}/raw?t=${signMedia(id)}`, original_name: req.file.originalname } }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); // stream público da imagem (img tags não enviam Authorization) app.get('/api/orto/fotos/:id/raw', async (req, res) => { try { + if (!verifyMedia(req.params.id, req.query.t)) return res.status(403).end(); const { rows } = await pool.query('SELECT filename, mimetype FROM ortho_photo WHERE id = $1', [req.params.id]); if (!rows.length) return res.status(404).end(); res.type(rows[0].mimetype || 'image/jpeg'); @@ -5541,6 +7701,7 @@ app.get('/api/orto/fotos/:id/raw', async (req, res) => { app.delete('/api/orto/fotos/:id', authGuard, async (req, res) => { try { + if (!await assertRowScoped(req, res, 'SELECT s.clinica_id AS cid FROM ortho_photo f JOIN ortho_photo_session s ON s.id = f.sessao_id WHERE f.id = $1', [req.params.id])) return; const { rows } = await pool.query('SELECT filename FROM ortho_photo WHERE id = $1', [req.params.id]); if (rows.length) { try { await fs.promises.unlink(path.join(MEDIA_ROOT, rows[0].filename)); } catch {} } await pool.query('DELETE FROM ortho_photo_annotation WHERE foto_id = $1', [req.params.id]); @@ -5552,6 +7713,7 @@ app.delete('/api/orto/fotos/:id', authGuard, async (req, res) => { // --- DOCUMENTAÇÃO FOTOGRÁFICA: anotações sobre a imagem --- app.get('/api/orto/fotos/:id/anotacoes', authGuard, async (req, res) => { try { + if (!await assertRowScoped(req, res, 'SELECT s.clinica_id AS cid FROM ortho_photo f JOIN ortho_photo_session s ON s.id = f.sessao_id WHERE f.id = $1', [req.params.id])) return; const { rows } = await pool.query('SELECT id, dados FROM ortho_photo_annotation WHERE foto_id = $1 ORDER BY created_at', [req.params.id]); res.json({ success: true, anotacoes: rows.map(r => ({ id: r.id, dados: r.dados })) }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } @@ -5559,6 +7721,7 @@ app.get('/api/orto/fotos/:id/anotacoes', authGuard, async (req, res) => { app.put('/api/orto/fotos/:id/anotacoes', authGuard, async (req, res) => { try { + if (!await assertRowScoped(req, res, 'SELECT s.clinica_id AS cid FROM ortho_photo f JOIN ortho_photo_session s ON s.id = f.sessao_id WHERE f.id = $1', [req.params.id])) return; const anotacoes = Array.isArray(req.body?.anotacoes) ? req.body.anotacoes : []; await withTransaction(async (client) => { await client.query('DELETE FROM ortho_photo_annotation WHERE foto_id = $1', [req.params.id]); @@ -5651,7 +7814,7 @@ app.get('/api/tutoria/solicitacoes/:id', authGuard, async (req, res) => { const { rows: msgs } = await pool.query('SELECT * FROM ortho_tutoring_message WHERE request_id = $1 ORDER BY created_at', [req.params.id]); const { rows: pkg } = await pool.query('SELECT foto_id, slot FROM ortho_tutoring_photo_package WHERE request_id = $1', [req.params.id]); sol.mensagens = msgs; - sol.fotos = pkg.map(p => ({ id: p.foto_id, url: `/api/orto/fotos/${p.foto_id}/raw`, slot: p.slot })); + sol.fotos = pkg.map(p => ({ id: p.foto_id, url: `/api/orto/fotos/${p.foto_id}/raw?t=${signMedia(p.foto_id)}`, slot: p.slot })); res.json({ success: true, solicitacao: sol }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); @@ -5758,12 +7921,12 @@ app.post('/api/tutoria/mp-webhook', async (req, res) => { }); // --- ADMIN DE TUTORES --- -app.get('/api/admin/tutor-config', authGuard, async (req, res) => { +app.get('/api/admin/tutor-config', adminGuard, async (req, res) => { try { res.json({ success: true, config: await getTutorConfig() }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); -app.put('/api/admin/tutor-config', authGuard, async (req, res) => { +app.put('/api/admin/tutor-config', adminGuard, async (req, res) => { try { const b = req.body; await pool.query(`INSERT INTO ortho_tutor_config (id, taxa_inscricao, chave_pix, instrucoes_pagamento, mp_access_token, mp_public_key, comissao_pct, texto_responsabilidade, updated_at) @@ -5776,7 +7939,7 @@ app.put('/api/admin/tutor-config', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); -app.get('/api/admin/candidaturas', authGuard, async (req, res) => { +app.get('/api/admin/candidaturas', adminGuard, async (req, res) => { try { const { status } = req.query; const { rows } = status @@ -5786,7 +7949,7 @@ app.get('/api/admin/candidaturas', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); -app.put('/api/admin/candidaturas/:id/status', authGuard, async (req, res) => { +app.put('/api/admin/candidaturas/:id/status', adminGuard, async (req, res) => { try { const { status, observacao } = req.body; const novo = status === 'APROVADO' ? 'AGUARDANDO_PAGAMENTO' : status; @@ -5796,14 +7959,14 @@ app.put('/api/admin/candidaturas/:id/status', authGuard, async (req, res) => { }); // confirma pagamento → ativa candidatura e cria/atualiza o perfil de tutor -app.post('/api/admin/candidaturas/:id/confirmar-pagamento', authGuard, async (req, res) => { +app.post('/api/admin/candidaturas/:id/confirmar-pagamento', adminGuard, async (req, res) => { try { await withTransaction((client) => ativarCandidaturaTutor(client, req.params.id)); res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); -app.get('/api/admin/repasses', authGuard, async (req, res) => { +app.get('/api/admin/repasses', adminGuard, async (req, res) => { try { const { rows } = await pool.query(`SELECT r.id, r.paciente_nome, r.valor_consultoria, r.valor_tutor, r.comissao_pct, COALESCE(r.status_repasse,'PENDENTE') AS status_repasse, r.created_at, t.nome AS tutor_nome, t.pix_key @@ -5813,7 +7976,7 @@ app.get('/api/admin/repasses', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); -app.post('/api/admin/repasses/:id/confirmar', authGuard, async (req, res) => { +app.post('/api/admin/repasses/:id/confirmar', adminGuard, async (req, res) => { try { await pool.query(`UPDATE ortho_tutoring_request SET status_repasse = 'PAGO' WHERE id = $1`, [req.params.id]); res.json({ success: true }); @@ -5824,9 +7987,12 @@ const httpServer = app.listen(PORT, '0.0.0.0', async () => { console.log(`[SERVER] Running on http://0.0.0.0:${PORT}`); await loadGoogleCredsFromDb(); await runMigrations(); + await seedContratoModelos(); await loadSheetsConfig(); generateIntelligentNotifications(); setInterval(generateIntelligentNotifications, 5 * 60 * 1000); + processarVigenciasContratos(); + setInterval(processarVigenciasContratos, 6 * 60 * 60 * 1000); }); // ── WebSocket server (tempo real) em /api/ws ──────────────────────────────── diff --git a/frontend/App.tsx b/frontend/App.tsx index e932db3..923bbf1 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -27,7 +27,11 @@ import { DentistRegisterView } from './views/DentistRegisterView.tsx'; import { DentistasView, EspecialidadesView, PlanosView, SyncView, ProcedimentosView } from './views/AdminViews.tsx'; import { NotificationsView } from './views/NotificationsView.tsx'; import { ReportsView } from './views/ReportsView.tsx'; +import { ComissoesView } from './views/ComissoesView.tsx'; +import { GlosasView } from './views/GlosasView.tsx'; +import { SalaHomeView } from './views/SalaHomeView.tsx'; import { ConfiguracoesView } from './views/ConfiguracoesView.tsx'; +import { OrcamentosView } from './views/OrcamentosView.tsx'; import { ContratosView } from './views/ContratosView.tsx'; import { PluginsView } from './views/PluginsView.tsx'; import { RXPlugin } from './views/plugins/RXPlugin.tsx'; @@ -53,6 +57,8 @@ export type ViewKey = | 'agenda' | 'ortodontia' | 'financeiro' + | 'comissoes' + | 'glosas' | 'login' | 'update' | 'dentistas' @@ -67,10 +73,14 @@ export type ViewKey = | 'relatorios' | 'cadastro-dentista' | 'configuracoes' + | 'orcamentos' | 'contratos' | 'plugins' | 'rx-radiografias' | 'salas' + | 'minhas-salas' + | 'alugar-salas' + | 'sala-home' | 'marketplace-profissionais' | 'criar-clinica' | 'rx-config' @@ -113,10 +123,14 @@ const ROUTE_MAP: Record = { '/relatorios': 'relatorios', '/cadastro-dentista': 'cadastro-dentista', '/configuracoes': 'configuracoes', + '/orcamentos': 'orcamentos', '/contratos': 'contratos', '/plugins': 'plugins', '/rx-radiografias': 'rx-radiografias', '/salas': 'salas', + '/minhas-salas': 'minhas-salas', + '/alugar-salas': 'alugar-salas', + '/sala-home': 'sala-home', '/marketplace-profissionais': 'marketplace-profissionais', '/criar-clinica': 'criar-clinica', '/rx-config': 'rx-config', @@ -144,6 +158,8 @@ const VIEW_TO_ROUTE: Record = { agenda: '/agenda', ortodontia: '/ortodontia', financeiro: '/financeiro', + comissoes: '/comissoes', + glosas: '/glosas', login: '/login', update: '/update', clinicas: '/clinicas', @@ -158,10 +174,14 @@ const VIEW_TO_ROUTE: Record = { sync: '/admin/sync', 'cadastro-dentista': '/cadastro-dentista', configuracoes: '/configuracoes', + orcamentos: '/orcamentos', contratos: '/contratos', plugins: '/plugins', 'rx-radiografias': '/rx-radiografias', salas: '/salas', + 'minhas-salas': '/minhas-salas', + 'alugar-salas': '/alugar-salas', + 'sala-home': '/sala-home', 'marketplace-profissionais': '/marketplace-profissionais', 'criar-clinica': '/criar-clinica', 'rx-config': '/rx-config', @@ -254,7 +274,9 @@ const App: React.FC = () => { if (view === 'sync') return false; // Locação de Salas: capability PESSOAL (add-on por conta), liberada a qualquer // usuário com o plugin ativo na conta — independe de ter clínica/cargo. - if (view === 'salas') return isPluginActive('locacao-salas'); + if (view === 'salas' || view === 'minhas-salas' || view === 'alugar-salas') return isPluginActive('locacao-salas'); + // Contexto de SALA: isola da clínica — só painel da sala/salas/conta/notificações. + if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return ['sala-home', 'configuracoes', 'notificacoes', 'clinicas'].includes(view); // Catálogo de PLUGINS: EXCLUSIVO do superadmin (já retorna true acima); ninguém mais. if (view === 'plugins') return false; // Admin/donos têm acesso amplo, MAS as telas de superadmin são exclusivas do superadmin. @@ -274,7 +296,7 @@ const App: React.FC = () => { dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'], biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'], protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'], - funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'], + funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'], }; return permissions[role]?.includes(view) || false; @@ -282,7 +304,10 @@ const App: React.FC = () => { const getDefaultViewForRole = (role: string): ViewKey => { if (role === 'superadmin') return 'superadmin-overview'; + // Contexto de SALA (workspace tipo='sala') sempre cai no painel da sala. + if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return 'sala-home'; if (role === 'paciente') return 'tratamentos'; + if (role === 'donosala') return 'minhas-salas'; return 'dashboard'; }; @@ -308,6 +333,7 @@ const App: React.FC = () => { const storedRole = getStoredUserRole(); if (storedRole === 'superadmin') return 'superadmin-overview'; if (storedRole === 'admin' || storedRole === 'donoclinica' || storedRole === 'donoconsultorio') return 'criar-clinica'; + if (storedRole === 'donosala') return 'minhas-salas'; if (storedRole === 'dentista' || storedRole === 'biomedico' || storedRole === 'protetico') return 'aguardando-convite'; return 'aguardando-convite'; } @@ -387,6 +413,7 @@ const App: React.FC = () => { if (result?.noWorkspace) { const userRole = result.userRole || 'dentista'; if (userRole === 'superadmin') { setCurrentView('superadmin'); navigate('superadmin'); } + else if (userRole === 'donosala') { setCurrentView('minhas-salas'); navigate('minhas-salas'); } else if (userRole === 'admin' || userRole === 'donoclinica' || userRole === 'donoconsultorio') { setCurrentView('criar-clinica'); navigate('criar-clinica'); } else { setCurrentView('aguardando-convite'); navigate('aguardando-convite'); } return; @@ -426,12 +453,18 @@ const App: React.FC = () => { case 'clinicas': return ; case 'notificacoes': return ; case 'relatorios': return ; + case 'comissoes': return ; + case 'glosas': return ; case 'cadastro-dentista': return ; case 'configuracoes': return ; + case 'orcamentos': return ; case 'contratos': return ; case 'plugins': return ; case 'rx-radiografias': return ; case 'salas': return ; + case 'minhas-salas': return ; + case 'alugar-salas': return ; + case 'sala-home': return ; case 'marketplace-profissionais': return ; case 'criar-clinica': return { window.location.reload(); }} />; case 'rx-config': return ; diff --git a/frontend/components/AgendaDetailModal.tsx b/frontend/components/AgendaDetailModal.tsx index 492fc59..45426c4 100644 --- a/frontend/components/AgendaDetailModal.tsx +++ b/frontend/components/AgendaDetailModal.tsx @@ -55,10 +55,10 @@ export const FamiliaChips: React.FC<{ pacienteId: string; onPick?: (m: any) => v
{membros.map(m => { - const inner = (<>{m.nome}{m.grupofamiliarrelacao ? · {m.grupofamiliarrelacao} : null}); - const cls = 'shrink-0 inline-flex items-center gap-1 bg-indigo-50 text-indigo-700 rounded-full px-2.5 py-1 text-[10px] font-bold uppercase whitespace-nowrap'; + const inner = (<>{m.nome}{m.grupofamiliarrelacao ? · {m.grupofamiliarrelacao} : null}); + const cls = 'shrink-0 inline-flex items-center gap-1 bg-emerald-50 text-emerald-700 rounded-full px-2.5 py-1 text-[10px] font-bold uppercase whitespace-nowrap'; return onPick - ? + ? : {inner}; })}
@@ -126,12 +126,12 @@ const HistoryPage: React.FC<{ appointmentId: string }> = ({ appointmentId }) => {hist.map((h, i) => (
-
+
{i < hist.length - 1 &&
}

- {h.actor_nome || 'Alguém'} {ACAO[h.acao] || h.acao} + {h.actor_nome || 'Alguém'} {ACAO[h.acao] || h.acao}

{new Date(h.at).toLocaleString('pt-BR')}

{h.detalhes?.de && h.detalhes?.para && ( @@ -201,12 +201,12 @@ const ConfirmImportadoPage: React.FC<{ ag: any; onConfirmed: () => void }> = ({
setQ(e.target.value)} placeholder="Buscar por nome, CPF ou telefone..." - className="w-full pl-9 pr-3 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-blue-400" /> + className="w-full pl-9 pr-3 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-teal-400" />
{results.map(pac => (
- @@ -225,8 +225,8 @@ const ConfirmImportadoPage: React.FC<{ ag: any; onConfirmed: () => void }> = ({
setNome(e.target.value.toUpperCase())} placeholder="NOME COMPLETO" - className="w-full px-3 py-2.5 border border-gray-200 rounded-xl text-sm font-bold focus:outline-none focus:border-blue-400" /> - + className="w-full px-3 py-2.5 border border-gray-200 rounded-xl text-sm font-bold focus:outline-none focus:border-teal-400" /> +
)} @@ -237,18 +237,18 @@ const ConfirmImportadoPage: React.FC<{ ag: any; onConfirmed: () => void }> = ({
setCpf(e.target.value)} placeholder="000.000.000-00" - className={`w-full px-2.5 py-2 border rounded-lg text-xs font-bold focus:outline-none ${!cpf.trim() ? 'border-amber-300 bg-amber-50' : 'border-gray-200 focus:border-blue-400'}`} /> + className={`w-full px-2.5 py-2 border rounded-lg text-xs font-bold focus:outline-none ${!cpf.trim() ? 'border-amber-300 bg-amber-50' : 'border-gray-200 focus:border-teal-400'}`} />
setTel(e.target.value)} placeholder="(00) 00000-0000" - className={`w-full px-2.5 py-2 border rounded-lg text-xs font-bold focus:outline-none ${!tel.trim() ? 'border-amber-300 bg-amber-50' : 'border-gray-200 focus:border-blue-400'}`} /> + className={`w-full px-2.5 py-2 border rounded-lg text-xs font-bold focus:outline-none ${!tel.trim() ? 'border-amber-300 bg-amber-50' : 'border-gray-200 focus:border-teal-400'}`} />
setProc(e.target.value.toUpperCase())} placeholder="EX: AVALIAÇÃO" - className="w-full px-2.5 py-2 border border-gray-200 rounded-lg text-xs font-bold focus:outline-none focus:border-blue-400" /> + className="w-full px-2.5 py-2 border border-gray-200 rounded-lg text-xs font-bold focus:outline-none focus:border-teal-400" />
{faltaDados &&

Preencha CPF e telefone para confirmar.

}
@@ -342,13 +342,13 @@ const DentistaRestritoView: React.FC<{ ag: any; nome: string; dentNome: string;