feat(ui): dropdowns viram modal com busca (SearchSelect) na Avaliação/GTO e Nova OS
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 55s

Mesmo padrão do seletor de protético, generalizado para os demais dropdowns:

- Componente SearchSelect (modal genérico: trigger + busca + lista filtrável por rótulo/hint).
- LancarGTO: Especialidade/Área, Produto/Prótese, Procedimento, Dentista e Convênio/Credenciada
  — todos os <select> convertidos (0 selects restantes).
- Nova OS (ProteseView): Produto e Dentista convertidos.
- Cotação (RFQ): input de busca na lista de laboratórios a convidar (continua multi-seleção).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-25 07:29:49 +02:00
parent e6d8b532e3
commit d83e0c82b2
3 changed files with 111 additions and 47 deletions
+66
View File
@@ -0,0 +1,66 @@
import React, { useState } from 'react';
import { Search, X, Check, ChevronDown } from 'lucide-react';
export interface SelectOption { value: string; label: string; hint?: string }
// Dropdown genérico que abre um modal com busca — substitui <select> em listas longas.
export const SearchSelect: React.FC<{
value: string;
onChange: (value: string) => void;
options: SelectOption[];
placeholder?: string;
title?: string;
className?: string;
disabled?: boolean;
}> = ({ value, onChange, options, placeholder = 'Selecione...', title, className, disabled }) => {
const [open, setOpen] = useState(false);
const [busca, setBusca] = useState('');
const sel = options.find(o => o.value === value);
const q = busca.trim().toLowerCase();
const filtrados = q ? options.filter(o => o.label.toLowerCase().includes(q) || (o.hint || '').toLowerCase().includes(q)) : options;
const pick = (v: string) => { onChange(v); setOpen(false); setBusca(''); };
return (
<>
<button type="button" disabled={disabled} 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 focus:border-teal-400 outline-none transition-all uppercase disabled:opacity-50'}>
<span className="flex items-center justify-between gap-2">
<span className={`truncate ${sel ? '' : 'text-gray-400'}`}>{sel ? sel.label : placeholder}</span>
<ChevronDown size={16} className="text-gray-400 shrink-0" />
</span>
</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-[85vh] 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">{title || placeholder}</h3>
<button onClick={() => setOpen(false)}><X size={20} className="text-gray-400" /></button>
</div>
<div className="p-3 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…"
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">Nada encontrado.</p>
) : filtrados.map(o => (
<button key={o.value} onClick={() => pick(o.value)}
className={`w-full text-left flex items-center gap-2 px-3 py-2.5 rounded-xl ${o.value === value ? 'bg-teal-50 border border-teal-200' : 'hover:bg-gray-50'}`}>
<div className="min-w-0 flex-1">
<p className="font-bold text-gray-800 text-sm truncate uppercase">{o.label}</p>
{o.hint && <p className="text-[11px] text-gray-400 truncate">{o.hint}</p>}
</div>
{o.value === value && <Check size={16} className="text-teal-600 shrink-0" />}
</button>
))}
</div>
</div>
</div>
)}
</>
);
};
+30 -38
View File
@@ -14,6 +14,7 @@ import {
} from 'lucide-react';
import { PageHeader } from '../components/PageHeader.tsx';
import { ProteticoPicker } from '../components/ProteticoPicker.tsx';
import { SearchSelect } from '../components/SearchSelect.tsx';
import { HybridBackend } from '../services/backend.ts';
import { Paciente, Dentista, Procedimento, Especialidade } from '../types.ts';
import { useToast } from '../contexts/ToastContext.tsx';
@@ -457,10 +458,10 @@ export const LancarGTO: React.FC = () => {
<Stethoscope size={20} strokeWidth={3} /> 02. Especialidade
</div>
<div className="space-y-3">
<select
<SearchSelect
value={selectedEsp}
onChange={(e) => {
setSelectedEsp(e.target.value);
onChange={(v) => {
setSelectedEsp(v);
setSelectedProc(null);
setSelectedDentes([]);
setSelectedArco(null);
@@ -468,11 +469,9 @@ export const LancarGTO: React.FC = () => {
setProtValor('');
setProtObs('');
}}
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 A ÁREA...</option>
{especialidades.map(es => <option key={es.id} value={es.id}>{es.nome}</option>)}
</select>
options={especialidades.map(es => ({ value: es.id, label: es.nome }))}
placeholder="ESCOLHA A ÁREA..." title="Especialidade"
/>
{selectedEsp && isProtese && (
<div className="space-y-3">
@@ -484,18 +483,17 @@ export const LancarGTO: React.FC = () => {
</div>
) : (
<>
<select
<SearchSelect
value={selectedProdutoId}
onChange={(e) => {
setSelectedProdutoId(e.target.value);
const pr = labProdutos.find(p => p.id === e.target.value);
onChange={(v) => {
setSelectedProdutoId(v);
const pr = labProdutos.find(p => p.id === v);
setProtValor(pr ? String(pr.preco ?? '') : '');
}}
options={labProdutos.map(p => ({ value: p.id, label: p.nome, hint: `R$ ${Number(p.preco || 0).toFixed(2)}` }))}
placeholder="ESCOLHA A PRÓTESE..." title="Produto do laboratório"
className="w-full p-4 bg-white border-2 border-teal-100 rounded-2xl font-black text-gray-800 text-sm shadow-inner uppercase"
>
<option value="">ESCOLHA A PRÓTESE...</option>
{labProdutos.map(p => <option key={p.id} value={p.id}>{p.nome} R$ {Number(p.preco || 0).toFixed(2)}</option>)}
</select>
/>
{selectedProduto && (
<>
@@ -543,21 +541,20 @@ export const LancarGTO: React.FC = () => {
<p className="text-[10px] font-bold text-amber-500 uppercase mt-1">Cadastre em Procedimentos para usá-lo aqui</p>
</div>
) : (
<select
<SearchSelect
value={selectedProc?.id || ''}
onChange={(e) => {
const pr = procedimentos.find(p => p.id === e.target.value);
onChange={(v) => {
const pr = procedimentos.find(p => p.id === v);
setSelectedProc(pr || null);
setSelectedDentes([]);
setSelectedArco(null);
setArcoFace('');
setArcoObs('');
}}
options={filteredProcedimentos.map(p => ({ value: p.id, label: p.nome }))}
placeholder="BUSCAR PROCEDIMENTO" title="Procedimento"
className="w-full p-4 bg-white border-2 border-teal-100 rounded-2xl font-black text-gray-800 text-sm shadow-inner uppercase"
>
<option value="">BUSCAR PROCEDIMENTO</option>
{filteredProcedimentos.map(p => <option key={p.id} value={p.id}>{p.nome}</option>)}
</select>
/>
)}
{selectedProc && (
@@ -588,28 +585,23 @@ export const LancarGTO: React.FC = () => {
<div className="flex items-center gap-3 text-[#2d6a4f] font-black text-base uppercase tracking-wider">
<Check size={20} strokeWidth={3} className="bg-green-100 p-1 rounded-full text-green-700" /> 03. Dentista Executor
</div>
<select
<SearchSelect
value={store.dentista?.id || ''}
onChange={(e) => {
const d = dentistas.find(d => d.id === e.target.value);
store.setDentista(d || null);
}}
onChange={(v) => { const d = dentistas.find(d => d.id === v); store.setDentista(d || null); }}
options={dentistas.map(d => ({ value: d.id, label: d.nome }))}
placeholder="SELECIONE O PROFISSIONAL" title="Dentista executor"
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-green-500 outline-none transition-all uppercase"
>
<option value="">SELECIONE O PROFISSIONAL</option>
{dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
</select>
/>
<div className="pt-1">
<label className="text-[11px] font-black text-gray-400 uppercase tracking-widest mb-1.5 block">Convênio / Credenciada</label>
<select
<SearchSelect
value={store.credenciada || ''}
onChange={(e) => store.setCredenciada(e.target.value)}
onChange={(v) => store.setCredenciada(v)}
options={convenios.map(c => ({ value: c.id, label: c.nome }))}
placeholder="SELECIONE O CONVÊNIO" title="Convênio / Credenciada"
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-green-500 outline-none transition-all uppercase"
>
<option value="">SELECIONE O CONVÊNIO</option>
{convenios.map(c => <option key={c.id} value={c.id}>{c.nome}</option>)}
</select>
/>
</div>
</div>
);
+15 -9
View File
@@ -6,6 +6,7 @@ import { useConfirm } from '../contexts/ConfirmContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
import { rotulosPaciente } from '../utils/pacienteLabel.ts';
import { ProteticoPicker } from '../components/ProteticoPicker.tsx';
import { SearchSelect } from '../components/SearchSelect.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('/');
@@ -159,10 +160,10 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o
<div>
<label className="text-[10px] font-black text-gray-500 uppercase">Produto / Trabalho *</label>
{form.protetico_id && produtos.length > 0 ? (
<select value={form.produto_id} onChange={e => set('produto_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>
{produtos.map(p => <option key={p.id} value={p.id}>{p.nome} {fmtBRL(p.preco)}</option>)}
</select>
<div className="mt-1"><SearchSelect value={form.produto_id} onChange={(v) => set('produto_id', v)}
options={produtos.map(p => ({ value: p.id, label: p.nome, hint: fmtBRL(p.preco) }))}
placeholder="Selecione..." title="Produto do laboratório"
className="w-full px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
) : (
<input value={form.tipo} onChange={e => set('tipo', e.target.value)} placeholder={form.protetico_id ? 'Sem catálogo — descreva o trabalho' : 'Selecione o laboratório primeiro'} disabled={!form.protetico_id}
className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm disabled:bg-gray-50" />
@@ -174,10 +175,10 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o
{/* Dentista */}
<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 className="mt-1"><SearchSelect value={form.dentista_id} onChange={(v) => set('dentista_id', v)}
options={dentistas.map(d => ({ value: d.id, label: d.nome }))}
placeholder="—" title="Dentista"
className="w-full px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
</div>
{/* Material + cor + dentes */}
<div className="grid grid-cols-3 gap-3">
@@ -1008,6 +1009,7 @@ const CotacoesClinicaModal: React.FC<{ onClose: () => void; onEscolhida: () => v
const [dentes, setDentes] = useState('');
const [prazo, setPrazo] = useState('');
const [sel, setSel] = useState<string[]>([]);
const [buscaLab, setBuscaLab] = useState('');
const [busy, setBusy] = useState(false);
const carregar = useCallback(() => HybridBackend.getRfqs().then(r => setRfqs(Array.isArray(r) ? r : [])).catch(() => setRfqs([])), []);
@@ -1050,8 +1052,12 @@ const CotacoesClinicaModal: React.FC<{ onClose: () => void; onEscolhida: () => v
</div>
<div>
<p className="text-[10px] font-black text-gray-400 uppercase mb-2">Laboratórios a convidar</p>
<div className="relative mb-2">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-300" />
<input value={buscaLab} onChange={e => setBuscaLab(e.target.value)} placeholder="Buscar laboratório…" className="w-full pl-8 pr-3 py-2 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400" />
</div>
<div className="space-y-1.5 max-h-56 overflow-y-auto">
{labs.map(l => (
{labs.filter(l => !buscaLab.trim() || (l.nome || '').toLowerCase().includes(buscaLab.toLowerCase()) || (l.email || '').toLowerCase().includes(buscaLab.toLowerCase())).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>