feat(protese): avançar etapas pela clínica + recibo térmico/laser + abas reordenadas

- Aba "Linha do tempo" movida para o FINAL (entregue/cancelar continuam nela).
- Etapas agora avançáveis também pela clínica (não só pelo lab): nova lista "Etapas do
  trabalho" na 1ª aba, com botão "Avançar" por etapa. Backend: avanço da produção passa
  a aceitar lab OU clínica de origem (auditado por actor). 'entregue' segue na timeline
  (exige foto).
- Impressão por etapa em DOIS formatos: térmica (bobina 80mm) e laser (A4), com os dados
  da OS preenchidos no recibo (paciente, trabalho, dentista, lab, dentes, material/cor,
  componentes, prazo, local atual + assinaturas).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-24 02:06:29 +02:00
parent 8cf4a83161
commit f4f433b14c
2 changed files with 51 additions and 37 deletions
+3 -1
View File
@@ -4122,7 +4122,9 @@ app.post('/api/protese/os/:id/status', authGuard, async (req, res) => {
// Entregue/cancelado são da clínica; estados de produção são do laboratório.
const estadosClinica = ['entregue', 'cancelado'];
if (estadosClinica.includes(novo) && !isClinic) return res.status(403).json({ error: 'Só a clínica de origem marca entregue/cancelado.' });
if (!estadosClinica.includes(novo) && !isLab) return res.status(403).json({ error: 'Só o laboratório avança a produção.' });
// Etapas de produção (recebido→…→finalizado): lab OU clínica de origem podem avançar
// (o coordenador da clínica acompanha o trabalho). Auditado por actor no histórico.
if (!estadosClinica.includes(novo) && !isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso para avançar a produção.' });
// Garantia com fotos: para ENTREGAR é obrigatório ter ao menos 1 foto do trabalho (evidência da garantia).
let garantiaAte = null, finId = os.financeiro_id || null, finLabId = os.financeiro_lab_id || null;
if (novo === 'entregue') {
+43 -31
View File
@@ -22,7 +22,6 @@ const STATUS_COR: Record<string, string> = {
prova: 'bg-indigo-100 text-indigo-700', ajuste: 'bg-orange-100 text-orange-700', finalizado: 'bg-teal-100 text-teal-700',
entregue: 'bg-green-100 text-green-700', cancelado: 'bg-red-100 text-red-600',
};
const LAB_FLUXO = ['recebido', 'producao', 'prova', 'ajuste', 'finalizado'];
const Badge: React.FC<{ s: string }> = ({ s }) => (
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${STATUS_COR[s] || 'bg-gray-100 text-gray-600'}`}>{STATUS_LABEL[s] || s}</span>
@@ -226,25 +225,31 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
const naGarantia = os.status === 'entregue' && os.garantia_ate && os.garantia_ate >= new Date().toISOString().slice(0, 10);
const noLab = (os.local_atual || 'clinica') === 'laboratorio';
// Cupom imprimível de uma etapa (comprovante que circula com o trabalho).
const imprimirCupom = (etapa: string) => {
// Recibo imprimível de uma etapa (comprovante que circula com o trabalho).
// formato 'termica' (bobina 80mm) | 'laser' (folha A4). Dados preenchidos da OS.
const imprimirCupom = (etapa: string, formato: 'termica' | 'laser') => {
const comps = (os.componentes || []).map((c: any) => `${c.quantidade}× ${c.nome}`).join(', ') || '—';
const w = window.open('', '_blank', 'width=420,height=640'); if (!w) return;
w.document.write(`<html><head><title>Cupom ${STATUS_LABEL[etapa] || etapa}</title><style>
*{font-family:Arial,Helvetica,sans-serif;color:#111}body{margin:0;padding:16px;font-size:12px}
h1{font-size:14px;margin:0 0 2px}.muted{color:#666;font-size:10px}
.et{background:#111;color:#fff;padding:6px 10px;border-radius:6px;font-weight:bold;text-align:center;text-transform:uppercase;margin:10px 0}
const termica = formato === 'termica';
const page = termica ? '@page{size:80mm auto;margin:3mm}body{width:72mm;font-size:11px}'
: '@page{size:A4;margin:16mm}body{max-width:520px;font-size:12px}';
const sign = termica
? '.sign{margin-top:24px}.sign div{border-top:1px solid #111;padding-top:4px;text-align:center;font-size:9px;text-transform:uppercase;color:#555;margin-top:18px}'
: '.sign{margin-top:34px;display:flex;justify-content:space-between;gap:16px}.sign div{flex:1;border-top:1px solid #111;padding-top:4px;text-align:center;font-size:9px;text-transform:uppercase;color:#555}';
const w = window.open('', '_blank', termica ? 'width=320,height=640' : 'width=560,height=720'); if (!w) return;
w.document.write(`<html><head><title>Recibo ${STATUS_LABEL[etapa] || etapa}</title><style>
*{font-family:Arial,Helvetica,sans-serif;color:#111;box-sizing:border-box}body{margin:0;padding:10px}
h1{font-size:${termica ? 13 : 16}px;margin:0 0 2px}.muted{color:#555;font-size:${termica ? 9 : 10}px}
.et{background:#111;color:#fff;padding:6px 8px;border-radius:5px;font-weight:bold;text-align:center;text-transform:uppercase;margin:10px 0;font-size:${termica ? 11 : 13}px}
table{width:100%;border-collapse:collapse;margin-top:6px}td{padding:3px 0;vertical-align:top}
.lbl{color:#666;text-transform:uppercase;font-size:9px;width:38%}
.sign{margin-top:30px;display:flex;justify-content:space-between;gap:14px}
.sign div{flex:1;border-top:1px solid #111;padding-top:4px;text-align:center;font-size:9px;text-transform:uppercase;color:#666}
.hr{border-top:1px dashed #999;margin:10px 0}</style></head><body>
<h1>ORDEM DE PRÓTESE</h1><div class="muted">OS ${os.id} · ${new Date().toLocaleString('pt-BR')}</div>
.lbl{color:#555;text-transform:uppercase;font-size:9px;width:40%}
.hr{border-top:1px dashed #999;margin:9px 0}${sign}${page}</style></head><body>
<h1>ORDEM DE PRÓTESE</h1><div class="muted">OS ${os.id}<br>${new Date().toLocaleString('pt-BR')}</div>
<div class="et">Etapa: ${STATUS_LABEL[etapa] || etapa}</div>
<table>
<tr><td class="lbl">Paciente</td><td><b>${os.paciente_nome || '—'}</b></td></tr>
<tr><td class="lbl">Trabalho</td><td>${os.tipo || '—'}</td></tr>
<tr><td class="lbl">Dentista</td><td>${os.dentista_nome || '—'}</td></tr>
<tr><td class="lbl">Laboratório</td><td>${os.protetico_nome || '—'}</td></tr>
<tr><td class="lbl">Dentes</td><td>${os.dentes || '—'}</td></tr>
<tr><td class="lbl">Material / Cor</td><td>${os.material || '—'} / ${os.cor || '—'}</td></tr>
<tr><td class="lbl">Componentes</td><td>${comps}</td></tr>
@@ -257,11 +262,13 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
w.document.close(); w.focus(); setTimeout(() => w.print(), 250);
};
const idxAtual = FLUXO_OS.indexOf(os.status);
const TABS = [
{ id: 'etapas', label: 'Etapas' },
{ id: 'timeline', label: 'Linha do tempo' },
{ id: 'itens', label: 'Itens & Valores' },
{ id: 'monitor', label: 'Monitoramento' },
{ id: 'timeline', label: 'Linha do tempo' },
] as const;
return (
@@ -305,13 +312,29 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
{modo === 'clinica' && Number(os.valor) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Valor cobrado</span>{fmtBRL(os.valor)}</div>}
</div>
{os.observacoes && <p className="text-xs text-gray-600 bg-gray-50 rounded-xl p-3">{os.observacoes}</p>}
{/* Etapas do trabalho: avançar + imprimir recibo (térmica/laser) por etapa */}
<div>
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Printer size={12} /> Imprimir cupom de etapa</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{FLUXO_OS.map(s => (
<button key={s} onClick={() => imprimirCupom(s)} className="flex items-center justify-center gap-1.5 px-2 py-2 rounded-xl border border-gray-200 text-gray-600 text-[11px] font-black uppercase hover:bg-gray-50"><Printer size={12} /> {STATUS_LABEL[s]}</button>
))}
<p className="text-[10px] font-black text-gray-400 uppercase mb-2">Etapas do trabalho</p>
<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);
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>}
<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>
</div>
</div>
)}
@@ -330,24 +353,13 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
</div>
))}
</div>
{!finalizado && (
{!finalizado && modo === 'clinica' && (
<div className="pt-3 border-t border-gray-100 space-y-2">
{modo === 'bancada' ? (
<div className="flex gap-2 flex-wrap">
{LAB_FLUXO.map(s => (
<button key={s} disabled={busy || os.status === s} onClick={() => mudar(s)}
className={`px-3 py-2 rounded-xl text-xs font-black uppercase disabled:opacity-40 ${os.status === s ? 'bg-violet-600 text-white' : 'border border-violet-200 text-violet-700 hover:bg-violet-50'}`}>{STATUS_LABEL[s]}</button>
))}
</div>
) : (
<>
{!os.fotos?.length && <p className="text-[10px] text-amber-600 font-bold uppercase flex items-center gap-1"><AlertTriangle size={11} /> Anexe ao menos 1 foto (aba Itens) antes de entregar</p>}
<div className="flex gap-2">
<button disabled={busy} onClick={() => mudar('entregue')} className="flex-1 py-2.5 rounded-xl bg-green-600 text-white font-black text-sm uppercase disabled:opacity-50">Marcar entregue</button>
<button disabled={busy} onClick={() => mudar('cancelado')} className="px-4 py-2.5 rounded-xl border border-red-200 text-red-600 font-black text-sm uppercase disabled:opacity-50">Cancelar</button>
</div>
</>
)}
</div>
)}
{naGarantia && modo === 'clinica' && (