c8d679eb70
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>
124 lines
7.7 KiB
TypeScript
124 lines
7.7 KiB
TypeScript
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>
|
||
);
|
||
};
|