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>
498 lines
32 KiB
TypeScript
498 lines
32 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { Filter, Plus, TrendingUp, Clock, TrendingDown, CheckCircle, Download, Trash2, X, DollarSign, Loader2 } from 'lucide-react';
|
|
import { HybridBackend } from '../services/backend.ts';
|
|
import { FinanceiroItem, Paciente } from '../types.ts';
|
|
import { useToast } from '../contexts/ToastContext.tsx';
|
|
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
|
|
|
// --- Reusable Debounce Hook ---
|
|
function useDebounce(value: string, delay: number) {
|
|
const [debouncedValue, setDebouncedValue] = useState(value);
|
|
useEffect(() => {
|
|
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
|
return () => clearTimeout(handler);
|
|
}, [value, delay]);
|
|
return debouncedValue;
|
|
}
|
|
|
|
// ----------- NOVO LANÇAMENTO MODAL -----------
|
|
const NovoLancamentoModal: React.FC<{
|
|
onClose: () => void;
|
|
onSaved: () => void;
|
|
}> = ({ onClose, onSaved }) => {
|
|
const toast = useToast();
|
|
const confirm = useConfirm();
|
|
const [selectedPatient, setSelectedPatient] = useState<Paciente | null>(null);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
|
const debouncedSearchTerm = useDebounce(searchTerm, 400);
|
|
|
|
const [searchResults, setSearchResults] = useState<Paciente[]>([]);
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!debouncedSearchTerm) { setSearchResults([]); return; }
|
|
setIsSearching(true);
|
|
HybridBackend.getPacientes(debouncedSearchTerm).then(results => {
|
|
setSearchResults(results.data || []);
|
|
setIsSearching(false);
|
|
});
|
|
}, [debouncedSearchTerm]);
|
|
|
|
const handlePatientSelect = (patient: Paciente) => {
|
|
setSelectedPatient(patient);
|
|
setSearchTerm('');
|
|
setIsSearchFocused(false);
|
|
};
|
|
|
|
const [form, setForm] = useState({
|
|
id: crypto.randomUUID(),
|
|
tipo: 'RECEITA' as 'RECEITA' | 'DESPESA',
|
|
descricao: '',
|
|
valor: '',
|
|
dataVencimento: new Date().toISOString().split('T')[0],
|
|
status: 'Pendente' as 'Pago' | 'Pendente' | 'Atrasado',
|
|
formaPagamento: 'Pix' as 'Boleto' | 'Pix' | 'Cartão' | 'Dinheiro',
|
|
fornecedor: '',
|
|
centroCusto: '',
|
|
recorrente: false,
|
|
});
|
|
const isDespesa = form.tipo === 'DESPESA';
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [onClose]);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!form.descricao || !form.valor || (!isDespesa && !selectedPatient)) {
|
|
toast.error('PREENCHA TODOS OS CAMPOS OBRIGATÓRIOS.');
|
|
return;
|
|
}
|
|
try {
|
|
const { centroCusto, ...rest } = form;
|
|
await HybridBackend.saveFinanceiro({
|
|
...rest,
|
|
origem: 'manual',
|
|
valor: parseFloat(form.valor),
|
|
...(selectedPatient ? { paciente_id: selectedPatient.id, pacienteNome: selectedPatient.nome } : {}),
|
|
...(isDespesa ? { centro_custo: centroCusto, fornecedor: form.fornecedor, recorrente: form.recorrente } : { fornecedor: undefined, centro_custo: undefined, recorrente: undefined }),
|
|
});
|
|
toast.success('LANÇAMENTO SALVO COM SUCESSO!');
|
|
onSaved();
|
|
onClose();
|
|
} catch {
|
|
toast.error('ERRO AO SALVAR LANÇAMENTO.');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-0">
|
|
<form onSubmit={handleSubmit} className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-xl flex flex-col overflow-hidden animate-in fade-in zoom-in-50 duration-200">
|
|
{/* HEADER */}
|
|
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-green-50 to-white">
|
|
<h2 className="text-lg font-bold text-gray-800 flex items-center gap-2">
|
|
<DollarSign size={22} className="text-green-600" /> NOVO LANÇAMENTO
|
|
</h2>
|
|
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
|
</div>
|
|
|
|
{/* BODY */}
|
|
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
|
{/* TIPO: RECEITA / DESPESA */}
|
|
<div className="mb-6">
|
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">TIPO DE LANÇAMENTO</label>
|
|
<div className="mt-2 inline-flex rounded-lg border border-gray-200 overflow-hidden">
|
|
<button type="button" onClick={() => setForm(f => ({ ...f, tipo: 'RECEITA' }))}
|
|
className={`px-5 py-2.5 text-sm font-bold uppercase transition-colors ${!isDespesa ? 'bg-green-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
|
Receita
|
|
</button>
|
|
<button type="button" onClick={() => setForm(f => ({ ...f, tipo: 'DESPESA' }))}
|
|
className={`px-5 py-2.5 text-sm font-bold uppercase transition-colors ${isDespesa ? 'bg-red-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
|
Despesa
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* PACIENTE SEARCH (apenas receita) */}
|
|
{!isDespesa && (
|
|
<div className="mb-6">
|
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">PACIENTE *</label>
|
|
{!selectedPatient ? (
|
|
<div className="relative mt-2">
|
|
<input
|
|
type="text"
|
|
value={searchTerm}
|
|
onChange={e => setSearchTerm(e.target.value.toUpperCase())}
|
|
onFocus={() => setIsSearchFocused(true)}
|
|
onBlur={() => setTimeout(() => setIsSearchFocused(false), 200)}
|
|
placeholder="NOME, CPF OU TELEFONE..."
|
|
className="w-full lg:w-1/2 bg-gray-100 p-3 lg:p-4 rounded-lg font-bold text-gray-800 uppercase focus:border-teal-500 outline-none border-2 border-transparent focus:border-2 text-sm lg:text-base"
|
|
/>
|
|
{isSearchFocused && debouncedSearchTerm && (
|
|
<ul className="absolute z-10 w-full lg:w-1/2 bg-white border border-gray-300 rounded-lg mt-1 max-h-48 overflow-y-auto shadow-lg">
|
|
{isSearching && <li className="p-3 text-sm text-gray-500">BUSCANDO...</li>}
|
|
{searchResults.map(p => (
|
|
<li key={p.id} onMouseDown={() => handlePatientSelect(p)} className="p-3 hover:bg-gray-100 cursor-pointer uppercase font-semibold text-sm">
|
|
{p.nome}
|
|
{p.cpf && <span className="text-gray-400 font-normal ml-1">· CPF {p.cpf}</span>}
|
|
{p.telefone && <span className="text-gray-400 font-normal ml-1">· TEL {p.telefone}</span>}
|
|
</li>
|
|
))}
|
|
{!isSearching && searchResults.length === 0 && <li className="p-3 text-sm text-gray-500">NENHUM PACIENTE ENCONTRADO.</li>}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="w-full lg:w-1/2 bg-green-50 border border-green-200 p-3 lg:p-4 rounded-lg mt-2 flex justify-between items-center">
|
|
<span className="font-bold text-green-800 uppercase text-sm lg:text-base">{selectedPatient.nome}</span>
|
|
<button type="button" onClick={() => setSelectedPatient(null)} className="text-xs font-bold bg-teal-100 text-teal-700 hover:bg-teal-200 px-3 py-1.5 rounded-md transition-colors">ALTERAR</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="lg:grid lg:grid-cols-2 lg:gap-x-8 lg:gap-y-5 space-y-5 lg:space-y-0">
|
|
{/* DESCRIÇÃO */}
|
|
<div className="lg:col-span-2">
|
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">DESCRIÇÃO *</label>
|
|
<input type="text" required value={form.descricao}
|
|
onChange={e => setForm(f => ({ ...f, descricao: e.target.value.toUpperCase() }))}
|
|
className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 uppercase-input text-sm" placeholder="EX: CONSULTA AVALIAÇÃO, LIMPEZA..." />
|
|
</div>
|
|
{/* VALOR */}
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">VALOR (R$) *</label>
|
|
<input type="number" step="0.01" required value={form.valor}
|
|
onChange={e => setForm(f => ({ ...f, valor: e.target.value }))}
|
|
className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 text-sm" placeholder="0,00" />
|
|
</div>
|
|
{/* VENCIMENTO */}
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">DATA VENCIMENTO</label>
|
|
<input type="date" value={form.dataVencimento}
|
|
onChange={e => setForm(f => ({ ...f, dataVencimento: e.target.value }))}
|
|
className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 text-sm" />
|
|
</div>
|
|
{/* STATUS */}
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">STATUS</label>
|
|
<select value={form.status}
|
|
onChange={e => setForm(f => ({ ...f, status: e.target.value as any }))}
|
|
className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 bg-white text-sm uppercase-select">
|
|
<option value="Pendente">PENDENTE</option>
|
|
<option value="Pago">PAGO</option>
|
|
<option value="Atrasado">ATRASADO</option>
|
|
</select>
|
|
</div>
|
|
{/* FORMA DE PAGAMENTO */}
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">FORMA DE PAGAMENTO</label>
|
|
<select value={form.formaPagamento}
|
|
onChange={e => setForm(f => ({ ...f, formaPagamento: e.target.value as any }))}
|
|
className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 bg-white text-sm uppercase-select">
|
|
<option value="Pix">PIX</option>
|
|
<option value="Boleto">BOLETO</option>
|
|
<option value="Cartão">CARTÃO</option>
|
|
<option value="Dinheiro">DINHEIRO</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* CAMPOS DE DESPESA (H2.3) */}
|
|
{isDespesa && (
|
|
<>
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">FORNECEDOR</label>
|
|
<input type="text" value={form.fornecedor}
|
|
onChange={e => setForm(f => ({ ...f, fornecedor: e.target.value.toUpperCase() }))}
|
|
className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 uppercase-input text-sm" placeholder="EX: DENTAL CREMER, ALUGUEL..." />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">CENTRO DE CUSTO</label>
|
|
<input type="text" list="centros-custo" value={form.centroCusto}
|
|
onChange={e => setForm(f => ({ ...f, centroCusto: e.target.value.toUpperCase() }))}
|
|
className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 uppercase-input text-sm" placeholder="EX: MATERIAIS, ALUGUEL..." />
|
|
<datalist id="centros-custo">
|
|
{['ALUGUEL', 'MATERIAIS', 'SALÁRIOS', 'MARKETING', 'EQUIPAMENTOS', 'IMPOSTOS', 'LABORATÓRIO', 'MANUTENÇÃO', 'UTILIDADES', 'OUTROS'].map(c => <option key={c} value={c} />)}
|
|
</datalist>
|
|
</div>
|
|
<div className="lg:col-span-2">
|
|
<label className="inline-flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" checked={form.recorrente}
|
|
onChange={e => setForm(f => ({ ...f, recorrente: e.target.checked }))}
|
|
className="w-4 h-4 rounded border-gray-300 text-red-600 focus:ring-red-500" />
|
|
<span className="text-xs font-bold text-gray-600 uppercase tracking-wider">Despesa recorrente (mensal)</span>
|
|
</label>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* FOOTER */}
|
|
<div className="px-5 py-4 bg-gray-50 border-t border-gray-200 flex justify-end gap-3 flex-shrink-0">
|
|
<button type="button" onClick={onClose} className="px-6 py-2.5 bg-gray-200 hover:bg-gray-300 text-gray-700 font-bold rounded-lg text-sm uppercase transition-colors">CANCELAR</button>
|
|
<button type="submit" className="px-8 py-2.5 bg-green-600 hover:bg-green-700 text-white font-bold rounded-lg shadow-sm text-sm uppercase flex items-center gap-2 transition-colors">
|
|
<Plus size={16} /> SALVAR
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ----------- MAIN VIEW -----------
|
|
import { PageHeader } from '../components/PageHeader.tsx';
|
|
|
|
// ----------- BAIXA DE PAGAMENTO MODAL (H2.1) -----------
|
|
const FORMAS = ['Pix', 'Cartão', 'Boleto', 'Dinheiro'] as const;
|
|
const BaixaPagamentoModal: React.FC<{
|
|
item: FinanceiroItem;
|
|
onClose: () => void;
|
|
onConfirm: (dados: { pago_em: string; formaPagamento: string }) => void;
|
|
}> = ({ item, onClose, onConfirm }) => {
|
|
const [pagoEm, setPagoEm] = useState(new Date().toISOString().split('T')[0]);
|
|
const [forma, setForma] = useState<string>(item.formaPagamento || 'Pix');
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
|
document.addEventListener('keydown', onKey);
|
|
return () => document.removeEventListener('keydown', onKey);
|
|
}, [onClose]);
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center backdrop-blur-sm p-4" onMouseDown={onClose}>
|
|
<div className="bg-white rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200" 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-green-50 to-white">
|
|
<h3 className="text-sm font-black text-gray-900 uppercase flex items-center gap-2"><CheckCircle size={18} className="text-green-600" /> Baixa de pagamento</h3>
|
|
<button onClick={onClose} className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"><X size={18} /></button>
|
|
</div>
|
|
<div className="p-6 space-y-4">
|
|
<div className="bg-gray-50 rounded-xl px-4 py-3">
|
|
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{item.pacienteNome || '—'}</p>
|
|
<p className="text-sm font-bold text-gray-700">{item.descricao}</p>
|
|
<p className="text-xl font-black text-green-600 mt-1">R$ {Number(item.valor).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1.5 block">Data do pagamento</label>
|
|
<input type="date" value={pagoEm} onChange={e => setPagoEm(e.target.value)}
|
|
className="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 focus:bg-white transition-colors" />
|
|
</div>
|
|
<div>
|
|
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1.5 block">Forma</label>
|
|
<select value={forma} onChange={e => setForma(e.target.value)}
|
|
className="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 focus:bg-white transition-colors">
|
|
{FORMAS.map(f => <option key={f} value={f}>{f.toUpperCase()}</option>)}
|
|
</select>
|
|
</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 transition-colors">Cancelar</button>
|
|
<button onClick={() => onConfirm({ pago_em: pagoEm, formaPagamento: forma })} className="px-5 py-2.5 bg-green-600 hover:bg-green-700 text-white font-black rounded-lg text-xs uppercase transition-colors flex items-center gap-2"><CheckCircle size={16} /> Confirmar baixa</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const FinanceiroView: React.FC = () => {
|
|
const [transacoes, setTransacoes] = useState<FinanceiroItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [filtroStatus, setFiltroStatus] = useState<'TODOS' | 'Pago' | 'Pendente' | 'Atrasado'>('TODOS');
|
|
const [showFiltro, setShowFiltro] = useState(false);
|
|
const [isNovoOpen, setIsNovoOpen] = useState(false);
|
|
const [baixaItem, setBaixaItem] = useState<FinanceiroItem | null>(null);
|
|
const toast = useToast();
|
|
const confirm = useConfirm();
|
|
|
|
const fetchData = useCallback(() => {
|
|
setLoading(true);
|
|
HybridBackend.getFinanceiro().then((data) => {
|
|
setTransacoes(data);
|
|
setLoading(false);
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => { fetchData(); }, [fetchData]);
|
|
|
|
const transacoesFiltradas = filtroStatus === 'TODOS' ? transacoes : transacoes.filter(t => t.status === filtroStatus);
|
|
|
|
const totalRecebido = transacoes.filter(t => t.status === 'Pago').reduce((acc, t) => acc + Number(t.valor), 0);
|
|
const totalPendente = transacoes.filter(t => t.status === 'Pendente').reduce((acc, t) => acc + Number(t.valor), 0);
|
|
const totalAtrasado = transacoes.filter(t => t.status === 'Atrasado').reduce((acc, t) => acc + Number(t.valor), 0);
|
|
|
|
const handleBaixaConfirm = async (dados: { pago_em: string; formaPagamento: string }) => {
|
|
const item = baixaItem;
|
|
if (!item) return;
|
|
// useConfirm extra para valores altos
|
|
if (Number(item.valor) >= 1000 && !await confirm(`CONFIRMAR BAIXA DE R$ ${Number(item.valor).toLocaleString('pt-BR', { minimumFractionDigits: 2 })} DE ${item.pacienteNome}?`)) return;
|
|
try {
|
|
await HybridBackend.updateFinanceiro({ ...item, status: 'Pago', pago_em: dados.pago_em, formaPagamento: dados.formaPagamento as FinanceiroItem['formaPagamento'] });
|
|
toast.success(`PAGAMENTO DE ${item.pacienteNome} CONFIRMADO!`);
|
|
setBaixaItem(null);
|
|
fetchData();
|
|
} catch {
|
|
toast.error('ERRO AO CONFIRMAR PAGAMENTO.');
|
|
}
|
|
};
|
|
|
|
const handleExcluir = async (item: FinanceiroItem) => {
|
|
if (!await confirm(`TEM CERTEZA QUE DESEJA EXCLUIR O LANÇAMENTO DE ${item.pacienteNome}? ESTA AÇÃO NÃO PODE SER DESFEITA.`)) return;
|
|
try {
|
|
await HybridBackend.deleteFinanceiro(item.id);
|
|
toast.success('LANÇAMENTO EXCLUÍDO!');
|
|
fetchData();
|
|
} catch {
|
|
toast.error('ERRO AO EXCLUIR LANÇAMENTO.');
|
|
}
|
|
};
|
|
|
|
const handleDownload = (item: FinanceiroItem) => {
|
|
toast.success(`RECIBO DE ${item.pacienteNome} GERADO COM SUCESSO!`);
|
|
};
|
|
|
|
const filtroLabel = filtroStatus === 'TODOS' ? 'FILTRAR' : filtroStatus.toUpperCase();
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader title="FINANCEIRO & FATURAMENTO">
|
|
<div className="flex gap-2 relative">
|
|
{/* FILTRAR */}
|
|
<div className="relative">
|
|
<button onClick={() => setShowFiltro(!showFiltro)}
|
|
className={`border px-3 py-2 rounded-lg flex items-center gap-2 font-bold text-xs uppercase transition-colors ${filtroStatus !== 'TODOS' ? 'bg-teal-50 border-teal-300 text-teal-700' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
|
|
}`}>
|
|
<Filter size={18} /> {filtroLabel}
|
|
</button>
|
|
{showFiltro && (
|
|
<div className="absolute right-0 top-full mt-1 bg-white border border-gray-200 rounded-lg shadow-xl z-20 min-w-[160px] overflow-hidden">
|
|
{(['TODOS', 'Pago', 'Pendente', 'Atrasado'] as const).map(status => (
|
|
<button key={status} onClick={() => { setFiltroStatus(status); setShowFiltro(false); }}
|
|
className={`w-full text-left px-4 py-2.5 text-sm font-bold uppercase hover:bg-gray-50 transition-colors flex items-center justify-between ${filtroStatus === status ? 'text-teal-700 bg-teal-50' : 'text-gray-700'
|
|
}`}>
|
|
{status.toUpperCase()}
|
|
{filtroStatus === status && <div className="w-2 h-2 rounded-full bg-teal-600"></div>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* NOVO LANÇAMENTO */}
|
|
<button onClick={() => setIsNovoOpen(true)}
|
|
className="bg-green-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-green-700 font-bold text-xs uppercase shadow-sm transition-colors">
|
|
<Plus size={18} /> NOVO LANÇAMENTO
|
|
</button>
|
|
</div>
|
|
</PageHeader>
|
|
|
|
{/* DASHBOARD CARDS */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs text-gray-500 font-bold mb-1 uppercase">RECEBIDO (MÊS)</p>
|
|
<h3 className="text-2xl font-bold text-green-600">R$ {totalRecebido.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</h3>
|
|
</div>
|
|
<div className="bg-green-100 p-3 rounded-full text-green-600"><TrendingUp size={24} /></div>
|
|
</div>
|
|
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs text-gray-500 font-bold mb-1 uppercase">A RECEBER / PENDENTE</p>
|
|
<h3 className="text-2xl font-bold text-yellow-600">R$ {totalPendente.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</h3>
|
|
</div>
|
|
<div className="bg-yellow-100 p-3 rounded-full text-yellow-600"><Clock size={24} /></div>
|
|
</div>
|
|
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs text-gray-500 font-bold mb-1 uppercase">EM ATRASO</p>
|
|
<h3 className="text-2xl font-bold text-red-600">R$ {totalAtrasado.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</h3>
|
|
</div>
|
|
<div className="bg-red-100 p-3 rounded-full text-red-600"><TrendingDown size={24} /></div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* LISTA DE TRANSAÇÕES */}
|
|
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
|
<div className="p-4 border-b border-gray-200 bg-gray-50 flex justify-between items-center">
|
|
<h3 className="font-bold text-gray-800 uppercase">
|
|
LANÇAMENTOS {filtroStatus !== 'TODOS' && <span className="text-teal-600 text-sm">({filtroStatus.toUpperCase()})</span>}
|
|
</h3>
|
|
<span className="text-xs text-gray-400 font-bold uppercase">{transacoesFiltradas.length} REGISTRO(S)</span>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm text-left">
|
|
<thead className="bg-gray-50 text-gray-500 uppercase text-xs font-bold">
|
|
<tr>
|
|
<th className="px-6 py-3">PACIENTE</th>
|
|
<th className="px-6 py-3">DESCRIÇÃO</th>
|
|
<th className="px-6 py-3">VENCIMENTO</th>
|
|
<th className="px-6 py-3">VALOR</th>
|
|
<th className="px-6 py-3">STATUS</th>
|
|
<th className="px-6 py-3 text-right">AÇÕES</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{loading ? (
|
|
<tr><td colSpan={6} className="p-6 text-center text-gray-500 uppercase font-bold">
|
|
<div className="flex items-center justify-center gap-2"><Loader2 className="animate-spin" size={18} /> CARREGANDO...</div>
|
|
</td></tr>
|
|
) : transacoesFiltradas.length === 0 ? (
|
|
<tr><td colSpan={6} className="p-10 text-center text-gray-400 uppercase font-bold text-sm">
|
|
NENHUM LANÇAMENTO ENCONTRADO {filtroStatus !== 'TODOS' && `COM STATUS "${filtroStatus.toUpperCase()}"`}.
|
|
</td></tr>
|
|
) : transacoesFiltradas.map(t => (
|
|
<tr key={t.id} className="hover:bg-gray-50 transition-colors">
|
|
<td className="px-6 py-4 font-bold text-gray-900 uppercase">{t.pacienteNome}</td>
|
|
<td className="px-6 py-4 text-gray-600 font-medium uppercase">
|
|
{t.descricao}
|
|
<span className="text-[10px] text-gray-400 block font-bold">{t.formaPagamento?.toUpperCase()}</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-gray-600 font-medium">{new Date(t.dataVencimento).toLocaleDateString('pt-BR')}</td>
|
|
<td className="px-6 py-4 font-bold text-gray-800">R$ {Number(t.valor).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</td>
|
|
<td className="px-6 py-4">
|
|
<span className={`px-2.5 py-1 rounded-full text-[10px] font-bold uppercase
|
|
${t.status === 'Pago' ? 'bg-green-100 text-green-700' :
|
|
t.status === 'Atrasado' ? 'bg-red-100 text-red-700' :
|
|
'bg-yellow-100 text-yellow-700'}`}>
|
|
{t.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-right">
|
|
<div className="flex justify-end gap-1">
|
|
{t.status !== 'Pago' && (
|
|
<button onClick={() => setBaixaItem(t)} title="CONFIRMAR PAGAMENTO"
|
|
className="p-1.5 text-green-600 hover:bg-green-50 rounded-lg transition-colors">
|
|
<CheckCircle size={18} />
|
|
</button>
|
|
)}
|
|
<button onClick={() => handleDownload(t)} title="GERAR RECIBO"
|
|
className="p-1.5 text-teal-600 hover:bg-teal-50 rounded-lg transition-colors">
|
|
<Download size={18} />
|
|
</button>
|
|
<button onClick={() => handleExcluir(t)} title="EXCLUIR"
|
|
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
|
<Trash2 size={18} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* CLOSE FILTRO DROPDOWN when clicking outside */}
|
|
{showFiltro && <div className="fixed inset-0 z-10" onClick={() => setShowFiltro(false)} />}
|
|
|
|
{/* NOVO LANÇAMENTO MODAL */}
|
|
{isNovoOpen && <NovoLancamentoModal onClose={() => setIsNovoOpen(false)} onSaved={fetchData} />}
|
|
|
|
{/* BAIXA DE PAGAMENTO MODAL (H2.1) */}
|
|
{baixaItem && <BaixaPagamentoModal item={baixaItem} onClose={() => setBaixaItem(null)} onConfirm={handleBaixaConfirm} />}
|
|
</div>
|
|
);
|
|
}; |