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:
+118
-10
@@ -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">Só 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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user