feat(pacientes): modais de receita, pedido de exame e edição em 4 páginas

- 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 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-05-14 16:53:28 +02:00
parent 3d287a4579
commit 15a8527f1c
7 changed files with 1337 additions and 108 deletions
+249 -106
View File
@@ -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<Plano[]>(HybridBackend.getPlanos);
const { data: agendamentos } = useHybridBackend<Agendamento[]>(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 (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg animate-in fade-in zoom-in-50 duration-200">
<div className="p-5 border-b border-gray-100 flex justify-between items-center">
<h2 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2">
<Pencil size={15} className="text-blue-500" /> Editar Paciente
</h2>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl flex flex-col max-h-[90vh] animate-in fade-in zoom-in-50 duration-200">
{/* Header */}
<div className="p-5 border-b border-gray-100 flex justify-between items-center flex-shrink-0">
<div className="flex items-center gap-2">
<Pencil size={15} className="text-blue-500" />
<div>
<h2 className="text-sm font-black text-gray-800 uppercase">Editar Paciente</h2>
<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>
<form onSubmit={handleSubmit} className="p-5 space-y-4 max-h-[80vh] overflow-y-auto">
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Nome Completo</label>
<input required value={form.nome} onChange={e => setForm(f => ({ ...f, nome: e.target.value.toUpperCase() }))} className={input} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">CPF</label>
<input value={form.cpf} onChange={e => setForm(f => ({ ...f, cpf: e.target.value }))} className={input} />
</div>
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Telefone</label>
<input value={form.telefone} onChange={e => setForm(f => ({ ...f, telefone: e.target.value }))} className={input} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Nascimento</label>
<input type="date" value={form.dataNascimento} onChange={e => setForm(f => ({ ...f, dataNascimento: e.target.value }))} className={input} />
</div>
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Convênio</label>
<select value={form.convenio || ''} onChange={e => setForm(f => ({ ...f, convenio: e.target.value }))} className={input}>
<option value=""> Particular </option>
{planos?.map(p => <option key={p.id} value={p.nome}>{p.nome}</option>)}
</select>
</div>
</div>
<div className="border-t border-gray-100 pt-3 space-y-3">
{/* Familiar */}
<AutocompleteField<Paciente>
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 => (
<span className="flex items-center gap-2 w-full">
<Users size={12} className="text-indigo-400 flex-shrink-0" />
<span className="font-bold flex-1">{p.nome}</span>
<span className="text-[10px] text-gray-400">{p.cpf}</span>
</span>
)}
/>
{familiar && (
{/* Step indicator */}
<div className="px-6 pt-4 pb-0 flex-shrink-0">
<div className="flex items-center">
{EDIT_STEPS.map((s, i) => (
<React.Fragment key={i}>
<button onClick={() => setPage(i)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] font-black uppercase transition-all whitespace-nowrap ${
page === i ? 'bg-blue-600 text-white' : i < page ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-400'
}`}>
<span className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-black">{i < page ? '✓' : i + 1}</span>
{s}
</button>
{i < EDIT_STEPS.length - 1 && (
<div className={`flex-1 h-0.5 mx-1 ${i < page ? 'bg-green-300' : 'bg-gray-200'}`} />
)}
</React.Fragment>
))}
</div>
</div>
{/* Page content */}
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
{/* Página 1: Dados básicos */}
{page === 0 && (<>
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Nome Completo</label>
<input required value={form.nome} onChange={e => setForm(f => ({ ...f, nome: e.target.value.toUpperCase() }))} className={inp} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Parentesco</label>
<input
list="relacoes-list-editar"
value={familiarRelacao}
onChange={e => setFamiliarRelacao(e.target.value.toUpperCase())}
placeholder="EX: MÃE, PAI, IRMÃO..."
className={input}
/>
<datalist id="relacoes-list-editar">
{RELACOES_COMUNS.map(r => <option key={r} value={r} />)}
</datalist>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">CPF</label>
<input value={form.cpf} onChange={e => setForm(f => ({ ...f, cpf: e.target.value }))} className={inp} />
</div>
)}
<AutocompleteField<Paciente>
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 => (
<span className="flex items-center gap-2 w-full">
<UserCheck size={12} className="text-green-400 flex-shrink-0" />
<span className="font-bold flex-1">{p.nome}</span>
<span className="text-[10px] text-gray-400">{p.cpf}</span>
</span>
)}
/>
</div>
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Telefone</label>
<input value={form.telefone} onChange={e => setForm(f => ({ ...f, telefone: e.target.value }))} className={inp} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Nascimento</label>
<input type="date" value={form.dataNascimento} onChange={e => setForm(f => ({ ...f, dataNascimento: e.target.value }))} className={inp} />
</div>
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Convênio</label>
<select value={form.convenio || ''} onChange={e => setForm(f => ({ ...f, convenio: e.target.value }))} className={inp}>
<option value=""> Particular </option>
{planos?.map(p => <option key={p.id} value={p.nome}>{p.nome}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">E-mail</label>
<input type="email" value={form.email || ''} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} className={inp} />
</div>
</>)}
<div className="flex justify-end gap-2 pt-2">
<button type="button" onClick={onClose} className="px-5 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold rounded-lg text-xs uppercase">Cancelar</button>
<button type="submit" className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-lg text-xs uppercase">Salvar</button>
</div>
</form>
{/* Página 2: Família e indicação */}
{page === 1 && (
<div className="space-y-4">
<AutocompleteField<Paciente>
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 => (
<span className="flex items-center gap-2 w-full">
<Users size={12} className="text-indigo-400 flex-shrink-0" />
<span className="font-bold flex-1">{p.nome}</span>
<span className="text-[10px] text-gray-400">{p.cpf}</span>
</span>
)}
/>
{familiar && (
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Parentesco</label>
<input
list="relacoes-list-editar"
value={familiarRelacao}
onChange={e => setFamiliarRelacao(e.target.value.toUpperCase())}
placeholder="EX: MÃE, PAI, IRMÃO..."
className={inp}
/>
<datalist id="relacoes-list-editar">
{RELACOES_COMUNS.map(r => <option key={r} value={r} />)}
</datalist>
</div>
)}
<AutocompleteField<Paciente>
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 => (
<span className="flex items-center gap-2 w-full">
<UserCheck size={12} className="text-green-400 flex-shrink-0" />
<span className="font-bold flex-1">{p.nome}</span>
<span className="text-[10px] text-gray-400">{p.cpf}</span>
</span>
)}
/>
</div>
)}
{/* Página 3: Endereço */}
{page === 2 && (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">CEP</label>
<input value={form.cep || ''} onChange={e => setForm(f => ({ ...f, cep: e.target.value }))} className={inp} placeholder="00000-000" maxLength={9} />
</div>
<div className="col-span-2">
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Logradouro</label>
<input value={form.logradouro || ''} onChange={e => setForm(f => ({ ...f, logradouro: e.target.value.toUpperCase() }))} className={inp} placeholder="RUA, AV, TRAVESSA..." />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Número</label>
<input value={form.numero || ''} onChange={e => setForm(f => ({ ...f, numero: e.target.value }))} className={inp} placeholder="123" />
</div>
<div className="col-span-2">
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Complemento</label>
<input value={form.complemento || ''} onChange={e => setForm(f => ({ ...f, complemento: e.target.value.toUpperCase() }))} className={inp} placeholder="APTO, BLOCO..." />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Bairro</label>
<input value={form.bairro || ''} onChange={e => setForm(f => ({ ...f, bairro: e.target.value.toUpperCase() }))} className={inp} placeholder="BAIRRO" />
</div>
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">UF</label>
<select value={form.estado || ''} onChange={e => setForm(f => ({ ...f, estado: e.target.value }))} className={inp}>
<option value=""></option>
{ESTADOS_BR.map(uf => <option key={uf}>{uf}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Cidade</label>
<input value={form.cidade || ''} onChange={e => setForm(f => ({ ...f, cidade: e.target.value.toUpperCase() }))} className={inp} placeholder="CIDADE" />
</div>
</div>
)}
{/* Página 4: Histórico e observações */}
{page === 3 && (
<div className="space-y-4">
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Observações Clínicas</label>
<textarea
rows={4}
value={form.observacoes || ''}
onChange={e => setForm(f => ({ ...f, observacoes: e.target.value }))}
className={inp + " resize-none"}
placeholder="ALERGIAS, HISTÓRICO MÉDICO, OBSERVAÇÕES..."
/>
</div>
<div>
<p className="text-[10px] font-black text-gray-500 uppercase tracking-wider mb-2">
Histórico de Atendimentos <span className="text-blue-600">({meusAgendamentos.length})</span>
</p>
{meusAgendamentos.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 max-h-52 overflow-y-auto">
{meusAgendamentos.map(a => (
<div key={a.id} className="flex items-center justify-between border border-gray-100 rounded-lg p-2.5 bg-gray-50/70">
<div>
<p className="font-bold text-gray-800 text-xs uppercase">{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 ${
a.status === 'Confirmado' ? 'bg-green-100 text-green-700' :
a.status === 'Pendente' ? 'bg-amber-100 text-amber-700' :
a.status === 'Faltou' ? 'bg-red-100 text-red-600' :
'bg-gray-100 text-gray-600'
}`}>{a.status}</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
{/* Footer navigation */}
<div className="p-4 border-t border-gray-100 flex justify-between items-center flex-shrink-0">
<button
type="button"
onClick={() => page > 0 ? setPage(p => p - 1) : onClose()}
className="px-5 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold rounded-lg text-xs uppercase flex items-center gap-1.5"
>
<ChevronLeft size={14} /> {page === 0 ? 'Cancelar' : 'Voltar'}
</button>
{page < EDIT_STEPS.length - 1 ? (
<button
type="button"
onClick={() => setPage(p => p + 1)}
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-lg text-xs uppercase flex items-center gap-1.5"
>
Próximo <ChevronRight size={14} />
</button>
) : (
<button
type="button"
onClick={handleSubmit}
className="px-5 py-2 bg-green-600 hover:bg-green-700 text-white font-bold rounded-lg text-xs uppercase flex items-center gap-1.5"
>
<Save size={14} /> Salvar Tudo
</button>
)}
</div>
</div>
</div>
);
};
import { PageHeader } from '../components/PageHeader.tsx';
import { ReceitaModal } from './ReceitaModal.tsx';
import { PedidoExameModal } from './PedidoExameModal.tsx';
const CopyField: React.FC<{ label: string; value?: string | null }> = ({ label, value }) => {
const [copied, setCopied] = useState(false);
@@ -679,11 +831,6 @@ export const PatientsView: React.FC = () => {
const saldoAParcelar = valorTotalNum - descontoNum - entradaNum;
const valorParcela = saldoAParcelar > 0 && asaasForm.parcelas > 0 ? saldoAParcelar / Number(asaasForm.parcelas) : 0;
const handleDocGeneration = () => {
toast.success(`DOCUMENTO GERADO E SALVO NO DRIVE PARA ${modal.patient?.nome}!`);
closeModal();
};
const handleAsaasSubmit = (e: React.FormEvent) => {
e.preventDefault();
toast.success(`COBRANÇA GERADA NO ASAAS PARA ${modal.patient?.nome}!`);
@@ -820,18 +967,14 @@ export const PatientsView: React.FC = () => {
<AgendamentoModal patient={modal.patient} onClose={closeModal} />
)}
{/* MODAL DOCUMENTOS */}
{(modal.type === 'doc-receita' || modal.type === 'doc-raiox') && (
<div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm">
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-md w-full animate-in fade-in zoom-in-50 duration-200">
<h3 className="text-xl font-bold mb-4 uppercase">GERAR {modal.type === 'doc-receita' ? 'RECEITA' : 'PEDIDO DE RAIO-X'}</h3>
<p className="text-gray-600 mb-4 text-sm font-bold uppercase">O SISTEMA IRÁ GERAR UM DOCUMENTO NO GOOGLE DOCS USANDO O MODELO PADRÃO.</p>
<div className="flex justify-end gap-3">
<button onClick={closeModal} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg text-xs font-bold uppercase">CANCELAR</button>
<button onClick={handleDocGeneration} className="px-4 py-2 bg-gray-800 text-white rounded-lg flex items-center gap-2 text-xs font-bold uppercase"><Printer size={16} /> GERAR PDF</button>
</div>
</div>
</div>
{/* RECEITA MODAL */}
{modal.type === 'doc-receita' && modal.patient && (
<ReceitaModal paciente={modal.patient} onClose={closeModal} />
)}
{/* PEDIDO DE EXAME MODAL */}
{modal.type === 'doc-raiox' && modal.patient && (
<PedidoExameModal paciente={modal.patient} onClose={closeModal} />
)}
{/* MODAL MENU FINANCEIRO */}
+411
View File
@@ -0,0 +1,411 @@
import React, { useState, useEffect } from 'react';
import { X, Plus, GripVertical, Trash2, Printer, ChevronLeft, Search, Microscope, Activity, Image, FileX } from 'lucide-react';
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
import { HybridBackend } from '../services/backend.ts';
import { PedidoExame, ItemExame, Paciente, Dentista } from '../types.ts';
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
const CATALOG: { categoria: string; icon: React.ReactNode; cor: string; items: { nome: string; regiao?: string }[] }[] = [
{
categoria: 'RAIO-X',
icon: <Image size={14} />,
cor: 'blue',
items: [
{ nome: 'Periapical' },
{ nome: 'Interproximal (Bite-Wing)' },
{ nome: 'Panorâmica' },
{ nome: 'Oclusal Superior' },
{ nome: 'Oclusal Inferior' },
{ nome: 'Cefalométrica Lateral' },
{ nome: 'P.A. de Face' },
],
},
{
categoria: 'TOMOGRAFIA',
icon: <Activity size={14} />,
cor: 'purple',
items: [
{ nome: 'Cone Beam — Dente Único', regiao: '' },
{ nome: 'Cone Beam — Terço Anterior' },
{ nome: 'Cone Beam — Arcada Completa Superior' },
{ nome: 'Cone Beam — Arcada Completa Inferior' },
{ nome: 'Cone Beam — Ambas Arcadas' },
{ nome: 'ATM (Articulação Temporomandibular)' },
],
},
{
categoria: 'OUTROS',
icon: <Microscope size={14} />,
cor: 'green',
items: [
{ nome: 'Cefalometria Analítica' },
{ nome: 'Fotografia Clínica Extraoral' },
{ nome: 'Fotografia Clínica Intraoral' },
{ nome: 'Modelos de Gesso / Escaneamento' },
{ nome: 'Biópsia' },
{ nome: 'Exame Laboratorial' },
],
},
];
const COR: Record<string, { bg: string; text: string; border: string; chip: string }> = {
blue: { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200', chip: 'bg-blue-100 text-blue-700' },
purple: { bg: 'bg-purple-50', text: 'text-purple-700', border: 'border-purple-200', chip: 'bg-purple-100 text-purple-700' },
green: { bg: 'bg-green-50', text: 'text-green-700', border: 'border-green-200', chip: 'bg-green-100 text-green-700' },
};
// --------------- Print ---------------
const PrintView: React.FC<{ pedido: PedidoExame; dentista?: Dentista; onClose: () => void }> = ({ pedido, dentista, onClose }) => {
const hoje = new Date().toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' });
const grupos = CATALOG.map(c => ({ ...c, selecionados: pedido.items.filter(it => it.categoria === c.categoria) })).filter(c => c.selecionados.length > 0);
return (
<div className="flex flex-col h-full">
<div className="p-4 border-b border-gray-100 flex items-center justify-between print:hidden">
<div className="flex items-center gap-2">
<button onClick={onClose} className="text-gray-400 hover:text-gray-700"><ChevronLeft size={18} /></button>
<h3 className="text-sm font-black text-gray-800 uppercase">Pré-visualização</h3>
</div>
<button onClick={() => window.print()} className="flex items-center gap-2 px-4 py-2 bg-gray-900 text-white rounded-lg text-xs font-bold hover:bg-black">
<Printer size={14} /> Imprimir / Salvar PDF
</button>
</div>
<div className="flex-1 overflow-y-auto bg-gray-100 p-6 print:p-0 print:bg-white">
<div className="max-w-xl 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">
<div className="text-center border-b-2 border-gray-800 pb-4 mb-6">
<h1 className="text-lg font-black text-gray-900 uppercase">PEDIDO DE EXAMES</h1>
{dentista && <p className="text-sm font-bold text-gray-700 mt-1">{dentista.nome} CRO: {dentista.cro || '—'}/{dentista.cro_uf || '—'}</p>}
</div>
<div className="mb-6 grid grid-cols-2 gap-3 text-sm">
<div><span className="text-gray-500 text-xs uppercase font-bold">Paciente</span><p className="font-bold text-gray-900 uppercase">{pedido.pacienteNome}</p></div>
<div><span className="text-gray-500 text-xs uppercase font-bold">Data</span><p className="font-bold text-gray-900">{hoje}</p></div>
</div>
<div className="space-y-5">
{grupos.map(g => (
<div key={g.categoria}>
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-2 border-b border-gray-100 pb-1">{g.categoria}</h3>
<ul className="space-y-1.5">
{g.selecionados.map((it, i) => (
<li key={it.id} className="flex items-start gap-2 text-sm text-gray-800">
<span className="font-black text-gray-400 w-5 flex-shrink-0">{i + 1}.</span>
<div>
<span className="font-bold">{it.nome}</span>
{it.regiao && <span className="text-gray-500"> Região: {it.regiao}</span>}
{it.observacao && <p className="text-xs text-gray-500 italic mt-0.5">{it.observacao}</p>}
</div>
</li>
))}
</ul>
</div>
))}
</div>
{pedido.observacoes && (
<div className="mt-5 p-3 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-800 italic">
<span className="font-black not-italic uppercase">Obs: </span>{pedido.observacoes}
</div>
)}
<div className="mt-12 pt-6 border-t-2 border-gray-800 flex justify-between items-end">
<div><p className="text-xs text-gray-500">Assinatura e Carimbo</p><div className="h-10" /></div>
<p className="text-xs text-gray-400">{hoje}</p>
</div>
</div>
</div>
</div>
);
};
// --------------- Detail chip for exam item ---------------
const ItemChip: React.FC<{
item: ItemExame;
onChange: (v: Partial<ItemExame>) => void;
onRemove: () => void;
dragHandle: React.ReactNode;
isDragging: boolean;
}> = ({ item, onChange, onRemove, dragHandle, isDragging }) => {
const [expanded, setExpanded] = useState(false);
const cat = CATALOG.find(c => c.categoria === item.categoria);
const cor = COR[cat?.cor || 'blue'];
return (
<div className={`rounded-xl border transition-all ${isDragging ? 'shadow-lg' : ''} ${cor.border} bg-white`}>
<div className="flex items-center gap-2 px-3 py-2.5">
<span className="text-gray-300 hover:text-gray-500 cursor-grab flex-shrink-0">{dragHandle}</span>
<span className={`text-[9px] font-black px-1.5 py-0.5 rounded-full flex-shrink-0 ${cor.chip}`}>{item.categoria}</span>
<p className="flex-1 text-xs font-bold text-gray-800 truncate">{item.nome}</p>
<button onClick={() => setExpanded(e => !e)} className={`text-[9px] font-bold px-2 py-0.5 rounded-full border flex-shrink-0 ${expanded ? 'bg-gray-100 text-gray-600 border-gray-200' : 'text-gray-400 border-gray-200 hover:bg-gray-50'}`}>
{expanded ? 'fechar' : 'detalhe'}
</button>
<button onClick={onRemove} className="text-gray-300 hover:text-red-500 flex-shrink-0"><Trash2 size={13} /></button>
</div>
{expanded && (
<div className="px-3 pb-3 pt-0 grid grid-cols-2 gap-2">
<div>
<label className="text-[9px] font-black text-gray-400 uppercase">Região / Dente</label>
<input value={item.regiao || ''} onChange={e => onChange({ regiao: e.target.value })}
placeholder="ex: dente 16, arcada inf."
className="w-full border border-gray-200 rounded-lg px-2.5 py-1.5 text-xs focus:ring-2 focus:ring-blue-100 outline-none" />
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase">Observação</label>
<input value={item.observacao || ''} onChange={e => onChange({ observacao: e.target.value })}
placeholder="ex: urgente, pós-cirúrgico"
className="w-full border border-gray-200 rounded-lg px-2.5 py-1.5 text-xs focus:ring-2 focus:ring-blue-100 outline-none" />
</div>
</div>
)}
</div>
);
};
// --------------- Modal principal ---------------
export const PedidoExameModal: React.FC<{ paciente: Paciente; onClose: () => void }> = ({ paciente, onClose }) => {
const toast = useToast();
const { data: dentistas = [] } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
const [items, setItems] = useState<ItemExame[]>([]);
const [observacoes, setObservacoes] = useState('');
const [busca, setBusca] = useState('');
const [catAtiva, setCatAtiva] = useState<string | null>(null);
const [view, setView] = useState<'editor' | 'print'>('editor');
const [saving, setSaving] = useState(false);
const dentistaPrincipal = dentistas[0];
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (view !== 'editor') setView('editor'); else onClose(); } };
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [view, onClose]);
const catalogFiltrado = CATALOG.map(c => ({
...c,
items: c.items.filter(it => {
if (catAtiva && c.categoria !== catAtiva) return false;
if (busca && !it.nome.toLowerCase().includes(busca.toLowerCase())) return false;
return true;
}),
})).filter(c => c.items.length > 0);
const addItem = (catNome: string, itemNome: string, regiao?: string) => {
if (items.some(it => it.nome === itemNome && it.categoria === catNome)) {
toast.error('EXAME JÁ ADICIONADO');
return;
}
setItems(prev => [...prev, {
id: `ex_${Date.now()}_${Math.random()}`,
nome: itemNome,
categoria: catNome,
regiao: regiao || '',
observacao: '',
ordem: prev.length,
}]);
};
const updateItem = (id: string, patch: Partial<ItemExame>) => {
setItems(prev => prev.map(it => it.id === id ? { ...it, ...patch } : it));
};
const removeItem = (id: string) => setItems(prev => prev.filter(it => it.id !== id));
const onDragEnd = (result: DropResult) => {
if (!result.destination) return;
const list = [...items];
const [moved] = list.splice(result.source.index, 1);
list.splice(result.destination.index, 0, moved);
setItems(list.map((it, i) => ({ ...it, ordem: i })));
};
const handleGerarPedido = async () => {
if (items.length === 0) { toast.error('ADICIONE AO MENOS UM EXAME'); return; }
setSaving(true);
try {
await HybridBackend.savePedidoExame({
pacienteId: paciente.id,
pacienteNome: paciente.nome,
profissionalId: dentistaPrincipal?.id,
profissionalNome: dentistaPrincipal?.nome,
data: new Date().toISOString().slice(0, 10),
observacoes: observacoes || undefined,
items,
});
setView('print');
} catch { toast.error('ERRO AO SALVAR PEDIDO'); } finally { setSaving(false); }
};
const pedidoParaImprimir: PedidoExame = {
id: 'preview',
pacienteId: paciente.id,
pacienteNome: paciente.nome,
profissionalId: dentistaPrincipal?.id,
profissionalNome: dentistaPrincipal?.nome,
data: new Date().toISOString().slice(0, 10),
observacoes,
items,
};
if (view === 'print') {
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-3">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl h-[85vh] flex flex-col overflow-hidden">
<PrintView pedido={pedidoParaImprimir} dentista={dentistaPrincipal} onClose={() => setView('editor')} />
</div>
</div>
);
}
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-3">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-4xl h-[90vh] flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<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-indigo-100 rounded-lg flex items-center justify-center"><FileX size={15} className="text-indigo-600" /></div>
<div>
<h2 className="text-sm font-black text-gray-800 uppercase">Pedido de Exames</h2>
<p className="text-[10px] text-gray-400 uppercase">{paciente.nome}</p>
</div>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={18} /></button>
</div>
{/* Body: catálogo + selecionados */}
<div className="flex flex-1 overflow-hidden">
{/* Catálogo (esquerda) */}
<div className="w-80 flex-shrink-0 border-r border-gray-100 flex flex-col">
<div className="p-3 border-b border-gray-100 space-y-2">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar exame..."
className="w-full pl-8 pr-3 py-2 border border-gray-200 rounded-lg text-xs focus:ring-2 focus:ring-blue-100 outline-none" />
</div>
<div className="flex gap-1 flex-wrap">
<button onClick={() => setCatAtiva(null)}
className={`text-[10px] font-bold px-2 py-0.5 rounded-full border transition-colors ${!catAtiva ? 'bg-gray-800 text-white border-gray-800' : 'text-gray-500 border-gray-200 hover:bg-gray-50'}`}>
Todos
</button>
{CATALOG.map(c => {
const cor = COR[c.cor];
return (
<button key={c.categoria} onClick={() => setCatAtiva(a => a === c.categoria ? null : c.categoria)}
className={`text-[10px] font-bold px-2 py-0.5 rounded-full border transition-colors ${catAtiva === c.categoria ? cor.chip + ' border-transparent' : 'text-gray-500 border-gray-200 hover:bg-gray-50'}`}>
{c.categoria}
</button>
);
})}
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{catalogFiltrado.map(cat => {
const cor = COR[cat.cor];
return (
<div key={cat.categoria}>
<div className={`flex items-center gap-1.5 mb-2 ${cor.text}`}>
{cat.icon}
<span className="text-[10px] font-black uppercase tracking-wider">{cat.categoria}</span>
</div>
<div className="space-y-1">
{cat.items.map(it => {
const jaSelecionado = items.some(s => s.nome === it.nome && s.categoria === cat.categoria);
return (
<button key={it.nome}
onClick={() => addItem(cat.categoria, it.nome, it.regiao)}
disabled={jaSelecionado}
className={`w-full text-left px-3 py-2 rounded-lg text-xs transition-all flex items-center justify-between group ${
jaSelecionado
? `${cor.bg} ${cor.text} font-bold opacity-60 cursor-default`
: `border border-gray-100 hover:${cor.bg} hover:${cor.border} hover:${cor.text} text-gray-600 font-medium`
}`}>
<span>{it.nome}</span>
{jaSelecionado
? <span className="text-[9px] font-black opacity-70"></span>
: <Plus size={12} className="opacity-0 group-hover:opacity-100 flex-shrink-0" />}
</button>
);
})}
</div>
</div>
);
})}
{catalogFiltrado.length === 0 && (
<p className="text-xs text-gray-400 text-center py-6">Nenhum exame encontrado</p>
)}
</div>
</div>
{/* Selecionados (direita) */}
<div className="flex-1 flex flex-col overflow-hidden">
<div className="p-3 border-b border-gray-100 flex items-center justify-between">
<p className="text-[10px] font-black text-gray-500 uppercase tracking-wider">
Exames Selecionados {items.length > 0 && <span className="text-blue-600 ml-1">({items.length})</span>}
</p>
{items.length > 0 && (
<button onClick={() => setItems([])} className="text-[10px] text-red-500 hover:text-red-700 font-bold">Limpar tudo</button>
)}
</div>
<div className="flex-1 overflow-y-auto p-3">
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="w-16 h-16 bg-gray-100 rounded-2xl flex items-center justify-center mb-3">
<FileX size={24} className="text-gray-300" />
</div>
<p className="text-sm font-bold text-gray-400">Nenhum exame selecionado</p>
<p className="text-xs text-gray-300 mt-1">Clique nos exames ao lado para adicionar</p>
</div>
) : (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="exames-selecionados">
{provided => (
<div ref={provided.innerRef} {...provided.droppableProps} className="space-y-2">
{items.map((it, idx) => (
<Draggable key={it.id} draggableId={it.id} index={idx}>
{(prov, snap) => (
<div ref={prov.innerRef} {...prov.draggableProps}>
<ItemChip
item={it}
isDragging={snap.isDragging}
dragHandle={<span {...prov.dragHandleProps}><GripVertical size={15} /></span>}
onChange={patch => updateItem(it.id, patch)}
onRemove={() => removeItem(it.id)}
/>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)}
</div>
{/* Observações + ação */}
<div className="p-3 border-t border-gray-100 space-y-3 flex-shrink-0">
<div>
<label className="block text-[9px] font-black text-gray-400 uppercase tracking-wider mb-1">Observações (opcional)</label>
<input value={observacoes} onChange={e => setObservacoes(e.target.value)}
placeholder="ex: urgente, pós-cirúrgico, encaminhar para..."
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-xs focus:ring-2 focus:ring-blue-100 outline-none" />
</div>
<div className="flex gap-2 justify-end">
<button onClick={onClose} className="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold rounded-lg text-xs uppercase">Cancelar</button>
<button
onClick={handleGerarPedido}
disabled={items.length === 0 || saving}
className="flex items-center gap-2 px-5 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-bold rounded-lg text-xs uppercase disabled:opacity-40">
<Printer size={14} /> {saving ? 'Salvando...' : 'Gerar Pedido'}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
+437
View File
@@ -0,0 +1,437 @@
import React, { useState, useEffect, useCallback } from 'react';
import { X, Plus, GripVertical, Trash2, Pencil, FileText, ChevronLeft, Printer, Save, Search, Tag, ClipboardList } from 'lucide-react';
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
import { HybridBackend } from '../services/backend.ts';
import { ModeloReceita, ItemReceita, Especialidade, Paciente, Dentista } from '../types.ts';
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
const VIAS = ['VO', 'SL', 'IV', 'IM', 'SC', 'Tópico', 'Inalatória', 'Colírio', 'Nasal'];
const FREQUENCIAS = ['1x/dia', '2x/dia', '3x/dia', '4x/dia', '6/6h', '8/8h', '12/12h', '24/24h', 'Dose única', 'Se necessário'];
const DURACOES = ['1 dia', '2 dias', '3 dias', '5 dias', '7 dias', '10 dias', '14 dias', '21 dias', '30 dias', 'Contínuo'];
const ITEM_VAZIO: Omit<ItemReceita, 'id' | 'ordem'> = { medicamento: '', dose: '', via: 'VO', frequencia: '8/8h', duracao: '7 dias', instrucoes: '' };
// --------------- Editor de item de medicamento ---------------
const ItemEditor: React.FC<{
item: Omit<ItemReceita, 'id' | 'ordem'>;
onChange: (v: Omit<ItemReceita, 'id' | 'ordem'>) => void;
onSave: () => void;
onCancel: () => void;
}> = ({ item, onChange, onSave, onCancel }) => {
const inp = "w-full border border-gray-200 rounded-lg px-3 py-2 text-xs focus:ring-2 focus:ring-blue-100 focus:border-blue-300 outline-none bg-white";
const sel = inp + " appearance-none";
return (
<div className="bg-blue-50 border border-blue-100 rounded-xl p-3 space-y-2">
<div>
<label className="text-[9px] font-black text-gray-500 uppercase tracking-wider">Medicamento</label>
<input autoFocus value={item.medicamento} onChange={e => onChange({ ...item, medicamento: e.target.value.toUpperCase() })}
placeholder="EX: AMOXICILINA 500MG" className={inp} />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-[9px] font-black text-gray-500 uppercase tracking-wider">Dose</label>
<input value={item.dose} onChange={e => onChange({ ...item, dose: e.target.value })} placeholder="EX: 500mg" className={inp} />
</div>
<div>
<label className="text-[9px] font-black text-gray-500 uppercase tracking-wider">Via</label>
<select value={item.via} onChange={e => onChange({ ...item, via: e.target.value })} className={sel}>
{VIAS.map(v => <option key={v}>{v}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-[9px] font-black text-gray-500 uppercase tracking-wider">Frequência</label>
<select value={item.frequencia} onChange={e => onChange({ ...item, frequencia: e.target.value })} className={sel}>
{FREQUENCIAS.map(f => <option key={f}>{f}</option>)}
</select>
</div>
<div>
<label className="text-[9px] font-black text-gray-500 uppercase tracking-wider">Duração</label>
<select value={item.duracao} onChange={e => onChange({ ...item, duracao: e.target.value })} className={sel}>
{DURACOES.map(d => <option key={d}>{d}</option>)}
</select>
</div>
</div>
<div>
<label className="text-[9px] font-black text-gray-500 uppercase tracking-wider">Instruções extras (opcional)</label>
<input value={item.instrucoes || ''} onChange={e => onChange({ ...item, instrucoes: e.target.value })}
placeholder="EX: TOMAR APÓS AS REFEIÇÕES" className={inp} />
</div>
<div className="flex justify-end gap-2 pt-1">
<button onClick={onCancel} className="px-3 py-1.5 bg-white border border-gray-200 text-gray-600 rounded-lg text-xs font-bold hover:bg-gray-50">Cancelar</button>
<button onClick={onSave} disabled={!item.medicamento.trim()} className="px-3 py-1.5 bg-blue-600 text-white rounded-lg text-xs font-bold hover:bg-blue-700 disabled:opacity-40 flex items-center gap-1">
<Save size={12} /> Confirmar
</button>
</div>
</div>
);
};
// --------------- Editor de modelo ---------------
const ModeloEditor: React.FC<{
modelo: ModeloReceita | null;
especialidades: Especialidade[];
onSave: (m: ModeloReceita) => void;
onBack: () => void;
}> = ({ modelo, especialidades, onSave, onBack }) => {
const toast = useToast();
const [nome, setNome] = useState(modelo?.nome || '');
const [espId, setEspId] = useState(modelo?.especialidadeId || '');
const [instrucoes, setInstrucoes] = useState(modelo?.instrucoes || '');
const [items, setItems] = useState<ItemReceita[]>(modelo?.items || []);
const [editingIdx, setEditingIdx] = useState<number | null>(null);
const [newItem, setNewItem] = useState<Omit<ItemReceita, 'id' | 'ordem'>>(ITEM_VAZIO);
const [adding, setAdding] = useState(false);
const [saving, setSaving] = useState(false);
const espNome = especialidades.find(e => e.id === espId)?.nome || '';
const onDragEnd = (result: DropResult) => {
if (!result.destination) return;
const list = [...items];
const [moved] = list.splice(result.source.index, 1);
list.splice(result.destination.index, 0, moved);
setItems(list.map((it, i) => ({ ...it, ordem: i })));
};
const addItem = () => {
if (!newItem.medicamento.trim()) return;
setItems(prev => [...prev, { ...newItem, id: `new_${Date.now()}`, ordem: prev.length }]);
setNewItem(ITEM_VAZIO);
setAdding(false);
};
const updateItem = (idx: number, val: Omit<ItemReceita, 'id' | 'ordem'>) => {
setItems(prev => prev.map((it, i) => i === idx ? { ...it, ...val } : it));
};
const removeItem = (idx: number) => setItems(prev => prev.filter((_, i) => i !== idx));
const handleSave = async () => {
if (!nome.trim()) { toast.error('NOME DO MODELO OBRIGATÓRIO'); return; }
setSaving(true);
try {
const payload = { nome: nome.toUpperCase(), especialidadeId: espId || undefined, especialidadeNome: espNome || undefined, instrucoes: instrucoes || undefined, items };
if (modelo?.id) {
await HybridBackend.updateModeloReceita({ ...payload, id: modelo.id });
} else {
const res = await HybridBackend.saveModeloReceita(payload);
onSave({ ...payload, id: res.id, items });
return;
}
onSave({ ...payload, id: modelo.id, items });
} catch { toast.error('ERRO AO SALVAR MODELO'); } finally { setSaving(false); }
};
const inp = "w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:ring-2 focus:ring-blue-100 focus:border-blue-300 outline-none bg-white";
return (
<div className="flex flex-col h-full">
<div className="p-5 border-b border-gray-100 flex items-center gap-3">
<button onClick={onBack} className="text-gray-400 hover:text-gray-700 flex-shrink-0"><ChevronLeft size={18} /></button>
<h3 className="text-sm font-black text-gray-800 uppercase">{modelo ? 'Editar Modelo' : 'Novo Modelo'}</h3>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-4">
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Nome do Modelo *</label>
<input value={nome} onChange={e => setNome(e.target.value.toUpperCase())} className={inp} placeholder="EX: ANTIBIÓTICO PÓS-CIRÚRGICO" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Especialidade</label>
<select value={espId} onChange={e => setEspId(e.target.value)} className={inp + " text-sm"}>
<option value=""> Geral </option>
{especialidades.map(e => <option key={e.id} value={e.id}>{e.nome}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Instrução Geral (opcional)</label>
<input value={instrucoes} onChange={e => setInstrucoes(e.target.value)} className={inp} placeholder="EX: USO EM CASO DE INFECÇÃO ODONTOGÊNICA" />
</div>
{/* Lista de medicamentos */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-[10px] font-black text-gray-500 uppercase tracking-wider">Medicamentos ({items.length})</label>
{!adding && <button onClick={() => setAdding(true)} className="flex items-center gap-1 text-xs font-bold text-blue-600 hover:text-blue-800">
<Plus size={13} /> Adicionar
</button>}
</div>
{adding && (
<div className="mb-2">
<ItemEditor item={newItem} onChange={setNewItem} onSave={addItem} onCancel={() => { setAdding(false); setNewItem(ITEM_VAZIO); }} />
</div>
)}
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="items-receita">
{provided => (
<div ref={provided.innerRef} {...provided.droppableProps} className="space-y-1.5">
{items.map((it, idx) => (
<Draggable key={it.id} draggableId={it.id} index={idx}>
{(prov, snap) => (
<div ref={prov.innerRef} {...prov.draggableProps}
className={`rounded-xl border transition-shadow ${snap.isDragging ? 'shadow-lg border-blue-300 bg-blue-50' : 'border-gray-200 bg-white'}`}>
{editingIdx === idx ? (
<div className="p-2">
<ItemEditor
item={{ medicamento: it.medicamento, dose: it.dose, via: it.via, frequencia: it.frequencia, duracao: it.duracao, instrucoes: it.instrucoes }}
onChange={v => updateItem(idx, v)}
onSave={() => setEditingIdx(null)}
onCancel={() => setEditingIdx(null)}
/>
</div>
) : (
<div className="flex items-center gap-2 px-3 py-2.5">
<span {...prov.dragHandleProps} className="text-gray-300 hover:text-gray-500 cursor-grab flex-shrink-0"><GripVertical size={15} /></span>
<div className="flex-1 min-w-0">
<p className="text-xs font-bold text-gray-800 uppercase truncate">{it.medicamento}</p>
<p className="text-[10px] text-gray-400">{[it.dose, it.via, it.frequencia, it.duracao].filter(Boolean).join(' · ')}</p>
{it.instrucoes && <p className="text-[10px] text-blue-500 italic">{it.instrucoes}</p>}
</div>
<button onClick={() => setEditingIdx(idx)} className="text-gray-300 hover:text-blue-500 flex-shrink-0"><Pencil size={13} /></button>
<button onClick={() => removeItem(idx)} className="text-gray-300 hover:text-red-500 flex-shrink-0"><Trash2 size={13} /></button>
</div>
)}
</div>
)}
</Draggable>
))}
{provided.placeholder}
{items.length === 0 && !adding && (
<div className="text-center py-6 text-xs text-gray-400 border-2 border-dashed border-gray-200 rounded-xl">
Nenhum medicamento adicionado ainda
</div>
)}
</div>
)}
</Droppable>
</DragDropContext>
</div>
</div>
<div className="p-4 border-t border-gray-100 flex justify-end gap-2">
<button onClick={onBack} className="px-5 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold rounded-lg text-xs uppercase">Cancelar</button>
<button onClick={handleSave} disabled={saving || !nome.trim()} className="px-5 py-2 bg-green-600 hover:bg-green-700 text-white font-bold rounded-lg text-xs uppercase flex items-center gap-1.5 disabled:opacity-40">
<Save size={14} /> {saving ? 'Salvando...' : 'Salvar Modelo'}
</button>
</div>
</div>
);
};
// --------------- Preview de impressão ---------------
const PrintPreview: React.FC<{
modelo: ModeloReceita;
paciente: Paciente;
dentista?: Dentista;
onClose: () => void;
}> = ({ modelo, paciente, dentista, onClose }) => {
const hoje = new Date().toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' });
return (
<div className="flex flex-col h-full">
<div className="p-4 border-b border-gray-100 flex items-center justify-between print:hidden">
<div className="flex items-center gap-2">
<button onClick={onClose} className="text-gray-400 hover:text-gray-700"><ChevronLeft size={18} /></button>
<h3 className="text-sm font-black text-gray-800 uppercase">Pré-visualização</h3>
</div>
<button onClick={() => window.print()} className="flex items-center gap-2 px-4 py-2 bg-gray-900 text-white rounded-lg text-xs font-bold hover:bg-black">
<Printer size={14} /> Imprimir / Salvar PDF
</button>
</div>
<div className="flex-1 overflow-y-auto bg-gray-100 p-6 print:p-0 print:bg-white">
<div id="receita-print" className="max-w-xl 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">
<div className="text-center border-b-2 border-gray-800 pb-4 mb-6">
<h1 className="text-lg font-black text-gray-900 uppercase">RECEITUÁRIO</h1>
{dentista && <p className="text-sm font-bold text-gray-700 mt-1">{dentista.nome} CRO: {dentista.cro || '—'}/{dentista.cro_uf || '—'}</p>}
</div>
<div className="mb-6 grid grid-cols-2 gap-3 text-sm">
<div><span className="text-gray-500 text-xs uppercase font-bold">Paciente</span><p className="font-bold text-gray-900 uppercase">{paciente.nome}</p></div>
<div><span className="text-gray-500 text-xs uppercase font-bold">Data</span><p className="font-bold text-gray-900">{hoje}</p></div>
</div>
{modelo.instrucoes && <div className="mb-5 p-3 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-800 font-medium italic">{modelo.instrucoes}</div>}
<div className="space-y-4">
{modelo.items.map((it, i) => (
<div key={it.id} className="border-b border-gray-100 pb-3 last:border-0 last:pb-0">
<p className="font-black text-gray-900 text-sm">{i + 1}. {it.medicamento}</p>
<p className="text-gray-700 text-sm ml-4">
{[it.dose, it.via, it.frequencia, `por ${it.duracao}`].filter(Boolean).join(' — ')}
</p>
{it.instrucoes && <p className="text-gray-500 text-xs ml-4 italic mt-0.5">{it.instrucoes}</p>}
</div>
))}
</div>
<div className="mt-12 pt-6 border-t-2 border-gray-800 flex justify-between items-end">
<div><p className="text-xs text-gray-500">Assinatura e Carimbo</p><div className="h-10" /></div>
<p className="text-xs text-gray-400">{hoje}</p>
</div>
</div>
</div>
</div>
);
};
// --------------- Modal principal ---------------
export const ReceitaModal: React.FC<{ paciente: Paciente; onClose: () => void }> = ({ paciente, onClose }) => {
const toast = useToast();
const { data: especialidades = [] } = useHybridBackend<Especialidade[]>(HybridBackend.getEspecialidades);
const { data: dentistas = [] } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
const [modelos, setModelos] = useState<ModeloReceita[]>([]);
const [loading, setLoading] = useState(true);
const [filtroEsp, setFiltroEsp] = useState('');
const [busca, setBusca] = useState('');
const [view, setView] = useState<'lista' | 'editor' | 'print'>('lista');
const [modeloAtivo, setModeloAtivo] = useState<ModeloReceita | null>(null);
const [printModelo, setPrintModelo] = useState<ModeloReceita | null>(null);
const dentistaPrincipal = dentistas[0];
const carregar = useCallback(async () => {
setLoading(true);
try { setModelos(await HybridBackend.getModelosReceita()); }
catch { toast.error('Erro ao carregar modelos'); }
finally { setLoading(false); }
}, []);
useEffect(() => { carregar(); }, [carregar]);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (view !== 'lista') setView('lista'); else onClose(); } };
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [view, onClose]);
const handleDelete = async (id: string) => {
if (!confirm('Excluir este modelo?')) return;
await HybridBackend.deleteModeloReceita(id);
setModelos(prev => prev.filter(m => m.id !== id));
};
const filtrados = modelos.filter(m => {
const matchEsp = !filtroEsp || m.especialidadeId === filtroEsp;
const matchBusca = !busca || m.nome.toLowerCase().includes(busca.toLowerCase());
return matchEsp && matchBusca;
});
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-3">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl h-[85vh] flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200">
{view === 'lista' && (
<>
<div className="p-5 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-red-100 rounded-lg flex items-center justify-center"><FileText size={15} className="text-red-600" /></div>
<div>
<h2 className="text-sm font-black text-gray-800 uppercase">Modelos de Receita</h2>
<p className="text-[10px] text-gray-400 uppercase">{paciente.nome}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button onClick={() => { setModeloAtivo(null); setView('editor'); }}
className="flex items-center gap-1.5 px-3 py-2 bg-green-600 hover:bg-green-700 text-white text-xs font-bold rounded-lg">
<Plus size={13} /> Novo Modelo
</button>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={18} /></button>
</div>
</div>
<div className="flex flex-1 overflow-hidden">
{/* Sidebar especialidades */}
<div className="w-44 border-r border-gray-100 flex-shrink-0 overflow-y-auto bg-gray-50 p-2">
<p className="text-[9px] font-black text-gray-400 uppercase px-2 mb-2 tracking-wider">Especialidade</p>
<button onClick={() => setFiltroEsp('')}
className={`w-full text-left px-2.5 py-1.5 rounded-lg text-xs font-bold mb-0.5 transition-colors ${!filtroEsp ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100'}`}>
Todas
</button>
{especialidades.map(e => (
<button key={e.id} onClick={() => setFiltroEsp(e.id)}
className={`w-full text-left px-2.5 py-1.5 rounded-lg text-xs font-bold mb-0.5 transition-colors truncate ${filtroEsp === e.id ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100'}`}>
{e.nome}
</button>
))}
</div>
{/* Lista de modelos */}
<div className="flex-1 flex flex-col overflow-hidden">
<div className="p-3 border-b border-gray-100">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar modelo..."
className="w-full pl-8 pr-3 py-2 border border-gray-200 rounded-lg text-xs focus:ring-2 focus:ring-blue-100 outline-none" />
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{loading && <div className="text-center py-8 text-xs text-gray-400">Carregando...</div>}
{!loading && filtrados.length === 0 && (
<div className="text-center py-10">
<ClipboardList size={28} className="text-gray-200 mx-auto mb-2" />
<p className="text-xs text-gray-400">Nenhum modelo encontrado</p>
<button onClick={() => { setModeloAtivo(null); setView('editor'); }}
className="mt-3 text-xs font-bold text-blue-600 hover:underline">+ Criar primeiro modelo</button>
</div>
)}
{filtrados.map(m => (
<div key={m.id} className="bg-white border border-gray-200 rounded-xl p-3 hover:border-blue-200 hover:shadow-sm transition-all group">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-xs font-black text-gray-800 uppercase truncate">{m.nome}</p>
<div className="flex items-center gap-2 mt-0.5">
{m.especialidadeNome && <span className="text-[9px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded-full font-bold uppercase">{m.especialidadeNome}</span>}
<span className="text-[9px] text-gray-400">{m.items.length} medicamento{m.items.length !== 1 ? 's' : ''}</span>
</div>
{m.instrucoes && <p className="text-[10px] text-gray-400 italic mt-1 truncate">{m.instrucoes}</p>}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
<button onClick={() => { setPrintModelo(m); setView('print'); }}
title="Prescrever para este paciente"
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg"><Printer size={13} /></button>
<button onClick={() => { setModeloAtivo(m); setView('editor'); }}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg"><Pencil size={13} /></button>
<button onClick={() => handleDelete(m.id)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"><Trash2 size={13} /></button>
</div>
</div>
{m.items.length > 0 && (
<div className="mt-2 pt-2 border-t border-gray-50 space-y-0.5">
{m.items.slice(0, 3).map(it => (
<p key={it.id} className="text-[10px] text-gray-500 truncate">
{it.medicamento} {it.dose} {it.frequencia}
</p>
))}
{m.items.length > 3 && <p className="text-[10px] text-gray-400">+{m.items.length - 3} mais...</p>}
</div>
)}
</div>
))}
</div>
</div>
</div>
</>
)}
{view === 'editor' && (
<ModeloEditor
modelo={modeloAtivo}
especialidades={especialidades}
onSave={m => { carregar(); setView('lista'); toast.success('MODELO SALVO!'); }}
onBack={() => setView('lista')}
/>
)}
{view === 'print' && printModelo && (
<PrintPreview
modelo={printModelo}
paciente={paciente}
dentista={dentistaPrincipal}
onClose={() => setView('lista')}
/>
)}
</div>
</div>
);
};