feat(protese): etapas livres, ajustes do trabalho e privacidade de valores do paciente

- Etapas repetíveis/puláveis: cada etapa tem ação Ir/Voltar/Repetir (não só "avançar
  para a próxima"). 'entregue' segue na Linha do tempo.
- Ajustes do trabalho (aba Etapas): editar cor/material (PATCH .../os/:id); trocar o
  trabalho por outro + MOTIVO obrigatório (POST .../trocar); acréscimo/redução do valor
  cobrado do paciente com observação (POST .../ajuste-valor) — tudo auditado no histórico.
- Privacidade: o protético (lab que não é da clínica) NÃO vê o que o paciente paga/deve —
  GET detalhe omite pagamentos do lado clínica e zera o valor cobrado; a bancada também
  zera o valor. O ajuste de valor do paciente é exclusivo da clínica (403 para o lab). No
  front, o modo bancada esconde "Recebido do paciente".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-24 02:32:21 +02:00
parent f4f433b14c
commit 9f103be397
3 changed files with 173 additions and 13 deletions
+86 -10
View File
@@ -318,24 +318,26 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
<div className="space-y-1.5">
{FLUXO_OS.map((s, i) => {
const done = i < idxAtual, atual = i === idxAtual;
const podeAvancar = !finalizado && i === idxAtual + 1 && s !== 'entregue';
const ev = (os.eventos || []).find((e: any) => e.status === s);
// Etapas livres: pode ir para qualquer uma (pular/repetir/voltar), exceto 'entregue' (timeline).
const acaoLabel = atual ? 'Repetir' : done ? 'Voltar' : 'Ir';
return (
<div key={s} className="flex items-center gap-2 bg-gray-50 rounded-xl px-3 py-2">
{done ? <CheckCircle2 size={16} className="text-teal-600 shrink-0" /> : atual ? <div className="w-4 h-4 rounded-full bg-violet-600 shrink-0" /> : <Circle size={16} className="text-gray-300 shrink-0" />}
<span className={`text-xs font-black uppercase ${atual ? 'text-violet-700' : done ? 'text-gray-700' : 'text-gray-400'}`}>{STATUS_LABEL[s]}</span>
<span className="text-[10px] text-gray-400">{ev ? dataBR(ev.created_at) : ''}</span>
<div className="flex-1" />
{podeAvancar && <button disabled={busy} onClick={() => mudar(s)} className="px-2.5 py-1 rounded-lg bg-violet-600 text-white text-[10px] font-black uppercase disabled:opacity-50 flex items-center gap-1"><ChevronRight size={11} /> Avançar</button>}
{!finalizado && i === idxAtual + 1 && s === 'entregue' && <span className="text-[9px] text-amber-500 font-black uppercase">aba Linha do tempo</span>}
{!finalizado && s !== 'entregue' && <button disabled={busy} onClick={() => mudar(s)} className={`px-2.5 py-1 rounded-lg text-[10px] font-black uppercase disabled:opacity-50 ${atual ? 'border border-violet-200 text-violet-700 hover:bg-violet-50' : 'bg-violet-600 text-white'}`}>{acaoLabel}</button>}
{!finalizado && s === 'entregue' && <span className="text-[9px] text-amber-500 font-black uppercase">aba Linha do tempo</span>}
<button onClick={() => imprimirCupom(s, 'termica')} title="Imprimir em impressora térmica (bobina 80mm)" className="p-1.5 rounded-lg border border-gray-200 text-gray-500 hover:bg-white"><Printer size={13} /></button>
<button onClick={() => imprimirCupom(s, 'laser')} title="Imprimir em impressora laser (folha A4)" className="px-2 py-1.5 rounded-lg border border-gray-200 text-gray-500 hover:bg-white text-[9px] font-black uppercase">A4</button>
</div>
);
})}
</div>
<p className="text-[9px] text-gray-300 mt-1.5 uppercase font-bold flex items-center gap-1"><Printer size={10} /> = térmica 80mm · A4 = laser</p>
<p className="text-[9px] text-gray-300 mt-1.5 uppercase font-bold flex items-center gap-1"><Printer size={10} /> = térmica 80mm · A4 = laser · etapas podem ser repetidas/puladas</p>
</div>
{!finalizado && <AjustesSecao os={os} modo={modo} onChanged={carregar} />}
</div>
)}
@@ -383,6 +385,75 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
);
};
// ── Seção: Ajustes do trabalho (cor/material, valor ao paciente, troca) ─────
const AjustesSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; onChanged: () => void }> = ({ os, modo, onChanged }) => {
const toast = useToast();
const [cor, setCor] = useState(os.cor || '');
const [material, setMaterial] = useState(os.material || '');
const [delta, setDelta] = useState('');
const [obsVal, setObsVal] = useState('');
const [trocaOpen, setTrocaOpen] = useState(false);
const [novoTrab, setNovoTrab] = useState('');
const [motivo, setMotivo] = useState('');
const [busy, setBusy] = useState(false);
const salvarCM = async () => {
setBusy(true);
try { await HybridBackend.editarProteseOS(os.id, { cor: cor.trim(), material: material.trim() }); toast.success('TRABALHO ATUALIZADO.'); onChanged(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const ajustar = async (sinal: number) => {
const v = parseFloat(delta);
if (!(v > 0)) { toast.error('INFORME O VALOR DO AJUSTE.'); return; }
setBusy(true);
try { await HybridBackend.ajustarValorProtese(os.id, sinal * v, obsVal.trim() || undefined); toast.success('VALOR DO PACIENTE AJUSTADO.'); setDelta(''); setObsVal(''); onChanged(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const trocar = async () => {
if (!novoTrab.trim() || !motivo.trim()) { toast.error('INFORME O NOVO TRABALHO E O MOTIVO.'); return; }
setBusy(true);
try { await HybridBackend.trocarTrabalhoProtese(os.id, { tipo: novoTrab.trim(), motivo: motivo.trim() }); toast.success('TRABALHO TROCADO.'); setTrocaOpen(false); setNovoTrab(''); setMotivo(''); onChanged(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
return (
<div className="border-t border-gray-100 pt-4 space-y-3">
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><Pencil size={12} /> Ajustes do trabalho</p>
{/* Cor / material */}
<div className="grid grid-cols-2 gap-2">
<input value={cor} onChange={e => setCor(e.target.value)} placeholder="Cor (ex.: A2)" className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
<input value={material} onChange={e => setMaterial(e.target.value)} placeholder="Material" className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
</div>
<button disabled={busy} onClick={salvarCM} className="px-3 py-1.5 rounded-lg bg-gray-700 text-white text-[11px] font-black uppercase disabled:opacity-50">Salvar cor / material</button>
{/* Ajuste de valor ao paciente — só clínica (protético não vê) */}
{modo === 'clinica' && (
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
<p className="text-[10px] font-black text-gray-500 uppercase">Ajustar valor do paciente {Number(os.valor) > 0 ? `· atual ${fmtBRL(os.valor)}` : ''}</p>
<div className="flex gap-2">
<input value={delta} onChange={e => setDelta(e.target.value)} type="number" step="0.01" placeholder="0,00" className="w-24 p-2 bg-white border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
<input value={obsVal} onChange={e => setObsVal(e.target.value)} placeholder="Motivo (ex.: pino extra)" className="flex-1 p-2 bg-white border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
</div>
<div className="flex gap-2">
<button disabled={busy} onClick={() => ajustar(1)} className="flex-1 py-1.5 rounded-lg bg-green-600 text-white text-[11px] font-black uppercase disabled:opacity-50">+ Acrescentar</button>
<button disabled={busy} onClick={() => ajustar(-1)} className="flex-1 py-1.5 rounded-lg bg-amber-500 text-white text-[11px] font-black uppercase disabled:opacity-50"> Reduzir</button>
</div>
</div>
)}
{/* Trocar trabalho */}
<button onClick={() => setTrocaOpen(o => !o)} className="px-3 py-1.5 rounded-lg border border-violet-200 text-violet-700 text-[11px] font-black uppercase hover:bg-violet-50 flex items-center gap-1.5"><RefreshCw size={12} /> Solicitar troca de trabalho</button>
{trocaOpen && (
<div className="bg-violet-50 rounded-xl p-3 space-y-2">
<input value={novoTrab} onChange={e => setNovoTrab(e.target.value)} placeholder="Novo trabalho (ex.: PPR metálica)" className="w-full p-2 bg-white border border-violet-100 rounded-lg text-xs outline-none focus:border-violet-400" />
<input value={motivo} onChange={e => setMotivo(e.target.value)} placeholder="Motivo da troca (obrigatório)" className="w-full p-2 bg-white border border-violet-100 rounded-lg text-xs outline-none focus:border-violet-400" />
<button disabled={busy} onClick={trocar} className="w-full py-2 rounded-lg bg-violet-600 text-white text-[11px] font-black uppercase disabled:opacity-50">Confirmar troca</button>
</div>
)}
</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';
@@ -545,8 +616,9 @@ const PagamentosSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; podeEdit
return (
<div>
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Wallet size={12} /> Valores recebidos</p>
<div className="grid grid-cols-2 gap-2 mb-2">
<div className="bg-green-50 rounded-xl px-3 py-2"><p className="text-[9px] font-black text-green-600 uppercase">Recebido do paciente</p><p className="text-sm font-black text-green-700">{fmtBRL(totalClinica)}</p></div>
{/* O protético (modo bancada) NÃO vê o que o paciente paga — só o que recebe do lab. */}
<div className={`grid ${modo === 'bancada' ? 'grid-cols-1' : 'grid-cols-2'} gap-2 mb-2`}>
{modo === 'clinica' && <div className="bg-green-50 rounded-xl px-3 py-2"><p className="text-[9px] font-black text-green-600 uppercase">Recebido do paciente</p><p className="text-sm font-black text-green-700">{fmtBRL(totalClinica)}</p></div>}
<div className="bg-violet-50 rounded-xl px-3 py-2"><p className="text-[9px] font-black text-violet-600 uppercase">Pago ao laboratório</p><p className="text-sm font-black text-violet-700">{fmtBRL(totalLab)}</p></div>
</div>
<div className="space-y-1.5">
@@ -562,10 +634,14 @@ const PagamentosSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; podeEdit
</div>
{podeEditar && (
<div className="flex gap-2 mt-2">
<select value={lado} onChange={e => setLado(e.target.value as any)} className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400">
<option value="clinica">Paciente</option>
<option value="lab">Lab</option>
</select>
{modo === 'clinica' ? (
<select value={lado} onChange={e => setLado(e.target.value as any)} className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400">
<option value="clinica">Paciente</option>
<option value="lab">Lab</option>
</select>
) : (
<span className="p-2 bg-violet-50 rounded-lg text-xs font-black text-violet-700 uppercase flex items-center">Lab</span>
)}
<input value={valor} onChange={e => setValor(e.target.value)} type="number" step="0.01" placeholder="0,00" className="w-20 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
<input value={forma} onChange={e => setForma(e.target.value)} placeholder="Forma (pix...)" 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>