feat(protese): modal da OS em abas + cupom de etapa + monitoramento de custódia
Reorganiza o gerenciamento da OS em ABAS (sem o scrollão único): - Etapas: dados + stepper; substitui entregue/cancelar por "imprimir cupom" de cada etapa (comprovante imprimível que circula com o trabalho — janela de impressão). - Linha do tempo: histórico com data/hora/autor; AQUI ficam marcar entregue / cancelar (e o fluxo do lab / acionar garantia). - Itens & Valores: componentes, fotos e pagamentos. - Monitoramento: custódia do trabalho (clínica ↔ laboratório) — "protético retirou" / "devolveu à clínica", com local atual em destaque e histórico de movimentações. Backend: protese_os.local_atual + tabela protese_os_custodia + POST .../custodia (valida transição, registra movimento e evento). GET detalhe expõe local_atual + custodia. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3976,12 +3976,15 @@ app.get('/api/protese/os/:id', authGuard, async (req, res) => {
|
||||
const { rows: fotos } = await pool.query('SELECT id, original_name, uploaded_nome, legenda, created_at FROM protese_os_foto WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
|
||||
const { rows: componentes } = await pool.query('SELECT id, nome, quantidade, observacao, enviado_nome, created_at FROM protese_os_componente WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
|
||||
const { rows: pagamentos } = await pool.query('SELECT id, lado, valor, forma, observacao, data, recebido_nome, created_at FROM protese_os_pagamento WHERE os_id=$1 ORDER BY data, created_at', [req.params.id]);
|
||||
const { rows: custodia } = await pool.query('SELECT id, de_local, para_local, etapa, observacao, actor_nome, created_at FROM protese_os_custodia WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
|
||||
res.json({
|
||||
...os, valor: parseFloat(os.valor || 0), custo: parseFloat(os.custo || 0),
|
||||
local_atual: os.local_atual || 'clinica',
|
||||
eventos,
|
||||
fotos: fotos.map(f => ({ ...f, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })),
|
||||
componentes,
|
||||
pagamentos: pagamentos.map(p => ({ ...p, valor: parseFloat(p.valor || 0) })),
|
||||
custodia,
|
||||
});
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
@@ -4082,6 +4085,28 @@ app.delete('/api/protese/os/pagamentos/:id', authGuard, async (req, res) => {
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Custódia: registrar que o trabalho mudou de mãos (clínica ↔ laboratório).
|
||||
app.post('/api/protese/os/:id/custodia', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||||
const para = ['clinica', 'laboratorio'].includes(req.body?.para_local) ? req.body.para_local : null;
|
||||
if (!para) return res.status(400).json({ error: "para_local deve ser 'clinica' ou 'laboratorio'." });
|
||||
const de = acc.os.local_atual || 'clinica';
|
||||
if (de === para) return res.status(409).json({ error: `O trabalho já está ${para === 'laboratorio' ? 'com o laboratório' : 'na clínica'}.` });
|
||||
const id = `pcus_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const nome = await actorNome(uid);
|
||||
await pool.query(
|
||||
`INSERT INTO protese_os_custodia (id, os_id, clinica_id, de_local, para_local, etapa, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||
[id, acc.os.id, acc.os.clinica_id, de, para, acc.os.status, req.body?.observacao || null, uid, nome]);
|
||||
await pool.query('UPDATE protese_os SET local_atual=$1 WHERE id=$2', [para, acc.os.id]);
|
||||
const rotulo = para === 'laboratorio' ? 'Protético retirou o trabalho (foi p/ o laboratório)' : 'Trabalho devolvido à clínica';
|
||||
await logEventoOS(acc.os, uid, rotulo, 'custodia');
|
||||
res.json({ success: true, id, local_atual: para });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Mudar status (laboratório avança a produção; clínica entrega/cancela).
|
||||
app.post('/api/protese/os/:id/status', authGuard, async (req, res) => {
|
||||
const novo = req.body?.status;
|
||||
@@ -5874,6 +5899,22 @@ async function runMigrations() {
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_pag ON protese_os_pagamento (os_id, data)`,
|
||||
// Custódia: onde o trabalho físico está agora (clinica|laboratorio) + log de movimentações
|
||||
// (saber se o protético "pegou" o trabalho ou se está na clínica).
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS local_atual TEXT DEFAULT 'clinica'`,
|
||||
`CREATE TABLE IF NOT EXISTS protese_os_custodia (
|
||||
id TEXT PRIMARY KEY,
|
||||
os_id TEXT NOT NULL,
|
||||
clinica_id TEXT,
|
||||
de_local TEXT,
|
||||
para_local TEXT NOT NULL,
|
||||
etapa TEXT,
|
||||
observacao TEXT,
|
||||
actor_id TEXT,
|
||||
actor_nome TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_cust ON protese_os_custodia (os_id, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS protese_produtos (
|
||||
id TEXT PRIMARY KEY,
|
||||
protetico_id TEXT NOT NULL, -- dono (usuário protético)
|
||||
|
||||
@@ -813,6 +813,12 @@ export const HybridBackend = {
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover pagamento');
|
||||
return await res.json();
|
||||
},
|
||||
// Custódia: registra que o trabalho mudou de mãos (para_local: 'clinica' | 'laboratorio')
|
||||
moverProteseCustodia: async (osId: string, para_local: 'clinica' | 'laboratorio', observacao?: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/custodia`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ para_local, observacao }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao registrar movimentação');
|
||||
return await res.json();
|
||||
},
|
||||
deleteProteseOS: async (id: string) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
await apiFetch(`${API_URL}/protese/os/${id}?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'DELETE' });
|
||||
|
||||
+130
-38
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { FlaskConical, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check, Package, Wallet, Camera, CheckCircle2, Circle, History } from 'lucide-react';
|
||||
import { FlaskConical, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check, Package, Wallet, Camera, CheckCircle2, Circle, History, Printer, MapPin, Building2 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||||
@@ -181,6 +181,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 carregar = useCallback(() => {
|
||||
HybridBackend.getProteseOSDetalhe(id).then(setOs).catch(() => toast.error('ERRO AO CARREGAR.'));
|
||||
@@ -195,14 +196,6 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const anexar = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]; if (!file) return;
|
||||
setBusy(true);
|
||||
try { await HybridBackend.uploadProteseFoto(id, file); toast.success('FOTO ANEXADA.'); carregar(); }
|
||||
catch (err: any) { toast.error((err.message || 'ERRO').toUpperCase()); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const acionarGarantia = async () => {
|
||||
if (!(await confirm('ABRIR UMA OS DE GARANTIA (SEM CUSTO) PARA REFAZER ESTE TRABALHO?'))) return;
|
||||
setBusy(true);
|
||||
@@ -221,36 +214,89 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const moverCustodia = async (para: 'clinica' | 'laboratorio') => {
|
||||
setBusy(true);
|
||||
try { await HybridBackend.moverProteseCustodia(id, para); toast.success(para === 'laboratorio' ? 'TRABALHO COM O LABORATÓRIO.' : 'TRABALHO NA CLÍNICA.'); carregar(); onChanged(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
if (!os) return null;
|
||||
const finalizado = os.status === 'entregue' || os.status === 'cancelado';
|
||||
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) => {
|
||||
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}
|
||||
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>
|
||||
<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">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>
|
||||
<tr><td class="lbl">Prazo</td><td>${os.prazo_entrega ? dataBR(os.prazo_entrega) : '—'}</td></tr>
|
||||
<tr><td class="lbl">Local atual</td><td>${noLab ? 'Laboratório' : 'Clínica'}</td></tr>
|
||||
</table><div class="hr"></div>
|
||||
${os.observacoes ? `<div class="muted">Obs.: ${os.observacoes}</div>` : ''}
|
||||
<div class="sign"><div>Entregue por</div><div>Recebido por</div></div>
|
||||
</body></html>`);
|
||||
w.document.close(); w.focus(); setTimeout(() => w.print(), 250);
|
||||
};
|
||||
|
||||
const TABS = [
|
||||
{ id: 'etapas', label: 'Etapas' },
|
||||
{ id: 'timeline', label: 'Linha do tempo' },
|
||||
{ id: 'itens', label: 'Itens & Valores' },
|
||||
{ id: 'monitor', label: 'Monitoramento' },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-2xl max-h-[92vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-100 sticky top-0 bg-white z-10">
|
||||
<div>
|
||||
<div className="bg-white rounded-2xl w-full max-w-2xl h-[88vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><FlaskConical size={16} className="text-violet-600" /> {os.tipo}</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{os.paciente_nome || 'Sem paciente'} {os.dentes ? `· dentes ${os.dentes}` : ''}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5 truncate">{os.paciente_nome || 'Sem paciente'} {os.dentes ? `· dentes ${os.dentes}` : ''}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge s={os.status} />
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-5">
|
||||
</div>
|
||||
{/* Abas */}
|
||||
<div className="flex border-b border-gray-100 px-2 overflow-x-auto">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} onClick={() => setTab(t.id)}
|
||||
className={`px-3 py-2.5 text-xs font-black uppercase border-b-2 -mb-px whitespace-nowrap transition-colors ${tab === t.id ? 'border-violet-600 text-violet-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{tab === 'etapas' && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge s={os.status} />
|
||||
{os.tipo_os === 'garantia' && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 uppercase flex items-center gap-1"><ShieldCheck size={10} /> Garantia</span>}
|
||||
{os.atrasada && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-red-100 text-red-600 uppercase flex items-center gap-1"><AlertTriangle size={10} /> Atrasada</span>}
|
||||
{os.prazo_entrega && <span className="text-[10px] text-gray-400 flex items-center gap-1"><Clock size={11} /> Prazo {dataBR(os.prazo_entrega)}</span>}
|
||||
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${noLab ? 'bg-violet-100 text-violet-700' : 'bg-blue-100 text-blue-700'}`}>{noLab ? 'Com o laboratório' : 'Na clínica'}</span>
|
||||
</div>
|
||||
|
||||
{/* Stepper de etapas */}
|
||||
<EtapasStepper os={os} />
|
||||
|
||||
{os.garantia_ate && (
|
||||
<div className="bg-emerald-50 rounded-xl px-3 py-2 flex items-center gap-2 text-xs text-emerald-700 font-bold">
|
||||
<ShieldCheck size={14} /> Garantia até {dataBR(os.garantia_ate)} ({os.garantia_meses || 12} meses)
|
||||
</div>
|
||||
)}
|
||||
{os.garantia_ate && (<div className="bg-emerald-50 rounded-xl px-3 py-2 flex items-center gap-2 text-xs text-emerald-700 font-bold"><ShieldCheck size={14} /> Garantia até {dataBR(os.garantia_ate)} ({os.garantia_meses || 12} meses)</div>)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-1.5 text-xs">
|
||||
{os.material && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Material</span>{os.material}</div>}
|
||||
{os.cor && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Cor</span>{os.cor}</div>}
|
||||
@@ -259,18 +305,24 @@ 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>}
|
||||
|
||||
<ComponentesSecao os={os} podeEditar={!finalizado} onChanged={carregar} />
|
||||
<FotosSecao os={os} podeEditar={!finalizado} onChanged={carregar} />
|
||||
<PagamentosSecao os={os} modo={modo} podeEditar={!finalizado} onChanged={carregar} />
|
||||
|
||||
{/* Histórico (auditoria) */}
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><History size={12} /> Histórico</p>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'timeline' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><History size={12} /> Linha do tempo</p>
|
||||
<div className="space-y-2.5">
|
||||
{os.eventos?.map((ev: any, i: number) => (
|
||||
<div key={i} className="flex items-start gap-2.5 text-xs">
|
||||
<span className={`w-2 h-2 rounded-full mt-1 shrink-0 ${ev.status === 'cancelado' ? 'bg-red-400' : ev.status === 'entregue' ? 'bg-green-500' : 'bg-violet-400'}`} />
|
||||
<span className={`w-2 h-2 rounded-full mt-1 shrink-0 ${ev.status === 'cancelado' ? 'bg-red-400' : ev.status === 'entregue' ? 'bg-green-500' : ev.status === 'custodia' ? 'bg-blue-400' : 'bg-violet-400'}`} />
|
||||
<div className="min-w-0">
|
||||
<span className="font-bold text-gray-700">{ev.observacao || STATUS_LABEL[ev.status] || ev.status}</span>
|
||||
<span className="text-gray-400 block text-[11px]">{dataHoraBR(ev.created_at)}{ev.actor_nome ? ` · ${ev.actor_nome}` : ''}</span>
|
||||
@@ -278,12 +330,8 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ações por papel */}
|
||||
{!finalizado && (
|
||||
<div className="p-5 border-t border-gray-100 sticky bottom-0 bg-white space-y-2">
|
||||
<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 => (
|
||||
@@ -293,7 +341,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
||||
</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 antes de entregar (garantia)</p>}
|
||||
{!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>
|
||||
@@ -303,10 +351,54 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
||||
</div>
|
||||
)}
|
||||
{naGarantia && modo === 'clinica' && (
|
||||
<div className="p-5 border-t border-gray-100 sticky bottom-0 bg-white">
|
||||
<button disabled={busy} onClick={acionarGarantia} className="w-full py-2.5 rounded-xl bg-emerald-600 text-white font-black text-sm uppercase disabled:opacity-50 flex items-center justify-center gap-2"><ShieldCheck size={16} /> Acionar garantia (refazer sem custo)</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'itens' && (
|
||||
<div className="space-y-5">
|
||||
<ComponentesSecao os={os} podeEditar={!finalizado} onChanged={carregar} />
|
||||
<FotosSecao os={os} podeEditar={!finalizado} onChanged={carregar} />
|
||||
<PagamentosSecao os={os} modo={modo} podeEditar={!finalizado} onChanged={carregar} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'monitor' && <MonitoramentoSecao os={os} podeEditar={!finalizado} busy={busy} onMover={moverCustodia} />}
|
||||
</div>
|
||||
</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';
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><MapPin size={12} /> Onde está o trabalho</p>
|
||||
<div className={`rounded-2xl p-5 text-center ${noLab ? 'bg-violet-50' : 'bg-blue-50'}`}>
|
||||
<div className={`w-14 h-14 rounded-2xl mx-auto flex items-center justify-center text-white ${noLab ? 'bg-violet-600' : 'bg-blue-600'}`}>{noLab ? <FlaskConical size={26} /> : <Building2 size={26} />}</div>
|
||||
<p className={`mt-3 font-black uppercase text-sm ${noLab ? 'text-violet-700' : 'text-blue-700'}`}>{noLab ? 'Com o laboratório' : 'Na clínica'}</p>
|
||||
<p className="text-[11px] text-gray-400 font-bold uppercase mt-0.5">{noLab ? 'O protético está com o trabalho' : 'O trabalho está na clínica'}</p>
|
||||
</div>
|
||||
{podeEditar && (
|
||||
<div className="flex gap-2">
|
||||
<button disabled={busy || noLab} onClick={() => onMover('laboratorio')} className="flex-1 py-2.5 rounded-xl bg-violet-600 text-white font-black text-xs uppercase disabled:opacity-40 flex items-center justify-center gap-2"><FlaskConical size={14} /> Protético retirou</button>
|
||||
<button disabled={busy || !noLab} onClick={() => onMover('clinica')} className="flex-1 py-2.5 rounded-xl bg-blue-600 text-white font-black text-xs uppercase disabled:opacity-40 flex items-center justify-center gap-2"><Building2 size={14} /> Devolveu à clínica</button>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><History size={12} /> Movimentações</p>
|
||||
<div className="space-y-2">
|
||||
{(os.custodia || []).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="text-[9px] font-black px-1.5 py-0.5 rounded uppercase bg-gray-200 text-gray-600">{c.de_local === 'clinica' ? 'Clínica' : 'Lab'} → {c.para_local === 'clinica' ? 'Clínica' : 'Lab'}</span>
|
||||
<span className="text-[10px] text-gray-400 flex-1 truncate">{c.actor_nome} · {dataHoraBR(c.created_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
{(os.custodia || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Sem movimentações registradas.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user