413 lines
24 KiB
TypeScript
413 lines
24 KiB
TypeScript
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: dentistasRaw } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
|
|
const dentistas = dentistasRaw ?? [];
|
|
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-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 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-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 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>
|
|
);
|
|
};
|