diff --git a/backend/server.js b/backend/server.js index edd0b93..4e10bd7 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3793,9 +3793,24 @@ async function labDoProtetico(proteticoId) { return rows[0]?.id || null; } +// Fase 4: o laboratório do usuário — como DONO (owner) ou como TÉCNICO (vínculo). Usado na bancada. +async function labDoUsuario(uid) { + if (!uid) return null; + const dono = await labDoProtetico(uid); + if (dono) return dono; + const { rows } = await pool.query( + "SELECT c.id FROM vinculos v JOIN clinicas c ON c.id = v.clinica_id WHERE v.usuario_id=$1 AND c.tipo='laboratorio' AND v.role='tecnico_protese' LIMIT 1", [uid]); + return rows[0]?.id || null; +} + async function proteseOsAccesso(os, uid) { if (!os) return { isLab: false, isClinic: false }; - const isLab = !!uid && os.protetico_id === uid; + let isLab = !!uid && os.protetico_id === uid; + // Técnico vinculado ao laboratório de destino também é "lab" (acessa a produção). + if (!isLab && uid && os.laboratorio_id) { + const { rows } = await pool.query("SELECT 1 FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2 AND role='tecnico_protese'", [uid, os.laboratorio_id]); + isLab = rows.length > 0; + } let isClinic = false; if (uid) { const { rows } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2', [uid, os.clinica_id]); @@ -3951,7 +3966,7 @@ app.get('/api/protese/os', tenantGuard, async (req, res) => { app.get('/api/protese/bancada', authGuard, async (req, res) => { try { const uid = req.authUser.userId; - const labId = await labDoProtetico(uid); // Fase 2: a bancada é do LABORATÓRIO (empresa) + const labId = await labDoUsuario(uid); // Fase 2/4: bancada do LABORATÓRIO (dono ou técnico) const vals = [uid, labId]; // Roteia por laboratorio_id; fallback por pessoa cobre OS legadas sem laboratorio_id. let where = `( (laboratorio_id IS NOT NULL AND laboratorio_id=$2) OR (laboratorio_id IS NULL AND protetico_id=$1) ) AND deleted_at IS NULL`; @@ -4010,6 +4025,62 @@ app.get('/api/protese/lab/pagamentos', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); +// FASE 4 — Dados do laboratório (PF/PJ, CPF/CNPJ). GET: dono ou técnico; PATCH: só dono. +app.get('/api/protese/lab', authGuard, async (req, res) => { + try { + const uid = req.authUser.userId; + const labId = await labDoUsuario(uid); + if (!labId) return res.status(404).json({ error: 'Sem laboratório.' }); + const { rows } = await pool.query("SELECT id, nome_fantasia, documento, COALESCE(pessoa_tipo,'PF') AS pessoa_tipo, owner_id FROM clinicas WHERE id=$1", [labId]); + res.json({ ...rows[0], ehDono: rows[0].owner_id === uid }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); +app.patch('/api/protese/lab', authGuard, async (req, res) => { + try { + const uid = req.authUser.userId; + const labId = await labDoProtetico(uid); + if (!labId) return res.status(403).json({ error: 'Apenas o dono do laboratório edita os dados.' }); + const b = req.body || {}; + if (!b.nome_fantasia || !String(b.nome_fantasia).trim()) return res.status(400).json({ error: 'Nome é obrigatório.' }); + const pessoa = ['PF', 'PJ'].includes(b.pessoa_tipo) ? b.pessoa_tipo : 'PF'; + await pool.query('UPDATE clinicas SET nome_fantasia=$1, documento=$2, pessoa_tipo=$3 WHERE id=$4', + [String(b.nome_fantasia).toUpperCase().trim(), (b.documento || '').trim() || null, pessoa, labId]); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// FASE 4 — Equipe (técnicos) do laboratório. Apenas o dono gerencia. +app.get('/api/protese/lab/equipe', authGuard, async (req, res) => { + try { + const labId = await labDoProtetico(req.authUser.userId); + if (!labId) return res.json([]); + const { rows } = await pool.query( + "SELECT u.id, u.nome, u.email FROM vinculos v JOIN usuarios u ON u.id=v.usuario_id WHERE v.clinica_id=$1 AND v.role='tecnico_protese' ORDER BY u.nome", [labId]); + res.json(rows); + } catch (err) { res.status(500).json({ error: err.message }); } +}); +app.post('/api/protese/lab/equipe', authGuard, async (req, res) => { + try { + const labId = await labDoProtetico(req.authUser.userId); + if (!labId) return res.status(403).json({ error: 'Apenas o dono do laboratório adiciona técnicos.' }); + const email = (req.body?.email || '').toLowerCase().trim(); + if (!email) return res.status(400).json({ error: 'E-mail obrigatório.' }); + const u = await ensureUsuarioPorEmail(email, req.body?.nome, 'protetico'); + await pool.query( + "INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1,$2,$3,'tecnico_protese') ON CONFLICT (usuario_id, clinica_id) DO UPDATE SET role='tecnico_protese'", + [`v_${u.id}_${labId}`, u.id, labId]); + res.json({ success: true, tecnico: { id: u.id, nome: u.nome, email }, created: u.created, tempPassword: u.tempPassword }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); +app.delete('/api/protese/lab/equipe/:userId', authGuard, async (req, res) => { + try { + const labId = await labDoProtetico(req.authUser.userId); + if (!labId) return res.status(403).json({ error: 'Apenas o dono remove técnicos.' }); + await pool.query("DELETE FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2 AND role='tecnico_protese'", [req.params.userId, labId]); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + // Detalhe + histórico de eventos + fotos. app.get('/api/protese/os/:id', authGuard, async (req, res) => { try { @@ -5331,7 +5402,9 @@ 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','donosala','admin']))`, + `ALTER TABLE vinculos ADD CONSTRAINT vinculos_role_check CHECK (role = ANY(ARRAY['paciente','dentista','biomedico','protetico','tecnico_protese','funcionario','donoclinica','donoconsultorio','donosala','admin']))`, + // Fase 4 — Provedor de Prótese como empresa: PF (individual) | PJ (laboratório). documento = CPF/CNPJ. + `ALTER TABLE clinicas ADD COLUMN IF NOT EXISTS pessoa_tipo TEXT DEFAULT 'PF'`, // 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','donosala','admin','superadmin']))`, diff --git a/doc/PROVEDOR-PROTESE-DECISAO.md b/doc/PROVEDOR-PROTESE-DECISAO.md index 7e7578a..bde08d5 100644 --- a/doc/PROVEDOR-PROTESE-DECISAO.md +++ b/doc/PROVEDOR-PROTESE-DECISAO.md @@ -82,8 +82,16 @@ Score/ranking/marketplace. Não codar indicador antes de existir dado. - Histórico completo: a Bancada já lista entregues/cancelados com filtro de status (mantida). - Validado em DEV: agregação por clínica (faturado 600 / recebido 250 / a-receber 350) + extrato. -### FASE 4 — Empresa de verdade -`pessoa_tipo` PF/PJ, CNPJ (expor `documento`), Equipe/Técnicos (estende `proteseOsAccesso` por vínculo). +### ✅ FASE 4 — Empresa de verdade (IMPLEMENTADA 24/jun) +- **PF/PJ + documento**: `clinicas.pessoa_tipo` (PF=individual | PJ=laboratório); `documento` = CPF/CNPJ + (já existia, agora editável). Tela **Laboratório & Equipe** (`lab-equipe`): GET/PATCH `/protese/lab`. +- **Equipe/Técnicos**: novo `vinculos.role='tecnico_protese'`. O dono convida por e-mail + (`ensureUsuarioPorEmail` → credencial temporária); endpoints `GET/POST/DELETE /protese/lab/equipe`. +- **Acesso estendido**: `proteseOsAccesso` reconhece o técnico (vínculo no `laboratorio_id`); a bancada + usa `labDoUsuario` (dono **ou** técnico). Técnico opera a produção; **não** vê clientes/financeiro + (menu reduzido + endpoints retornam vazio/403). Gestão é exclusiva do dono. +- Validado em DEV: dados PJ+CNPJ; técnico convidado acessa a bancada (vê clínica de origem), não vê + clientes (0) e leva 403 ao tentar gerenciar equipe. ### FASE 5 — Ecossistema Marketplace transacional, Score, indicadores, ranking (sobre os dados das fases 2-4). diff --git a/frontend/App.tsx b/frontend/App.tsx index 406cc6d..e726364 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -33,6 +33,7 @@ import { GlosasView } from './views/GlosasView.tsx'; import { SalaHomeView } from './views/SalaHomeView.tsx'; import { LabHomeView } from './views/LabHomeView.tsx'; import { LabClientesView } from './views/LabClientesView.tsx'; +import { LabEquipeView } from './views/LabEquipeView.tsx'; import { ConfiguracoesView } from './views/ConfiguracoesView.tsx'; import { OrcamentosView } from './views/OrcamentosView.tsx'; import { ContratosView } from './views/ContratosView.tsx'; @@ -90,6 +91,7 @@ export type ViewKey = | 'lab-bancada' | 'lab-clientes' | 'lab-financeiro' + | 'lab-equipe' | 'marketplace-profissionais' | 'criar-clinica' | 'rx-config' @@ -144,6 +146,7 @@ const ROUTE_MAP: Record = { '/lab-bancada': 'lab-bancada', '/lab-clientes': 'lab-clientes', '/lab-financeiro': 'lab-financeiro', + '/lab-equipe': 'lab-equipe', '/marketplace-profissionais': 'marketplace-profissionais', '/criar-clinica': 'criar-clinica', '/rx-config': 'rx-config', @@ -203,6 +206,7 @@ const VIEW_TO_ROUTE: Record = { 'lab-bancada': '/lab-bancada', 'lab-clientes': '/lab-clientes', 'lab-financeiro': '/lab-financeiro', + 'lab-equipe': '/lab-equipe', 'marketplace-profissionais': '/marketplace-profissionais', 'criar-clinica': '/criar-clinica', 'rx-config': '/rx-config', @@ -302,7 +306,7 @@ const App: React.FC = () => { // 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); // Contexto de LABORATÓRIO: área do protético — painel do lab + bancada/conta/notificações. - if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'configuracoes', 'notificacoes', 'clinicas'].includes(view); + if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', '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. @@ -321,7 +325,7 @@ const App: React.FC = () => { paciente: ['tratamentos', 'notificacoes', 'configuracoes'], dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'proteses', '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', 'proteses', 'lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'], + protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', 'lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'], funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'proteses', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'], }; @@ -498,6 +502,7 @@ const App: React.FC = () => { case 'lab-bancada': return ; case 'lab-clientes': return ; case 'lab-financeiro': return ; + case 'lab-equipe': return ; case 'marketplace-profissionais': return ; case 'criar-clinica': return { window.location.reload(); }} />; case 'rx-config': return ; diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx index e49adb1..27ce809 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/Sidebar.tsx @@ -65,6 +65,7 @@ export const Sidebar: React.FC = ({ activeTab, setActiveTab }) => { id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical }, { id: 'lab-clientes', label: 'CLIENTES', icon: Building2 }, { id: 'lab-financeiro', label: 'FINANCEIRO', icon: DollarSign }, + { id: 'lab-equipe', label: 'EQUIPE', icon: UsersRound }, { id: 'dashboard', label: 'VISÃO GERAL', icon: LayoutDashboard }, { id: 'agenda', label: 'AGENDA', icon: CalendarIcon }, { id: 'pacientes', label: 'PACIENTES', icon: Users }, @@ -102,7 +103,7 @@ export const Sidebar: React.FC = ({ activeTab, setActiveTab }) => // PAINEL DA SALA só existe quando o workspace ativo é uma sala. if (item.id === 'sala-home') return (activeWorkspace as any)?.tipo === 'sala'; // PAINEL DO LAB / BANCADA só existem no workspace de laboratório. - if (['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro'].includes(item.id)) return (activeWorkspace as any)?.tipo === 'laboratorio'; + if (['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe'].includes(item.id)) return (activeWorkspace as any)?.tipo === 'laboratorio'; // Contexto de SALA (workspace tipo='sala'): isola da clínica — menu base só conta/notificações. // (MINHAS SALAS / ALUGAR SALAS são itens de plugin, mostrados à parte.) @@ -110,7 +111,13 @@ export const Sidebar: React.FC = ({ activeTab, setActiveTab }) => // Contexto de LABORATÓRIO (workspace tipo='laboratorio'): área própria do protético — // painel do lab + bancada de produção, sem as telas clínicas da clínica. - if ((activeWorkspace as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'notificacoes', 'configuracoes'].includes(item.id); + if ((activeWorkspace as any)?.tipo === 'laboratorio') { + // Técnico (vínculo tecnico_protese) só opera a produção; gestão é do dono. + const ehTecnico = (activeWorkspace as any)?.role === 'tecnico_protese'; + return ehTecnico + ? ['lab-home', 'lab-bancada', 'notificacoes', 'configuracoes'].includes(item.id) + : ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'notificacoes', 'configuracoes'].includes(item.id); + } // plugins e gestão de tutores: exclusivo superadmin if (item.id === 'plugins' || item.id === 'admin-gestao-tutores') return false; diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index 146b805..c4ccb5e 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -1095,6 +1095,30 @@ export const HybridBackend = { const res = await apiFetch(`${API_URL}/protese/lab/pagamentos`); return await res.json().catch(() => []); }, + // Fase 4: dados do laboratório (PF/PJ, CPF/CNPJ) + equipe de técnicos + getLabDados: async (): Promise => { + const res = await apiFetch(`${API_URL}/protese/lab`); + return await res.json().catch(() => ({})); + }, + updateLabDados: async (body: { nome_fantasia: string; documento?: string; pessoa_tipo?: 'PF' | 'PJ' }) => { + const res = await apiFetch(`${API_URL}/protese/lab`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao salvar'); + return await res.json(); + }, + getLabEquipe: async (): Promise => { + const res = await apiFetch(`${API_URL}/protese/lab/equipe`); + return await res.json().catch(() => []); + }, + addLabTecnico: async (body: { email: string; nome?: string }) => { + const res = await apiFetch(`${API_URL}/protese/lab/equipe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao adicionar técnico'); + return await res.json(); + }, + removeLabTecnico: async (userId: string) => { + const res = await apiFetch(`${API_URL}/protese/lab/equipe/${userId}`, { method: 'DELETE' }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover'); + return await res.json(); + }, // Agenda de uma sala (reservas + bloqueios de manutenção) — usado pelo operador da unidade. getSalaReservas: async (salaId: string): Promise => { diff --git a/frontend/views/LabEquipeView.tsx b/frontend/views/LabEquipeView.tsx new file mode 100644 index 0000000..1fbc6ee --- /dev/null +++ b/frontend/views/LabEquipeView.tsx @@ -0,0 +1,117 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { UsersRound, Building2, RefreshCw, Trash2, Plus, KeyRound, Save } from 'lucide-react'; +import { HybridBackend } from '../services/backend.ts'; +import { useToast } from '../contexts/ToastContext.tsx'; +import { useConfirm } from '../contexts/ConfirmContext.tsx'; +import { PageHeader } from '../components/PageHeader.tsx'; + +// Área do Laboratório (Fase 4): dados PF/PJ (CPF/CNPJ) + equipe de técnicos. +export const LabEquipeView: React.FC = () => { + const toast = useToast(); + const confirm = useConfirm(); + const [dados, setDados] = useState(null); + const [equipe, setEquipe] = useState([]); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [email, setEmail] = useState(''); + const [nome, setNome] = useState(''); + const [cred, setCred] = useState<{ email: string; senha: string } | null>(null); + + const carregar = useCallback(async () => { + setLoading(true); + try { + const [d, e] = await Promise.all([HybridBackend.getLabDados(), HybridBackend.getLabEquipe()]); + setDados(d || {}); + setEquipe(Array.isArray(e) ? e : []); + } catch { /* silent */ } finally { setLoading(false); } + }, []); + useEffect(() => { carregar(); }, [carregar]); + + const salvarDados = async () => { + if (!dados?.nome_fantasia?.trim()) { toast.error('INFORME O NOME.'); return; } + setBusy(true); + try { await HybridBackend.updateLabDados({ nome_fantasia: dados.nome_fantasia, documento: dados.documento || '', pessoa_tipo: dados.pessoa_tipo }); toast.success('DADOS SALVOS.'); carregar(); } + catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); } + }; + const addTecnico = async () => { + if (!email.trim()) { toast.error('INFORME O E-MAIL.'); return; } + setBusy(true); + try { + const r = await HybridBackend.addLabTecnico({ email: email.trim(), nome: nome.trim() || undefined }); + toast.success('TÉCNICO ADICIONADO.'); + if (r?.created && r?.tempPassword) setCred({ email: email.trim().toLowerCase(), senha: r.tempPassword }); + setEmail(''); setNome(''); carregar(); + } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); } + }; + const remover = async (u: any) => { + if (!(await confirm(`REMOVER ${u.nome || u.email} DA EQUIPE?`))) return; + try { await HybridBackend.removeLabTecnico(u.id); toast.success('REMOVIDO.'); carregar(); } + catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } + }; + + if (loading || !dados) return
; + const ehPJ = dados.pessoa_tipo === 'PJ'; + + return ( +
+ + + + + {/* Dados do provedor */} +
+

Dados do provedor

+
+ {(['PF', 'PJ'] as const).map(t => ( + + ))} +
+
+ + setDados((d: any) => ({ ...d, nome_fantasia: e.target.value }))} className="w-full p-3 bg-gray-50 border-2 border-gray-100 rounded-xl text-sm font-bold text-gray-700 outline-none focus:border-teal-400 uppercase" /> +
+
+ + setDados((d: any) => ({ ...d, documento: e.target.value }))} placeholder={ehPJ ? '00.000.000/0000-00' : '000.000.000-00'} className="w-full p-3 bg-gray-50 border-2 border-gray-100 rounded-xl text-sm font-bold text-gray-700 outline-none focus:border-teal-400" /> +
+ +
+ + {/* Equipe */} +
+

Equipe de técnicos

+

Técnicos veem a bancada e operam a produção — sem acesso a clientes, financeiro ou valores do paciente.

+ + {cred && ( +
+

Credencial criada

+

{cred.email} · senha temporária: {cred.senha}

+ +
+ )} + +
+ {equipe.map(u => ( +
+
{(u.nome || u.email || '?')[0].toUpperCase()}
+
+

{u.nome || '—'}

+

{u.email}

+
+ +
+ ))} + {equipe.length === 0 &&

Nenhum técnico na equipe.

} +
+ +
+ setNome(e.target.value)} placeholder="Nome (opcional)" className="w-32 p-2.5 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" /> + setEmail(e.target.value)} placeholder="E-mail do técnico" className="flex-1 p-2.5 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" /> + +
+
+
+ ); +};