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:
@@ -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)
|
||||
|
||||
+172
-14
@@ -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,8 +480,82 @@ export const LancarGTO: React.FC = () => {
|
||||
{especialidades.map(es => <option key={es.id} value={es.id}>{es.nome}</option>)}
|
||||
</select>
|
||||
|
||||
{selectedEsp && (
|
||||
{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)' : ''}</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) => {
|
||||
@@ -414,6 +571,7 @@ export const LancarGTO: React.FC = () => {
|
||||
<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">
|
||||
@@ -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