Files
scoreodonto.com/frontend/views/LabClientesView.tsx
T
VPS 4 Builder c8d679eb70 feat(protese): Fase 3 — Clientes e Financeiro do laboratório (operação multi-clínica)
Provedor de Prótese, Fase 3 (doc/PROVEDOR-PROTESE-DECISAO.md):

- Clientes (lab-clientes): clínicas que enviam OS, com OS totais/ativas/atrasadas/entregues,
  faturado e a-receber por cliente. GET /protese/lab/clientes (agrega por clinica_id no escopo
  do laboratório).
- Financeiro do lab (lab-financeiro): KPIs faturado/recebido/a-receber + extrato de recebimentos
  (pagamentos lado lab) com clínica/paciente/forma/data. GET /protese/lab/pagamentos.
  Regra: faturado = custo das OS entregues (≠ garantia); a-receber = faturado − recebido.
- Menu do laboratório ganha CLIENTES e FINANCEIRO (Sidebar + App rotas/contexto + nova
  LabClientesView com abas).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:09:25 +02:00

124 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback } from 'react';
import { Building2, Wallet, Clock, RefreshCw, Inbox, AlertTriangle, CheckCircle2 } 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('/');
// Á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.
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 [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 : []);
} catch { /* silent */ } finally { setLoading(false); }
}, []);
useEffect(() => { carregar(); }, [carregar]);
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);
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="absolute top-0 right-0 p-3 opacity-10"><Icon size={56} className={cor} /></div>
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{titulo}</h4>
<div className="mt-2 text-xl font-black text-gray-900">{valor}</div>
</div>
);
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>
</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>
))}
</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>
)
)}
{aba === 'financeiro' && (
<>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<KPI titulo="Faturado" valor={fmtBRL(totFaturado)} cor="text-gray-700" Icon={CheckCircle2} />
<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>
<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>
<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>
</>
)}
</>
)}
</div>
);
};