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:
VPS 4 Builder
2026-06-23 17:49:36 +02:00
parent d005fe77a9
commit 321ca7e127
2 changed files with 225 additions and 27 deletions
+40
View File
@@ -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)
+185 -27
View File
@@ -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={() => {