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>
322 lines
18 KiB
TypeScript
322 lines
18 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Filter, MessageSquare, UserPlus, CheckCircle, X, Loader2, Plus, ChevronDown } from 'lucide-react';
|
|
import { HybridBackend } from '../services/backend.ts';
|
|
import { Lead, Dentista } from '../types.ts';
|
|
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
|
import { useToast } from '../contexts/ToastContext.tsx';
|
|
|
|
const LoadingSpinner = () => (
|
|
<div className="col-span-3 flex justify-center items-center py-10">
|
|
<Loader2 className="animate-spin text-teal-600" size={32} />
|
|
</div>
|
|
);
|
|
|
|
const ErrorDisplay = ({ error }: { error: Error }) => (
|
|
<div className="col-span-3 text-center py-10 text-red-500 uppercase font-bold text-sm">
|
|
ERRO AO CARREGAR LEADS: {error.message}
|
|
</div>
|
|
);
|
|
|
|
const NovoLeadModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => {
|
|
const toast = useToast();
|
|
const [form, setForm] = useState({ nome: '', sobrenome: '', whatsapp: '', tipoInteresse: 'Particular' });
|
|
|
|
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.nome || !form.whatsapp) {
|
|
toast.error('PREENCHA NOME E WHATSAPP.');
|
|
return;
|
|
}
|
|
try {
|
|
await HybridBackend.saveLead({
|
|
...form,
|
|
status: 'Novo',
|
|
dataCadastro: new Date().toISOString(),
|
|
} as any);
|
|
toast.success('LEAD CADASTRADO!');
|
|
onSaved();
|
|
onClose();
|
|
} catch {
|
|
toast.error('ERRO AO CADASTRAR LEAD.');
|
|
}
|
|
};
|
|
|
|
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-xl flex flex-col overflow-hidden animate-in fade-in zoom-in-50 duration-200">
|
|
<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-base font-bold text-gray-800 uppercase flex items-center gap-2">
|
|
<UserPlus size={18} className="text-green-500" /> CADASTRAR NOVO LEAD
|
|
</h2>
|
|
<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>
|
|
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
|
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl mx-auto">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase">NOME</label>
|
|
<input
|
|
type="text"
|
|
value={form.nome}
|
|
onChange={e => setForm(f => ({ ...f, nome: e.target.value.toUpperCase() }))}
|
|
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 text-sm font-semibold uppercase-input"
|
|
placeholder="NOME"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase">SOBRENOME</label>
|
|
<input
|
|
type="text"
|
|
value={form.sobrenome}
|
|
onChange={e => setForm(f => ({ ...f, sobrenome: e.target.value.toUpperCase() }))}
|
|
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 text-sm font-semibold uppercase-input"
|
|
placeholder="SOBRENOME"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase">WHATSAPP</label>
|
|
<input
|
|
type="text"
|
|
value={form.whatsapp}
|
|
onChange={e => setForm(f => ({ ...f, whatsapp: e.target.value }))}
|
|
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 text-sm"
|
|
placeholder="(XX) XXXXX-XXXX"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase">TIPO DE INTERESSE</label>
|
|
<select
|
|
value={form.tipoInteresse}
|
|
onChange={e => setForm(f => ({ ...f, tipoInteresse: e.target.value }))}
|
|
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 text-sm bg-white uppercase-select"
|
|
>
|
|
<option value="Particular">PARTICULAR</option>
|
|
<option value="Plano">PLANO</option>
|
|
</select>
|
|
</div>
|
|
<div className="flex justify-end gap-3 pt-6">
|
|
<button type="button" onClick={onClose} className="px-6 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold rounded-lg text-sm uppercase">CANCELAR</button>
|
|
<button type="submit" className="px-6 py-2.5 bg-green-600 hover:bg-green-700 text-white font-bold rounded-lg text-sm uppercase flex items-center gap-2">
|
|
<UserPlus size={16} /> CADASTRAR
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
import { PageHeader } from '../components/PageHeader.tsx';
|
|
|
|
export const LeadsView: React.FC = () => {
|
|
const { data: leads, isLoading, error, refresh } = useHybridBackend<Lead[]>(HybridBackend.getLeads);
|
|
const { data: dentistas } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
|
|
|
|
const [isConvertModalOpen, setIsConvertModalOpen] = useState(false);
|
|
const [isNovoLeadOpen, setIsNovoLeadOpen] = useState(false);
|
|
const [selectedLead, setSelectedLead] = useState<Lead | null>(null);
|
|
const [convData, setConvData] = useState('');
|
|
const [convHora, setConvHora] = useState('');
|
|
const [convDentista, setConvDentista] = useState('');
|
|
const [converting, setConverting] = useState(false);
|
|
const [filterStatus, setFilterStatus] = useState<string>('TODOS');
|
|
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
|
const toast = useToast();
|
|
|
|
// Effect to handle Esc key to close modal
|
|
useEffect(() => {
|
|
if (!isConvertModalOpen) return;
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
setIsConvertModalOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => {
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
}, [isConvertModalOpen]);
|
|
|
|
const handleConvert = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!selectedLead || converting) return;
|
|
setConverting(true);
|
|
try {
|
|
const r = await HybridBackend.converterLead(selectedLead.id, {
|
|
data: convData || undefined,
|
|
hora: convHora || undefined,
|
|
dentistaId: convDentista || (dentistas && dentistas[0]?.id) || undefined,
|
|
});
|
|
if (r && r.success === false) { toast.error(r.message || 'FALHA AO CONVERTER LEAD.'); return; }
|
|
toast.success(`${selectedLead.nome.toUpperCase()} CONVERTIDO EM PACIENTE${r.agendamentoId ? ' E AGENDADO' : ''}.`);
|
|
setIsConvertModalOpen(false);
|
|
setConvData(''); setConvHora(''); setConvDentista('');
|
|
refresh();
|
|
} catch {
|
|
toast.error("FALHA AO CONVERTER LEAD.");
|
|
} finally {
|
|
setConverting(false);
|
|
}
|
|
};
|
|
|
|
const filterOptions = ['TODOS', 'NOVO', 'CONVERTIDO'];
|
|
|
|
const filteredLeads = leads?.filter(lead => {
|
|
if (filterStatus === 'TODOS') return true;
|
|
return lead.status?.toUpperCase() === filterStatus;
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader title="GESTÃO DE LEADS">
|
|
<div className="flex gap-2">
|
|
{/* Filter Dropdown */}
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setIsFilterOpen(o => !o)}
|
|
className="bg-white border border-gray-300 text-gray-600 px-3 py-2 rounded-lg text-sm flex items-center gap-2 hover:bg-gray-50 uppercase font-bold"
|
|
>
|
|
<Filter size={16} />
|
|
{filterStatus}
|
|
<ChevronDown size={14} className={`transition-transform ${isFilterOpen ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
{isFilterOpen && (
|
|
<div className="absolute right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-10 min-w-[140px]">
|
|
{filterOptions.map(opt => (
|
|
<button
|
|
key={opt}
|
|
onClick={() => { setFilterStatus(opt); setIsFilterOpen(false); }}
|
|
className={`w-full text-left px-4 py-2 text-sm font-bold uppercase hover:bg-gray-50 ${filterStatus === opt ? 'text-teal-600 bg-teal-50' : 'text-gray-700'}`}
|
|
>
|
|
{opt}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* New Lead Button */}
|
|
<button
|
|
onClick={() => setIsNovoLeadOpen(true)}
|
|
className="bg-green-600 text-white px-4 py-2 rounded-lg text-sm flex items-center gap-2 hover:bg-green-700 uppercase font-bold shadow-sm"
|
|
>
|
|
<Plus size={16} /> NOVO LEAD
|
|
</button>
|
|
</div>
|
|
</PageHeader>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{isLoading && <LoadingSpinner />}
|
|
{error && <ErrorDisplay error={error} />}
|
|
{filteredLeads && filteredLeads.map(lead => (
|
|
<div key={lead.id} className={`bg-white rounded-xl shadow-sm border ${lead.status === 'Novo' ? 'border-amber-200 bg-amber-50/30' : 'border-gray-200'} p-6 relative`}>
|
|
{lead.status === 'Novo' && (
|
|
<div className="absolute top-4 right-4 w-2 h-2 bg-amber-500 rounded-full animate-pulse"></div>
|
|
)}
|
|
<div className="flex items-start justify-between mb-4">
|
|
<div>
|
|
<h3 className="text-lg font-bold text-gray-900 uppercase">{lead.nome} {lead.sobrenome}</h3>
|
|
<p className="text-sm text-gray-500 flex items-center gap-1 mt-1 font-medium">
|
|
<MessageSquare size={14} /> {lead.whatsapp}
|
|
</p>
|
|
</div>
|
|
<span className={`px-2 py-1 text-xs font-bold rounded uppercase ${lead.tipoInteresse === 'Particular' ? 'bg-teal-100 text-teal-800' : 'bg-purple-100 text-purple-800'}`}>
|
|
{lead.tipoInteresse}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="text-xs text-gray-400 mb-6 font-bold uppercase">
|
|
CADASTRADO EM: {new Date(lead.dataCadastro).toLocaleDateString()}
|
|
</div>
|
|
|
|
{lead.status !== 'Convertido' ? (
|
|
<button
|
|
onClick={() => { setSelectedLead(lead); setIsConvertModalOpen(true); }}
|
|
className="w-full py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg flex items-center justify-center gap-2 font-bold transition-colors uppercase text-xs"
|
|
>
|
|
<UserPlus size={18} /> AGENDAR E CADASTRAR
|
|
</button>
|
|
) : (
|
|
<div className="w-full py-2 bg-gray-100 text-gray-500 rounded-lg flex items-center justify-center gap-2 font-bold uppercase text-xs">
|
|
<CheckCircle size={18} /> CONVERTIDO
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
{filteredLeads?.length === 0 && !isLoading && (
|
|
<div className="col-span-3 text-center py-10 text-gray-400 uppercase font-bold text-sm">
|
|
NENHUM LEAD ENCONTRADO.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{isConvertModalOpen && selectedLead && (
|
|
<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-xl flex flex-col overflow-hidden animate-in fade-in zoom-in-50 duration-200">
|
|
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-teal-50 to-white">
|
|
<h3 className="text-lg font-bold text-gray-800 uppercase flex items-center gap-2">
|
|
<UserPlus size={18} className="text-teal-500" /> CONVERTER LEAD
|
|
</h3>
|
|
<button onClick={() => setIsConvertModalOpen(false)} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
|
<form onSubmit={handleConvert} className="space-y-4 max-w-2xl mx-auto">
|
|
<div className="bg-teal-50 p-4 rounded-lg mb-4 text-xs text-teal-800 border border-teal-100 font-bold uppercase">
|
|
AO SALVAR, O SISTEMA IRÁ AUTOMATICAMENTE <strong>CADASTRAR O PACIENTE</strong> E CRIAR O AGENDAMENTO.
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-700 mb-1 uppercase">PACIENTE</label>
|
|
<input disabled value={`${selectedLead.nome} ${selectedLead.sobrenome}`.toUpperCase()} className="w-full bg-gray-100 border border-gray-300 rounded-lg p-2.5 text-gray-500 uppercase font-bold" />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-700 mb-1 uppercase">DATA AGENDAMENTO</label>
|
|
<input type="date" value={convData} onChange={e => setConvData(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 font-semibold" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-700 mb-1 uppercase">HORA</label>
|
|
<input type="time" value={convHora} onChange={e => setConvHora(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 font-semibold" />
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-700 mb-1 uppercase">DENTISTA RESPONSÁVEL</label>
|
|
<select value={convDentista} onChange={e => setConvDentista(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 bg-white uppercase-select font-semibold">
|
|
{dentistas && dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
|
</select>
|
|
</div>
|
|
|
|
<p className="text-[11px] text-gray-400 font-medium -mt-2">Sem data/hora, o lead vira paciente sem agendamento (você marca depois).</p>
|
|
|
|
<div className="pt-6">
|
|
<button type="submit" disabled={converting} className="w-full bg-green-600 text-white py-3 rounded-lg font-bold hover:bg-green-700 uppercase text-sm shadow-sm transition-all disabled:opacity-50">
|
|
{converting ? 'CONVERTENDO...' : 'CONFIRMAR CONVERSÃO'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isNovoLeadOpen && (
|
|
<NovoLeadModal onClose={() => setIsNovoLeadOpen(false)} onSaved={refresh} />
|
|
)}
|
|
</div>
|
|
);
|
|
}; |