From 15a8527f1c85250bb999aa3bd508529615967b51 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Thu, 14 May 2026 16:53:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(pacientes):=20modais=20de=20receita,=20ped?= =?UTF-8?q?ido=20de=20exame=20e=20edi=C3=A7=C3=A3o=20em=204=20p=C3=A1ginas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReceitaModal: gerenciamento de modelos de receita por especialidade com drag-and-drop para reordenar medicamentos e preview de impressão - PedidoExameModal: pedido de exames por catálogo (Raio-X / Tomografia / Outros) com DnD para ordenar, campos de região/observação por item e preview de impressão - EditarPacienteModal: reformulado em 4 páginas (Dados / Família / Endereço / Histórico) com navegação anterior/próximo e modal 2xl - Backend: tabelas modelos_receita, items_receita, pedidos_exame, items_pedido_exame criadas na migração; colunas de endereço adicionadas em pacientes; endpoints CRUD para receitas e pedidos de exame - Integração: botões Receita e Raio-X no menu clínico agora abrem os novos modais reais em vez do placeholder Co-Authored-By: Claude Sonnet 4.6 --- backend/server.js | 151 +++++++++- frontend/constants.ts | 2 +- frontend/services/backend.ts | 39 +++ frontend/types.ts | 50 ++++ frontend/views/PatientsView.tsx | 355 +++++++++++++++------- frontend/views/PedidoExameModal.tsx | 411 ++++++++++++++++++++++++++ frontend/views/ReceitaModal.tsx | 437 ++++++++++++++++++++++++++++ 7 files changed, 1337 insertions(+), 108 deletions(-) create mode 100644 frontend/views/PedidoExameModal.tsx create mode 100644 frontend/views/ReceitaModal.tsx diff --git a/backend/server.js b/backend/server.js index e299991..040c9c0 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1665,16 +1665,165 @@ async function generateIntelligentNotifications() { } } +// --- MODELOS DE RECEITA --- +app.get('/api/modelos-receita', async (req, res) => { + try { + const { especialidadeId } = req.query; + let q = `SELECT m.*, COALESCE(json_agg(i ORDER BY i.ordem) FILTER (WHERE i.id IS NOT NULL), '[]') AS items + FROM modelos_receita m LEFT JOIN items_receita i ON i.modelo_id = m.id`; + const params = []; + if (especialidadeId) { q += ` WHERE m.especialidade_id = $1`; params.push(especialidadeId); } + q += ` GROUP BY m.id ORDER BY m.nome`; + const { rows } = await pool.query(q, params); + res.json(rows.map(r => ({ + id: r.id, nome: r.nome, + especialidadeId: r.especialidade_id, especialidadeNome: r.especialidade_nome, + instrucoes: r.instrucoes, items: r.items || [] + }))); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.post('/api/modelos-receita', async (req, res) => { + try { + const { nome, especialidadeId, especialidadeNome, instrucoes, items = [] } = req.body; + const id = `mr_${Date.now()}`; + await pool.query( + `INSERT INTO modelos_receita (id, nome, especialidade_id, especialidade_nome, instrucoes) VALUES ($1,$2,$3,$4,$5)`, + [id, nome, especialidadeId || null, especialidadeNome || null, instrucoes || null] + ); + for (let i = 0; i < items.length; i++) { + const it = items[i]; + await pool.query( + `INSERT INTO items_receita (id, modelo_id, medicamento, dose, via, frequencia, duracao, instrucoes, ordem) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [`ir_${Date.now()}_${i}`, id, it.medicamento, it.dose || '', it.via || 'VO', it.frequencia || '', it.duracao || '', it.instrucoes || null, i] + ); + } + res.json({ success: true, id }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.put('/api/modelos-receita/:id', async (req, res) => { + try { + const { nome, especialidadeId, especialidadeNome, instrucoes, items = [] } = req.body; + await pool.query( + `UPDATE modelos_receita SET nome=$1, especialidade_id=$2, especialidade_nome=$3, instrucoes=$4 WHERE id=$5`, + [nome, especialidadeId || null, especialidadeNome || null, instrucoes || null, req.params.id] + ); + await pool.query(`DELETE FROM items_receita WHERE modelo_id = $1`, [req.params.id]); + for (let i = 0; i < items.length; i++) { + const it = items[i]; + await pool.query( + `INSERT INTO items_receita (id, modelo_id, medicamento, dose, via, frequencia, duracao, instrucoes, ordem) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [`ir_${Date.now()}_${i}`, req.params.id, it.medicamento, it.dose || '', it.via || 'VO', it.frequencia || '', it.duracao || '', it.instrucoes || null, i] + ); + } + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.delete('/api/modelos-receita/:id', async (req, res) => { + try { + await pool.query(`DELETE FROM items_receita WHERE modelo_id = $1`, [req.params.id]); + await pool.query(`DELETE FROM modelos_receita WHERE id = $1`, [req.params.id]); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// --- PEDIDOS DE EXAME --- +app.get('/api/pedidos-exame', async (req, res) => { + try { + const { pacienteId } = req.query; + let q = `SELECT p.*, COALESCE(json_agg(i ORDER BY i.ordem) FILTER (WHERE i.id IS NOT NULL), '[]') AS items + FROM pedidos_exame p LEFT JOIN items_pedido_exame i ON i.pedido_id = p.id`; + const params = []; + if (pacienteId) { q += ` WHERE p.paciente_id = $1`; params.push(pacienteId); } + q += ` GROUP BY p.id ORDER BY p.created_at DESC LIMIT 50`; + const { rows } = await pool.query(q, params); + res.json(rows.map(r => ({ + id: r.id, pacienteId: r.paciente_id, pacienteNome: r.paciente_nome, + profissionalId: r.profissional_id, profissionalNome: r.profissional_nome, + data: r.data, observacoes: r.observacoes, items: r.items || [] + }))); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.post('/api/pedidos-exame', async (req, res) => { + try { + const { pacienteId, pacienteNome, profissionalId, profissionalNome, data, observacoes, items = [] } = req.body; + const id = `pe_${Date.now()}`; + await pool.query( + `INSERT INTO pedidos_exame (id, paciente_id, paciente_nome, profissional_id, profissional_nome, data, observacoes) VALUES ($1,$2,$3,$4,$5,$6,$7)`, + [id, pacienteId || null, pacienteNome, profissionalId || null, profissionalNome || null, data, observacoes || null] + ); + for (let i = 0; i < items.length; i++) { + const it = items[i]; + await pool.query( + `INSERT INTO items_pedido_exame (id, pedido_id, nome, categoria, regiao, observacao, ordem) VALUES ($1,$2,$3,$4,$5,$6,$7)`, + [`ie_${Date.now()}_${i}`, id, it.nome, it.categoria, it.regiao || null, it.observacao || null, i] + ); + } + res.json({ success: true, id }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + async function runMigrations() { const migrations = [ `ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS grupofamiliarid TEXT`, `ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS grupofamiliarrelacao TEXT`, `ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS indicadorporid TEXT`, + `ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS observacoes TEXT`, + `ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS logradouro TEXT`, + `ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS numero TEXT`, + `ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS complemento TEXT`, + `ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS bairro TEXT`, + `ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS cidade TEXT`, + `ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS estado TEXT`, + `ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS cep TEXT`, + `CREATE TABLE IF NOT EXISTS modelos_receita ( + id TEXT PRIMARY KEY, + nome TEXT NOT NULL, + especialidade_id TEXT, + especialidade_nome TEXT, + instrucoes TEXT, + clinica_id TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + `CREATE TABLE IF NOT EXISTS items_receita ( + id TEXT PRIMARY KEY, + modelo_id TEXT NOT NULL, + medicamento TEXT NOT NULL, + dose TEXT NOT NULL DEFAULT '', + via TEXT NOT NULL DEFAULT 'VO', + frequencia TEXT NOT NULL DEFAULT '', + duracao TEXT NOT NULL DEFAULT '', + instrucoes TEXT, + ordem INTEGER DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS pedidos_exame ( + id TEXT PRIMARY KEY, + paciente_id TEXT, + paciente_nome TEXT, + profissional_id TEXT, + profissional_nome TEXT, + data TEXT NOT NULL, + observacoes TEXT, + clinica_id TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + `CREATE TABLE IF NOT EXISTS items_pedido_exame ( + id TEXT PRIMARY KEY, + pedido_id TEXT NOT NULL, + nome TEXT NOT NULL, + categoria TEXT NOT NULL, + regiao TEXT, + observacao TEXT, + ordem INTEGER DEFAULT 0 + )`, ]; for (const sql of migrations) { try { await pool.query(sql); } catch (e) { console.error('[MIGRATION]', e.message); } } - console.log('[MIGRATION] pacientes columns OK'); + console.log('[MIGRATION] all migrations OK'); } // Start server after all routes are registered diff --git a/frontend/constants.ts b/frontend/constants.ts index e6423e3..feff737 100644 --- a/frontend/constants.ts +++ b/frontend/constants.ts @@ -1 +1 @@ -export const APP_VERSION = 'V1.0.4'; +export const APP_VERSION = 'V1.0.5'; diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index bfe1a3f..6704c4c 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -260,6 +260,45 @@ export const HybridBackend = { return res.json(); }, + getModelosReceita: async (especialidadeId?: string): Promise => { + const url = especialidadeId ? `${API_URL}/modelos-receita?especialidadeId=${especialidadeId}` : `${API_URL}/modelos-receita`; + const res = await apiFetch(url); + const data = await res.json(); + return Array.isArray(data) ? data : []; + }, + + saveModeloReceita: async (modelo: Omit) => { + const res = await apiFetch(`${API_URL}/modelos-receita`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(modelo) + }); + return res.json(); + }, + + updateModeloReceita: async (modelo: import('../types.ts').ModeloReceita) => { + const res = await apiFetch(`${API_URL}/modelos-receita/${modelo.id}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(modelo) + }); + return res.json(); + }, + + deleteModeloReceita: async (id: string) => { + await apiFetch(`${API_URL}/modelos-receita/${id}`, { method: 'DELETE' }); + }, + + savePedidoExame: async (pedido: Omit) => { + const res = await apiFetch(`${API_URL}/pedidos-exame`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pedido) + }); + return res.json(); + }, + + getPedidosExame: async (pacienteId?: string): Promise => { + const url = pacienteId ? `${API_URL}/pedidos-exame?pacienteId=${pacienteId}` : `${API_URL}/pedidos-exame`; + const res = await apiFetch(url); + const data = await res.json(); + return Array.isArray(data) ? data : []; + }, + getDentistas: async (clinicaId?: string): Promise => { const url = clinicaId ? `${API_URL}/dentistas?clinicaId=${clinicaId}` : `${API_URL}/dentistas`; const res = await apiFetch(url); diff --git a/frontend/types.ts b/frontend/types.ts index e7d10c7..5c35045 100644 --- a/frontend/types.ts +++ b/frontend/types.ts @@ -20,6 +20,56 @@ export interface Paciente { grupoFamiliarRelacao?: string; indicadoPorId?: string; indicadoPorNome?: string; + // endereço + logradouro?: string; + numero?: string; + complemento?: string; + bairro?: string; + cidade?: string; + estado?: string; + cep?: string; + // clínico + observacoes?: string; +} + +export interface ItemReceita { + id: string; + medicamento: string; + dose: string; + via: string; + frequencia: string; + duracao: string; + instrucoes?: string; + ordem: number; +} + +export interface ModeloReceita { + id: string; + nome: string; + especialidadeId?: string; + especialidadeNome?: string; + instrucoes?: string; + items: ItemReceita[]; +} + +export interface ItemExame { + id: string; + nome: string; + categoria: string; + regiao?: string; + observacao?: string; + ordem: number; +} + +export interface PedidoExame { + id: string; + pacienteId?: string; + pacienteNome: string; + profissionalId?: string; + profissionalNome?: string; + data: string; + observacoes?: string; + items: ItemExame[]; } export interface Dentista { diff --git a/frontend/views/PatientsView.tsx b/frontend/views/PatientsView.tsx index 10b9f70..2dc48fe 100644 --- a/frontend/views/PatientsView.tsx +++ b/frontend/views/PatientsView.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { Search, Plus, DollarSign, X, Printer, CreditCard, Rocket, Loader2, FolderOpen, CalendarDays, RadioTower, Pencil, Wallet, PlusCircle, Banknote, History, FileText, Calendar, Copy, CheckCircle2, Users, UserCheck } from 'lucide-react'; +import { Search, Plus, DollarSign, X, Printer, CreditCard, Rocket, Loader2, FolderOpen, CalendarDays, RadioTower, Pencil, Wallet, PlusCircle, Banknote, History, FileText, Calendar, Copy, CheckCircle2, Users, UserCheck, ChevronLeft, ChevronRight, Save } from 'lucide-react'; import { HybridBackend } from '../services/backend.ts'; import { Paciente, Dentista, Agendamento, Plano } from '../types.ts'; import { useHybridBackend } from '../hooks/useHybridBackend.ts'; @@ -464,10 +464,15 @@ const HistoricoModal: React.FC<{ patient: Paciente; onClose: () => void }> = ({ ); }; -// ----------- EDITAR PACIENTE MODAL ----------- +// ----------- EDITAR PACIENTE MODAL (4 páginas) ----------- +const ESTADOS_BR = ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB','PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO']; +const EDIT_STEPS = ['Dados', 'Família', 'Endereço', 'Histórico']; + const EditarPacienteModal: React.FC<{ patient: Paciente; onClose: () => void; onSaved: () => void }> = ({ patient, onClose, onSaved }) => { const toast = useToast(); const { data: planos } = useHybridBackend(HybridBackend.getPlanos); + const { data: agendamentos } = useHybridBackend(HybridBackend.getAgendamentos); + const [page, setPage] = useState(0); const [form, setForm] = useState({ ...patient, dataNascimento: toInputDate(patient.dataNascimento) }); const [familiar, setFamiliar] = useState<{ id: string; nome: string } | null>( patient.grupoFamiliarId ? { id: patient.grupoFamiliarId, nome: patient.grupoFamiliarNome || '' } : null @@ -477,14 +482,15 @@ const EditarPacienteModal: React.FC<{ patient: Paciente; onClose: () => void; on patient.indicadoPorId ? { id: patient.indicadoPorId, nome: patient.indicadoPorNome || '' } : null ); + const meusAgendamentos = (agendamentos ?? []).filter(a => a.pacienteNome === patient.nome); + useEffect(() => { const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', h); return () => document.removeEventListener('keydown', h); }, [onClose]); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const handleSubmit = async () => { try { const { grupoFamiliarNome: _gn, indicadoPorNome: _in, ...formData } = form; await HybridBackend.updatePaciente({ @@ -498,106 +504,252 @@ const EditarPacienteModal: React.FC<{ patient: Paciente; onClose: () => void; on } catch { toast.error('ERRO AO SALVAR.'); } }; - const input = "w-full border border-gray-200 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-100 focus:border-blue-300 outline-none"; + const inp = "w-full border border-gray-200 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-100 focus:border-blue-300 outline-none"; return (
-
-
-

- Editar Paciente -

+
+ + {/* Header */} +
+
+ +
+

Editar Paciente

+

{patient.nome}

+
+
-
-
- - setForm(f => ({ ...f, nome: e.target.value.toUpperCase() }))} className={input} /> -
-
-
- - setForm(f => ({ ...f, cpf: e.target.value }))} className={input} /> -
-
- - setForm(f => ({ ...f, telefone: e.target.value }))} className={input} /> -
-
-
-
- - setForm(f => ({ ...f, dataNascimento: e.target.value }))} className={input} /> -
-
- - -
-
-
- {/* Familiar */} - - label="Familiar (vínculo)" - placeholder="Buscar paciente familiar..." - value={familiar} - onSelect={p => { setFamiliar({ id: p.id, nome: p.nome }); }} - onClear={() => { setFamiliar(null); setFamiliarRelacao(''); }} - fetchFn={q => HybridBackend.getPacientes(q).then(r => r.data)} - renderOption={p => ( - - - {p.nome} - {p.cpf} - - )} - /> - {familiar && ( + {/* Step indicator */} +
+
+ {EDIT_STEPS.map((s, i) => ( + + + {i < EDIT_STEPS.length - 1 && ( +
+ )} + + ))} +
+
+ + {/* Page content */} +
+ + {/* Página 1: Dados básicos */} + {page === 0 && (<> +
+ + setForm(f => ({ ...f, nome: e.target.value.toUpperCase() }))} className={inp} /> +
+
- - setFamiliarRelacao(e.target.value.toUpperCase())} - placeholder="EX: MÃE, PAI, IRMÃO..." - className={input} - /> - - {RELACOES_COMUNS.map(r => + + setForm(f => ({ ...f, cpf: e.target.value }))} className={inp} />
- )} - - label="Indicado por" - placeholder="Buscar paciente que indicou..." - value={indicadoPor} - onSelect={p => setIndicadoPor({ id: p.id, nome: p.nome })} - onClear={() => setIndicadoPor(null)} - fetchFn={q => HybridBackend.getPacientes(q).then(r => r.data)} - renderOption={p => ( - - - {p.nome} - {p.cpf} - - )} - /> -
+
+ + setForm(f => ({ ...f, telefone: e.target.value }))} className={inp} /> +
+
+
+
+ + setForm(f => ({ ...f, dataNascimento: e.target.value }))} className={inp} /> +
+
+ + +
+
+
+ + setForm(f => ({ ...f, email: e.target.value }))} className={inp} /> +
+ )} -
- - -
- + {/* Página 2: Família e indicação */} + {page === 1 && ( +
+ + label="Familiar (vínculo)" + placeholder="Buscar paciente familiar..." + value={familiar} + onSelect={p => { setFamiliar({ id: p.id, nome: p.nome }); }} + onClear={() => { setFamiliar(null); setFamiliarRelacao(''); }} + fetchFn={q => HybridBackend.getPacientes(q).then(r => r.data)} + renderOption={p => ( + + + {p.nome} + {p.cpf} + + )} + /> + {familiar && ( +
+ + setFamiliarRelacao(e.target.value.toUpperCase())} + placeholder="EX: MÃE, PAI, IRMÃO..." + className={inp} + /> + + {RELACOES_COMUNS.map(r => +
+ )} + + label="Indicado por" + placeholder="Buscar paciente que indicou..." + value={indicadoPor} + onSelect={p => setIndicadoPor({ id: p.id, nome: p.nome })} + onClear={() => setIndicadoPor(null)} + fetchFn={q => HybridBackend.getPacientes(q).then(r => r.data)} + renderOption={p => ( + + + {p.nome} + {p.cpf} + + )} + /> +
+ )} + + {/* Página 3: Endereço */} + {page === 2 && ( +
+
+
+ + setForm(f => ({ ...f, cep: e.target.value }))} className={inp} placeholder="00000-000" maxLength={9} /> +
+
+ + setForm(f => ({ ...f, logradouro: e.target.value.toUpperCase() }))} className={inp} placeholder="RUA, AV, TRAVESSA..." /> +
+
+
+
+ + setForm(f => ({ ...f, numero: e.target.value }))} className={inp} placeholder="123" /> +
+
+ + setForm(f => ({ ...f, complemento: e.target.value.toUpperCase() }))} className={inp} placeholder="APTO, BLOCO..." /> +
+
+
+
+ + setForm(f => ({ ...f, bairro: e.target.value.toUpperCase() }))} className={inp} placeholder="BAIRRO" /> +
+
+ + +
+
+
+ + setForm(f => ({ ...f, cidade: e.target.value.toUpperCase() }))} className={inp} placeholder="CIDADE" /> +
+
+ )} + + {/* Página 4: Histórico e observações */} + {page === 3 && ( +
+
+ +