feat(clinico): adicionar botões tratamentos, GTOs, agendamentos e relatórios ao menu clínico

- Menu clínico expandido para 8 botões em grade 4 colunas (2 no mobile)
- AGENDAMENTOS: reutiliza HistoricoModal existente
- GTOs: exporta useGTOStore do LancarGTO, pré-preenche paciente e navega
  para /lancargto via hash
- TRATAMENTOS: novo modal com stats (total/concluídos/faltas), resumo de
  procedimentos realizados e histórico cronológico
- RELATÓRIOS: novo modal com visão completa imprimível — dados pessoais,
  endereço, vínculos, resumo de atendimentos e observações clínicas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-05-14 18:17:15 +02:00
parent 173c1c777e
commit 6cb8169d62
2 changed files with 239 additions and 7 deletions
+2
View File
@@ -62,6 +62,8 @@ const useGTOStore = create<GTOStore>((set) => ({
clear: () => set({ paciente: null, dentista: null, items: [] }), clear: () => set({ paciente: null, dentista: null, items: [] }),
})); }));
export { useGTOStore };
// --- ODONTOGRAM DATA (4x2 Grid Layout) --- // --- ODONTOGRAM DATA (4x2 Grid Layout) ---
const DENTES_ADULTO = { const DENTES_ADULTO = {
q1: [14, 13, 12, 11, 18, 17, 16, 15], // Top: 14-11, Bottom: 18-15 q1: [14, 13, 12, 11, 18, 17, 16, 15], // Top: 14-11, Bottom: 18-15
+237 -7
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; 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, ChevronLeft, ChevronRight, Save } 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, Stethoscope, ClipboardList, BarChart3, MapPin } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts'; import { HybridBackend } from '../services/backend.ts';
import { Paciente, Dentista, Agendamento, Plano } from '../types.ts'; import { Paciente, Dentista, Agendamento, Plano } from '../types.ts';
import { useHybridBackend } from '../hooks/useHybridBackend.ts'; import { useHybridBackend } from '../hooks/useHybridBackend.ts';
@@ -750,6 +750,218 @@ const EditarPacienteModal: React.FC<{ patient: Paciente; onClose: () => void; on
import { PageHeader } from '../components/PageHeader.tsx'; import { PageHeader } from '../components/PageHeader.tsx';
import { ReceitaModal } from './ReceitaModal.tsx'; import { ReceitaModal } from './ReceitaModal.tsx';
import { PedidoExameModal } from './PedidoExameModal.tsx'; import { PedidoExameModal } from './PedidoExameModal.tsx';
import { useGTOStore } from './LancarGTO.tsx';
// ----------- TRATAMENTOS MODAL -----------
const TratamentosModal: React.FC<{ patient: Paciente; onClose: () => void }> = ({ patient, onClose }) => {
const { data: agendamentos, isLoading } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
const meus = (agendamentos ?? []).filter(a => a.pacienteNome === patient.nome);
const concluidos = meus.filter(a => a.status === 'Concluido');
const faltou = meus.filter(a => a.status === 'Faltou');
const procedureCounts = concluidos.reduce<Record<string, number>>((acc, a) => {
acc[a.procedimento] = (acc[a.procedimento] || 0) + 1;
return acc;
}, {});
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [onClose]);
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center backdrop-blur-sm p-0">
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-2xl flex flex-col animate-in fade-in zoom-in-50 duration-200">
<div className="p-5 border-b border-gray-100 flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-teal-100 rounded-lg flex items-center justify-center"><Stethoscope size={16} className="text-teal-600" /></div>
<div>
<p className="text-xs font-black text-gray-800 uppercase">Tratamentos</p>
<p className="text-[10px] text-gray-400 font-medium uppercase">{patient.nome}</p>
</div>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={18} /></button>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-3 p-5 flex-shrink-0">
<div className="bg-blue-50 rounded-xl p-3 text-center">
<p className="text-2xl font-black text-blue-600">{meus.length}</p>
<p className="text-[10px] font-bold text-blue-400 uppercase">Total Consultas</p>
</div>
<div className="bg-green-50 rounded-xl p-3 text-center">
<p className="text-2xl font-black text-green-600">{concluidos.length}</p>
<p className="text-[10px] font-bold text-green-400 uppercase">Concluídas</p>
</div>
<div className="bg-red-50 rounded-xl p-3 text-center">
<p className="text-2xl font-black text-red-600">{faltou.length}</p>
<p className="text-[10px] font-bold text-red-400 uppercase">Faltas</p>
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 pb-5 space-y-5">
{/* Procedimentos realizados */}
{Object.keys(procedureCounts).length > 0 && (
<div>
<p className="text-[10px] font-black text-gray-500 uppercase tracking-wider mb-2">Procedimentos Realizados</p>
<div className="space-y-1.5">
{Object.entries(procedureCounts).sort(([,a],[,b]) => b - a).map(([proc, count]) => (
<div key={proc} className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
<p className="text-xs font-bold text-gray-700 uppercase">{proc}</p>
<span className="text-[10px] font-black bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">{count}x</span>
</div>
))}
</div>
</div>
)}
{/* Histórico cronológico */}
<div>
<p className="text-[10px] font-black text-gray-500 uppercase tracking-wider mb-2">Histórico Cronológico ({meus.length})</p>
{isLoading && <p className="text-xs text-gray-400 text-center py-4">Carregando...</p>}
{!isLoading && meus.length === 0 && <p className="text-xs text-gray-400 text-center py-4 border-2 border-dashed border-gray-100 rounded-xl">Nenhum atendimento registrado.</p>}
<div className="space-y-1.5">
{[...meus].sort((a, b) => new Date(b.start).getTime() - new Date(a.start).getTime()).map(a => (
<div key={a.id} className="flex items-center justify-between border border-gray-100 rounded-lg p-2.5">
<div className="min-w-0 flex-1">
<p className="font-bold text-gray-800 text-xs uppercase truncate">{a.procedimento}</p>
<p className="text-[10px] text-gray-400">{new Date(a.start).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' })}</p>
</div>
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase flex-shrink-0 ml-2 ${
a.status === 'Concluido' ? 'bg-green-100 text-green-700' :
a.status === 'Confirmado' ? 'bg-blue-100 text-blue-700' :
a.status === 'Pendente' ? 'bg-amber-100 text-amber-700' :
'bg-red-100 text-red-600'
}`}>{a.status}</span>
</div>
))}
</div>
</div>
</div>
<div className="p-4 border-t border-gray-100 flex justify-end flex-shrink-0">
<button onClick={onClose} className="px-5 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs font-bold rounded-lg uppercase">Fechar</button>
</div>
</div>
</div>
);
};
// ----------- RELATORIO PACIENTE MODAL -----------
const RelatorioPacienteModal: React.FC<{ patient: Paciente; onClose: () => void }> = ({ patient, onClose }) => {
const { data: agendamentos } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
const meus = (agendamentos ?? []).filter(a => a.pacienteNome === patient.nome);
const concluidos = meus.filter(a => a.status === 'Concluido').length;
const faltou = meus.filter(a => a.status === 'Faltou').length;
const hoje = new Date().toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' });
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [onClose]);
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center backdrop-blur-sm p-0">
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-2xl flex flex-col animate-in fade-in zoom-in-50 duration-200">
<div className="p-4 border-b border-gray-100 flex items-center justify-between flex-shrink-0 print:hidden">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-violet-100 rounded-lg flex items-center justify-center"><BarChart3 size={16} className="text-violet-600" /></div>
<div>
<p className="text-xs font-black text-gray-800 uppercase">Relatório do Paciente</p>
<p className="text-[10px] text-gray-400 font-medium uppercase">{patient.nome}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button onClick={() => window.print()} className="flex items-center gap-1.5 px-3 py-2 bg-gray-900 text-white rounded-lg text-xs font-bold hover:bg-black">
<Printer size={13} /> Imprimir
</button>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={18} /></button>
</div>
</div>
<div className="flex-1 overflow-y-auto bg-gray-50 p-6 print:p-0 print:bg-white">
<div className="max-w-2xl mx-auto bg-white rounded-2xl shadow-sm border border-gray-200 p-8 print:shadow-none print:rounded-none print:border-0 print:max-w-full space-y-6">
{/* Cabeçalho */}
<div className="border-b-2 border-gray-800 pb-4">
<h1 className="text-lg font-black text-gray-900 uppercase">Relatório do Paciente</h1>
<p className="text-xs text-gray-500 mt-1">Gerado em {hoje}</p>
</div>
{/* Dados pessoais */}
<div>
<h2 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3 border-b border-gray-100 pb-1">Dados Pessoais</h2>
<div className="grid grid-cols-2 gap-3 text-sm">
<div><span className="text-gray-400 text-xs font-bold uppercase block">Nome</span><p className="font-bold text-gray-800 uppercase">{patient.nome}</p></div>
<div><span className="text-gray-400 text-xs font-bold uppercase block">CPF</span><p className="font-bold text-gray-800">{patient.cpf || '—'}</p></div>
<div><span className="text-gray-400 text-xs font-bold uppercase block">Telefone</span><p className="font-bold text-gray-800">{patient.telefone || '—'}</p></div>
<div><span className="text-gray-400 text-xs font-bold uppercase block">E-mail</span><p className="font-bold text-gray-800">{patient.email || '—'}</p></div>
<div><span className="text-gray-400 text-xs font-bold uppercase block">Nascimento</span><p className="font-bold text-gray-800">{patient.dataNascimento || '—'}</p></div>
<div><span className="text-gray-400 text-xs font-bold uppercase block">Convênio</span><p className="font-bold text-gray-800">{patient.convenio || 'Particular'}</p></div>
</div>
</div>
{/* Endereço */}
{(patient.logradouro || patient.cidade) && (
<div>
<h2 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3 border-b border-gray-100 pb-1 flex items-center gap-1"><MapPin size={11} /> Endereço</h2>
<p className="text-sm text-gray-700">
{[patient.logradouro, patient.numero, patient.complemento].filter(Boolean).join(', ')}
{patient.bairro && <span className="block">{patient.bairro}{patient.cidade && `${patient.cidade}/${patient.estado}`}</span>}
{patient.cep && <span className="block text-gray-400 text-xs">CEP {patient.cep}</span>}
</p>
</div>
)}
{/* Vínculos */}
{(patient.grupoFamiliarNome || patient.indicadoPorNome) && (
<div>
<h2 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3 border-b border-gray-100 pb-1">Vínculos</h2>
<div className="grid grid-cols-2 gap-3 text-sm">
{patient.grupoFamiliarNome && <div><span className="text-gray-400 text-xs font-bold uppercase block">Familiar</span><p className="font-bold text-gray-800 uppercase">{patient.grupoFamiliarNome} {patient.grupoFamiliarRelacao && `(${patient.grupoFamiliarRelacao})`}</p></div>}
{patient.indicadoPorNome && <div><span className="text-gray-400 text-xs font-bold uppercase block">Indicado por</span><p className="font-bold text-gray-800 uppercase">{patient.indicadoPorNome}</p></div>}
</div>
</div>
)}
{/* Resumo de atendimentos */}
<div>
<h2 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3 border-b border-gray-100 pb-1">Resumo de Atendimentos</h2>
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="bg-blue-50 rounded-xl p-3 text-center"><p className="text-xl font-black text-blue-600">{meus.length}</p><p className="text-[10px] font-bold text-blue-400 uppercase">Total</p></div>
<div className="bg-green-50 rounded-xl p-3 text-center"><p className="text-xl font-black text-green-600">{concluidos}</p><p className="text-[10px] font-bold text-green-400 uppercase">Concluídos</p></div>
<div className="bg-red-50 rounded-xl p-3 text-center"><p className="text-xl font-black text-red-600">{faltou}</p><p className="text-[10px] font-bold text-red-400 uppercase">Faltas</p></div>
</div>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{[...meus].sort((a, b) => new Date(b.start).getTime() - new Date(a.start).getTime()).slice(0, 20).map(a => (
<div key={a.id} className="flex items-center justify-between border border-gray-100 rounded-lg px-3 py-2">
<div><p className="text-xs font-bold text-gray-800 uppercase">{a.procedimento}</p><p className="text-[10px] text-gray-400">{new Date(a.start).toLocaleDateString('pt-BR')}</p></div>
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${a.status === 'Concluido' ? 'bg-green-100 text-green-700' : a.status === 'Faltou' ? 'bg-red-100 text-red-600' : 'bg-amber-100 text-amber-700'}`}>{a.status}</span>
</div>
))}
</div>
</div>
{/* Observações */}
{patient.observacoes && (
<div>
<h2 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3 border-b border-gray-100 pb-1">Observações Clínicas</h2>
<p className="text-sm text-gray-700 italic">{patient.observacoes}</p>
</div>
)}
<div className="pt-6 border-t-2 border-gray-800 flex justify-between items-end">
<div><p className="text-xs text-gray-500">Assinatura / Responsável</p><div className="h-8" /></div>
<p className="text-xs text-gray-400">{hoje}</p>
</div>
</div>
</div>
</div>
</div>
);
};
const CopyField: React.FC<{ label: string; value?: string | null }> = ({ label, value }) => { const CopyField: React.FC<{ label: string; value?: string | null }> = ({ label, value }) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -950,13 +1162,21 @@ export const PatientsView: React.FC = () => {
<div className="flex items-center gap-3 text-gray-700 font-bold text-xl"><FolderOpen size={28} className="text-gray-400" />Menu Clínico</div> <div className="flex items-center gap-3 text-gray-700 font-bold text-xl"><FolderOpen size={28} className="text-gray-400" />Menu Clínico</div>
<button onClick={closeModal} className="text-gray-400 hover:text-gray-600"><X size={20} /></button> <button onClick={closeModal} className="text-gray-400 hover:text-gray-600"><X size={20} /></button>
</div> </div>
<p className="text-gray-500 text-sm mb-8 font-bold uppercase">{modal.patient.nome}</p> <p className="text-gray-500 text-sm mb-6 font-bold uppercase">{modal.patient.nome}</p>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-4 flex-1 content-start">
<button onClick={() => setModal({ type: 'agendamento', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 bg-white border border-gray-200 rounded-xl hover:border-orange-400 hover:bg-orange-50/50 transition-all group shadow-sm"><CalendarDays size={32} className="text-orange-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">AGENDAR</span></button> <button onClick={() => setModal({ type: 'agendamento', patient: modal.patient })} className="flex flex-col items-center justify-center p-5 bg-white border border-gray-200 rounded-xl hover:border-orange-400 hover:bg-orange-50/50 transition-all group shadow-sm"><CalendarDays size={32} className="text-orange-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm text-center">AGENDAR</span></button>
<button onClick={() => setModal({ type: 'doc-receita', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 bg-white border border-gray-200 rounded-xl hover:border-red-400 hover:bg-red-50/50 transition-all group shadow-sm"><FileText size={32} className="text-red-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">RECEITA</span></button> <button onClick={() => setModal({ type: 'historico', patient: modal.patient })} className="flex flex-col items-center justify-center p-5 bg-white border border-gray-200 rounded-xl hover:border-sky-400 hover:bg-sky-50/50 transition-all group shadow-sm"><Calendar size={32} className="text-sky-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm text-center">AGENDAMENTOS</span></button>
<button onClick={() => setModal({ type: 'doc-raiox', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 bg-white border border-gray-200 rounded-xl hover:border-purple-400 hover:bg-purple-50/50 transition-all group shadow-sm"><RadioTower size={32} className="text-purple-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">RAIO-X</span></button> <button onClick={() => setModal({ type: 'tratamentos', patient: modal.patient })} className="flex flex-col items-center justify-center p-5 bg-white border border-gray-200 rounded-xl hover:border-teal-400 hover:bg-teal-50/50 transition-all group shadow-sm"><Stethoscope size={32} className="text-teal-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm text-center">TRATAMENTOS</span></button>
<button onClick={() => setModal({ type: 'editar', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 bg-white border border-gray-200 rounded-xl hover:border-blue-400 hover:bg-blue-50/50 transition-all group shadow-sm"><Pencil size={32} className="text-blue-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">EDITAR DADOS</span></button> <button onClick={() => {
useGTOStore.getState().setPaciente(modal.patient);
closeModal();
window.location.hash = '#/lancargto';
}} className="flex flex-col items-center justify-center p-5 bg-white border border-gray-200 rounded-xl hover:border-yellow-400 hover:bg-yellow-50/50 transition-all group shadow-sm"><ClipboardList size={32} className="text-yellow-600 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm text-center">GTOs</span></button>
<button onClick={() => setModal({ type: 'doc-receita', patient: modal.patient })} className="flex flex-col items-center justify-center p-5 bg-white border border-gray-200 rounded-xl hover:border-red-400 hover:bg-red-50/50 transition-all group shadow-sm"><FileText size={32} className="text-red-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm text-center">RECEITA</span></button>
<button onClick={() => setModal({ type: 'doc-raiox', patient: modal.patient })} className="flex flex-col items-center justify-center p-5 bg-white border border-gray-200 rounded-xl hover:border-purple-400 hover:bg-purple-50/50 transition-all group shadow-sm"><RadioTower size={32} className="text-purple-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm text-center">RAIO-X</span></button>
<button onClick={() => setModal({ type: 'relatorio-paciente', patient: modal.patient })} className="flex flex-col items-center justify-center p-5 bg-white border border-gray-200 rounded-xl hover:border-violet-400 hover:bg-violet-50/50 transition-all group shadow-sm"><BarChart3 size={32} className="text-violet-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm text-center">RELATÓRIOS</span></button>
<button onClick={() => setModal({ type: 'editar', patient: modal.patient })} className="flex flex-col items-center justify-center p-5 bg-white border border-gray-200 rounded-xl hover:border-blue-400 hover:bg-blue-50/50 transition-all group shadow-sm"><Pencil size={32} className="text-blue-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm text-center">EDITAR DADOS</span></button>
</div> </div>
</div> </div>
</div> </div>
@@ -977,6 +1197,16 @@ export const PatientsView: React.FC = () => {
<PedidoExameModal paciente={modal.patient} onClose={closeModal} /> <PedidoExameModal paciente={modal.patient} onClose={closeModal} />
)} )}
{/* TRATAMENTOS MODAL */}
{modal.type === 'tratamentos' && modal.patient && (
<TratamentosModal patient={modal.patient} onClose={closeModal} />
)}
{/* RELATÓRIO PACIENTE MODAL */}
{modal.type === 'relatorio-paciente' && modal.patient && (
<RelatorioPacienteModal patient={modal.patient} onClose={closeModal} />
)}
{/* MODAL MENU FINANCEIRO */} {/* MODAL MENU FINANCEIRO */}
{modal.type === 'financial' && modal.patient && ( {modal.type === 'financial' && modal.patient && (
<div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm p-0"> <div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm p-0">