feat(protese): Fase 1 — finalizar GTO abre OS de prótese na Bancada
Itens de GTO ligados a um produto do catálogo do protético geram, na finalização da GTO, uma OS de prótese (status 'solicitado') na Bancada do laboratório — idempotente por gto_item_id e isolado em try/catch para nunca derrubar o finalize. Migrações aditivas (gto_items.produto_id/protetico_id, protese_os.gto_item_id, re-tag de especialidades para area='protese'). No Lançar GTO, a especialidade de prótese troca o seletor de procedimentos pelo fluxo laboratório → produto → valor → observação. Busca de pacientes passa a ser server-side com debounce (antes filtrava só a 1ª página). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+185
-27
@@ -32,6 +32,9 @@ interface GTOItem {
|
||||
valorUnitario: number;
|
||||
valorTotal: number;
|
||||
observacao?: string;
|
||||
// Prótese (Fase 1): item ligado ao catálogo do protético → gera OS ao finalizar.
|
||||
produtoId?: string;
|
||||
proteticoId?: string;
|
||||
}
|
||||
|
||||
interface GTOStore {
|
||||
@@ -119,6 +122,14 @@ export const LancarGTO: React.FC = () => {
|
||||
const [patientSearchOpen, setPatientSearchOpen] = useState(false);
|
||||
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>('');
|
||||
const [protValor, setProtValor] = useState<string>('');
|
||||
const [protObs, setProtObs] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const p = await HybridBackend.getPacientes();
|
||||
@@ -140,14 +151,55 @@ export const LancarGTO: React.FC = () => {
|
||||
return procedimentos.filter(p => p.especialidadeId === selectedEsp);
|
||||
}, [selectedEsp, procedimentos]);
|
||||
|
||||
const filteredPatients = useMemo(() => {
|
||||
if (!patientSearchTerm) return pacientes.slice(0, 30);
|
||||
const term = normalize(patientSearchTerm);
|
||||
return pacientes.filter(p =>
|
||||
normalize(p.nome).includes(term) ||
|
||||
(p.cpf && p.cpf.includes(patientSearchTerm.replace(/\D/g, '')))
|
||||
).slice(0, 30);
|
||||
}, [pacientes, patientSearchTerm]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// Especialidade de prótese → o "procedimento" vem do catálogo do protético (não da
|
||||
// tabela procedimentos). Detecta pela área da especialidade (re-tagueada no backend).
|
||||
const isProtese = useMemo(
|
||||
() => (especialidades.find(e => e.id === selectedEsp)?.area || '') === 'protese',
|
||||
[especialidades, selectedEsp]
|
||||
);
|
||||
const selectedProduto = useMemo(
|
||||
() => labProdutos.find(p => p.id === selectedProdutoId) || null,
|
||||
[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; }
|
||||
let active = true;
|
||||
HybridBackend.getLaboratorioProdutos(selectedLab)
|
||||
.then(p => { if (active) setLabProdutos(Array.isArray(p) ? p : []); })
|
||||
.catch(() => { if (active) setLabProdutos([]); });
|
||||
return () => { active = false; };
|
||||
}, [selectedLab]);
|
||||
|
||||
// Busca de pacientes é SERVER-SIDE: a clínica pode ter milhares e a rota sem termo
|
||||
// devolve só as 50 primeiras. Filtrar essas 50 no cliente fazia a busca "não achar"
|
||||
// quem estava fora da 1ª página. Sem termo, mostra a lista inicial (recentes).
|
||||
const [patientResults, setPatientResults] = useState<Paciente[]>([]);
|
||||
const [searchingPatients, setSearchingPatients] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const term = patientSearchTerm.trim();
|
||||
if (!term) { setPatientResults(pacientes.slice(0, 30)); setSearchingPatients(false); return; }
|
||||
let active = true;
|
||||
setSearchingPatients(true);
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const r: any = await HybridBackend.getPacientes(term);
|
||||
const list: Paciente[] = Array.isArray(r) ? r : (r?.data ?? []);
|
||||
if (active) setPatientResults(list);
|
||||
} finally {
|
||||
if (active) setSearchingPatients(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => { active = false; clearTimeout(t); };
|
||||
}, [patientSearchTerm, pacientes]);
|
||||
|
||||
useEffect(() => {
|
||||
const { pendingSearch } = store;
|
||||
@@ -250,6 +302,31 @@ export const LancarGTO: React.FC = () => {
|
||||
setArcoObs('');
|
||||
};
|
||||
|
||||
// Adiciona um item de prótese (catálogo do protético) ao rascunho do GTO.
|
||||
const handleAddProtese = () => {
|
||||
if (!selectedLab) { toast.error("SELECIONE O LABORATÓRIO."); return; }
|
||||
if (!selectedProduto) { toast.error("SELECIONE O PRODUTO DE PRÓTESE."); return; }
|
||||
if (store.items.length >= 20) { toast.error("LIMITE DE 20 ITENS ATINGIDO."); return; }
|
||||
const valor = parseFloat(protValor) || Number(selectedProduto.preco) || 0;
|
||||
store.addItem({
|
||||
id: Math.random().toString(36).substring(7),
|
||||
procedimentoId: '',
|
||||
codigoTUSS: '',
|
||||
codigoInterno: '',
|
||||
descricao: selectedProduto.nome,
|
||||
quantidade: 1,
|
||||
valorUnitario: valor,
|
||||
valorTotal: valor,
|
||||
observacao: protObs || undefined,
|
||||
produtoId: selectedProduto.id,
|
||||
proteticoId: selectedLab,
|
||||
});
|
||||
toast.success("PRÓTESE ADICIONADA AO RASCUNHO.");
|
||||
setSelectedProdutoId('');
|
||||
setProtValor('');
|
||||
setProtObs('');
|
||||
};
|
||||
|
||||
const handleFinalize = async () => {
|
||||
if (!store.paciente || !store.dentista || store.items.length === 0) {
|
||||
toast.error("PACIENTE, DENTISTA E PELO MENOS 1 ITEM SÃO OBRIGATÓRIOS.");
|
||||
@@ -276,9 +353,12 @@ export const LancarGTO: React.FC = () => {
|
||||
credenciadaId: store.credenciada,
|
||||
});
|
||||
for (const it of store.items) {
|
||||
const { arco, id: _localId, ...rest } = it as any;
|
||||
const { arco, id: _localId, produtoId, proteticoId, ...rest } = it as any;
|
||||
void arco; void _localId;
|
||||
await HybridBackend.addGtoItem(id, rest);
|
||||
const payload: any = { ...rest };
|
||||
// Prótese: envia produto/protético (snake_case) → o backend abre a OS na finalização.
|
||||
if (produtoId) { payload.produto_id = produtoId; payload.protetico_id = proteticoId; }
|
||||
await HybridBackend.addGtoItem(id, payload);
|
||||
}
|
||||
const fin = await HybridBackend.finalizarGto(id);
|
||||
toast.success(fin?.financeiro_id
|
||||
@@ -390,6 +470,9 @@ export const LancarGTO: React.FC = () => {
|
||||
setSelectedProc(null);
|
||||
setSelectedDentes([]);
|
||||
setSelectedArco(null);
|
||||
setSelectedProdutoId('');
|
||||
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"
|
||||
>
|
||||
@@ -397,24 +480,99 @@ export const LancarGTO: React.FC = () => {
|
||||
{especialidades.map(es => <option key={es.id} value={es.id}>{es.nome}</option>)}
|
||||
</select>
|
||||
|
||||
{selectedEsp && (
|
||||
<div className="space-y-2">
|
||||
{selectedEsp && isProtese && (
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={selectedProc?.id || ''}
|
||||
onChange={(e) => {
|
||||
const pr = procedimentos.find(p => p.id === e.target.value);
|
||||
setSelectedProc(pr || null);
|
||||
setSelectedDentes([]);
|
||||
setSelectedArco(null);
|
||||
setArcoFace('');
|
||||
setArcoObs('');
|
||||
}}
|
||||
className="w-full p-4 bg-white border-2 border-teal-100 rounded-2xl font-black text-gray-800 text-sm shadow-inner uppercase"
|
||||
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="">BUSCAR PROCEDIMENTO</option>
|
||||
{filteredProcedimentos.map(p => <option key={p.id} value={p.id}>{p.nome}</option>)}
|
||||
<option value="">ESCOLHA O LABORATÓRIO / PROTÉTICO...</option>
|
||||
{laboratorios.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (INTERNO)' : ''}</option>)}
|
||||
</select>
|
||||
|
||||
{selectedLab && (labProdutos.length === 0 ? (
|
||||
<div className="bg-amber-50 border-2 border-amber-100 rounded-2xl p-4 text-center">
|
||||
<p className="text-[11px] font-black text-amber-700 uppercase tracking-wide">Este laboratório não tem produtos ativos</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
value={selectedProdutoId}
|
||||
onChange={(e) => {
|
||||
setSelectedProdutoId(e.target.value);
|
||||
const pr = labProdutos.find(p => p.id === e.target.value);
|
||||
setProtValor(pr ? String(pr.preco ?? '') : '');
|
||||
}}
|
||||
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 && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1.5">Valor (R$)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={protValor}
|
||||
onChange={e => setProtValor(e.target.value)}
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-teal-400 outline-none transition-all"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
value={protObs}
|
||||
onChange={e => setProtObs(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="OBSERVAÇÃO PARA O PROTÉTICO (cor, material, dentes...)"
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl text-[11px] focus:border-amber-400 outline-none transition-all resize-none"
|
||||
/>
|
||||
<div className="bg-teal-50 p-3 rounded-xl border border-teal-100">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-teal-800">
|
||||
<ChevronRight size={10} /> AO FINALIZAR A GTO, UMA OS DE PRÓTESE SERÁ ABERTA NA BANCADA DO LABORATÓRIO.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddProtese}
|
||||
className="w-full bg-[#2d6a4f] text-white py-4 rounded-2xl font-black text-sm uppercase shadow-lg shadow-green-100 hover:bg-[#1b4332] active:scale-95 transition-all flex items-center justify-center gap-2 group"
|
||||
>
|
||||
ADICIONAR PRÓTESE <ChevronRight size={16} className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedEsp && !isProtese && (
|
||||
<div className="space-y-2">
|
||||
{filteredProcedimentos.length === 0 ? (
|
||||
<div className="bg-amber-50 border-2 border-amber-100 rounded-2xl p-4 text-center">
|
||||
<p className="text-[11px] font-black text-amber-700 uppercase tracking-wide">Nenhum procedimento cadastrado nesta especialidade</p>
|
||||
<p className="text-[10px] font-bold text-amber-500 uppercase mt-1">Cadastre em Procedimentos para usá-lo aqui</p>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={selectedProc?.id || ''}
|
||||
onChange={(e) => {
|
||||
const pr = procedimentos.find(p => p.id === e.target.value);
|
||||
setSelectedProc(pr || null);
|
||||
setSelectedDentes([]);
|
||||
setSelectedArco(null);
|
||||
setArcoFace('');
|
||||
setArcoObs('');
|
||||
}}
|
||||
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 && (
|
||||
<div className="bg-teal-50 p-3 rounded-xl border border-teal-100">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-teal-800">
|
||||
@@ -854,12 +1012,12 @@ export const LancarGTO: React.FC = () => {
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-teal-500 outline-none transition-all uppercase"
|
||||
/>
|
||||
<div className="max-h-72 overflow-y-auto space-y-1">
|
||||
{filteredPatients.length === 0 ? (
|
||||
{patientResults.length === 0 ? (
|
||||
<div className="py-10 text-center text-gray-300 font-bold uppercase text-xs">
|
||||
{patientSearchTerm ? 'NENHUM PACIENTE ENCONTRADO' : 'CARREGANDO...'}
|
||||
{searchingPatients ? 'BUSCANDO...' : (patientSearchTerm ? 'NENHUM PACIENTE ENCONTRADO' : 'CARREGANDO...')}
|
||||
</div>
|
||||
) : (
|
||||
filteredPatients.map(p => (
|
||||
patientResults.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
|
||||
Reference in New Issue
Block a user