diff --git a/backend/server.js b/backend/server.js index 267a62a..4c2f4e6 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4445,6 +4445,39 @@ app.put('/api/gto-builder/:id/finalizar', authGuard, async (req, res) => { await registrarAudit(pool, { clinicaId: b.clinica_id, entidade: 'gto', entidadeId: b.id, acao: 'finalizou_gto', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { total, financeiro_id: financeiroId } }); schedulePush('financeiro'); } + + // Prótese (Fase 1): cada item de GTO ligado a um produto do protético abre uma OS + // de prótese (cai na Bancada do laboratório, com etapas/fotos/garantia já existentes). + // Idempotente por gto_item_id; isolado em try/catch para nunca derrubar o finalize. + try { + const { rows: protItems } = await pool.query( + 'SELECT * FROM gto_items WHERE builderid = $1 AND produto_id IS NOT NULL AND protetico_id IS NOT NULL', + [req.params.id]); + const uid = req.authUser?.userId; + for (const it of protItems) { + const { rows: jaTem } = await pool.query('SELECT id FROM protese_os WHERE gto_item_id = $1 LIMIT 1', [it.id]); + if (jaTem.length) continue; + const { rows: pr } = await pool.query( + 'SELECT id, nome, preco, garantia_meses FROM protese_produtos WHERE id=$1 AND protetico_id=$2 AND deleted_at IS NULL', + [it.produto_id, it.protetico_id]); + if (!pr.length) continue; + const { rows: prot } = await pool.query("SELECT nome FROM usuarios WHERE id=$1 AND role='protetico'", [it.protetico_id]); + const osId = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + await pool.query( + `INSERT INTO protese_os (id, clinica_id, protetico_id, protetico_nome, paciente_id, paciente_nome, dentista_id, procedimento_id, tipo, produto_id, tipo_os, garantia_meses, dentes, observacoes, valor, custo, status, created_by, gto_item_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'nova',$11,$12,$13,$14,$15,'solicitado',$16,$17)`, + [osId, b.clinica_id, it.protetico_id, prot[0]?.nome || null, b.pacienteid, b.paciente_nome, + b.dentistaid, it.procedimentoid || null, pr[0].nome, pr[0].id, Number(pr[0].garantia_meses) || 12, + it.dente || null, it.observacao || null, Number(it.valortotal) || 0, parseFloat(pr[0].preco || 0), uid, it.id]); + await pool.query( + `INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,'solicitado',$4,$5,$6)`, + [`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, osId, b.clinica_id, 'OS criada a partir da GTO', uid, await actorNome(uid)]); + await registrarAudit(pool, { clinicaId: b.clinica_id, entidade: 'protese_os', entidadeId: osId, acao: 'criou', actorId: uid, actorNome: await actorNome(uid), detalhes: { origem: 'gto', gto_item_id: it.id, tipo: pr[0].nome } }); + await notificarUsuario(it.protetico_id, 'Nova ordem de prótese', `Você recebeu uma nova OS (${pr[0].nome}) gerada por GTO. Veja na Bancada.`); + wsBroadcast(b.clinica_id, 'protese', { entidadeId: osId, acao: 'criou' }); + } + } catch (e) { console.error('[GTO→protese_os]', e.message); } + res.json({ success: true, financeiro_id: financeiroId }); } catch (err) { res.status(500).json({ error: err.message }); } }); @@ -5630,6 +5663,13 @@ async function runMigrations() { `ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS garantia_meses INTEGER DEFAULT 12`, `ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS garantia_ate DATE`, `ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS financeiro_id TEXT`, // DESPESA gerada na entrega + `ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS gto_item_id TEXT`, // item de GTO que originou a OS (Fase 1 prótese↔GTO) + // Prótese no Lançar GTO: o item de GTO referencia um produto do catálogo do protético. + `ALTER TABLE gto_items ADD COLUMN IF NOT EXISTS produto_id TEXT`, + `ALTER TABLE gto_items ADD COLUMN IF NOT EXISTS protetico_id TEXT`, + // Especialidade de prótese vinha marcada como 'odontologia' (legado) → re-tag para 'protese' + // permite o front detectar a especialidade e puxar o catálogo do protético. Idempotente. + `UPDATE especialidades SET area='protese' WHERE area IS DISTINCT FROM 'protese' AND (nome ILIKE '%protese%' OR nome ILIKE '%prótese%')`, `CREATE TABLE IF NOT EXISTS protese_produtos ( id TEXT PRIMARY KEY, protetico_id TEXT NOT NULL, -- dono (usuário protético) diff --git a/frontend/views/LancarGTO.tsx b/frontend/views/LancarGTO.tsx index b7b18c5..8737037 100644 --- a/frontend/views/LancarGTO.tsx +++ b/frontend/views/LancarGTO.tsx @@ -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([]); + const [selectedLab, setSelectedLab] = useState(''); + const [labProdutos, setLabProdutos] = useState([]); + const [selectedProdutoId, setSelectedProdutoId] = useState(''); + const [protValor, setProtValor] = useState(''); + const [protObs, setProtObs] = useState(''); + 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([]); + 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 => )} - {selectedEsp && ( -
+ {selectedEsp && isProtese && ( +
+ {selectedLab && (labProdutos.length === 0 ? ( +
+

Este laboratório não tem produtos ativos

+
+ ) : ( + <> + + + {selectedProduto && ( + <> +
+ + 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" + /> +
+