feat(protese): Fase 2 — rastreabilidade de componentes + ocorrências com contraditório

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

- Componentes: eixo 'direcao' (enviado clínica→lab | devolvido lab→clínica) + conferência
  (pendente/ok/divergente/ausente, com autor/data). Endpoint .../componentes/:id/conferir.
  UI: badges de direção e conferência + botões inline na aba Itens & Valores.
- Ocorrências (protese_os_ocorrencia): 10 categorias (parafuso substituído, peça danificada,
  retrabalho…), descrição, responsável, autor. Evidências reusam protese_os_foto (ocorrencia_id).
- Contraditório: aberta → respondida → em_analise → resolvida | contestada; 'resolvida' é final.
  Endpoints /ocorrencias, /ocorrencias/:id/resposta, /ocorrencias/:id/status. Nova aba
  "Ocorrências" (com contador) no modal da OS.
- Indicadores propositalmente NÃO implementados (só ocorrências resolvidas pesarão — Fase 5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-24 04:53:33 +02:00
parent 7ae6993c4b
commit 3ffaff9d7d
4 changed files with 271 additions and 24 deletions
+118 -10
View File
@@ -180,7 +180,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
const confirm = useConfirm();
const [os, setOs] = useState<any>(null);
const [busy, setBusy] = useState(false);
const [tab, setTab] = useState<'etapas' | 'timeline' | 'itens' | 'monitor'>('etapas');
const [tab, setTab] = useState<'etapas' | 'timeline' | 'itens' | 'monitor' | 'ocorrencias'>('etapas');
const carregar = useCallback(() => {
HybridBackend.getProteseOSDetalhe(id).then(setOs).catch(() => toast.error('ERRO AO CARREGAR.'));
@@ -268,6 +268,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
{ id: 'etapas', label: 'Etapas' },
{ id: 'itens', label: 'Itens & Valores' },
{ id: 'monitor', label: 'Monitoramento' },
{ id: 'ocorrencias', label: `Ocorrências${os.ocorrencias?.length ? ` (${os.ocorrencias.length})` : ''}` },
{ id: 'timeline', label: 'Linha do tempo' },
] as const;
@@ -383,6 +384,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
)}
{tab === 'monitor' && <MonitoramentoSecao os={os} podeEditar={!finalizado} busy={busy} onMover={moverCustodia} />}
{tab === 'ocorrencias' && <OcorrenciasSecao os={os} onChanged={carregar} />}
</div>
</div>
</div>
@@ -458,6 +460,96 @@ const AjustesSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; onChanged:
);
};
// ── Seção: Ocorrências (rastreabilidade técnica + contraditório) ────────────
const OCOR_CATS: Record<string, string> = {
nao_devolvido: 'Componente não devolvido', danificado: 'Componente danificado', incompativel: 'Componente incompatível',
parafuso_substituido: 'Parafuso substituído', parafuso_desgastado: 'Parafuso desgastado', peca_incorreta: 'Peça recebida incorreta',
ajuste_inadequado: 'Ajuste inadequado', falha_fabricacao: 'Falha de fabricação', retrabalho: 'Retrabalho solicitado', outro: 'Outro',
};
const OCOR_STATUS_COR: Record<string, string> = {
aberta: 'bg-red-100 text-red-600', respondida: 'bg-amber-100 text-amber-700', em_analise: 'bg-blue-100 text-blue-700',
resolvida: 'bg-green-100 text-green-700', contestada: 'bg-orange-100 text-orange-700',
};
const OcorrenciasSecao: React.FC<{ os: any; onChanged: () => void }> = ({ os, onChanged }) => {
const toast = useToast();
const [open, setOpen] = useState(false);
const [cat, setCat] = useState('danificado');
const [desc, setDesc] = useState('');
const [resp, setResp] = useState('');
const [busy, setBusy] = useState(false);
const [respostas, setRespostas] = useState<Record<string, string>>({});
const finalizadoOS = os.status === 'entregue' || os.status === 'cancelado';
const err = (e: any) => toast.error((e.message || 'ERRO').toUpperCase());
const abrir = async () => {
if (!desc.trim()) { toast.error('DESCREVA A OCORRÊNCIA.'); return; }
setBusy(true);
try { await HybridBackend.abrirProteseOcorrencia(os.id, { categoria: cat, descricao: desc.trim(), responsavel: resp.trim() || undefined }); toast.success('OCORRÊNCIA ABERTA.'); setDesc(''); setResp(''); setOpen(false); onChanged(); }
catch (e) { err(e); } finally { setBusy(false); }
};
const responder = async (id: string) => {
const t = (respostas[id] || '').trim(); if (!t) { toast.error('ESCREVA A RESPOSTA.'); return; }
setBusy(true);
try { await HybridBackend.responderProteseOcorrencia(id, t); setRespostas(s => ({ ...s, [id]: '' })); onChanged(); } catch (e) { err(e); } finally { setBusy(false); }
};
const mudar = async (id: string, st: string) => { setBusy(true); try { await HybridBackend.statusProteseOcorrencia(id, st); onChanged(); } catch (e) { err(e); } finally { setBusy(false); } };
const anexar = async (id: string, e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0]; if (!f) return; setBusy(true);
try { await HybridBackend.uploadProteseFoto(os.id, f, undefined, id); onChanged(); } catch (er) { err(er); } finally { setBusy(false); e.target.value = ''; }
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><AlertTriangle size={12} /> Ocorrências</p>
{!finalizadoOS && <button onClick={() => setOpen(o => !o)} className="px-3 py-1.5 rounded-lg bg-red-600 text-white text-[11px] font-black uppercase">+ Abrir</button>}
</div>
{open && (
<div className="bg-red-50 rounded-xl p-3 space-y-2">
<select value={cat} onChange={e => setCat(e.target.value)} className="w-full p-2 bg-white border border-red-100 rounded-lg text-xs font-bold outline-none focus:border-red-300">
{Object.entries(OCOR_CATS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<textarea value={desc} onChange={e => setDesc(e.target.value)} rows={3} placeholder="Descreva (ex.: enviado parafuso original Neodent CM; devolvido com sinais de uso incompatíveis)." className="w-full p-2 bg-white border border-red-100 rounded-lg text-xs outline-none focus:border-red-300 resize-none" />
<input value={resp} onChange={e => setResp(e.target.value)} placeholder="Responsável apontado (ex.: Dr. João / Laboratório)" className="w-full p-2 bg-white border border-red-100 rounded-lg text-xs outline-none focus:border-red-300" />
<button disabled={busy} onClick={abrir} className="w-full py-2 rounded-lg bg-red-600 text-white text-[11px] font-black uppercase disabled:opacity-50">Abrir ocorrência</button>
</div>
)}
<div className="space-y-3">
{(os.ocorrencias || []).map((o: any) => (
<div key={o.id} className="border border-gray-100 rounded-xl p-3 space-y-2">
<div className="flex items-center gap-2">
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${OCOR_STATUS_COR[o.status] || 'bg-gray-100 text-gray-500'}`}>{(o.status || '').replace('_', ' ')}</span>
<span className="text-xs font-black text-gray-700 flex-1">{OCOR_CATS[o.categoria] || o.categoria}</span>
<span className="text-[10px] text-gray-400">{dataBR(o.created_at)}</span>
</div>
<p className="text-xs text-gray-600">{o.descricao}</p>
<p className="text-[10px] text-gray-400">Aberta por {o.autor_nome}{o.responsavel ? ` · responsável: ${o.responsavel}` : ''}</p>
{o.resposta && <div className="bg-gray-50 rounded-lg p-2 text-xs"><span className="font-black text-gray-500 uppercase text-[9px]">Resposta ({o.resposta_nome}):</span> {o.resposta}</div>}
{o.fotos?.length > 0 && <div className="flex gap-1.5 flex-wrap">{o.fotos.map((f: any) => <a key={f.id} href={f.url} target="_blank" rel="noreferrer"><img src={f.url} alt="" className="w-12 h-12 object-cover rounded border border-gray-100" /></a>)}</div>}
{o.status !== 'resolvida' && (
<div className="space-y-1.5 pt-1 border-t border-gray-50">
<div className="flex gap-1.5">
<input value={respostas[o.id] || ''} onChange={e => setRespostas(s => ({ ...s, [o.id]: e.target.value }))} placeholder="Responder (contraditório)..." className="flex-1 p-1.5 bg-gray-50 border border-gray-100 rounded text-xs outline-none focus:border-teal-400" />
<button disabled={busy} onClick={() => responder(o.id)} className="px-2 rounded bg-gray-700 text-white text-[10px] font-black uppercase disabled:opacity-50">Responder</button>
<label className="px-2 py-1.5 rounded border border-gray-200 text-gray-500 cursor-pointer flex items-center"><ImagePlus size={12} /><input type="file" accept="image/*" className="hidden" onChange={e => anexar(o.id, e)} /></label>
</div>
<div className="flex gap-1.5">
<button disabled={busy} onClick={() => mudar(o.id, 'em_analise')} className="flex-1 py-1 rounded bg-blue-100 text-blue-700 text-[9px] font-black uppercase disabled:opacity-50">Em análise</button>
<button disabled={busy} onClick={() => mudar(o.id, 'contestada')} className="flex-1 py-1 rounded bg-orange-100 text-orange-700 text-[9px] font-black uppercase disabled:opacity-50">Contestar</button>
<button disabled={busy} onClick={() => mudar(o.id, 'resolvida')} className="flex-1 py-1 rounded bg-green-600 text-white text-[9px] font-black uppercase disabled:opacity-50">Resolver</button>
</div>
</div>
)}
{o.status === 'resolvida' && o.resolved_at && <p className="text-[9px] text-green-600 font-black uppercase">Resolvida em {dataBR(o.resolved_at)}</p>}
</div>
))}
{(os.ocorrencias || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhuma ocorrência registrada.</p>}
</div>
<p className="text-[9px] text-gray-300 uppercase font-bold"> ocorrências resolvidas pesarão em indicadores (futuro).</p>
</div>
);
};
// ── Seção: Monitoramento de custódia (onde o trabalho está) ─────────────────
const MonitoramentoSecao: React.FC<{ os: any; podeEditar: boolean; busy: boolean; onMover: (p: 'clinica' | 'laboratorio') => void }> = ({ os, podeEditar, busy, onMover }) => {
const noLab = (os.local_atual || 'clinica') === 'laboratorio';
@@ -521,36 +613,52 @@ const EtapasStepper: React.FC<{ os: any }> = ({ os }) => {
};
// ── Seção: Componentes enviados ao laboratório ──────────────────────────────
const CONF_COR: Record<string, string> = { pendente: 'bg-gray-100 text-gray-500', ok: 'bg-green-100 text-green-700', divergente: 'bg-amber-100 text-amber-700', ausente: 'bg-red-100 text-red-600' };
const ComponentesSecao: React.FC<{ os: any; podeEditar: boolean; onChanged: () => void }> = ({ os, podeEditar, onChanged }) => {
const toast = useToast();
const [nome, setNome] = useState('');
const [qtd, setQtd] = useState('1');
const [direcao, setDirecao] = useState<'enviado' | 'devolvido'>('enviado');
const [busy, setBusy] = useState(false);
const add = async () => {
if (!nome.trim()) { toast.error('INFORME O COMPONENTE.'); return; }
setBusy(true);
try { await HybridBackend.addProteseComponente(os.id, { nome: nome.trim(), quantidade: parseInt(qtd, 10) || 1 }); setNome(''); setQtd('1'); onChanged(); }
try { await HybridBackend.addProteseComponente(os.id, { nome: nome.trim(), quantidade: parseInt(qtd, 10) || 1, direcao }); setNome(''); setQtd('1'); onChanged(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const del = async (id: string) => { try { await HybridBackend.deleteProteseComponente(id); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
const conferir = async (id: string, st: string) => { try { await HybridBackend.conferirProteseComponente(id, st); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
return (
<div>
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Package size={12} /> Componentes enviados</p>
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Package size={12} /> Rastreabilidade de componentes</p>
<div className="space-y-1.5">
{(os.componentes || []).map((c: any) => (
<div key={c.id} className="flex items-center gap-2 text-xs bg-gray-50 rounded-lg px-3 py-2">
<span className="font-black text-gray-700">{c.quantidade}×</span>
<span className="font-bold text-gray-700 flex-1 truncate">{c.nome}</span>
<span className="text-[10px] text-gray-400">{c.enviado_nome} · {dataBR(c.created_at)}</span>
{podeEditar && <button onClick={() => del(c.id)} className="text-gray-300 hover:text-red-500"><Trash2 size={13} /></button>}
<div key={c.id} className="text-xs bg-gray-50 rounded-lg px-3 py-2">
<div className="flex items-center gap-2">
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${c.direcao === 'devolvido' ? 'bg-blue-100 text-blue-700' : 'bg-teal-100 text-teal-700'}`}>{c.direcao === 'devolvido' ? 'Devolvido' : 'Enviado'}</span>
<span className="font-black text-gray-700">{c.quantidade}×</span>
<span className="font-bold text-gray-700 flex-1 truncate">{c.nome}</span>
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${CONF_COR[c.status_conferencia] || CONF_COR.pendente}`}>{c.status_conferencia || 'pendente'}</span>
{podeEditar && <button onClick={() => del(c.id)} className="text-gray-300 hover:text-red-500"><Trash2 size={13} /></button>}
</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-[9px] text-gray-400 flex-1 truncate">{c.enviado_nome} · {dataBR(c.created_at)}{c.conferido_nome ? ` · conferido por ${c.conferido_nome}` : ''}</span>
{podeEditar && ['ok', 'divergente', 'ausente'].map(st => (
<button key={st} onClick={() => conferir(c.id, st)} className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase border ${c.status_conferencia === st ? CONF_COR[st] + ' border-transparent' : 'border-gray-200 text-gray-400 hover:bg-white'}`}>{st}</button>
))}
</div>
</div>
))}
{(os.componentes || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum componente registrado.</p>}
</div>
{podeEditar && (
<div className="flex gap-2 mt-2">
<input value={qtd} onChange={e => setQtd(e.target.value)} type="number" min="1" className="w-14 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold text-center outline-none focus:border-teal-400" />
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Ex.: Implante, pilar, modelo..." className="flex-1 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
<select value={direcao} onChange={e => setDirecao(e.target.value as any)} className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-[11px] font-bold outline-none focus:border-teal-400">
<option value="enviado">Enviado</option>
<option value="devolvido">Devolvido</option>
</select>
<input value={qtd} onChange={e => setQtd(e.target.value)} type="number" min="1" className="w-12 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold text-center outline-none focus:border-teal-400" />
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Implante, pilar, parafuso..." className="flex-1 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
<button disabled={busy} onClick={add} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={13} /></button>
</div>
)}