feat(protese): CRM financeiro do laboratório (origem, contato, ticket, fechamento mensal)

Atende ao pedido de CRM financeiro do protético — antes as telas Clientes/Financeiro existiam mas
não distinguiam origem nem traziam relacionamento/fechamento.

- lab/clientes agora traz por cliente: ORIGEM (Clínica / Consultório / Dentista / Biomédico, derivada
  de clinicas.tipo + role do owner), CONTATO (telefone), TICKET MÉDIO, ÚLTIMO PEDIDO, além de
  OS/ativas/atrasadas/faturado/recebido/a-receber.
- Novo lab/fechamento: faturado x recebido por mês.
- LabClientesView reformulada: aba "Clientes (CRM)" com resumo por origem + tabela
  (origem/contato/ticket/últ. pedido/a-receber); aba "Financeiro" com KPIs + FECHAMENTO MENSAL + extrato.

Validado em DEV: cliente Clínica (a-receber 0) e Dentista avulso (a-receber 100); fechamento 06/2026
faturado 550 / recebido 450.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-25 04:07:11 +02:00
parent 29b5b38678
commit 87411e1566
3 changed files with 132 additions and 65 deletions
+92 -57
View File
@@ -1,27 +1,35 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Building2, Wallet, Clock, RefreshCw, Inbox, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { Building2, Wallet, Clock, RefreshCw, Inbox, CheckCircle2, Phone, CalendarDays, Stethoscope, Briefcase, User } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
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('/');
const mesBR = (m: string) => { const [y, mo] = (m || '').split('-'); return mo ? new Date(Number(y), Number(mo) - 1, 1).toLocaleDateString('pt-BR', { month: 'short', year: 'numeric' }) : m; };
// Área do Laboratório (Fase 3): Clientes (clínicas que enviam OS) + Financeiro (a-receber).
// Uma view, duas abas — o item de menu define a aba inicial.
const ORIGEM_META: Record<string, { icon: any; cls: string }> = {
'Clínica': { icon: Building2, cls: 'bg-blue-100 text-blue-700' },
'Consultório': { icon: Briefcase, cls: 'bg-teal-100 text-teal-700' },
'Dentista': { icon: Stethoscope, cls: 'bg-violet-100 text-violet-700' },
'Biomédico': { icon: User, cls: 'bg-pink-100 text-pink-700' },
'Profissional': { icon: User, cls: 'bg-gray-100 text-gray-600' },
};
// CRM Financeiro do laboratório: Clientes (por origem, com contato/ticket/recebíveis) + Financeiro
// (fechamento mensal + recebimentos).
export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({ tab = 'clientes' }) => {
const [aba, setAba] = useState<'clientes' | 'financeiro'>(tab);
const [clientes, setClientes] = useState<any[]>([]);
const [pagamentos, setPagamentos] = useState<any[]>([]);
const [fechamento, setFechamento] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => { setAba(tab); }, [tab]);
const carregar = useCallback(async () => {
setLoading(true);
try {
const [c, p] = await Promise.all([HybridBackend.getLabClientes(), HybridBackend.getLabPagamentos()]);
setClientes(Array.isArray(c) ? c : []);
setPagamentos(Array.isArray(p) ? p : []);
const [c, p, f] = await Promise.all([HybridBackend.getLabClientes(), HybridBackend.getLabPagamentos(), HybridBackend.getLabFechamento()]);
setClientes(Array.isArray(c) ? c : []); setPagamentos(Array.isArray(p) ? p : []); setFechamento(Array.isArray(f) ? f : []);
} catch { /* silent */ } finally { setLoading(false); }
}, []);
useEffect(() => { carregar(); }, [carregar]);
@@ -29,6 +37,9 @@ export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({
const totFaturado = clientes.reduce((s, c) => s + Number(c.faturado || 0), 0);
const totRecebido = clientes.reduce((s, c) => s + Number(c.recebido || 0), 0);
const totReceber = clientes.reduce((s, c) => s + Number(c.a_receber || 0), 0);
// resumo por origem
const porOrigem: Record<string, number> = {};
for (const c of clientes) porOrigem[c.origem] = (porOrigem[c.origem] || 0) + 1;
const KPI: React.FC<{ titulo: string; valor: string; cor: string; Icon: any }> = ({ titulo, valor, cor, Icon }) => (
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm relative overflow-hidden">
@@ -41,41 +52,50 @@ export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({
return (
<div className="space-y-6 pb-10">
<PageHeader title={aba === 'clientes' ? 'CLIENTES DO LABORATÓRIO' : 'FINANCEIRO DO LABORATÓRIO'}>
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold transition-all shadow-sm">
<RefreshCw size={16} /> <span className="hidden sm:inline">ATUALIZAR</span>
</button>
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
</PageHeader>
<div className="flex border-b border-gray-100">
{(['clientes', 'financeiro'] as const).map(t => (
<button key={t} onClick={() => setAba(t)} className={`px-4 py-2.5 text-xs font-black uppercase border-b-2 -mb-px transition-colors ${aba === t ? 'border-teal-600 text-teal-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{t === 'clientes' ? 'Clientes' : 'Financeiro'}</button>
<button key={t} onClick={() => setAba(t)} className={`px-4 py-2.5 text-xs font-black uppercase border-b-2 -mb-px transition-colors ${aba === t ? 'border-teal-600 text-teal-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{t === 'clientes' ? 'Clientes (CRM)' : 'Financeiro'}</button>
))}
</div>
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
<>
{aba === 'clientes' && (
clientes.length === 0 ? (
<div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhuma clínica enviou OS ainda.</div>
) : (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="bg-gray-50/60"><tr>{['Clínica', 'OS', 'Ativas', 'Atrasadas', 'Entregues', 'Faturado', 'A receber'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>)}</tr></thead>
<tbody className="divide-y divide-gray-100">
{clientes.map(c => (
<tr key={c.clinica_id} className="hover:bg-gray-50/50">
<td className="px-4 py-3 font-black text-gray-700 uppercase flex items-center gap-2"><Building2 size={14} className="text-teal-600 shrink-0" /> {c.clinica_nome || '—'}</td>
<td className="px-4 py-3 text-gray-600 font-bold">{c.total}</td>
<td className="px-4 py-3 text-teal-600 font-bold">{c.ativas}</td>
<td className={`px-4 py-3 font-bold ${c.atrasadas > 0 ? 'text-red-500' : 'text-gray-400'}`}>{c.atrasadas}</td>
<td className="px-4 py-3 text-green-600 font-bold">{c.entregues}</td>
<td className="px-4 py-3 font-black text-gray-800">{fmtBRL(c.faturado)}</td>
<td className={`px-4 py-3 font-black ${c.a_receber > 0 ? 'text-amber-600' : 'text-gray-300'}`}>{fmtBRL(c.a_receber)}</td>
</tr>
))}
</tbody>
</table>
</div>
clientes.length === 0 ? <div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhuma clínica enviou OS ainda.</div> : (
<>
{/* resumo por origem */}
<div className="flex flex-wrap gap-2">
{Object.entries(porOrigem).map(([o, n]) => {
const m = ORIGEM_META[o] || ORIGEM_META['Profissional']; const I = m.icon;
return <span key={o} className={`text-[10px] font-black px-3 py-1.5 rounded-full uppercase flex items-center gap-1.5 ${m.cls}`}><I size={12} /> {o}: {n}</span>;
})}
</div>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="bg-gray-50/60"><tr>{['Cliente', 'Origem', 'Contato', 'OS', 'Ativas', 'Ticket méd.', 'Últ. pedido', 'A receber'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>)}</tr></thead>
<tbody className="divide-y divide-gray-100">
{clientes.map(c => {
const m = ORIGEM_META[c.origem] || ORIGEM_META['Profissional'];
return (
<tr key={c.clinica_id} className="hover:bg-gray-50/50">
<td className="px-4 py-3 font-black text-gray-700 uppercase">{c.clinica_nome || '—'}</td>
<td className="px-4 py-3"><span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${m.cls}`}>{c.origem}</span></td>
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{c.contato ? <span className="flex items-center gap-1"><Phone size={11} /> {c.contato}</span> : '—'}</td>
<td className="px-4 py-3 text-gray-600 font-bold">{c.total}</td>
<td className={`px-4 py-3 font-bold ${c.atrasadas > 0 ? 'text-red-500' : 'text-teal-600'}`}>{c.ativas}{c.atrasadas > 0 ? ` (${c.atrasadas}!)` : ''}</td>
<td className="px-4 py-3 text-gray-600">{fmtBRL(c.ticket_medio)}</td>
<td className="px-4 py-3 text-gray-400 whitespace-nowrap">{c.ultimo_pedido ? dataBR(c.ultimo_pedido) : '—'}</td>
<td className={`px-4 py-3 font-black ${c.a_receber > 0 ? 'text-amber-600' : 'text-gray-300'}`}>{fmtBRL(c.a_receber)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)
)}
@@ -86,34 +106,49 @@ export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({
<KPI titulo="Recebido" valor={fmtBRL(totRecebido)} cor="text-green-600" Icon={Wallet} />
<KPI titulo="A receber" valor={fmtBRL(totReceber)} cor="text-amber-500" Icon={Clock} />
</div>
{/* Fechamento mensal */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2">
<Inbox size={16} className="text-teal-600" /><h3 className="text-sm font-black text-gray-800 uppercase">Recebimentos</h3>
<span className="text-[10px] font-bold text-gray-400 uppercase ml-auto">{pagamentos.length} lançamento(s)</span>
</div>
{pagamentos.length === 0 ? (
<div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhum recebimento registrado.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="bg-gray-50/60"><tr>{['Clínica', 'Paciente', 'Trabalho', 'Forma', 'Data', 'Valor'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>)}</tr></thead>
<tbody className="divide-y divide-gray-100">
{pagamentos.map(p => (
<tr key={p.id} className="hover:bg-gray-50/50">
<td className="px-4 py-3 font-bold text-gray-700 uppercase">{p.clinica_nome || '—'}</td>
<td className="px-4 py-3 text-gray-500 uppercase">{p.paciente_nome || '—'}</td>
<td className="px-4 py-3 text-gray-500">{p.tipo || '—'}</td>
<td className="px-4 py-3 text-gray-500">{p.forma || '—'}</td>
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{dataBR(p.data)}</td>
<td className="px-4 py-3 font-black text-green-700">{fmtBRL(p.valor)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2"><CalendarDays size={16} className="text-teal-600" /><h3 className="text-sm font-black text-gray-800 uppercase">Fechamento mensal</h3></div>
{fechamento.length === 0 ? <div className="py-10 text-center text-gray-400 text-sm font-bold uppercase">Sem movimento ainda.</div> : (
<div className="overflow-x-auto"><table className="w-full text-left text-xs">
<thead className="bg-gray-50/60"><tr>{['Mês', 'Entregas', 'Faturado', 'Recebido', 'Saldo'].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">
{fechamento.map(m => (
<tr key={m.mes} className="hover:bg-gray-50/50">
<td className="px-4 py-3 font-black text-gray-700 uppercase">{mesBR(m.mes)}</td>
<td className="px-4 py-3 text-gray-600">{m.entregues}</td>
<td className="px-4 py-3 font-bold text-gray-800">{fmtBRL(m.faturado)}</td>
<td className="px-4 py-3 font-bold text-green-600">{fmtBRL(m.recebido)}</td>
<td className={`px-4 py-3 font-black ${m.faturado - m.recebido > 0 ? 'text-amber-600' : 'text-gray-300'}`}>{fmtBRL(Math.max(0, m.faturado - m.recebido))}</td>
</tr>
))}
</tbody>
</table></div>
)}
</div>
<p className="text-[10px] text-gray-300 font-bold uppercase flex items-center gap-1"><AlertTriangle size={11} /> Faturado = custo das OS entregues (exceto garantia). A receber = faturado recebido.</p>
{/* Extrato */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2"><Inbox size={16} className="text-teal-600" /><h3 className="text-sm font-black text-gray-800 uppercase">Recebimentos</h3><span className="text-[10px] font-bold text-gray-400 uppercase ml-auto">{pagamentos.length}</span></div>
{pagamentos.length === 0 ? <div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhum recebimento.</div> : (
<div className="overflow-x-auto"><table className="w-full text-left text-xs">
<thead className="bg-gray-50/60"><tr>{['Cliente', 'Trabalho', 'Forma', 'Data', 'Valor'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>)}</tr></thead>
<tbody className="divide-y divide-gray-100">
{pagamentos.map(p => (
<tr key={p.id} className="hover:bg-gray-50/50">
<td className="px-4 py-3 font-bold text-gray-700 uppercase">{p.clinica_nome || '—'}</td>
<td className="px-4 py-3 text-gray-500">{p.tipo || '—'}</td>
<td className="px-4 py-3 text-gray-500">{p.forma || '—'}</td>
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{dataBR(p.data)}</td>
<td className="px-4 py-3 font-black text-green-700">{fmtBRL(p.valor)}</td>
</tr>
))}
</tbody>
</table></div>
)}
</div>
<p className="text-[10px] text-gray-300 font-bold uppercase">Faturado = custo das OS entregues ( garantia). A receber = faturado recebido.</p>
</>
)}
</>