Files
VPS 4 Builder 3e64514b08 feat(financeiro+salas): motor financeiro V1/V2, glosas, GTO→financeiro, contratos de convênio e redesenho de Salas
Financeiro V1 (Épicos 1–4):
- FKs de origem no lançamento (paciente/profissional/procedimento/agendamento/contrato/realizado), soft delete e cron "Atrasado"
- prontuário de procedimentos realizados (fotos antes/depois) + regra sem-comissão (garantia/reabertura do mesmo profissional)
- normalizeFinanceiro: corrige CHECK capitalizado vs enforceUppercase (lançamentos não salvavam pela UI)
- baixa de pagamento (pago_em), despesas (fornecedor/centro de custo/recorrente), competência por data de conclusão
- relatório financeiro (período, paciente, profissional, forma, convênio, glosa)
- orçamento Assinado -> contas a receber; contrato vigente -> cobrança recorrente; renovação automática de contrato

Financeiro V2 (Comissão/Repasse):
- regras de % (padrão/procedimento/override por profissional) + custo de laboratório
- base selecionável (recebido/produção), fechamento persistido, pagar (gera despesa) + comprovante + estorno

Glosas de convênio: por item de GTO, recurso/protocolo/prazo, recuperar/estornar, impacto no relatório

GTO -> Financeiro: finalizar gera RECEITA (convênio a receber); LancarGTO passa a persistir; convênios reais (planos)

Contratos: modelo "Atendimento a Convênios / Repasse (Dentista Credenciado)" + variáveis de convênio

Salas (redesenho): perfil "Dono de Sala"; locação sempre ativa; menus MINHAS/ALUGAR SALAS;
sala como workspace isolado (seletor agrupado Clínicas x Salas, tenantGuard por dono); financeiro de
locação (reserva confirmada -> recebível); Painel da Sala (KPIs, agenda visual, recebíveis, relatório por sala)

Docs: BACKLOG-FINANCEIRO-V1, AUDITORIA-FUNCIONAL-SCOREODONTO, CHECKLIST-DEPLOY-PRODUCAO

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:28:07 +02:00

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-teal-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-teal-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-emerald-100 rounded-lg flex items-center justify-center"><FileX size={15} className="text-emerald-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-teal-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-teal-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-teal-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-emerald-600 hover:bg-emerald-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>
);
};