feat(protese): Fase 4 — PF/PJ + CNPJ + Equipe (técnicos do laboratório)

Provedor de Prótese, Fase 4 (doc/PROVEDOR-PROTESE-DECISAO.md):

- PF/PJ: clinicas.pessoa_tipo (individual | laboratório) + documento (CPF/CNPJ) editáveis.
  Tela "Laboratório & Equipe" (lab-equipe). GET/PATCH /protese/lab.
- Equipe: novo vinculos.role='tecnico_protese'. Dono convida por e-mail (ensureUsuarioPorEmail
  → credencial temporária). GET/POST/DELETE /protese/lab/equipe (só dono).
- Acesso estendido: proteseOsAccesso reconhece o técnico (vínculo no laboratorio_id) e a bancada
  passa a usar labDoUsuario (dono OU técnico). Técnico opera a produção; menu reduzido e sem
  clientes/financeiro/equipe (endpoints retornam vazio/403). Gestão é exclusiva do dono.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-24 05:40:10 +02:00
parent c8d679eb70
commit ae48feaa8a
6 changed files with 243 additions and 9 deletions
+76 -3
View File
@@ -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']))`,
+10 -2
View File
@@ -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).
+7 -2
View File
@@ -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<string, ViewKey> = {
'/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<ViewKey, string> = {
'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 <ProteseView />;
case 'lab-clientes': return <LabClientesView tab="clientes" />;
case 'lab-financeiro': return <LabClientesView tab="financeiro" />;
case 'lab-equipe': return <LabEquipeView />;
case 'marketplace-profissionais': return <ProfissionaisPlugin />;
case 'criar-clinica': return <CreateClinicView onCreated={() => { window.location.reload(); }} />;
case 'rx-config': return <RxConfigView />;
+9 -2
View File
@@ -65,6 +65,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ 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<SidebarProps> = ({ 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<SidebarProps> = ({ 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;
+24
View File
@@ -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<any> => {
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<any[]> => {
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<any[]> => {
+117
View File
@@ -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<any>(null);
const [equipe, setEquipe] = useState<any[]>([]);
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 <div className="flex justify-center py-20"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>;
const ehPJ = dados.pessoa_tipo === 'PJ';
return (
<div className="space-y-6 pb-10 max-w-2xl">
<PageHeader title="LABORATÓRIO & EQUIPE">
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
</PageHeader>
{/* Dados do provedor */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 space-y-4">
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2"><Building2 size={16} className="text-teal-600" /> Dados do provedor</h3>
<div className="flex gap-2">
{(['PF', 'PJ'] as const).map(t => (
<button key={t} onClick={() => setDados((d: any) => ({ ...d, pessoa_tipo: t }))} className={`flex-1 py-2.5 rounded-xl font-black text-xs uppercase border-2 transition-colors ${dados.pessoa_tipo === t ? 'border-teal-500 bg-teal-50 text-teal-700' : 'border-gray-100 text-gray-400'}`}>
{t === 'PF' ? 'Protético Individual (PF)' : 'Laboratório (PJ)'}
</button>
))}
</div>
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">{ehPJ ? 'Nome Fantasia' : 'Nome'}</label>
<input value={dados.nome_fantasia || ''} onChange={e => 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" />
</div>
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">{ehPJ ? 'CNPJ' : 'CPF'}</label>
<input value={dados.documento || ''} onChange={e => 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" />
</div>
<button disabled={busy} onClick={salvarDados} className="bg-teal-600 text-white px-4 py-2.5 rounded-xl font-black text-sm uppercase disabled:opacity-50 flex items-center gap-2"><Save size={15} /> Salvar dados</button>
</div>
{/* Equipe */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 space-y-4">
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2"><UsersRound size={16} className="text-violet-600" /> Equipe de técnicos</h3>
<p className="text-[11px] text-gray-400 font-bold uppercase">Técnicos veem a bancada e operam a produção sem acesso a clientes, financeiro ou valores do paciente.</p>
{cred && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 text-xs">
<p className="font-black text-amber-700 uppercase flex items-center gap-1.5"><KeyRound size={13} /> Credencial criada</p>
<p className="text-amber-800 mt-1">{cred.email} · senha temporária: <b>{cred.senha}</b></p>
<button onClick={() => setCred(null)} className="text-[10px] font-black text-amber-600 uppercase mt-1">OK, anotei</button>
</div>
)}
<div className="space-y-1.5">
{equipe.map(u => (
<div key={u.id} className="flex items-center gap-2 bg-gray-50 rounded-lg px-3 py-2 text-xs">
<div className="w-7 h-7 rounded-lg bg-violet-100 flex items-center justify-center text-violet-600 font-black shrink-0">{(u.nome || u.email || '?')[0].toUpperCase()}</div>
<div className="min-w-0 flex-1">
<p className="font-bold text-gray-700 truncate uppercase">{u.nome || '—'}</p>
<p className="text-[10px] text-gray-400 truncate">{u.email}</p>
</div>
<button onClick={() => remover(u)} className="text-gray-300 hover:text-red-500"><Trash2 size={15} /></button>
</div>
))}
{equipe.length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum técnico na equipe.</p>}
</div>
<div className="flex gap-2">
<input value={nome} onChange={e => 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" />
<input value={email} onChange={e => 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" />
<button disabled={busy} onClick={addTecnico} className="px-3 rounded-lg bg-violet-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={14} /></button>
</div>
</div>
</div>
);
};