feat(protese): seletor de protético em modal (busca, favorito/padrão, vincular por e-mail)
O dropdown só listava protéticos internos (vinculados) ou do diretório público — um protético com conta fora do diretório (ex.: claudemir) não aparecia para a clínica. Reformulado: - Componente ProteticoPicker (modal): busca por nome/e-mail; estrela de FAVORITO; marcar PADRÃO (pré-selecionado na próxima OS/GTO, só um por clínica); e "Adicionar por e-mail", que VINCULA um protético existente como interno — resolve o caso de não estar no diretório, sem expô-lo publicamente. - Backend: tabela protese_protetico_pref (favorito/padrão por clínica); GET /protese/laboratorios passa a retornar favorito/padrao e ordena padrão→favorito→interno→nota→nome; POST .../:id/pref e POST /protese/laboratorios/vincular. - Picker aplicado no LancarGTO (avaliação/GTO) e na NovaOSModal; ProteticoInternoInline (protético SEM conta) mantido ao lado. Validado em DEV na CONSULTT CLINIC: claudemir ausente → vinculado por e-mail → aparece interno e, como favorito+padrão, no topo da lista. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+60
-8
@@ -3839,23 +3839,66 @@ app.get('/api/protese/laboratorios', tenantGuard, async (req, res) => {
|
||||
try {
|
||||
const clinicaId = req.clinicaId;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT u.id, u.nome, u.email,
|
||||
BOOL_OR(v.clinica_id = $1) AS interno
|
||||
`SELECT u.id, u.nome, u.email, u.celular,
|
||||
BOOL_OR(v.clinica_id = $1) AS interno,
|
||||
BOOL_OR(pf.favorito) AS favorito,
|
||||
BOOL_OR(pf.padrao) AS padrao
|
||||
FROM usuarios u
|
||||
LEFT JOIN vinculos v ON v.usuario_id = u.id
|
||||
LEFT JOIN protese_protetico_pref pf ON pf.protetico_id = u.id AND pf.clinica_id = $1
|
||||
WHERE u.role = 'protetico'
|
||||
AND (v.clinica_id = $1 OR u.disponivel_diretorio = true)
|
||||
GROUP BY u.id, u.nome, u.email
|
||||
ORDER BY interno DESC, u.nome`, [clinicaId]);
|
||||
AND (v.clinica_id = $1 OR u.disponivel_diretorio = true OR pf.clinica_id = $1)
|
||||
GROUP BY u.id, u.nome, u.email, u.celular
|
||||
ORDER BY u.nome`, [clinicaId]);
|
||||
// Nota ScoreOdonto por protético (ranking no marketplace) — mesma fórmula da tela do lab.
|
||||
const notas = await notasScorePorProtetico(rows.map(r => r.id));
|
||||
const lista = rows.map(r => ({ ...r, interno: !!r.interno, nota: notas[r.id]?.nota ?? null, verificado: notas[r.id]?.verificado || false, avaliacoes: notas[r.id]?.avaliacoes || 0 }));
|
||||
// Ranking: internos primeiro, depois maior nota, depois nome.
|
||||
lista.sort((a, b) => (b.interno ? 1 : 0) - (a.interno ? 1 : 0) || (b.nota ?? -1) - (a.nota ?? -1) || a.nome.localeCompare(b.nome));
|
||||
const lista = rows.map(r => ({ ...r, interno: !!r.interno, favorito: !!r.favorito, padrao: !!r.padrao, nota: notas[r.id]?.nota ?? null, verificado: notas[r.id]?.verificado || false, avaliacoes: notas[r.id]?.avaliacoes || 0 }));
|
||||
// Ranking: padrão → favorito → interno → maior nota → nome.
|
||||
lista.sort((a, b) => (b.padrao ? 1 : 0) - (a.padrao ? 1 : 0) || (b.favorito ? 1 : 0) - (a.favorito ? 1 : 0) || (b.interno ? 1 : 0) - (a.interno ? 1 : 0) || (b.nota ?? -1) - (a.nota ?? -1) || a.nome.localeCompare(b.nome));
|
||||
res.json(lista);
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Marcar protético como FAVORITO (★) e/ou PADRÃO (pré-selecionado) na clínica ativa.
|
||||
app.post('/api/protese/laboratorios/:proteticoId/pref', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
const protId = req.params.proteticoId;
|
||||
const { rows: pr } = await pool.query("SELECT 1 FROM usuarios WHERE id=$1 AND role='protetico'", [protId]);
|
||||
if (!pr.length) return res.status(404).json({ error: 'Protético não encontrado.' });
|
||||
const favorito = req.body?.favorito;
|
||||
const padrao = req.body?.padrao;
|
||||
// Só pode haver UM padrão por clínica.
|
||||
if (padrao === true) await pool.query('UPDATE protese_protetico_pref SET padrao=false WHERE clinica_id=$1', [req.clinicaId]);
|
||||
// COALESCE(EXCLUDED...) preserva o outro flag quando só um é enviado.
|
||||
await pool.query(
|
||||
`INSERT INTO protese_protetico_pref (clinica_id, protetico_id, favorito, padrao)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (clinica_id, protetico_id) DO UPDATE
|
||||
SET favorito = COALESCE(EXCLUDED.favorito, protese_protetico_pref.favorito),
|
||||
padrao = COALESCE(EXCLUDED.padrao, protese_protetico_pref.padrao)`,
|
||||
[req.clinicaId, protId, favorito ?? null, padrao ?? null]);
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Vincular um protético EXISTENTE (com conta) como interno da clínica — resolve "não aparece no
|
||||
// dropdown" sem precisar do diretório público. Busca por e-mail exato.
|
||||
app.post('/api/protese/laboratorios/vincular', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
const email = (req.body?.email || '').toLowerCase().trim();
|
||||
if (!email) return res.status(400).json({ error: 'E-mail obrigatório.' });
|
||||
const { rows } = await pool.query("SELECT id, nome, email FROM usuarios WHERE lower(email)=$1 AND role='protetico'", [email]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Nenhum protético com este e-mail. Confira ou cadastre um protético interno.' });
|
||||
const p = rows[0];
|
||||
await pool.query(
|
||||
"INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1,$2,$3,'protetico') ON CONFLICT (usuario_id, clinica_id) DO NOTHING",
|
||||
[`v_${p.id}_${req.clinicaId}`, p.id, req.clinicaId]);
|
||||
res.json({ success: true, protetico: { id: p.id, nome: p.nome, email: p.email } });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Protético INTERNO (sem conta): a clínica registra um protético que não usa a plataforma.
|
||||
// Cria um usuário "mudo" (e-mail placeholder, senha-fantasma) + vínculo com a clínica (interno,
|
||||
// fora do diretório). A partir daí já recebe OS e o link /p/TOKEN.
|
||||
@@ -6858,6 +6901,15 @@ async function runMigrations() {
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_coleta_lab ON protese_coleta (protetico_id, data_hora)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_coleta_os ON protese_coleta (os_id)`,
|
||||
// Preferência de protético por clínica: favorito (★) e padrão (pré-selecionado na nova OS/GTO).
|
||||
`CREATE TABLE IF NOT EXISTS protese_protetico_pref (
|
||||
clinica_id TEXT NOT NULL,
|
||||
protetico_id TEXT NOT NULL,
|
||||
favorito BOOLEAN DEFAULT false,
|
||||
padrao BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (clinica_id, protetico_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS protese_produtos (
|
||||
id TEXT PRIMARY KEY,
|
||||
protetico_id TEXT NOT NULL, -- dono (usuário protético)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Search, X, Star, Check, ShieldCheck, UserPlus, Building2 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
|
||||
// Seletor de protético em modal: busca por nome/e-mail, favorito (★), padrão (pré-selecionado)
|
||||
// e "adicionar por e-mail" (vincula como interno — resolve o protético que não está no diretório).
|
||||
export const ProteticoPicker: React.FC<{ value: string; onChange: (id: string, lab?: any) => void; className?: string }> = ({ value, onChange, className }) => {
|
||||
const toast = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [labs, setLabs] = useState<any[]>([]);
|
||||
const [busca, setBusca] = useState('');
|
||||
const [addEmail, setAddEmail] = useState('');
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const preselected = useRef(false);
|
||||
|
||||
const carregar = useCallback(async (): Promise<any[]> => {
|
||||
try { const r = await HybridBackend.getProteseLaboratorios(); const arr = Array.isArray(r) ? r : []; setLabs(arr); return arr; }
|
||||
catch { setLabs([]); return []; }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
// Pré-seleciona o protético PADRÃO uma única vez, quando nada foi escolhido ainda.
|
||||
useEffect(() => {
|
||||
if (preselected.current || value) return;
|
||||
const pad = labs.find(l => l.padrao);
|
||||
if (pad) { preselected.current = true; onChange(pad.id, pad); }
|
||||
}, [labs, value]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const sel = labs.find(l => l.id === value);
|
||||
const q = busca.toLowerCase();
|
||||
const filtrados = labs.filter(l => !q || (l.nome || '').toLowerCase().includes(q) || (l.email || '').toLowerCase().includes(q));
|
||||
|
||||
const toggleFav = async (l: any, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try { await HybridBackend.setProteticoPref(l.id, { favorito: !l.favorito }); await carregar(); }
|
||||
catch { toast.error('ERRO'); }
|
||||
};
|
||||
const tornarPadrao = async (l: any, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try { await HybridBackend.setProteticoPref(l.id, { padrao: !l.padrao }); await carregar(); toast.success(l.padrao ? 'PADRÃO REMOVIDO.' : 'DEFINIDO COMO PADRÃO.'); }
|
||||
catch { toast.error('ERRO'); }
|
||||
};
|
||||
const selecionar = (l: any) => { onChange(l.id, l); setOpen(false); setBusca(''); };
|
||||
const adicionar = async () => {
|
||||
if (!addEmail.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await HybridBackend.vincularProteticoEmail(addEmail.trim());
|
||||
if (r?.error) throw new Error(r.error);
|
||||
toast.success('PROTÉTICO VINCULADO.');
|
||||
setAddEmail(''); setShowAdd(false);
|
||||
const novos = await carregar();
|
||||
const novo = novos.find((x: any) => x.id === r.protetico?.id);
|
||||
if (novo) selecionar(novo);
|
||||
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}
|
||||
className={className || 'w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm text-left flex items-center justify-between gap-2 uppercase focus:border-teal-400 outline-none transition-all'}>
|
||||
<span className="truncate flex items-center gap-2">
|
||||
{sel ? (<><Building2 size={16} className="text-teal-600 shrink-0" /> {sel.nome}{sel.padrao ? ' · PADRÃO' : ''}</>) : 'Escolha o laboratório / protético...'}
|
||||
</span>
|
||||
<Search size={16} className="text-gray-400 shrink-0" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 bg-black/40 z-[60] flex items-center justify-center p-4" onClick={() => setOpen(false)}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<h3 className="font-black text-gray-800 uppercase text-sm">Escolher protético</h3>
|
||||
<button onClick={() => setOpen(false)}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="p-4 border-b border-gray-50">
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-300" />
|
||||
<input autoFocus value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar por nome ou e-mail…"
|
||||
className="w-full pl-9 pr-3 py-2.5 rounded-xl border border-gray-200 text-sm outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{filtrados.length === 0 ? (
|
||||
<p className="text-center text-gray-400 text-sm font-bold uppercase py-10">Nenhum protético encontrado.</p>
|
||||
) : filtrados.map(l => (
|
||||
<div key={l.id} onClick={() => selecionar(l)}
|
||||
className={`flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer ${l.id === value ? 'bg-teal-50 border border-teal-200' : 'hover:bg-gray-50'}`}>
|
||||
<button onClick={e => toggleFav(l, e)} title="Favorito" className="shrink-0">
|
||||
<Star size={16} className={l.favorito ? 'text-amber-400 fill-amber-400' : 'text-gray-300'} />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-black text-gray-800 text-sm truncate uppercase flex items-center gap-1.5">
|
||||
{l.nome}{l.verificado && <ShieldCheck size={13} className="text-blue-500 shrink-0" />}
|
||||
</p>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase truncate flex items-center gap-1.5">
|
||||
<span className={l.interno ? 'text-teal-600' : ''}>{l.interno ? 'Interno' : 'Diretório'}</span>
|
||||
{l.nota != null && <span>· ★ {l.nota}</span>}
|
||||
{l.padrao && <span className="text-violet-600">· Padrão</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={e => tornarPadrao(l, e)} title="Definir como padrão"
|
||||
className={`shrink-0 text-[9px] font-black uppercase px-2 py-1 rounded-lg border ${l.padrao ? 'border-violet-300 bg-violet-50 text-violet-700' : 'border-gray-200 text-gray-400'}`}>Padrão</button>
|
||||
{l.id === value && <Check size={16} className="text-teal-600 shrink-0" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-4 border-t border-gray-100">
|
||||
{showAdd ? (
|
||||
<div className="flex gap-2">
|
||||
<input value={addEmail} onChange={e => setAddEmail(e.target.value)} type="email" placeholder="e-mail do protético"
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-gray-200 text-sm outline-none focus:border-teal-400" />
|
||||
<button disabled={busy} onClick={adicionar} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50">Vincular</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAdd(true)} className="w-full py-2.5 rounded-xl border border-dashed border-gray-300 text-gray-500 text-xs font-black uppercase flex items-center justify-center gap-2">
|
||||
<UserPlus size={14} /> Não encontrou? Adicionar por e-mail
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -748,6 +748,18 @@ export const HybridBackend = {
|
||||
const res = await apiFetch(`${API_URL}/protese/laboratorios?clinicaId=${encodeURIComponent(clinicaId)}`);
|
||||
return await res.json();
|
||||
},
|
||||
// Favorito (★) / padrão do protético na clínica ativa.
|
||||
setProteticoPref: async (proteticoId: string, body: { favorito?: boolean; padrao?: boolean }) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/laboratorios/${proteticoId}/pref?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
return await res.json().catch(() => ({ error: 'falha' }));
|
||||
},
|
||||
// Vincular um protético existente (com conta) como interno, por e-mail.
|
||||
vincularProteticoEmail: async (email: string) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/laboratorios/vincular?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) });
|
||||
return await res.json().catch(() => ({ error: 'falha' }));
|
||||
},
|
||||
getProteseOS: async (opts: { status?: string; paciente_id?: string } = {}) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const qs = new URLSearchParams({ clinicaId });
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { ProteticoPicker } from '../components/ProteticoPicker.tsx';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Paciente, Dentista, Procedimento, Especialidade } from '../types.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
@@ -123,7 +124,6 @@ export const LancarGTO: React.FC = () => {
|
||||
const [patientSearchTerm, setPatientSearchTerm] = useState('');
|
||||
|
||||
// --- PRÓTESE (Fase 1): catálogo do protético entra como "procedimentos" do GTO ---
|
||||
const [laboratorios, setLaboratorios] = useState<any[]>([]);
|
||||
const [selectedLab, setSelectedLab] = useState<string>('');
|
||||
const [labProdutos, setLabProdutos] = useState<any[]>([]);
|
||||
const [selectedProdutoId, setSelectedProdutoId] = useState<string>('');
|
||||
@@ -162,12 +162,6 @@ export const LancarGTO: React.FC = () => {
|
||||
[labProdutos, selectedProdutoId]
|
||||
);
|
||||
|
||||
// Carrega os laboratórios acessíveis assim que a especialidade de prótese é escolhida.
|
||||
useEffect(() => {
|
||||
if (!isProtese || laboratorios.length) return;
|
||||
HybridBackend.getProteseLaboratorios().then(l => setLaboratorios(Array.isArray(l) ? l : [])).catch(() => {});
|
||||
}, [isProtese, laboratorios.length]);
|
||||
|
||||
// Catálogo do laboratório selecionado.
|
||||
useEffect(() => {
|
||||
if (!selectedLab) { setLabProdutos([]); return; }
|
||||
@@ -482,14 +476,7 @@ export const LancarGTO: React.FC = () => {
|
||||
|
||||
{selectedEsp && isProtese && (
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={selectedLab}
|
||||
onChange={(e) => { setSelectedLab(e.target.value); setSelectedProdutoId(''); setProtValor(''); }}
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase"
|
||||
>
|
||||
<option value="">ESCOLHA O LABORATÓRIO / PROTÉTICO...</option>
|
||||
{laboratorios.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (INTERNO)' : ''}{l.nota != null ? ` · ★ ${l.nota}` : ''}</option>)}
|
||||
</select>
|
||||
<ProteticoPicker value={selectedLab} onChange={(id) => { setSelectedLab(id); setSelectedProdutoId(''); setProtValor(''); }} />
|
||||
|
||||
{selectedLab && (labProdutos.length === 0 ? (
|
||||
<div className="bg-amber-50 border-2 border-amber-100 rounded-2xl p-4 text-center">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { rotulosPaciente } from '../utils/pacienteLabel.ts';
|
||||
import { ProteticoPicker } from '../components/ProteticoPicker.tsx';
|
||||
|
||||
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
||||
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
|
||||
@@ -63,7 +64,7 @@ const ProteticoInternoInline: React.FC<{ onCriado: (id: string) => void }> = ({
|
||||
// ── Modal: Nova OS (lado clínica) ───────────────────────────────────────────
|
||||
const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => {
|
||||
const toast = useToast();
|
||||
const [labs, setLabs] = useState<any[]>([]);
|
||||
const [pickerKey, setPickerKey] = useState(0);
|
||||
const [dentistas, setDentistas] = useState<any[]>([]);
|
||||
const [busca, setBusca] = useState('');
|
||||
const [pacientes, setPacientes] = useState<any[]>([]);
|
||||
@@ -74,7 +75,6 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o
|
||||
const ws = HybridBackend.getActiveWorkspace();
|
||||
|
||||
useEffect(() => {
|
||||
HybridBackend.getProteseLaboratorios().then(r => setLabs(Array.isArray(r) ? r : [])).catch(() => setLabs([]));
|
||||
HybridBackend.getDentistas(ws?.id).then(r => setDentistas(Array.isArray(r) ? r : [])).catch(() => setDentistas([]));
|
||||
}, [ws?.id]);
|
||||
|
||||
@@ -152,11 +152,8 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o
|
||||
{/* Laboratório */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Laboratório / Protético *</label>
|
||||
<select value={form.protetico_id} onChange={e => set('protetico_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
||||
<option value="">Selecione...</option>
|
||||
{labs.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (interno)' : ''}{l.nota != null ? ` · ★ ${l.nota}` : ''}</option>)}
|
||||
</select>
|
||||
<ProteticoInternoInline onCriado={(id) => { HybridBackend.getProteseLaboratorios().then(r => { setLabs(Array.isArray(r) ? r : []); set('protetico_id', id); }); }} />
|
||||
<div className="mt-1"><ProteticoPicker key={pickerKey} value={form.protetico_id} onChange={(id) => set('protetico_id', id)} /></div>
|
||||
<ProteticoInternoInline onCriado={(id) => { set('protetico_id', id); setPickerKey(k => k + 1); }} />
|
||||
</div>
|
||||
{/* Produto (catálogo do laboratório — preço fixo) */}
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user