Files
scoreodonto.com/frontend/views/LabEquipeView.tsx
T
VPS 4 Builder ae48feaa8a 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>
2026-06-24 05:40:10 +02:00

118 lines
7.5 KiB
TypeScript

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>
);
};