Files
scoreodonto.com/frontend/views/LancarProcedimentoModal.tsx
T
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

456 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useMemo } from 'react';
import {
X, Search, Plus, Minus, Trash2, DollarSign, Check,
ChevronDown, Loader2, Tag, Percent, Banknote
} from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { Paciente, Procedimento, Especialidade, Plano } from '../types.ts';
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
// ─── Tipos internos ────────────────────────────────────────────────────────────
interface LancItem {
uid: string;
proc: Procedimento;
quantidade: number;
dente: string;
face: string;
arco: string;
observacao: string;
valorUnit: number;
}
const FACES = ['V', 'L', 'M', 'D', 'O'];
const ARCOS = ['Superior', 'Inferior', 'Ambas'];
const FORMAS_PAG = ['Pix', 'Cartão', 'Dinheiro', 'Boleto'] as const;
const STATUS_LIST = ['Pendente', 'Pago', 'Atrasado'] as const;
const fmt = (v: number) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
function addMonths(date: string, n: number): string {
const d = new Date(date + 'T12:00:00');
d.setMonth(d.getMonth() + n);
return d.toISOString().slice(0, 10);
}
// ─── Chip de procedimento selecionado ─────────────────────────────────────────
const ItemCard: React.FC<{
item: LancItem;
onChange: (patch: Partial<LancItem>) => void;
onRemove: () => void;
}> = ({ item, onChange, onRemove }) => {
const [open, setOpen] = useState(item.proc.tipo_regiao !== 'GERAL');
const inp = "border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:ring-2 focus:ring-teal-100 outline-none w-full";
return (
<div className="border border-gray-200 rounded-xl bg-white overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2.5">
<div className="flex-1 min-w-0">
<p className="text-xs font-black text-gray-800 uppercase truncate">{item.proc.nome}</p>
<div className="flex items-center gap-2 mt-0.5">
{item.proc.especialidadeNome && (
<span className="text-[9px] bg-teal-50 text-teal-600 px-1.5 py-0.5 rounded-full font-bold uppercase">{item.proc.especialidadeNome}</span>
)}
<span className="text-[10px] font-bold text-green-600">{fmt(item.valorUnit * item.quantidade)}</span>
{item.quantidade > 1 && <span className="text-[9px] text-gray-400">{item.quantidade}× {fmt(item.valorUnit)}</span>}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button onClick={() => onChange({ quantidade: Math.max(1, item.quantidade - 1) })} className="w-6 h-6 rounded-full border border-gray-200 flex items-center justify-center hover:bg-gray-100"><Minus size={10} /></button>
<span className="w-5 text-center text-xs font-bold">{item.quantidade}</span>
<button onClick={() => onChange({ quantidade: item.quantidade + 1 })} className="w-6 h-6 rounded-full border border-gray-200 flex items-center justify-center hover:bg-gray-100"><Plus size={10} /></button>
</div>
<button onClick={() => setOpen(o => !o)} className="text-gray-300 hover:text-gray-600 flex-shrink-0">
<ChevronDown size={14} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
<button onClick={onRemove} className="text-gray-300 hover:text-red-500 flex-shrink-0"><Trash2 size={13} /></button>
</div>
{open && (
<div className="border-t border-gray-100 bg-gray-50/50 px-3 py-2.5 grid grid-cols-2 gap-2">
{item.proc.tipo_regiao === 'DENTE' && (
<div>
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Dente(s)</label>
<input value={item.dente} onChange={e => onChange({ dente: e.target.value })} className={inp} placeholder="ex: 16, 21-22" />
</div>
)}
{item.proc.tipo_regiao === 'ARCO' && (
<div>
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Arcada</label>
<select value={item.arco} onChange={e => onChange({ arco: e.target.value })} className={inp}>
<option value=""> Selecione </option>
{ARCOS.map(a => <option key={a}>{a}</option>)}
</select>
</div>
)}
{item.proc.exige_face && (
<div>
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Face(s)</label>
<div className="flex gap-1 flex-wrap">
{FACES.map(f => (
<button key={f} type="button"
onClick={() => {
const faces = item.face.split('').filter(Boolean);
const next = faces.includes(f) ? faces.filter(x => x !== f) : [...faces, f];
onChange({ face: next.join('') });
}}
className={`w-7 h-7 rounded-lg text-[10px] font-black border transition-colors ${item.face.includes(f) ? 'bg-teal-600 text-white border-teal-600' : 'bg-white text-gray-500 border-gray-200 hover:border-teal-400'}`}>
{f}
</button>
))}
</div>
</div>
)}
<div className={item.proc.tipo_regiao !== 'GERAL' || item.proc.exige_face ? '' : 'col-span-2'}>
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Obs</label>
<input value={item.observacao} onChange={e => onChange({ observacao: e.target.value })} className={inp} placeholder="observação opcional" />
</div>
</div>
)}
</div>
);
};
// ─── Modal principal ──────────────────────────────────────────────────────────
export const LancarProcedimentoModal: React.FC<{
patient: Paciente;
onClose: () => void;
onSaved: () => void;
}> = ({ patient, onClose, onSaved }) => {
const toast = useToast();
const { data: procsRaw } = useHybridBackend<Procedimento[]>(HybridBackend.getProcedimentos);
const { data: espsRaw } = useHybridBackend<Especialidade[]>(HybridBackend.getEspecialidades);
const { data: planosRaw } = useHybridBackend<Plano[]>(HybridBackend.getPlanos);
const procs = procsRaw ?? [];
const esps = espsRaw ?? [];
const planos = planosRaw ?? [];
const [busca, setBusca] = useState('');
const [espFiltro, setEspFiltro] = useState('');
const [items, setItems] = useState<LancItem[]>([]);
const [saving, setSaving] = useState(false);
// Pagamento
const [descTipo, setDescTipo] = useState<'R$' | '%'>('R$');
const [descValor, setDescValor] = useState('');
const [entrada, setEntrada] = useState('');
const [parcelas, setParcelas] = useState(1);
const [primVenc, setPrimVenc] = useState(new Date().toISOString().slice(0, 10));
const [formaPag, setFormaPag] = useState<typeof FORMAS_PAG[number]>('Pix');
const [statusPag, setStatusPag] = useState<typeof STATUS_LIST[number]>('Pendente');
// Plano vigente do paciente
const planoVigente = useMemo(() => {
if (!patient.convenio) return null;
return planos.find(p => p.nome === patient.convenio) ?? null;
}, [patient.convenio, planos]);
const getValorProc = (proc: Procedimento): number => {
if (planoVigente) {
const vp = proc.valoresPlanos?.find(v => v.planoId === planoVigente.id);
if (vp) return vp.valor;
}
return proc.valorParticular ?? 0;
};
const addProc = (proc: Procedimento) => {
if (items.some(i => i.proc.id === proc.id)) {
setItems(prev => prev.map(i => i.proc.id === proc.id ? { ...i, quantidade: i.quantidade + 1 } : i));
return;
}
setItems(prev => [...prev, {
uid: `${proc.id}_${Date.now()}`,
proc, quantidade: 1,
dente: '', face: '', arco: '', observacao: '',
valorUnit: getValorProc(proc),
}]);
};
const updateItem = (uid: string, patch: Partial<LancItem>) =>
setItems(prev => prev.map(i => i.uid === uid ? { ...i, ...patch } : i));
const removeItem = (uid: string) => setItems(prev => prev.filter(i => i.uid !== uid));
const subtotal = items.reduce((s, i) => s + i.valorUnit * i.quantidade, 0);
const descNum = parseFloat(descValor) || 0;
const descReais = descTipo === '%' ? subtotal * descNum / 100 : descNum;
const entradaNum = parseFloat(entrada) || 0;
const restante = Math.max(0, subtotal - descReais - entradaNum);
const valorParcela = parcelas > 0 ? restante / parcelas : 0;
const procsFiltrados = useMemo(() => {
return procs.filter(p => {
if (espFiltro && p.especialidadeId !== espFiltro) return false;
if (busca && !p.nome.toLowerCase().includes(busca.toLowerCase())) return false;
return true;
});
}, [procs, espFiltro, busca]);
const procsPorEsp = useMemo(() => {
const grupos: Record<string, { esp: Especialidade | null; procs: Procedimento[] }> = {};
procsFiltrados.forEach(p => {
const key = p.especialidadeId || '__geral';
if (!grupos[key]) {
grupos[key] = { esp: esps.find(e => e.id === p.especialidadeId) ?? null, procs: [] };
}
grupos[key].procs.push(p);
});
return Object.values(grupos).sort((a, b) => (a.esp?.ordem ?? 99) - (b.esp?.ordem ?? 99));
}, [procsFiltrados, esps]);
const handleSave = async () => {
if (items.length === 0) { toast.error('SELECIONE AO MENOS UM PROCEDIMENTO'); return; }
setSaving(true);
try {
const entriesToCreate: any[] = [];
// FKs de origem (Épico 1): sempre liga ao paciente; procedimento_id só quando há 1 item (não ambíguo)
const fkBase = {
paciente_id: patient.id,
procedimento_id: items.length === 1 ? items[0].proc.id : null,
origem: 'procedimento',
};
const baseDesc = items.map(i => {
const region = [i.dente, i.arco, i.face].filter(Boolean).join('/');
return i.proc.nome + (region ? ` (${region})` : '') + (i.quantidade > 1 ? ` x${i.quantidade}` : '');
}).join(', ');
if (entradaNum > 0) {
entriesToCreate.push({
id: crypto.randomUUID(),
...fkBase,
pacienteNome: patient.nome,
descricao: `ENTRADA — ${baseDesc}`,
valor: entradaNum,
dataVencimento: primVenc,
status: 'Pago',
formaPagamento: formaPag,
});
}
for (let i = 0; i < parcelas; i++) {
const label = parcelas > 1 ? `${i + 1}/${parcelas}` : '';
entriesToCreate.push({
id: crypto.randomUUID(),
...fkBase,
pacienteNome: patient.nome,
descricao: baseDesc + label,
valor: parseFloat(valorParcela.toFixed(2)),
dataVencimento: entradaNum > 0 ? addMonths(primVenc, i + 1) : addMonths(primVenc, i),
status: i === 0 && parcelas === 1 ? statusPag : (i === 0 ? statusPag : 'Pendente'),
formaPagamento: formaPag,
});
}
for (const entry of entriesToCreate) {
await HybridBackend.saveFinanceiro(entry);
}
toast.success(`${entriesToCreate.length} LANÇAMENTO(S) GERADO(S) COM SUCESSO!`);
onSaved();
onClose();
} catch { toast.error('ERRO AO SALVAR LANÇAMENTOS'); } finally { setSaving(false); }
};
const inp = "border border-gray-200 rounded-lg px-2.5 py-2 text-sm focus:ring-2 focus:ring-teal-100 outline-none w-full bg-white";
const jaAdicionado = (id: string) => items.some(i => i.proc.id === id);
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="flex-shrink-0 px-5 py-4 border-b border-gray-100 flex items-center justify-between bg-gradient-to-r from-emerald-50 to-white">
<div className="flex items-center gap-3">
<div className="w-9 h-9 bg-emerald-100 rounded-xl flex items-center justify-center">
<DollarSign size={18} className="text-emerald-600" />
</div>
<div>
<h2 className="text-sm font-black text-gray-800 uppercase">Lançar Procedimento</h2>
<p className="text-[10px] text-gray-400 uppercase font-medium">
{patient.nome}
{planoVigente && <span className="ml-2 bg-emerald-100 text-emerald-700 px-1.5 py-0.5 rounded-full font-bold">{planoVigente.nome}</span>}
</p>
</div>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 p-1.5 hover:bg-gray-100 rounded-lg transition-colors"><X size={18} /></button>
</div>
{/* Body */}
<div className="flex flex-1 overflow-hidden">
{/* ── Catálogo (esquerda) ── */}
<div className="w-72 flex-shrink-0 border-r border-gray-100 flex flex-col bg-gray-50/30">
{/* Busca + filtro especialidade */}
<div className="p-3 space-y-2 border-b border-gray-100 flex-shrink-0">
<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 procedimento..."
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 bg-white" />
</div>
<div className="flex gap-1 flex-wrap">
<button onClick={() => setEspFiltro('')}
className={`text-[10px] font-bold px-2 py-0.5 rounded-full border transition-colors ${!espFiltro ? 'bg-gray-800 text-white border-gray-800' : 'text-gray-500 border-gray-200 hover:bg-gray-100'}`}>
Todas
</button>
{esps.map(e => (
<button key={e.id} onClick={() => setEspFiltro(f => f === e.id ? '' : e.id)}
className={`text-[10px] font-bold px-2 py-0.5 rounded-full border transition-colors ${espFiltro === e.id ? 'bg-teal-600 text-white border-teal-600' : 'text-gray-500 border-gray-200 hover:bg-gray-100'}`}>
{e.nome}
</button>
))}
</div>
</div>
{/* Lista procedimentos */}
<div className="flex-1 overflow-y-auto p-2 space-y-3">
{!procsRaw && <div className="flex justify-center pt-8"><Loader2 size={18} className="animate-spin text-gray-300" /></div>}
{procsPorEsp.map((grupo, gi) => (
<div key={gi}>
{grupo.esp && (
<p className="text-[9px] font-black text-gray-400 uppercase tracking-widest px-2 mb-1">{grupo.esp.nome}</p>
)}
<div className="space-y-0.5">
{grupo.procs.map(p => {
const val = getValorProc(p);
const adicionado = jaAdicionado(p.id);
return (
<button key={p.id} onClick={() => addProc(p)}
className={`w-full text-left px-2.5 py-2 rounded-lg transition-all flex items-center justify-between gap-2 group ${adicionado ? 'bg-emerald-50 border border-emerald-200' : 'border border-transparent hover:bg-white hover:border-gray-200 hover:shadow-sm'}`}>
<div className="min-w-0 flex-1">
<p className={`text-xs font-bold truncate ${adicionado ? 'text-emerald-700' : 'text-gray-700'}`}>{p.nome}</p>
{p.codigo && <p className="text-[9px] text-gray-400">{p.codigo}</p>}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<span className={`text-[10px] font-black ${adicionado ? 'text-emerald-600' : 'text-green-600'}`}>{fmt(val)}</span>
{adicionado
? <Check size={13} className="text-emerald-500" />
: <Plus size={13} className="text-gray-300 group-hover:text-teal-500" />}
</div>
</button>
);
})}
</div>
</div>
))}
{procsRaw && procsFiltrados.length === 0 && (
<p className="text-xs text-gray-400 text-center pt-6">Nenhum procedimento encontrado</p>
)}
</div>
</div>
{/* ── Selecionados + pagamento (direita) ── */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Items selecionados */}
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{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">
<Tag size={24} className="text-gray-300" />
</div>
<p className="text-sm font-bold text-gray-400">Nenhum procedimento selecionado</p>
<p className="text-xs text-gray-300 mt-1">Clique nos procedimentos ao lado para adicionar</p>
</div>
) : (
items.map(item => (
<ItemCard key={item.uid} item={item}
onChange={patch => updateItem(item.uid, patch)}
onRemove={() => removeItem(item.uid)} />
))
)}
</div>
{/* ── Painel financeiro ── */}
<div className="flex-shrink-0 border-t border-gray-100 bg-gray-50/50 p-4 space-y-3">
{/* Subtotal */}
<div className="flex items-center justify-between text-sm">
<span className="font-bold text-gray-500 uppercase text-xs">Subtotal</span>
<span className="font-black text-gray-800 text-base">{fmt(subtotal)}</span>
</div>
{/* Desconto + entrada */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Desconto</label>
<div className="flex gap-1">
<button onClick={() => setDescTipo(t => t === 'R$' ? '%' : 'R$')}
className="px-2 py-2 border border-gray-200 rounded-lg text-[10px] font-black text-gray-500 hover:bg-gray-100 flex-shrink-0 bg-white">
{descTipo === 'R$' ? <Banknote size={12} /> : <Percent size={12} />}
</button>
<input type="number" step="0.01" value={descValor} onChange={e => setDescValor(e.target.value)}
placeholder="0" className={inp + " text-sm"} />
</div>
{descReais > 0 && <p className="text-[10px] text-red-500 mt-0.5"> {fmt(descReais)}</p>}
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Entrada (R$)</label>
<input type="number" step="0.01" value={entrada} onChange={e => setEntrada(e.target.value)}
placeholder="0" className={inp} />
{entradaNum > 0 && <p className="text-[10px] text-teal-500 mt-0.5"> {fmt(entradaNum)} (marca como Pago)</p>}
</div>
</div>
{/* Restante + parcelas */}
<div className="bg-white rounded-xl border border-gray-200 p-3 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-black text-gray-500 uppercase">Restante a parcelar</span>
<span className="text-lg font-black text-emerald-600">{fmt(restante)}</span>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Parcelas</label>
<select value={parcelas} onChange={e => setParcelas(Number(e.target.value))} className={inp + " text-xs"}>
{Array.from({ length: 48 }, (_, i) => i + 1).map(n => (
<option key={n} value={n}>{n}x {fmt(restante / n)}</option>
))}
</select>
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">1ª Parcela</label>
<input type="date" value={primVenc} onChange={e => setPrimVenc(e.target.value)} className={inp + " text-xs"} />
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Forma</label>
<select value={formaPag} onChange={e => setFormaPag(e.target.value as any)} className={inp + " text-xs"}>
{FORMAS_PAG.map(f => <option key={f}>{f}</option>)}
</select>
</div>
</div>
{parcelas > 1 && (
<div className="flex items-center justify-between bg-emerald-50 rounded-lg px-3 py-2">
<span className="text-xs font-bold text-emerald-700">{parcelas}x de</span>
<span className="text-sm font-black text-emerald-700">{fmt(valorParcela)}</span>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Status 1ª parcela</label>
<select value={statusPag} onChange={e => setStatusPag(e.target.value as any)} className={inp + " text-xs"}>
{STATUS_LIST.map(s => <option key={s}>{s}</option>)}
</select>
</div>
<div className="flex items-end">
<div className="w-full bg-gray-50 border border-gray-100 rounded-lg p-2 text-center">
<p className="text-[9px] font-black text-gray-400 uppercase">Total geral</p>
<p className="text-sm font-black text-gray-800">{fmt(entradaNum + restante)}</p>
</div>
</div>
</div>
</div>
{/* Ações */}
<div className="flex gap-2 pt-1">
<button onClick={onClose} className="px-5 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold rounded-lg text-xs uppercase flex-shrink-0">Cancelar</button>
<button onClick={handleSave} disabled={items.length === 0 || saving}
className="flex-1 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white font-black rounded-lg text-xs uppercase disabled:opacity-40 flex items-center justify-center gap-2 transition-colors">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
{saving ? 'Salvando...' : `Confirmar ${items.length > 0 ? `${fmt(entradaNum + restante)}` : ''}`}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
};