3e64514b08
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>
218 lines
16 KiB
TypeScript
218 lines
16 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { ShieldAlert, Plus, RotateCcw, Trash2, RefreshCw, X, Search, FileSearch } from 'lucide-react';
|
|
import { HybridBackend } from '../services/backend.ts';
|
|
import { useToast } from '../contexts/ToastContext.tsx';
|
|
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
|
import { PageHeader } from '../components/PageHeader.tsx';
|
|
|
|
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
|
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
|
|
|
|
// Modal de registro de glosa sobre um recebível (suporta glosa por item de GTO)
|
|
const GlosaModal: React.FC<{ receb: any; onClose: () => void; onSaved: () => void }> = ({ receb, onClose, onSaved }) => {
|
|
const toast = useToast();
|
|
const restante = Number(receb.valor) - Number(receb.valor_glosa || 0);
|
|
const [valor, setValor] = useState('');
|
|
const [motivo, setMotivo] = useState('');
|
|
const [protocolo, setProtocolo] = useState('');
|
|
const [prazo, setPrazo] = useState('');
|
|
const [itens, setItens] = useState<any[]>([]);
|
|
const [itemSel, setItemSel] = useState<string>('');
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
HybridBackend.getGlosaItens(receb.id).then(r => setItens(Array.isArray(r) ? r : [])).catch(() => setItens([]));
|
|
}, [receb.id]);
|
|
|
|
const pickItem = (id: string) => {
|
|
setItemSel(id);
|
|
const it = itens.find(i => i.id === id);
|
|
if (it) setValor(String(it.valortotal));
|
|
};
|
|
|
|
const salvar = async () => {
|
|
const v = Number(valor);
|
|
if (!(v > 0)) { toast.error('INFORME UM VALOR DE GLOSA.'); return; }
|
|
if (v > restante + 0.001) { toast.error(`GLOSA EXCEDE O RESTANTE (${fmtBRL(restante)}).`); return; }
|
|
const it = itens.find(i => i.id === itemSel);
|
|
setSaving(true);
|
|
try {
|
|
await HybridBackend.registrarGlosa({
|
|
financeiro_id: receb.id, valor: v, motivo,
|
|
gto_item_id: itemSel || undefined, item_descricao: it?.descricao || undefined,
|
|
protocolo: protocolo || undefined, prazo_recurso: prazo || undefined,
|
|
});
|
|
toast.success('GLOSA REGISTRADA.'); onSaved(); onClose();
|
|
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const inCls = 'w-full bg-gray-50 border border-gray-200 rounded-xl px-3 py-2.5 text-sm font-bold text-gray-700 outline-none focus:border-teal-400';
|
|
const lbl = 'text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1.5 block';
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" onMouseDown={onClose}>
|
|
<div className="bg-white rounded-2xl w-full max-w-md shadow-2xl overflow-hidden max-h-[92vh] flex flex-col" onMouseDown={e => e.stopPropagation()}>
|
|
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between bg-gradient-to-r from-red-50 to-white">
|
|
<h3 className="text-sm font-black text-gray-900 uppercase flex items-center gap-2"><ShieldAlert size={18} className="text-red-600" /> Registrar glosa</h3>
|
|
<button onClick={onClose} className="p-1.5 hover:bg-gray-100 rounded-lg"><X size={18} /></button>
|
|
</div>
|
|
<div className="p-6 space-y-4 overflow-y-auto">
|
|
<div className="bg-gray-50 rounded-xl px-4 py-3">
|
|
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{receb.convenio_nome || 'Convênio'} · {receb.pacientenome || '—'}</p>
|
|
<p className="text-sm font-bold text-gray-700">{receb.descricao}</p>
|
|
<p className="text-xs font-bold text-gray-500 mt-1">Valor {fmtBRL(receb.valor)} · Restante <span className="text-red-600">{fmtBRL(restante)}</span></p>
|
|
</div>
|
|
{itens.length > 0 && (
|
|
<div>
|
|
<label className={lbl}>Item da guia (opcional)</label>
|
|
<select value={itemSel} onChange={e => pickItem(e.target.value)} className={inCls}>
|
|
<option value="">Glosa do total / livre</option>
|
|
{itens.map(i => <option key={i.id} value={i.id} disabled={i.ja_glosado}>{i.descricao} · {fmtBRL(i.valortotal)}{i.ja_glosado ? ' (já glosado)' : ''}</option>)}
|
|
</select>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label className={lbl}>Valor da glosa</label>
|
|
<input type="number" min="0" step="0.01" value={valor} onChange={e => setValor(e.target.value)} placeholder="0,00" className={inCls} />
|
|
</div>
|
|
<div>
|
|
<label className={lbl}>Motivo</label>
|
|
<input value={motivo} onChange={e => setMotivo(e.target.value.toUpperCase())} placeholder="EX: CÓDIGO NÃO AUTORIZADO..." className={inCls + ' uppercase'} />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div><label className={lbl}>Protocolo (opc.)</label><input value={protocolo} onChange={e => setProtocolo(e.target.value.toUpperCase())} placeholder="Nº" className={inCls + ' uppercase'} /></div>
|
|
<div><label className={lbl}>Prazo recurso (opc.)</label><input type="date" value={prazo} onChange={e => setPrazo(e.target.value)} className={inCls} /></div>
|
|
</div>
|
|
</div>
|
|
<div className="px-6 py-4 border-t border-gray-100 flex justify-end gap-2">
|
|
<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">Cancelar</button>
|
|
<button onClick={salvar} disabled={saving} className="px-5 py-2.5 bg-red-600 hover:bg-red-700 text-white font-black rounded-lg text-xs uppercase flex items-center gap-2 disabled:opacity-60">{saving ? <RefreshCw size={15} className="animate-spin" /> : <Plus size={15} />} Registrar</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const GlosasView: React.FC = () => {
|
|
const toast = useToast();
|
|
const confirm = useConfirm();
|
|
const [aba, setAba] = useState<'registrar' | 'glosas'>('registrar');
|
|
const [recebiveis, setRecebiveis] = useState<any[]>([]);
|
|
const [glosas, setGlosas] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [busca, setBusca] = useState('');
|
|
const [filtro, setFiltro] = useState<'' | 'glosado' | 'recurso' | 'recuperado'>('');
|
|
const [modalReceb, setModalReceb] = useState<any | null>(null);
|
|
|
|
const carregar = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [r, g] = await Promise.all([HybridBackend.getGlosaRecebiveis(), HybridBackend.getGlosas(filtro || undefined)]);
|
|
setRecebiveis(Array.isArray(r) ? r : []);
|
|
setGlosas(Array.isArray(g) ? g : []);
|
|
} catch { /* silent */ } finally { setLoading(false); }
|
|
}, [filtro]);
|
|
useEffect(() => { carregar(); }, [carregar]);
|
|
|
|
const recuperar = async (g: any) => {
|
|
if (!await confirm(`RECUPERAR a glosa de ${fmtBRL(g.valor)} (recurso ganho)? O valor volta ao recebível.`)) return;
|
|
try { await HybridBackend.recuperarGlosa(g.id); toast.success('GLOSA RECUPERADA.'); carregar(); }
|
|
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
|
};
|
|
const recorrer = async (g: any) => {
|
|
const protocolo = window.prompt('Nº do protocolo do recurso (opcional):') || '';
|
|
const prazo = window.prompt('Prazo do recurso (AAAA-MM-DD, opcional):') || '';
|
|
try { await HybridBackend.recorrerGlosa(g.id, { protocolo: protocolo || undefined, prazo_recurso: prazo || undefined }); toast.success('RECURSO ABERTO.'); carregar(); }
|
|
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
|
};
|
|
const excluir = async (g: any) => {
|
|
if (!await confirm('EXCLUIR esta glosa? (correção)')) return;
|
|
try { await HybridBackend.deleteGlosa(g.id); toast.success('GLOSA EXCLUÍDA.'); carregar(); }
|
|
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
|
};
|
|
|
|
const recebFiltrados = recebiveis.filter(r => !busca || (r.descricao || '').toUpperCase().includes(busca.toUpperCase()) || (r.pacientenome || '').toUpperCase().includes(busca.toUpperCase()) || (r.convenio_nome || '').toUpperCase().includes(busca.toUpperCase()));
|
|
|
|
return (
|
|
<div className="space-y-6 pb-10">
|
|
<PageHeader title="GLOSAS DE CONVÊNIO" />
|
|
<div className="flex gap-2 border-b border-gray-200">
|
|
{([['registrar', 'Recebíveis / Marcar glosa'], ['glosas', `Glosas (${glosas.length})`]] as const).map(([id, label]) => (
|
|
<button key={id} onClick={() => setAba(id)} className={`px-4 py-2.5 text-xs font-black uppercase tracking-wide border-b-2 -mb-px transition-colors ${aba === id ? 'border-teal-600 text-teal-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{label}</button>
|
|
))}
|
|
</div>
|
|
|
|
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : aba === 'registrar' ? (
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
|
<div className="px-5 py-3 border-b border-gray-100 bg-gray-50/50">
|
|
<div className="relative max-w-sm">
|
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-300" />
|
|
<input value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar recebível..." className="w-full bg-white border border-gray-200 rounded-xl pl-9 pr-3 py-2 text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
|
|
</div>
|
|
</div>
|
|
{recebFiltrados.length === 0 ? <div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhum recebível de convênio em aberto.</div> : (
|
|
<table className="w-full text-left text-xs">
|
|
<thead className="bg-gray-50/60"><tr>{['Convênio', 'Descrição', 'Paciente', 'Venc.', 'Valor', 'Glosado', 'Restante', ''].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest">{h}</th>)}</tr></thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{recebFiltrados.map(r => {
|
|
const restante = Number(r.valor) - Number(r.valor_glosa || 0);
|
|
return (
|
|
<tr key={r.id} className="hover:bg-gray-50/50">
|
|
<td className="px-4 py-3 font-black text-gray-700 uppercase">{r.convenio_nome || '—'}</td>
|
|
<td className="px-4 py-3 font-bold text-gray-600">{r.descricao}</td>
|
|
<td className="px-4 py-3 text-gray-500 uppercase">{r.pacientenome || '—'}</td>
|
|
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{dataBR(r.datavencimento)}</td>
|
|
<td className="px-4 py-3 font-bold text-gray-700">{fmtBRL(r.valor)}</td>
|
|
<td className="px-4 py-3 font-bold text-red-500">{Number(r.valor_glosa) > 0 ? fmtBRL(r.valor_glosa) : '—'}</td>
|
|
<td className="px-4 py-3 font-black text-gray-800">{fmtBRL(restante)}</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<button onClick={() => setModalReceb(r)} className="px-3 py-1.5 rounded-lg text-white text-[10px] font-black uppercase flex items-center gap-1.5 ml-auto" style={{ backgroundColor: '#dc2626' }}><ShieldAlert size={12} /> Glosar</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<div className="flex gap-2">
|
|
{([['', 'Todas'], ['glosado', 'Glosadas'], ['recurso', 'Em recurso'], ['recuperado', 'Recuperadas']] as const).map(([id, label]) => (
|
|
<button key={id} onClick={() => setFiltro(id)} className={`px-4 py-2 rounded-xl text-xs font-black uppercase transition-colors ${filtro === id ? 'bg-teal-600 text-white' : 'bg-white border border-gray-200 text-gray-500 hover:bg-gray-50'}`}>{label}</button>
|
|
))}
|
|
</div>
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-x-auto">
|
|
{glosas.length === 0 ? <div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhuma glosa.</div> : (
|
|
<table className="w-full text-left text-xs">
|
|
<thead className="bg-gray-50/60"><tr>{['Convênio', 'Recebível', 'Paciente', 'Valor', 'Motivo', 'Status', 'Por', ''].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest">{h}</th>)}</tr></thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{glosas.map(g => (
|
|
<tr key={g.id} className={`hover:bg-gray-50/50 ${g.status === 'recuperado' ? 'opacity-60' : ''}`}>
|
|
<td className="px-4 py-3 font-black text-gray-700 uppercase">{g.convenio_nome || '—'}</td>
|
|
<td className="px-4 py-3 font-bold text-gray-600">{g.financeiro_descricao}{g.item_descricao && <span className="block text-[9px] font-black text-red-500 uppercase">item: {g.item_descricao}</span>}</td>
|
|
<td className="px-4 py-3 text-gray-500 uppercase">{g.pacientenome || '—'}</td>
|
|
<td className="px-4 py-3 font-black text-red-600">{fmtBRL(g.valor)}</td>
|
|
<td className="px-4 py-3 text-gray-500 uppercase">{g.motivo || '—'}{g.protocolo && <span className="block text-[9px] text-gray-400">prot. {g.protocolo}{g.prazo_recurso ? ` · prazo ${dataBR(g.prazo_recurso)}` : ''}</span>}</td>
|
|
<td className="px-4 py-3"><span className={`px-2 py-0.5 rounded-full text-[9px] font-black uppercase ${g.status === 'recuperado' ? 'bg-green-100 text-green-700' : g.status === 'recurso' ? 'bg-amber-100 text-amber-700' : 'bg-red-100 text-red-600'}`}>{g.status}</span></td>
|
|
<td className="px-4 py-3 text-gray-400 uppercase">{g.criado_por_nome || '—'}</td>
|
|
<td className="px-4 py-3 text-right whitespace-nowrap">
|
|
{g.status === 'glosado' && <button onClick={() => recorrer(g)} title="ABRIR RECURSO" className="p-1.5 text-amber-600 hover:bg-amber-50 rounded-lg"><FileSearch size={15} /></button>}
|
|
{(g.status === 'glosado' || g.status === 'recurso') && <button onClick={() => recuperar(g)} title="RECUPERAR (RECURSO GANHO)" className="p-1.5 text-green-600 hover:bg-green-50 rounded-lg"><RotateCcw size={15} /></button>}
|
|
<button onClick={() => excluir(g)} title="EXCLUIR" className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"><Trash2 size={15} /></button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{modalReceb && <GlosaModal receb={modalReceb} onClose={() => setModalReceb(null)} onSaved={carregar} />}
|
|
</div>
|
|
);
|
|
};
|