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
+85
View File
@@ -0,0 +1,85 @@
import React, { useState, useEffect, useCallback } from 'react';
import { FileText, RefreshCw, Building2, CheckCircle2, XCircle, Send, Clock } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
// Área do Laboratório (Fatia 3): pedidos de cotação recebidos das clínicas.
export const LabCotacoesView: React.FC = () => {
const toast = useToast();
const [lista, setLista] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [forms, setForms] = useState<Record<string, { valor: string; prazo: string; obs: string }>>({});
const [busy, setBusy] = useState('');
const carregar = useCallback(async () => {
setLoading(true);
try { const r = await HybridBackend.getRfqRecebidas(); setLista(Array.isArray(r) ? r : []); }
catch { /* silent */ } finally { setLoading(false); }
}, []);
useEffect(() => { carregar(); }, [carregar]);
const setF = (id: string, k: string, v: string) => setForms(s => ({ ...s, [id]: { ...(s[id] || { valor: '', prazo: '', obs: '' }), [k]: v } }));
const responder = async (c: any) => {
const f = forms[c.cotacao_id] || { valor: '', prazo: '', obs: '' };
const valor = parseFloat(f.valor);
if (!(valor > 0)) { toast.error('INFORME O VALOR.'); return; }
setBusy(c.cotacao_id);
try { await HybridBackend.responderCotacao(c.cotacao_id, { valor, prazo_dias: parseInt(f.prazo, 10) || undefined, observacao: f.obs.trim() || undefined }); toast.success('COTAÇÃO ENVIADA.'); carregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(''); }
};
return (
<div className="space-y-6 pb-10 max-w-2xl">
<PageHeader title="PEDIDOS DE COTAÇÃO">
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
</PageHeader>
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>
: lista.length === 0 ? <div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhum pedido de cotação.</div>
: (
<div className="space-y-3">
{lista.map(c => {
const aberta = c.rfq_status === 'aberta';
const ganhou = c.cotacao_status === 'escolhida';
const perdeu = c.cotacao_status === 'recusada';
const f = forms[c.cotacao_id] || { valor: '', prazo: '', obs: '' };
return (
<div key={c.cotacao_id} className={`bg-white rounded-2xl border shadow-sm p-4 ${ganhou ? 'border-green-200' : 'border-gray-100'}`}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-black text-gray-800 text-sm">{c.descricao}</p>
<p className="text-[11px] text-gray-400 uppercase font-bold flex items-center gap-1 mt-0.5"><Building2 size={11} /> {c.clinica_nome || 'Clínica'}{c.dentes ? ` · dentes ${c.dentes}` : ''}</p>
</div>
{ganhou && <span className="text-[9px] font-black px-2 py-1 rounded-full bg-green-100 text-green-700 uppercase flex items-center gap-1 shrink-0"><CheckCircle2 size={11} /> Você ganhou</span>}
{perdeu && <span className="text-[9px] font-black px-2 py-1 rounded-full bg-gray-100 text-gray-500 uppercase flex items-center gap-1 shrink-0"><XCircle size={11} /> Não escolhida</span>}
{aberta && c.cotacao_status === 'enviada' && <span className="text-[9px] font-black px-2 py-1 rounded-full bg-amber-100 text-amber-700 uppercase shrink-0">Cotado · {fmtBRL(c.valor)}</span>}
</div>
{(c.paciente_nome || c.prazo_desejado) && <p className="text-[11px] text-gray-400 mt-2">{c.paciente_nome || ''}{c.prazo_desejado ? ` · prazo desejado ${dataBR(c.prazo_desejado)}` : ''}</p>}
{aberta && (
<div className="mt-3 pt-3 border-t border-gray-100 flex flex-wrap items-end gap-2">
<div>
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Valor (R$)</label>
<input type="number" step="0.01" value={f.valor} onChange={e => setF(c.cotacao_id, 'valor', e.target.value)} placeholder={c.valor ? String(c.valor) : '0,00'} className="w-24 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Prazo (dias)</label>
<input type="number" value={f.prazo} onChange={e => setF(c.cotacao_id, 'prazo', e.target.value)} className="w-20 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
</div>
<input value={f.obs} onChange={e => setF(c.cotacao_id, 'obs', e.target.value)} placeholder="Observação" className="flex-1 min-w-[120px] p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
<button disabled={busy === c.cotacao_id} onClick={() => responder(c)} className="px-3 py-2 rounded-lg bg-[#2d6a4f] text-white text-[11px] font-black uppercase disabled:opacity-50 flex items-center gap-1.5"><Send size={13} /> {c.cotacao_status === 'enviada' ? 'Atualizar' : 'Cotar'}</button>
</div>
)}
{!aberta && !ganhou && !perdeu && <p className="text-[10px] text-gray-400 uppercase font-bold mt-2 flex items-center gap-1"><Clock size={11} /> Encerrado</p>}
</div>
);
})}
</div>
)}
</div>
);
};
+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>
);
};