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:
+36
-8
@@ -4014,26 +4014,54 @@ app.get('/api/protese/lab/clientes', authGuard, async (req, res) => {
|
|||||||
const labId = await labDoProtetico(uid);
|
const labId = await labDoProtetico(uid);
|
||||||
const escopo = `(laboratorio_id = $2 OR (laboratorio_id IS NULL AND protetico_id = $1)) AND deleted_at IS NULL`;
|
const escopo = `(laboratorio_id = $2 OR (laboratorio_id IS NULL AND protetico_id = $1)) AND deleted_at IS NULL`;
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT clinica_id, (SELECT nome_fantasia FROM clinicas WHERE id = o.clinica_id) AS clinica_nome,
|
`SELECT o.clinica_id, c.nome_fantasia AS clinica_nome, c.tipo AS clinica_tipo, u.role AS owner_role,
|
||||||
|
u.celular AS contato, u.email AS contato_email,
|
||||||
COUNT(*)::int AS total,
|
COUNT(*)::int AS total,
|
||||||
COUNT(*) FILTER (WHERE status NOT IN ('entregue','cancelado'))::int AS ativas,
|
COUNT(*) FILTER (WHERE o.status NOT IN ('entregue','cancelado'))::int AS ativas,
|
||||||
COUNT(*) FILTER (WHERE prazo_entrega IS NOT NULL AND prazo_entrega < CURRENT_DATE AND status NOT IN ('entregue','cancelado'))::int AS atrasadas,
|
COUNT(*) FILTER (WHERE o.prazo_entrega IS NOT NULL AND o.prazo_entrega < CURRENT_DATE AND o.status NOT IN ('entregue','cancelado'))::int AS atrasadas,
|
||||||
COUNT(*) FILTER (WHERE status = 'entregue')::int AS entregues,
|
COUNT(*) FILTER (WHERE o.status = 'entregue')::int AS entregues,
|
||||||
COALESCE(SUM(custo) FILTER (WHERE status = 'entregue' AND tipo_os <> 'garantia'), 0) AS faturado
|
COALESCE(SUM(o.custo) FILTER (WHERE o.status = 'entregue' AND o.tipo_os <> 'garantia'), 0) AS faturado,
|
||||||
FROM protese_os o WHERE ${escopo}
|
MAX(o.created_at) AS ultimo_pedido
|
||||||
GROUP BY clinica_id ORDER BY total DESC`, [uid, labId]);
|
FROM protese_os o LEFT JOIN clinicas c ON c.id = o.clinica_id LEFT JOIN usuarios u ON u.id = c.owner_id
|
||||||
|
WHERE ${escopo}
|
||||||
|
GROUP BY o.clinica_id, c.nome_fantasia, c.tipo, u.role, u.celular, u.email ORDER BY total DESC`, [uid, labId]);
|
||||||
const { rows: pagos } = await pool.query(
|
const { rows: pagos } = await pool.query(
|
||||||
`SELECT o.clinica_id, COALESCE(SUM(p.valor), 0) AS recebido
|
`SELECT o.clinica_id, COALESCE(SUM(p.valor), 0) AS recebido
|
||||||
FROM protese_os_pagamento p JOIN protese_os o ON o.id = p.os_id
|
FROM protese_os_pagamento p JOIN protese_os o ON o.id = p.os_id
|
||||||
WHERE p.lado = 'lab' AND (o.laboratorio_id = $2 OR (o.laboratorio_id IS NULL AND o.protetico_id = $1))
|
WHERE p.lado = 'lab' AND (o.laboratorio_id = $2 OR (o.laboratorio_id IS NULL AND o.protetico_id = $1))
|
||||||
GROUP BY o.clinica_id`, [uid, labId]);
|
GROUP BY o.clinica_id`, [uid, labId]);
|
||||||
const recPorClinica = Object.fromEntries(pagos.map(p => [p.clinica_id, parseFloat(p.recebido || 0)]));
|
const recPorClinica = Object.fromEntries(pagos.map(p => [p.clinica_id, parseFloat(p.recebido || 0)]));
|
||||||
|
const origemDe = (tipo, role) => tipo === 'consultorio' ? 'Consultório'
|
||||||
|
: tipo === 'pessoal' ? (role === 'dentista' ? 'Dentista' : role === 'biomedico' ? 'Biomédico' : 'Profissional')
|
||||||
|
: 'Clínica';
|
||||||
res.json(rows.map(r => {
|
res.json(rows.map(r => {
|
||||||
const faturado = parseFloat(r.faturado || 0), recebido = recPorClinica[r.clinica_id] || 0;
|
const faturado = parseFloat(r.faturado || 0), recebido = recPorClinica[r.clinica_id] || 0;
|
||||||
return { ...r, faturado, recebido, a_receber: Math.max(0, faturado - recebido) };
|
return {
|
||||||
|
...r, faturado, recebido, a_receber: Math.max(0, faturado - recebido),
|
||||||
|
origem: origemDe(r.clinica_tipo, r.owner_role),
|
||||||
|
ticket_medio: r.entregues > 0 ? Math.round(faturado / r.entregues * 100) / 100 : 0,
|
||||||
|
};
|
||||||
}));
|
}));
|
||||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
});
|
});
|
||||||
|
// CRM financeiro do lab — fechamento mensal (faturado x recebido por mês).
|
||||||
|
app.get('/api/protese/lab/fechamento', authGuard, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const uid = req.authUser.userId;
|
||||||
|
const labId = await labDoProtetico(uid);
|
||||||
|
const esc = `(o.laboratorio_id = $2 OR (o.laboratorio_id IS NULL AND o.protetico_id = $1))`;
|
||||||
|
const { rows: fat } = await pool.query(
|
||||||
|
`SELECT to_char(o.updated_at,'YYYY-MM') AS mes, COALESCE(SUM(o.custo),0) AS faturado, COUNT(*)::int AS entregues
|
||||||
|
FROM protese_os o WHERE o.status='entregue' AND o.tipo_os<>'garantia' AND o.updated_at IS NOT NULL AND ${esc} AND o.deleted_at IS NULL GROUP BY mes`, [uid, labId]);
|
||||||
|
const { rows: rec } = await pool.query(
|
||||||
|
`SELECT to_char(p.data,'YYYY-MM') AS mes, COALESCE(SUM(p.valor),0) AS recebido
|
||||||
|
FROM protese_os_pagamento p JOIN protese_os o ON o.id=p.os_id WHERE p.lado='lab' AND ${esc} GROUP BY mes`, [uid, labId]);
|
||||||
|
const meses = {};
|
||||||
|
for (const f of fat) meses[f.mes] = { mes: f.mes, faturado: parseFloat(f.faturado || 0), entregues: f.entregues, recebido: 0 };
|
||||||
|
for (const r of rec) { (meses[r.mes] = meses[r.mes] || { mes: r.mes, faturado: 0, entregues: 0, recebido: 0 }).recebido = parseFloat(r.recebido || 0); }
|
||||||
|
res.json(Object.values(meses).sort((a, b) => b.mes.localeCompare(a.mes)));
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
// FASE 3 — Extrato de recebimentos do laboratório (pagamentos lado 'lab').
|
// FASE 3 — Extrato de recebimentos do laboratório (pagamentos lado 'lab').
|
||||||
app.get('/api/protese/lab/pagamentos', authGuard, async (req, res) => {
|
app.get('/api/protese/lab/pagamentos', authGuard, async (req, res) => {
|
||||||
|
|||||||
@@ -1095,6 +1095,10 @@ export const HybridBackend = {
|
|||||||
const res = await apiFetch(`${API_URL}/protese/lab/pagamentos`);
|
const res = await apiFetch(`${API_URL}/protese/lab/pagamentos`);
|
||||||
return await res.json().catch(() => []);
|
return await res.json().catch(() => []);
|
||||||
},
|
},
|
||||||
|
getLabFechamento: async (): Promise<any[]> => {
|
||||||
|
const res = await apiFetch(`${API_URL}/protese/lab/fechamento`);
|
||||||
|
return await res.json().catch(() => []);
|
||||||
|
},
|
||||||
// Fase 4: dados do laboratório (PF/PJ, CPF/CNPJ) + equipe de técnicos
|
// Fase 4: dados do laboratório (PF/PJ, CPF/CNPJ) + equipe de técnicos
|
||||||
getLabDados: async (): Promise<any> => {
|
getLabDados: async (): Promise<any> => {
|
||||||
const res = await apiFetch(`${API_URL}/protese/lab`);
|
const res = await apiFetch(`${API_URL}/protese/lab`);
|
||||||
|
|||||||
@@ -1,27 +1,35 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
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 { HybridBackend } from '../services/backend.ts';
|
||||||
import { PageHeader } from '../components/PageHeader.tsx';
|
import { PageHeader } from '../components/PageHeader.tsx';
|
||||||
|
|
||||||
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
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 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).
|
const ORIGEM_META: Record<string, { icon: any; cls: string }> = {
|
||||||
// Uma view, duas abas — o item de menu define a aba inicial.
|
'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' }) => {
|
export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({ tab = 'clientes' }) => {
|
||||||
const [aba, setAba] = useState<'clientes' | 'financeiro'>(tab);
|
const [aba, setAba] = useState<'clientes' | 'financeiro'>(tab);
|
||||||
const [clientes, setClientes] = useState<any[]>([]);
|
const [clientes, setClientes] = useState<any[]>([]);
|
||||||
const [pagamentos, setPagamentos] = useState<any[]>([]);
|
const [pagamentos, setPagamentos] = useState<any[]>([]);
|
||||||
|
const [fechamento, setFechamento] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => { setAba(tab); }, [tab]);
|
useEffect(() => { setAba(tab); }, [tab]);
|
||||||
|
|
||||||
const carregar = useCallback(async () => {
|
const carregar = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [c, p] = await Promise.all([HybridBackend.getLabClientes(), HybridBackend.getLabPagamentos()]);
|
const [c, p, f] = await Promise.all([HybridBackend.getLabClientes(), HybridBackend.getLabPagamentos(), HybridBackend.getLabFechamento()]);
|
||||||
setClientes(Array.isArray(c) ? c : []);
|
setClientes(Array.isArray(c) ? c : []); setPagamentos(Array.isArray(p) ? p : []); setFechamento(Array.isArray(f) ? f : []);
|
||||||
setPagamentos(Array.isArray(p) ? p : []);
|
|
||||||
} catch { /* silent */ } finally { setLoading(false); }
|
} catch { /* silent */ } finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => { carregar(); }, [carregar]);
|
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 totFaturado = clientes.reduce((s, c) => s + Number(c.faturado || 0), 0);
|
||||||
const totRecebido = clientes.reduce((s, c) => s + Number(c.recebido || 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);
|
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 }) => (
|
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">
|
<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 (
|
return (
|
||||||
<div className="space-y-6 pb-10">
|
<div className="space-y-6 pb-10">
|
||||||
<PageHeader title={aba === 'clientes' ? 'CLIENTES DO LABORATÓRIO' : 'FINANCEIRO DO LABORATÓRIO'}>
|
<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">
|
<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>
|
||||||
<RefreshCw size={16} /> <span className="hidden sm:inline">ATUALIZAR</span>
|
|
||||||
</button>
|
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div className="flex border-b border-gray-100">
|
<div className="flex border-b border-gray-100">
|
||||||
{(['clientes', 'financeiro'] as const).map(t => (
|
{(['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>
|
</div>
|
||||||
|
|
||||||
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
|
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
|
||||||
<>
|
<>
|
||||||
{aba === 'clientes' && (
|
{aba === 'clientes' && (
|
||||||
clientes.length === 0 ? (
|
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="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">
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-x-auto">
|
||||||
<table className="w-full text-left text-xs">
|
<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>
|
<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">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{clientes.map(c => (
|
{clientes.map(c => {
|
||||||
|
const m = ORIGEM_META[c.origem] || ORIGEM_META['Profissional'];
|
||||||
|
return (
|
||||||
<tr key={c.clinica_id} className="hover:bg-gray-50/50">
|
<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 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 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-teal-600'}`}>{c.ativas}{c.atrasadas > 0 ? ` (${c.atrasadas}!)` : ''}</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-gray-600">{fmtBRL(c.ticket_medio)}</td>
|
||||||
<td className="px-4 py-3 text-green-600 font-bold">{c.entregues}</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 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>
|
<td className={`px-4 py-3 font-black ${c.a_receber > 0 ? 'text-amber-600' : 'text-gray-300'}`}>{fmtBRL(c.a_receber)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -86,22 +106,38 @@ export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({
|
|||||||
<KPI titulo="Recebido" valor={fmtBRL(totRecebido)} cor="text-green-600" Icon={Wallet} />
|
<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} />
|
<KPI titulo="A receber" valor={fmtBRL(totReceber)} cor="text-amber-500" Icon={Clock} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Fechamento mensal */}
|
||||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
<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">
|
<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>
|
||||||
<Inbox size={16} className="text-teal-600" /><h3 className="text-sm font-black text-gray-800 uppercase">Recebimentos</h3>
|
{fechamento.length === 0 ? <div className="py-10 text-center text-gray-400 text-sm font-bold uppercase">Sem movimento ainda.</div> : (
|
||||||
<span className="text-[10px] font-bold text-gray-400 uppercase ml-auto">{pagamentos.length} lançamento(s)</span>
|
<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>
|
</div>
|
||||||
{pagamentos.length === 0 ? (
|
|
||||||
<div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhum recebimento registrado.</div>
|
{/* Extrato */}
|
||||||
) : (
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<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>
|
||||||
<table className="w-full text-left text-xs">
|
{pagamentos.length === 0 ? <div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhum recebimento.</div> : (
|
||||||
<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>
|
<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">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{pagamentos.map(p => (
|
{pagamentos.map(p => (
|
||||||
<tr key={p.id} className="hover:bg-gray-50/50">
|
<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 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.tipo || '—'}</td>
|
||||||
<td className="px-4 py-3 text-gray-500">{p.forma || '—'}</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 text-gray-500 whitespace-nowrap">{dataBR(p.data)}</td>
|
||||||
@@ -109,11 +145,10 @@ export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table></div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</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>
|
<p className="text-[10px] text-gray-300 font-bold uppercase">Faturado = custo das OS entregues (≠ garantia). A receber = faturado − recebido.</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user