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>
334 lines
16 KiB
TypeScript
334 lines
16 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Plus, Search, FileText, Pencil, Printer, Trash2, Loader2, Zap, X, UserSearch } from 'lucide-react';
|
|
import { HybridBackend } from '../services/backend.ts';
|
|
import { Orcamento } from '../types.ts';
|
|
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
|
import { useToast } from '../contexts/ToastContext.tsx';
|
|
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
|
import { PageHeader } from '../components/PageHeader.tsx';
|
|
import { OrcamentoModal, DEFAULT_CLAUSULAS } from './OrcamentoModal.tsx';
|
|
|
|
type StatusFilter = 'TODOS' | 'Ativo' | 'Rascunho' | 'Assinado' | 'Encerrado';
|
|
|
|
const STATUS_TABS: StatusFilter[] = ['TODOS', 'Ativo', 'Rascunho', 'Assinado', 'Encerrado'];
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
Ativo: 'bg-green-100 text-green-700',
|
|
Rascunho: 'bg-amber-100 text-amber-700',
|
|
Assinado: 'bg-blue-100 text-blue-700',
|
|
Encerrado: 'bg-gray-100 text-gray-600',
|
|
};
|
|
|
|
const fmt = (v: number) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
|
|
|
export const OrcamentosView: React.FC = () => {
|
|
const toast = useToast();
|
|
const confirm = useConfirm();
|
|
const { data: contratos, isLoading, refresh } = useHybridBackend<Orcamento[]>(HybridBackend.getOrcamentos);
|
|
const list = contratos ?? [];
|
|
|
|
const [search, setSearch] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('TODOS');
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [modalOrcamento, setModalOrcamento] = useState<Orcamento | null>(null);
|
|
const [modalView, setModalView] = useState<'editor' | 'print'>('editor');
|
|
|
|
// Gerar automático: escolher paciente → cria contrato com cláusulas padrão → abre a prévia.
|
|
const [autoOpen, setAutoOpen] = useState(false);
|
|
const [autoSearch, setAutoSearch] = useState('');
|
|
const [autoPacientes, setAutoPacientes] = useState<{ id: string; nome: string }[]>([]);
|
|
const [autoLoading, setAutoLoading] = useState(false);
|
|
const [autoGerando, setAutoGerando] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!autoOpen) return;
|
|
let cancel = false;
|
|
setAutoLoading(true);
|
|
const t = setTimeout(async () => {
|
|
try {
|
|
const r = await HybridBackend.getPacientes(autoSearch);
|
|
if (!cancel) setAutoPacientes((r?.data ?? []).map((p: any) => ({ id: p.id, nome: p.nome })));
|
|
} catch { if (!cancel) setAutoPacientes([]); }
|
|
finally { if (!cancel) setAutoLoading(false); }
|
|
}, 300);
|
|
return () => { cancel = true; clearTimeout(t); };
|
|
}, [autoOpen, autoSearch]);
|
|
|
|
const filtered = list.filter(c => {
|
|
const matchSearch = !search.trim() ||
|
|
c.pacienteNome.toLowerCase().includes(search.toLowerCase()) ||
|
|
c.titulo.toLowerCase().includes(search.toLowerCase());
|
|
const matchStatus = statusFilter === 'TODOS' || c.status === statusFilter;
|
|
return matchSearch && matchStatus;
|
|
});
|
|
|
|
const stats = {
|
|
total: list.length,
|
|
ativos: list.filter(c => c.status === 'Ativo').length,
|
|
rascunhos: list.filter(c => c.status === 'Rascunho').length,
|
|
encerrados: list.filter(c => c.status === 'Encerrado' || c.status === 'Assinado').length,
|
|
};
|
|
|
|
const handleDelete = async (id: string, nome: string) => {
|
|
if (!await confirm(`EXCLUIR o orçamento de ${nome}?`)) return;
|
|
try {
|
|
await HybridBackend.deleteOrcamento(id);
|
|
toast.success('ORÇAMENTO EXCLUÍDO.');
|
|
refresh();
|
|
} catch {
|
|
toast.error('ERRO AO EXCLUIR ORÇAMENTO.');
|
|
}
|
|
};
|
|
|
|
const openNew = () => { setModalOrcamento(null); setModalView('editor'); setModalOpen(true); };
|
|
const openEdit = (c: Orcamento) => { setModalOrcamento(c); setModalView('editor'); setModalOpen(true); };
|
|
const openPrint = (c: Orcamento) => { setModalOrcamento(c); setModalView('print'); setModalOpen(true); };
|
|
|
|
// Gera um contrato automaticamente para o paciente (cláusulas padrão) e abre a prévia.
|
|
const gerarAuto = async (p: { id: string; nome: string }) => {
|
|
setAutoGerando(true);
|
|
try {
|
|
const hoje = new Date().toISOString().split('T')[0];
|
|
const res = await HybridBackend.saveOrcamento({
|
|
titulo: 'CONTRATO DE SERVIÇOS ODONTOLÓGICOS',
|
|
status: 'Rascunho',
|
|
paciente_id: p.id,
|
|
paciente_nome: p.nome,
|
|
data_inicio: hoje,
|
|
clausulas: DEFAULT_CLAUSULAS,
|
|
items: [],
|
|
} as any);
|
|
const novo = {
|
|
id: (res as any)?.id, titulo: 'CONTRATO DE SERVIÇOS ODONTOLÓGICOS', status: 'Rascunho',
|
|
pacienteId: p.id, pacienteNome: p.nome, dataInicio: hoje, clausulas: DEFAULT_CLAUSULAS, items: [],
|
|
} as unknown as Orcamento;
|
|
toast.success('ORÇAMENTO GERADO! REVISE E IMPRIMA.');
|
|
setAutoOpen(false); setAutoSearch('');
|
|
refresh();
|
|
setModalOrcamento(novo); setModalView('print'); setModalOpen(true);
|
|
} catch {
|
|
toast.error('ERRO AO GERAR ORÇAMENTO.');
|
|
} finally { setAutoGerando(false); }
|
|
};
|
|
|
|
return (
|
|
<div className="h-full flex flex-col">
|
|
<PageHeader title="ORÇAMENTOS" description="Gestão de orçamentos de serviços odontológicos">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => { setAutoSearch(''); setAutoPacientes([]); setAutoOpen(true); }}
|
|
title="Gerar orçamento automaticamente a partir de um paciente"
|
|
className="bg-teal-600 hover:bg-teal-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors uppercase font-bold text-sm shadow-sm"
|
|
>
|
|
<Zap size={18} /> GERAR AUTOMÁTICO
|
|
</button>
|
|
<button
|
|
onClick={openNew}
|
|
className="bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors uppercase font-bold text-sm shadow-sm"
|
|
>
|
|
<Plus size={18} /> NOVO ORÇAMENTO
|
|
</button>
|
|
</div>
|
|
</PageHeader>
|
|
|
|
{/* Stats bar */}
|
|
<div className="grid grid-cols-4 gap-3 mb-4">
|
|
{[
|
|
{ label: 'Total', value: stats.total, color: 'bg-emerald-50 text-emerald-700' },
|
|
{ label: 'Ativos', value: stats.ativos, color: 'bg-green-50 text-green-700' },
|
|
{ label: 'Rascunhos', value: stats.rascunhos, color: 'bg-amber-50 text-amber-700' },
|
|
{ label: 'Encerrados/Assinados', value: stats.encerrados, color: 'bg-gray-50 text-gray-600' },
|
|
].map(s => (
|
|
<div key={s.label} className={`${s.color} rounded-xl p-3 text-center`}>
|
|
<p className="text-2xl font-black">{s.value}</p>
|
|
<p className="text-[10px] font-bold uppercase opacity-70">{s.label}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Search + status filters */}
|
|
<div className="flex flex-col sm:flex-row gap-3 mb-4">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" size={16} />
|
|
<input
|
|
type="text"
|
|
placeholder="Buscar por paciente ou título..."
|
|
className="w-full pl-9 pr-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-100 focus:border-emerald-300 outline-none bg-white"
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1 overflow-x-auto">
|
|
{STATUS_TABS.map(s => (
|
|
<button
|
|
key={s}
|
|
onClick={() => setStatusFilter(s)}
|
|
className={`px-2.5 py-1 rounded-md text-[11px] font-bold transition-all whitespace-nowrap ${statusFilter === s ? 'bg-white text-gray-800 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
|
>
|
|
{s}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Loading */}
|
|
{isLoading && (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<Loader2 className="animate-spin text-emerald-400" size={32} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty state */}
|
|
{!isLoading && filtered.length === 0 && (
|
|
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 py-20">
|
|
<FileText size={48} className="mb-4 opacity-30" />
|
|
<p className="text-sm font-bold uppercase">Nenhum orçamento encontrado</p>
|
|
<p className="text-xs mt-1">{search || statusFilter !== 'TODOS' ? 'Tente limpar os filtros.' : 'Clique em "NOVO ORÇAMENTO" para começar.'}</p>
|
|
{!search && statusFilter === 'TODOS' && (
|
|
<button
|
|
onClick={openNew}
|
|
className="mt-4 flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-sm font-bold transition-colors"
|
|
>
|
|
<Plus size={16} /> Criar Primeiro Orcamento
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Grid */}
|
|
{!isLoading && filtered.length > 0 && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 overflow-y-auto pb-6">
|
|
{filtered.map(c => (
|
|
<div key={c.id} className="bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-all duration-200 flex flex-col">
|
|
<div className="p-4 flex-1">
|
|
{/* Header row */}
|
|
<div className="flex items-start justify-between gap-2 mb-2">
|
|
<h3 className="font-black text-gray-800 text-sm uppercase leading-tight flex-1">{c.pacienteNome}</h3>
|
|
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase flex-shrink-0 ${STATUS_COLORS[c.status] || 'bg-gray-100 text-gray-600'}`}>
|
|
{c.status}
|
|
</span>
|
|
</div>
|
|
|
|
<p className="text-[11px] text-gray-500 font-medium uppercase truncate mb-2">{c.titulo}</p>
|
|
|
|
<div className="flex flex-wrap gap-1 mb-3">
|
|
{c.especialidadeNome && (
|
|
<span className="bg-emerald-50 text-emerald-600 text-[9px] font-bold px-2 py-0.5 rounded-full uppercase">
|
|
{c.especialidadeNome}
|
|
</span>
|
|
)}
|
|
{c.dentistaNome && (
|
|
<span className="bg-gray-50 text-gray-500 text-[9px] font-bold px-2 py-0.5 rounded-full uppercase">
|
|
{c.dentistaNome}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2 text-[10px]">
|
|
<div>
|
|
<span className="text-gray-400 font-bold uppercase block">Valor Total</span>
|
|
<span className="font-black text-emerald-700 text-sm">{fmt(c.valorTotal)}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-400 font-bold uppercase block">Itens</span>
|
|
<span className="font-bold text-gray-700">{c.items?.length ?? 0} proc.</span>
|
|
</div>
|
|
{c.dataInicio && (
|
|
<div>
|
|
<span className="text-gray-400 font-bold uppercase block">Início</span>
|
|
<span className="font-bold text-gray-700">
|
|
{new Date(c.dataInicio + 'T12:00').toLocaleDateString('pt-BR')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{c.dataFim && (
|
|
<div>
|
|
<span className="text-gray-400 font-bold uppercase block">Fim</span>
|
|
<span className="font-bold text-gray-700">
|
|
{new Date(c.dataFim + 'T12:00').toLocaleDateString('pt-BR')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex border-t border-gray-100">
|
|
<button
|
|
onClick={() => openEdit(c)}
|
|
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-emerald-600 hover:bg-emerald-50 rounded-bl-xl text-[10px] font-bold transition-colors uppercase"
|
|
>
|
|
<Pencil size={12} /> Editar
|
|
</button>
|
|
<div className="border-l border-gray-100" />
|
|
<button
|
|
onClick={() => openPrint(c)}
|
|
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-gray-600 hover:bg-gray-50 text-[10px] font-bold transition-colors uppercase"
|
|
>
|
|
<Printer size={12} /> Imprimir
|
|
</button>
|
|
<div className="border-l border-gray-100" />
|
|
<button
|
|
onClick={() => handleDelete(c.id, c.pacienteNome)}
|
|
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-red-500 hover:bg-red-50 rounded-br-xl text-[10px] font-bold transition-colors uppercase"
|
|
>
|
|
<Trash2 size={12} /> Excluir
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal */}
|
|
{modalOpen && (
|
|
<OrcamentoModal
|
|
contrato={modalOrcamento}
|
|
initialView={modalView}
|
|
onClose={() => { setModalOpen(false); setModalOrcamento(null); setModalView('editor'); }}
|
|
onSaved={refresh}
|
|
/>
|
|
)}
|
|
|
|
{/* Gerar automático: picker de paciente */}
|
|
{autoOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
|
onMouseDown={(e) => { if (e.target === e.currentTarget && !autoGerando) setAutoOpen(false); }}>
|
|
<div className="bg-white rounded-2xl w-full max-w-md shadow-2xl overflow-hidden">
|
|
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-sm font-black text-gray-900 uppercase flex items-center gap-2"><Zap size={16} className="text-teal-600" /> Gerar automático</h3>
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-teal-600">Escolha o paciente · cláusulas padrão</p>
|
|
</div>
|
|
<button onClick={() => setAutoOpen(false)} disabled={autoGerando} className="p-2 hover:bg-gray-100 rounded-xl transition-colors disabled:opacity-50"><X size={18} /></button>
|
|
</div>
|
|
<div className="p-5">
|
|
<div className="relative mb-3">
|
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
|
|
<input autoFocus value={autoSearch} onChange={e => setAutoSearch(e.target.value)} placeholder="Buscar paciente por nome, CPF ou telefone…"
|
|
className="w-full pl-9 pr-3 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-sm outline-none focus:border-teal-400 focus:bg-white" />
|
|
</div>
|
|
<div className="max-h-72 overflow-y-auto -mx-1 px-1 space-y-1">
|
|
{autoLoading ? (
|
|
<div className="flex justify-center py-6"><Loader2 size={20} className="animate-spin text-teal-500" /></div>
|
|
) : autoPacientes.length === 0 ? (
|
|
<div className="text-center py-6 text-gray-400">
|
|
<UserSearch size={28} className="mx-auto mb-2 opacity-40" />
|
|
<p className="text-xs font-bold uppercase">{autoSearch ? 'Nenhum paciente encontrado' : 'Digite para buscar um paciente'}</p>
|
|
</div>
|
|
) : autoPacientes.map(p => (
|
|
<button key={p.id} type="button" disabled={autoGerando} onClick={() => gerarAuto(p)}
|
|
className="w-full text-left px-3 py-2.5 rounded-lg border border-gray-100 hover:border-teal-300 hover:bg-teal-50/50 transition-colors flex items-center justify-between gap-2 disabled:opacity-60">
|
|
<span className="text-sm font-bold text-gray-800 uppercase truncate">{p.nome}</span>
|
|
{autoGerando ? <Loader2 size={14} className="animate-spin text-teal-500 shrink-0" /> : <Zap size={14} className="text-teal-600 shrink-0" />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|