From ee3f6c3bb200ca02a8b9582303f4e5261169ef17 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Thu, 14 May 2026 15:54:54 +0200 Subject: [PATCH 1/2] feat(patients): fix date bug, convenio dropdown, redesign grupo familiar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix dataNascimento date format error: convert dd/MM/yyyy → yyyy-MM-dd when loading EditarPacienteModal so validates correctly - Change convênio field from free text to dropdown populated from planos table - Redesign grupo familiar: search a patient as family member instead of named group, with parentesco field (datalist with common options, accepts custom) - GET /api/pacientes: proper camelCase aliases, LEFT JOINs for grupoFamiliarNome and indicadoPorNome, sort parameter support (az/za/convenio/recentes) - Add startup migrations: ADD COLUMN IF NOT EXISTS for grupofamiliarid, grupofamiliarrelacao, indicadorporid on pacientes table - savePaciente: preserve caller-supplied id instead of overwriting with Math.random - Add grupoFamiliarRelacao to Paciente type Co-Authored-By: Claude Sonnet 4.6 --- backend/server.js | 32 ++++- frontend/services/backend.ts | 2 +- frontend/types.ts | 1 + frontend/views/PatientsView.tsx | 200 ++++++++++++++++---------------- 4 files changed, 130 insertions(+), 105 deletions(-) diff --git a/backend/server.js b/backend/server.js index 221df79..e299991 100644 --- a/backend/server.js +++ b/backend/server.js @@ -728,14 +728,27 @@ app.put('/api/notificacoes/:id/read', async (req, res) => { // --- PACIENTES --- app.get('/api/pacientes', async (req, res) => { try { - const { q } = req.query; + const { q, sort } = req.query; + const orderMap = { az: 'p.nome ASC', za: 'p.nome DESC', convenio: 'p.convenio ASC NULLS LAST, p.nome ASC', recentes: 'p.nome ASC' }; + const orderBy = orderMap[sort] || 'p.nome ASC'; + const base = `SELECT p.id, p.nome, p.cpf, p.telefone, p.email, + p.ultimotratamento AS "ultimoTratamento", + p.convenio, p.datanascimento AS "dataNascimento", + p.grupofamiliarid AS "grupoFamiliarId", + pf.nome AS "grupoFamiliarNome", + p.grupofamiliarrelacao AS "grupoFamiliarRelacao", + p.indicadorporid AS "indicadoPorId", + pi.nome AS "indicadoPorNome" + FROM pacientes p + LEFT JOIN pacientes pf ON pf.id = p.grupofamiliarid + LEFT JOIN pacientes pi ON pi.id = p.indicadorporid`; let text, params; if (q && q.trim()) { const term = `%${q.trim().toUpperCase()}%`; - text = `SELECT * FROM pacientes WHERE UPPER(nome) LIKE $1 OR cpf LIKE $1 ORDER BY nome LIMIT 100`; + text = `${base} WHERE UPPER(p.nome) LIKE $1 OR p.cpf LIKE $1 ORDER BY ${orderBy} LIMIT 100`; params = [term]; } else { - text = `SELECT * FROM pacientes ORDER BY nome LIMIT 50`; + text = `${base} ORDER BY ${orderBy} LIMIT 50`; params = []; } const { rows } = await pool.query(text, params); @@ -1652,10 +1665,23 @@ async function generateIntelligentNotifications() { } } +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`, + ]; + for (const sql of migrations) { + try { await pool.query(sql); } catch (e) { console.error('[MIGRATION]', e.message); } + } + console.log('[MIGRATION] pacientes columns OK'); +} + // Start server after all routes are registered app.listen(PORT, '0.0.0.0', async () => { console.log(`[SERVER] Running on http://0.0.0.0:${PORT}`); await loadGoogleCredsFromDb(); + await runMigrations(); generateIntelligentNotifications(); setInterval(generateIntelligentNotifications, 5 * 60 * 1000); }); diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index b3b56b4..bfe1a3f 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -220,7 +220,7 @@ export const HybridBackend = { savePaciente: async (paciente: Partial) => { const data = enforceUppercase(paciente); - const body = { ...data, id: Math.random().toString() }; + const body = { ...data, id: data.id || `p_${Date.now()}` }; const res = await apiFetch(`${API_URL}/pacientes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/frontend/types.ts b/frontend/types.ts index 74a7eff..e7d10c7 100644 --- a/frontend/types.ts +++ b/frontend/types.ts @@ -17,6 +17,7 @@ export interface Paciente { dataNascimento?: string; grupoFamiliarId?: string; grupoFamiliarNome?: string; + grupoFamiliarRelacao?: string; indicadoPorId?: string; indicadoPorNome?: string; } diff --git a/frontend/views/PatientsView.tsx b/frontend/views/PatientsView.tsx index 7e3a5cc..10b9f70 100644 --- a/frontend/views/PatientsView.tsx +++ b/frontend/views/PatientsView.tsx @@ -1,10 +1,19 @@ 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, ChevronRight, Crown } 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 } from 'lucide-react'; import { HybridBackend } from '../services/backend.ts'; -import { Paciente, Dentista, Agendamento, GrupoFamiliar } from '../types.ts'; +import { Paciente, Dentista, Agendamento, Plano } from '../types.ts'; import { useHybridBackend } from '../hooks/useHybridBackend.ts'; import { useToast } from '../contexts/ToastContext.tsx'; +const RELACOES_COMUNS = ['MÃE', 'PAI', 'FILHO', 'FILHA', 'IRMÃO', 'IRMÃ', 'CÔNJUGE', 'AVÔ', 'AVÓ', 'NETO', 'NETA', 'TIO', 'TIA', 'SOBRINHO', 'SOBRINHA', 'PRIMO', 'PRIMA', 'PADRASTO', 'MADRASTA', 'ENTEADO', 'ENTEADA']; + +function toInputDate(str?: string): string { + if (!str) return ''; + if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str; + const m = str.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + return m ? `${m[3]}-${m[2]}-${m[1]}` : ''; +} + // Debounce hook to avoid excessive API calls on search function useDebounce(value: string, delay: number) { const [debouncedValue, setDebouncedValue] = useState(value); @@ -81,15 +90,8 @@ function AutocompleteField({ ); } -// Modal de membros da família +// Modal de familiar vinculado const FamiliaModal: React.FC<{ paciente: Paciente; onClose: () => void }> = ({ paciente, onClose }) => { - const [membros, setMembros] = useState([]); - const [loading, setLoading] = useState(true); - useEffect(() => { - if (!paciente.grupoFamiliarId) return; - HybridBackend.getMembrosFamilia(paciente.grupoFamiliarId) - .then(setMembros).finally(() => setLoading(false)); - }, [paciente.grupoFamiliarId]); useEffect(() => { const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', h); @@ -98,33 +100,33 @@ const FamiliaModal: React.FC<{ paciente: Paciente; onClose: () => void }> = ({ p return (
-
+
-

{paciente.grupoFamiliarNome}

-

Grupo Familiar

+

Vínculo Familiar

+

{paciente.nome}

-
- {loading &&
} - {!loading && membros.map(m => ( -
-
- {m.is_responsavel ? : } +
+ {paciente.grupoFamiliarId ? ( +
+
+
-
-

{m.nome}

-

{m.cpf}{m.is_responsavel ? ' · Responsável' : ''}

+
+

{paciente.grupoFamiliarNome}

+

{paciente.grupoFamiliarRelacao || 'Familiar'}

- ))} - {!loading && membros.length === 0 &&

Nenhum membro encontrado.

} + ) : ( +

Nenhum familiar vinculado.

+ )}
@@ -287,10 +289,10 @@ const AgendamentoModal: React.FC<{ patient: Paciente, onClose: () => void }> = ( const NovoPacienteModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => { const toast = useToast(); const [form, setForm] = useState({ nome: '', cpf: '', telefone: '', dataNascimento: '', convenio: '', email: '' }); - const [grupo, setGrupo] = useState(null); - const [criandoGrupo, setCriandoGrupo] = useState(false); - const [novoGrupoNome, setNovoGrupoNome] = useState(''); + const [familiar, setFamiliar] = useState<{ id: string; nome: string } | null>(null); + const [familiarRelacao, setFamiliarRelacao] = useState(''); const [indicadoPor, setIndicadoPor] = useState<{ id: string; nome: string } | null>(null); + const { data: planos } = useHybridBackend(HybridBackend.getPlanos); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; @@ -298,14 +300,6 @@ const NovoPacienteModal: React.FC<{ onClose: () => void; onSaved: () => void }> return () => document.removeEventListener('keydown', handleKeyDown); }, [onClose]); - const criarGrupo = async () => { - if (!novoGrupoNome.trim()) return; - const g = await HybridBackend.criarGrupoFamiliar(novoGrupoNome); - setGrupo(g); - setCriandoGrupo(false); - setNovoGrupoNome(''); - }; - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!form.nome || !form.cpf) { toast.error('PREENCHA NOME E CPF.'); return; } @@ -313,7 +307,8 @@ const NovoPacienteModal: React.FC<{ onClose: () => void; onSaved: () => void }> const id = Date.now().toString(); await HybridBackend.savePaciente({ ...form, id, - grupoFamiliarId: grupo?.id, + grupoFamiliarId: familiar?.id, + grupoFamiliarRelacao: familiarRelacao || undefined, indicadoPorId: indicadoPor?.id, } as any); toast.success('PACIENTE CADASTRADO!'); @@ -333,7 +328,6 @@ const NovoPacienteModal: React.FC<{ onClose: () => void; onSaved: () => void }>
- {/* Dados básicos */}
setForm(f => ({ ...f, nome: e.target.value.toUpperCase() }))} className={input} placeholder="NOME COMPLETO" /> @@ -355,7 +349,10 @@ const NovoPacienteModal: React.FC<{ onClose: () => void; onSaved: () => void }>
- setForm(f => ({ ...f, convenio: e.target.value.toUpperCase() }))} className={input} placeholder="Ex: CASSEMS" /> +
@@ -363,38 +360,36 @@ const NovoPacienteModal: React.FC<{ onClose: () => void; onSaved: () => void }> setForm(f => ({ ...f, email: e.target.value }))} className={input} placeholder="email@exemplo.com" />
- {/* Separador */}
- {/* Grupo Familiar */} - - label="Grupo Familiar" - placeholder="Buscar grupo existente..." - value={grupo} - onSelect={g => setGrupo(g)} - onClear={() => setGrupo(null)} - fetchFn={q => HybridBackend.getGruposFamiliares(q)} - renderOption={g => ( + {/* 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 => ( - {g.nome} - {g.membros} membro{g.membros !== 1 ? 's' : ''} + {p.nome} + {p.cpf} )} - extraAction={ - - } /> - {criandoGrupo && ( -
- setNovoGrupoNome(e.target.value.toUpperCase())} - onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); criarGrupo(); } }} - placeholder="NOME DO GRUPO (EX: FAMÍLIA SILVA)" - className="flex-1 border border-indigo-200 rounded-lg px-3 py-2 text-xs focus:ring-2 focus:ring-indigo-100 outline-none" /> - - + {familiar && ( +
+ + setFamiliarRelacao(e.target.value.toUpperCase())} + placeholder="EX: MÃE, PAI, IRMÃO..." + className={input} + /> + + {RELACOES_COMUNS.map(r =>
)} @@ -472,12 +467,12 @@ const HistoricoModal: React.FC<{ patient: Paciente; onClose: () => void }> = ({ // ----------- EDITAR PACIENTE MODAL ----------- const EditarPacienteModal: React.FC<{ patient: Paciente; onClose: () => void; onSaved: () => void }> = ({ patient, onClose, onSaved }) => { const toast = useToast(); - const [form, setForm] = useState({ ...patient }); - const [grupo, setGrupo] = useState( + const { data: planos } = useHybridBackend(HybridBackend.getPlanos); + 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 ); - const [criandoGrupo, setCriandoGrupo] = useState(false); - const [novoGrupoNome, setNovoGrupoNome] = useState(''); + const [familiarRelacao, setFamiliarRelacao] = useState(patient.grupoFamiliarRelacao || ''); const [indicadoPor, setIndicadoPor] = useState<{ id: string; nome: string } | null>( patient.indicadoPorId ? { id: patient.indicadoPorId, nome: patient.indicadoPorNome || '' } : null ); @@ -488,16 +483,16 @@ const EditarPacienteModal: React.FC<{ patient: Paciente; onClose: () => void; on return () => document.removeEventListener('keydown', h); }, [onClose]); - const criarGrupo = async () => { - if (!novoGrupoNome.trim()) return; - const g = await HybridBackend.criarGrupoFamiliar(novoGrupoNome); - setGrupo(g); setCriandoGrupo(false); setNovoGrupoNome(''); - }; - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { - await HybridBackend.updatePaciente({ ...form, grupoFamiliarId: grupo?.id, indicadoPorId: indicadoPor?.id }); + const { grupoFamiliarNome: _gn, indicadoPorNome: _in, ...formData } = form; + await HybridBackend.updatePaciente({ + ...formData, + grupoFamiliarId: familiar?.id, + grupoFamiliarRelacao: familiarRelacao || undefined, + indicadoPorId: indicadoPor?.id, + } as any); toast.success('PACIENTE ATUALIZADO!'); onSaved(); onClose(); } catch { toast.error('ERRO AO SALVAR.'); } @@ -536,40 +531,43 @@ const EditarPacienteModal: React.FC<{ patient: Paciente; onClose: () => void; on
- setForm(f => ({ ...f, convenio: e.target.value.toUpperCase() }))} className={input} /> +
- - label="Grupo Familiar" - placeholder="Buscar grupo existente..." - value={grupo} - onSelect={g => setGrupo(g)} - onClear={() => setGrupo(null)} - fetchFn={q => HybridBackend.getGruposFamiliares(q)} - renderOption={g => ( + {/* 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 => ( - {g.nome} - {g.membros} membro{g.membros !== 1 ? 's' : ''} + {p.nome} + {p.cpf} )} - extraAction={ - - } /> - {criandoGrupo && ( -
- setNovoGrupoNome(e.target.value.toUpperCase())} - onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); criarGrupo(); } }} - placeholder="NOME DO GRUPO" - className="flex-1 border border-indigo-200 rounded-lg px-3 py-2 text-xs focus:ring-2 focus:ring-indigo-100 outline-none" /> - - + {familiar && ( +
+ + setFamiliarRelacao(e.target.value.toUpperCase())} + placeholder="EX: MÃE, PAI, IRMÃO..." + className={input} + /> + + {RELACOES_COMUNS.map(r =>
)} @@ -747,7 +745,7 @@ export const PatientsView: React.FC = () => { {p.grupoFamiliarNome && ( )} {p.indicadoPorNome && ( From 3d287a45794a87b560847a805a585739c43182c6 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Thu, 14 May 2026 15:59:24 +0200 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20bump=20APP=5FVERSION=20V1.0.3=20?= =?UTF-8?q?=E2=86=92=20V1.0.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- frontend/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/constants.ts b/frontend/constants.ts index 5294422..e6423e3 100644 --- a/frontend/constants.ts +++ b/frontend/constants.ts @@ -1 +1 @@ -export const APP_VERSION = 'V1.0.3'; +export const APP_VERSION = 'V1.0.4';