diff --git a/backend/server.js b/backend/server.js index 49f06e5..5937f02 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4014,26 +4014,54 @@ app.get('/api/protese/lab/clientes', authGuard, async (req, res) => { 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 { 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(*) FILTER (WHERE 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 status = 'entregue')::int AS entregues, - COALESCE(SUM(custo) FILTER (WHERE status = 'entregue' AND tipo_os <> 'garantia'), 0) AS faturado - FROM protese_os o WHERE ${escopo} - GROUP BY clinica_id ORDER BY total DESC`, [uid, labId]); + COUNT(*) FILTER (WHERE o.status NOT IN ('entregue','cancelado'))::int AS ativas, + 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 o.status = 'entregue')::int AS entregues, + COALESCE(SUM(o.custo) FILTER (WHERE o.status = 'entregue' AND o.tipo_os <> 'garantia'), 0) AS faturado, + MAX(o.created_at) AS ultimo_pedido + 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( `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 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]); 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 => { 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 }); } }); +// 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'). app.get('/api/protese/lab/pagamentos', authGuard, async (req, res) => { diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index f9576be..134a811 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -1095,6 +1095,10 @@ export const HybridBackend = { const res = await apiFetch(`${API_URL}/protese/lab/pagamentos`); return await res.json().catch(() => []); }, + getLabFechamento: async (): Promise => { + 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 getLabDados: async (): Promise => { const res = await apiFetch(`${API_URL}/protese/lab`); diff --git a/frontend/views/LabClientesView.tsx b/frontend/views/LabClientesView.tsx index e9c4de7..92cb5ca 100644 --- a/frontend/views/LabClientesView.tsx +++ b/frontend/views/LabClientesView.tsx @@ -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 = { + '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([]); const [pagamentos, setPagamentos] = useState([]); + const [fechamento, setFechamento] = useState([]); 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 = {}; + 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 }) => (
@@ -41,41 +52,50 @@ export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({ return (
- +
{(['clientes', 'financeiro'] as const).map(t => ( - + ))}
{loading ?
: ( <> {aba === 'clientes' && ( - clientes.length === 0 ? ( -
Nenhuma clínica enviou OS ainda.
- ) : ( -
- - {['Clínica', 'OS', 'Ativas', 'Atrasadas', 'Entregues', 'Faturado', 'A receber'].map(h => )} - - {clientes.map(c => ( - - - - - - - - - - ))} - -
{h}
{c.clinica_nome || '—'}{c.total}{c.ativas} 0 ? 'text-red-500' : 'text-gray-400'}`}>{c.atrasadas}{c.entregues}{fmtBRL(c.faturado)} 0 ? 'text-amber-600' : 'text-gray-300'}`}>{fmtBRL(c.a_receber)}
-
+ clientes.length === 0 ?
Nenhuma clínica enviou OS ainda.
: ( + <> + {/* resumo por origem */} +
+ {Object.entries(porOrigem).map(([o, n]) => { + const m = ORIGEM_META[o] || ORIGEM_META['Profissional']; const I = m.icon; + return {o}: {n}; + })} +
+
+ + {['Cliente', 'Origem', 'Contato', 'OS', 'Ativas', 'Ticket méd.', 'Últ. pedido', 'A receber'].map(h => )} + + {clientes.map(c => { + const m = ORIGEM_META[c.origem] || ORIGEM_META['Profissional']; + return ( + + + + + + + + + + + ); + })} + +
{h}
{c.clinica_nome || '—'}{c.origem}{c.contato ? {c.contato} : '—'}{c.total} 0 ? 'text-red-500' : 'text-teal-600'}`}>{c.ativas}{c.atrasadas > 0 ? ` (${c.atrasadas}!)` : ''}{fmtBRL(c.ticket_medio)}{c.ultimo_pedido ? dataBR(c.ultimo_pedido) : '—'} 0 ? 'text-amber-600' : 'text-gray-300'}`}>{fmtBRL(c.a_receber)}
+
+ ) )} @@ -86,34 +106,49 @@ export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({
+ + {/* Fechamento mensal */}
-
-

Recebimentos

- {pagamentos.length} lançamento(s) -
- {pagamentos.length === 0 ? ( -
Nenhum recebimento registrado.
- ) : ( -
- - {['Clínica', 'Paciente', 'Trabalho', 'Forma', 'Data', 'Valor'].map(h => )} - - {pagamentos.map(p => ( - - - - - - - - - ))} - -
{h}
{p.clinica_nome || '—'}{p.paciente_nome || '—'}{p.tipo || '—'}{p.forma || '—'}{dataBR(p.data)}{fmtBRL(p.valor)}
-
+

Fechamento mensal

+ {fechamento.length === 0 ?
Sem movimento ainda.
: ( +
+ {['Mês', 'Entregas', 'Faturado', 'Recebido', 'Saldo'].map(h => )} + + {fechamento.map(m => ( + + + + + + + + ))} + +
{h}
{mesBR(m.mes)}{m.entregues}{fmtBRL(m.faturado)}{fmtBRL(m.recebido)} 0 ? 'text-amber-600' : 'text-gray-300'}`}>{fmtBRL(Math.max(0, m.faturado - m.recebido))}
)}
-

Faturado = custo das OS entregues (exceto garantia). A receber = faturado − recebido.

+ + {/* Extrato */} +
+

Recebimentos

{pagamentos.length}
+ {pagamentos.length === 0 ?
Nenhum recebimento.
: ( +
+ {['Cliente', 'Trabalho', 'Forma', 'Data', 'Valor'].map(h => )} + + {pagamentos.map(p => ( + + + + + + + + ))} + +
{h}
{p.clinica_nome || '—'}{p.tipo || '—'}{p.forma || '—'}{dataBR(p.data)}{fmtBRL(p.valor)}
+ )} +
+

Faturado = custo das OS entregues (≠ garantia). A receber = faturado − recebido.

)}