feat(protese): marketplace transacional fatia 2 — solicitar prótese cria a OS direto
"Solicitar prótese" no card do protético abre o SolicitarProteseModal (lab pré-selecionado): produto do catálogo (ou livre) + paciente + dentista + dentes + prazo + obs → cria a OS via createProteseOS (que já aceita lab do diretório, sem vínculo prévio). Confirmação com CTA p/ a Bancada. Modal auto-contido reusando getLaboratorioProdutos/getDentistas/getPacientes/createProteseOS. Validado em DEV: OS criada para lab do diretório (Coroa de Zircônia, status solicitado). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -24,10 +24,13 @@ via `getLaboratorioProdutos(id)` (reuso — zero endpoint novo). A clínica pass
|
|||||||
serviços + preço + garantia + ★nota** antes de contratar. Botão de contato vira **"Solicitar
|
serviços + preço + garantia + ★nota** antes de contratar. Botão de contato vira **"Solicitar
|
||||||
prótese"** (proposta com contexto de prótese).
|
prótese"** (proposta com contexto de prótese).
|
||||||
|
|
||||||
### Fatia 2 — Solicitação → OS direto
|
### ✅ Fatia 2 — Solicitação → OS direto — IMPLEMENTADA
|
||||||
"Solicitar prótese" abre um fluxo enxuto (produto do catálogo + paciente + dentes + prazo + obs) que
|
"Solicitar prótese" (card do protético) abre o `SolicitarProteseModal` — fluxo enxuto com o lab
|
||||||
**cria a OS** já com o lab selecionado (reusa `POST /api/protese/os`). Requer extrair o seletor de
|
**pré-selecionado**: produto do catálogo (ou descrição livre) + paciente (busca) + dentista + dentes
|
||||||
paciente/dentista da Nova OS para um componente reutilizável fora da tela de Prótese.
|
+ prazo + observações → **cria a OS** via `createProteseOS` (que já aceita lab do diretório, sem
|
||||||
|
vínculo prévio). Tela de confirmação com CTA para a Bancada. Optei por um modal próprio e auto-contido
|
||||||
|
(reusa getLaboratorioProdutos/getDentistas/getPacientes/createProteseOS) em vez de extrair a Nova OS —
|
||||||
|
menor risco. Validado em DEV: OS criada para lab do diretório (Coroa de Zircônia, status solicitado).
|
||||||
|
|
||||||
### Fatia 3 — Cotação (RFQ) para trabalho sob medida
|
### Fatia 3 — Cotação (RFQ) para trabalho sob medida
|
||||||
Quando não há produto de catálogo: a clínica descreve o trabalho e **N labs cotam** (preço + prazo);
|
Quando não há produto de catálogo: a clínica descreve o trabalho e **N labs cotam** (preço + prazo);
|
||||||
|
|||||||
@@ -7,6 +7,112 @@ import {
|
|||||||
|
|
||||||
const fmtBRLp = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
const fmtBRLp = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
||||||
|
|
||||||
|
// Fatia 2: solicitar prótese direto do marketplace → cria a OS (lab pré-selecionado).
|
||||||
|
const SolicitarProteseModal: React.FC<{ prof: any; onClose: () => void }> = ({ prof, onClose }) => {
|
||||||
|
const ws = HybridBackend.getActiveWorkspace();
|
||||||
|
const [produtos, setProdutos] = useState<any[]>([]);
|
||||||
|
const [dentistas, setDentistas] = useState<any[]>([]);
|
||||||
|
const [busca, setBusca] = useState('');
|
||||||
|
const [pacientes, setPacientes] = useState<any[]>([]);
|
||||||
|
const [paciente, setPaciente] = useState<any>(null);
|
||||||
|
const [form, setForm] = useState<any>({ produto_id: '', tipo: '', dentista_id: '', dentes: '', prazo_entrega: '', observacoes: '' });
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [okMsg, setOkMsg] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
HybridBackend.getLaboratorioProdutos(prof.id).then(r => setProdutos(Array.isArray(r) ? r : [])).catch(() => setProdutos([]));
|
||||||
|
HybridBackend.getDentistas(ws?.id).then(r => setDentistas(Array.isArray(r) ? r : [])).catch(() => setDentistas([]));
|
||||||
|
}, [prof.id, ws?.id]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (paciente) return;
|
||||||
|
const t = setTimeout(() => { HybridBackend.getPacientes(busca).then(r => setPacientes(r.data || [])).catch(() => setPacientes([])); }, 250);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [busca, paciente]);
|
||||||
|
const set = (k: string, v: any) => setForm((f: any) => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const salvar = async () => {
|
||||||
|
if (!form.produto_id && !form.tipo.trim()) { alert('Escolha um produto do catálogo ou descreva o trabalho.'); return; }
|
||||||
|
const dent = dentistas.find(d => d.id === form.dentista_id);
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await HybridBackend.createProteseOS({
|
||||||
|
...form, protetico_id: prof.id, valor: 0,
|
||||||
|
paciente_id: paciente?.id || null, paciente_nome: paciente?.nome || null, dentista_nome: dent?.nome || null,
|
||||||
|
});
|
||||||
|
setOkMsg(true);
|
||||||
|
} catch (e: any) { alert((e.message || 'Erro').toUpperCase()); } finally { setSaving(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 max-h-[90vh] 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">
|
||||||
|
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><Wrench size={16} className="text-violet-600" /> Solicitar prótese</h3>
|
||||||
|
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||||
|
</div>
|
||||||
|
{okMsg ? (
|
||||||
|
<div className="p-8 text-center space-y-2">
|
||||||
|
<CheckCircle2 size={40} className="text-green-500 mx-auto" />
|
||||||
|
<p className="font-black text-gray-800 uppercase text-sm">OS enviada ao laboratório</p>
|
||||||
|
<p className="text-xs text-gray-500">{prof.nome} recebeu a solicitação. Acompanhe em Prótese / Bancada.</p>
|
||||||
|
<button onClick={onClose} className="mt-2 px-4 py-2 rounded-xl bg-gray-800 text-white text-xs font-black uppercase">Fechar</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<div className="bg-violet-50 rounded-xl px-3 py-2 text-xs font-black text-violet-700 uppercase">Laboratório: {prof.nome}</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-black text-gray-500 uppercase">Produto do catálogo</label>
|
||||||
|
{produtos.length > 0 ? (
|
||||||
|
<select value={form.produto_id} onChange={e => { const pr = produtos.find(x => x.id === e.target.value); set('produto_id', e.target.value); set('tipo', pr?.nome || ''); }} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
||||||
|
<option value="">Escolha o trabalho…</option>
|
||||||
|
{produtos.map(pr => <option key={pr.id} value={pr.id}>{pr.nome} — {fmtBRLp(pr.preco)}{pr.garantia_meses ? ` · ${pr.garantia_meses}m` : ''}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input value={form.tipo} onChange={e => set('tipo', e.target.value)} placeholder="Descreva o trabalho (sem catálogo)" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-black text-gray-500 uppercase">Paciente (opcional)</label>
|
||||||
|
{paciente ? (
|
||||||
|
<div className="flex items-center justify-between bg-gray-50 rounded-xl px-3 py-2 mt-1"><span className="text-sm font-bold text-gray-700">{paciente.nome}</span><button onClick={() => { setPaciente(null); setBusca(''); }}><X size={16} className="text-gray-400" /></button></div>
|
||||||
|
) : (
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<Search size={15} className="absolute left-3 top-2.5 text-gray-300" />
|
||||||
|
<input value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar paciente..." className="w-full pl-9 pr-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
||||||
|
{busca && pacientes.length > 0 && (
|
||||||
|
<div className="absolute z-10 left-0 right-0 mt-1 bg-white border border-gray-100 rounded-xl shadow-lg max-h-40 overflow-y-auto">
|
||||||
|
{pacientes.slice(0, 8).map(p => <button key={p.id} onClick={() => setPaciente(p)} className="w-full text-left px-3 py-2 hover:bg-gray-50 text-sm font-medium text-gray-700">{p.nome}</button>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-black text-gray-500 uppercase">Dentista</label>
|
||||||
|
<select value={form.dentista_id} onChange={e => set('dentista_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
||||||
|
<option value="">—</option>
|
||||||
|
{dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-black text-gray-500 uppercase">Prazo</label>
|
||||||
|
<input type="date" value={form.prazo_entrega} onChange={e => set('prazo_entrega', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-black text-gray-500 uppercase">Dentes / observações</label>
|
||||||
|
<input value={form.dentes} onChange={e => set('dentes', e.target.value)} placeholder="Ex.: 11, 21" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
||||||
|
<textarea value={form.observacoes} onChange={e => set('observacoes', e.target.value)} rows={2} placeholder="Cor, material, instruções…" className="w-full mt-2 px-3 py-2 rounded-xl border border-gray-200 text-sm resize-none" />
|
||||||
|
</div>
|
||||||
|
<button disabled={saving} onClick={salvar} className="w-full py-3 rounded-xl bg-[#2d6a4f] text-white font-black text-sm uppercase disabled:opacity-50 flex items-center justify-center gap-2"><Send size={15} /> Enviar solicitação</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Fatia 1 do marketplace transacional: vitrine do catálogo do laboratório (reusa getLaboratorioProdutos).
|
// Fatia 1 do marketplace transacional: vitrine do catálogo do laboratório (reusa getLaboratorioProdutos).
|
||||||
const VitrineProtetico: React.FC<{ proteticoId: string }> = ({ proteticoId }) => {
|
const VitrineProtetico: React.FC<{ proteticoId: string }> = ({ proteticoId }) => {
|
||||||
const [aberto, setAberto] = useState(false);
|
const [aberto, setAberto] = useState(false);
|
||||||
@@ -443,6 +549,7 @@ export const ProfissionaisPlugin: React.FC = () => {
|
|||||||
// propostas
|
// propostas
|
||||||
const [propostas, setPropostas] = useState<{ enviadas: Proposta[]; recebidas: Proposta[] }>({ enviadas: [], recebidas: [] });
|
const [propostas, setPropostas] = useState<{ enviadas: Proposta[]; recebidas: Proposta[] }>({ enviadas: [], recebidas: [] });
|
||||||
const [proposingTo, setProposingTo] = useState<Profissional | null>(null);
|
const [proposingTo, setProposingTo] = useState<Profissional | null>(null);
|
||||||
|
const [solicitarTo, setSolicitarTo] = useState<Profissional | null>(null);
|
||||||
// null = checando; true/false = endereço da origem (clínica ou perfil) preenchido?
|
// null = checando; true/false = endereço da origem (clínica ou perfil) preenchido?
|
||||||
const [enderecoOk, setEnderecoOk] = useState<boolean | null>(null);
|
const [enderecoOk, setEnderecoOk] = useState<boolean | null>(null);
|
||||||
|
|
||||||
@@ -761,7 +868,7 @@ export const ProfissionaisPlugin: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
{p.role === 'protetico' && <VitrineProtetico proteticoId={p.id} />}
|
{p.role === 'protetico' && <VitrineProtetico proteticoId={p.id} />}
|
||||||
{ws?.id && (
|
{ws?.id && (
|
||||||
<button onClick={() => setProposingTo(p)}
|
<button onClick={() => p.role === 'protetico' ? setSolicitarTo(p) : setProposingTo(p)}
|
||||||
className="w-full py-2.5 rounded-xl text-white text-[10px] font-black uppercase flex items-center justify-center gap-1.5 transition-colors hover:opacity-90" style={{ backgroundColor: COLOR }}>
|
className="w-full py-2.5 rounded-xl text-white text-[10px] font-black uppercase flex items-center justify-center gap-1.5 transition-colors hover:opacity-90" style={{ backgroundColor: COLOR }}>
|
||||||
<Send size={13} /> {p.role === 'protetico' ? 'SOLICITAR PRÓTESE' : 'ENVIAR PROPOSTA'}
|
<Send size={13} /> {p.role === 'protetico' ? 'SOLICITAR PRÓTESE' : 'ENVIAR PROPOSTA'}
|
||||||
</button>
|
</button>
|
||||||
@@ -932,6 +1039,7 @@ export const ProfissionaisPlugin: React.FC = () => {
|
|||||||
{tab === 'modelos' && <ModelosManager />}
|
{tab === 'modelos' && <ModelosManager />}
|
||||||
|
|
||||||
{proposingTo && <PropostaModal prof={proposingTo} onClose={() => setProposingTo(null)} onSent={fetchPropostas} />}
|
{proposingTo && <PropostaModal prof={proposingTo} onClose={() => setProposingTo(null)} onSent={fetchPropostas} />}
|
||||||
|
{solicitarTo && <SolicitarProteseModal prof={solicitarTo} onClose={() => setSolicitarTo(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user