feat(protese): marketplace transacional fatia 3 — cotação (RFQ) multi-lab
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 58s

Entidades protese_rfq + protese_rfq_cotacao. A clínica descreve o trabalho e convida N labs;
cada lab cota (valor/prazo/obs); a clínica compara (ordenado por preço) e escolhe → vira OS
(custo = valor da cotação) e a RFQ fecha (demais recusadas).

- Backend: POST/GET /protese/rfq, GET /protese/rfq/recebidas, POST /protese/rfq/cotacao/:id,
  POST /protese/rfq/:id/escolher (cria a OS).
- Lado lab: tela Cotações (lab-cotacoes) — RFQs recebidas + responder.
- Lado clínica: botão Cotações na tela de Prótese — pedir multi-lab + comparar + escolher.

Validado em DEV ponta a ponta: pedido → cotação R$420 → escolha → OS criada → RFQ fechada.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-24 14:43:56 +02:00
parent 07d798c370
commit cd89ca245c
7 changed files with 352 additions and 11 deletions
+103 -3
View File
@@ -885,6 +885,103 @@ const ProdutosModal: React.FC<{ onClose: () => void }> = ({ onClose }) => {
};
// ── View principal ──────────────────────────────────────────────────────────
// ── Modal: Cotações (lado clínica) — pedir a N labs, comparar e escolher ────
const CotacoesClinicaModal: React.FC<{ onClose: () => void; onEscolhida: () => void }> = ({ onClose, onEscolhida }) => {
const toast = useToast();
const confirm = useConfirm();
const [aba, setAba] = useState<'novo' | 'lista'>('lista');
const [rfqs, setRfqs] = useState<any[]>([]);
const [labs, setLabs] = useState<any[]>([]);
const [descricao, setDescricao] = useState('');
const [dentes, setDentes] = useState('');
const [prazo, setPrazo] = useState('');
const [sel, setSel] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const carregar = useCallback(() => HybridBackend.getRfqs().then(r => setRfqs(Array.isArray(r) ? r : [])).catch(() => setRfqs([])), []);
useEffect(() => { carregar(); HybridBackend.getProteseLaboratorios().then(r => setLabs(Array.isArray(r) ? r : [])).catch(() => setLabs([])); }, [carregar]);
const toggleLab = (id: string) => setSel(s => s.includes(id) ? s.filter(x => x !== id) : [...s, id]);
const criar = async () => {
if (!descricao.trim()) { toast.error('DESCREVA O TRABALHO.'); return; }
if (!sel.length) { toast.error('SELECIONE AO MENOS UM LABORATÓRIO.'); return; }
setBusy(true);
try { await HybridBackend.criarRfq({ descricao: descricao.trim(), dentes: dentes.trim() || undefined, prazo_desejado: prazo || undefined, proteticos: sel }); toast.success('COTAÇÃO ENVIADA AOS LABORATÓRIOS.'); setDescricao(''); setDentes(''); setPrazo(''); setSel([]); setAba('lista'); carregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const escolher = async (rfq: any, cot: any) => {
if (!(await confirm(`ESCOLHER ${cot.protetico_nome} POR ${fmtBRL(cot.valor)}? UMA OS SERÁ CRIADA.`))) return;
setBusy(true);
try { await HybridBackend.escolherCotacao(rfq.id, cot.id); toast.success('OS CRIADA A PARTIR DA COTAÇÃO.'); carregar(); onEscolhida(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
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-lg h-[88vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><FileText size={16} className="text-violet-600" /> Cotações de prótese</h3>
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
</div>
<div className="flex border-b border-gray-100 px-2">
{(['lista', 'novo'] as const).map(t => (
<button key={t} onClick={() => setAba(t)} className={`px-3 py-2.5 text-xs font-black uppercase border-b-2 -mb-px ${aba === t ? 'border-violet-600 text-violet-700' : 'border-transparent text-gray-400'}`}>{t === 'lista' ? 'Meus pedidos' : 'Pedir cotação'}</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-5">
{aba === 'novo' ? (
<div className="space-y-3">
<textarea value={descricao} onChange={e => setDescricao(e.target.value)} rows={3} placeholder="Descreva o trabalho (ex.: Coroa sobre implante, dente 36, cor A2)" className="w-full p-3 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400 resize-none" />
<div className="grid grid-cols-2 gap-2">
<input value={dentes} onChange={e => setDentes(e.target.value)} placeholder="Dentes (ex.: 36)" className="p-2.5 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400" />
<input type="date" value={prazo} onChange={e => setPrazo(e.target.value)} className="p-2.5 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400" />
</div>
<div>
<p className="text-[10px] font-black text-gray-400 uppercase mb-2">Laboratórios a convidar</p>
<div className="space-y-1.5 max-h-56 overflow-y-auto">
{labs.map(l => (
<button key={l.id} onClick={() => toggleLab(l.id)} className={`w-full flex items-center gap-2 px-3 py-2 rounded-xl border text-left ${sel.includes(l.id) ? 'border-violet-400 bg-violet-50' : 'border-gray-100'}`}>
<span className={`w-4 h-4 rounded border flex items-center justify-center ${sel.includes(l.id) ? 'bg-violet-600 border-violet-600' : 'border-gray-300'}`}>{sel.includes(l.id) && <Check size={11} className="text-white" />}</span>
<span className="text-sm font-bold text-gray-700 flex-1 truncate">{l.nome}</span>
{l.interno && <span className="text-[8px] font-black text-teal-600 uppercase">interno</span>}
{l.nota != null && <span className="text-[9px] font-black text-amber-600"> {l.nota}</span>}
</button>
))}
</div>
</div>
<button disabled={busy} onClick={criar} className="w-full py-3 rounded-xl bg-[#2d6a4f] text-white font-black text-sm uppercase disabled:opacity-50">Enviar pedido a {sel.length} laboratório(s)</button>
</div>
) : (
rfqs.length === 0 ? <p className="text-center text-gray-300 font-bold uppercase text-sm py-12">Nenhum pedido de cotação ainda.</p> : (
<div className="space-y-3">
{rfqs.map(r => (
<div key={r.id} className="border border-gray-100 rounded-2xl p-3">
<div className="flex items-center justify-between gap-2">
<p className="font-black text-gray-800 text-sm">{r.descricao}</p>
<span className={`text-[8px] font-black px-2 py-0.5 rounded-full uppercase ${r.status === 'fechada' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'}`}>{r.status === 'fechada' ? 'fechada' : 'aberta'}</span>
</div>
<div className="mt-2 space-y-1.5">
{(r.cotacoes || []).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-bold text-gray-700 flex-1 truncate">{c.protetico_nome}</span>
{c.status === 'convidada' ? <span className="text-[10px] text-gray-400 uppercase">aguardando</span>
: <><span className="font-black text-gray-800">{fmtBRL(c.valor)}</span>{c.prazo_dias ? <span className="text-[10px] text-gray-400">{c.prazo_dias}d</span> : null}</>}
{c.status === 'escolhida' && <span className="text-[8px] font-black text-green-700 uppercase">escolhida</span>}
{r.status === 'aberta' && c.status === 'enviada' && <button disabled={busy} onClick={() => escolher(r, c)} className="px-2 py-1 rounded-lg bg-[#2d6a4f] text-white text-[9px] font-black uppercase disabled:opacity-50">Escolher</button>}
</div>
))}
</div>
</div>
))}
</div>
)
)}
</div>
</div>
</div>
);
};
export const ProteseView: React.FC = () => {
const role = HybridBackend.getCurrentRole();
const modo: 'bancada' | 'clinica' = role === 'protetico' ? 'bancada' : 'clinica';
@@ -896,6 +993,7 @@ export const ProteseView: React.FC = () => {
const [novaOS, setNovaOS] = useState(false);
const [detalhe, setDetalhe] = useState<string | null>(null);
const [produtosModal, setProdutosModal] = useState(false);
const [cotacoesOpen, setCotacoesOpen] = useState(false);
const carregar = useCallback(() => {
setLoading(true);
@@ -923,9 +1021,10 @@ export const ProteseView: React.FC = () => {
description={modo === 'bancada' ? 'Fila de trabalhos do laboratório' : 'Ordens de serviço enviadas ao laboratório'}
>
{modo === 'clinica' ? (
<button onClick={() => setNovaOS(true)} className="flex items-center gap-2 bg-violet-600 text-white px-4 py-2 rounded-xl font-black text-sm uppercase">
<Plus size={16} /> Nova OS
</button>
<>
<button onClick={() => setCotacoesOpen(true)} className="flex items-center gap-2 border border-violet-200 text-violet-700 px-4 py-2 rounded-xl font-black text-sm uppercase hover:bg-violet-50"><FileText size={16} /> Cotações</button>
<button onClick={() => setNovaOS(true)} className="flex items-center gap-2 bg-violet-600 text-white px-4 py-2 rounded-xl font-black text-sm uppercase"><Plus size={16} /> Nova OS</button>
</>
) : (
<>
<button onClick={() => setProdutosModal(true)} className="flex items-center gap-2 bg-violet-600 text-white px-4 py-2 rounded-xl font-black text-sm uppercase"><Tags size={16} /> Meus produtos</button>
@@ -986,6 +1085,7 @@ export const ProteseView: React.FC = () => {
{novaOS && <NovaOSModal onClose={() => setNovaOS(false)} onSaved={() => { setNovaOS(false); carregar(); }} />}
{detalhe && <OSDetailModal id={detalhe} modo={modo} onClose={() => setDetalhe(null)} onChanged={carregar} />}
{produtosModal && <ProdutosModal onClose={() => setProdutosModal(false)} />}
{cotacoesOpen && <CotacoesClinicaModal onClose={() => setCotacoesOpen(false)} onEscolhida={() => carregar()} />}
</div>
);
};