From c8d679eb703d35fc3f36fcdacb6a1c0b1dc37592 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Wed, 24 Jun 2026 05:09:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(protese):=20Fase=203=20=E2=80=94=20Cliente?= =?UTF-8?q?s=20e=20Financeiro=20do=20laborat=C3=B3rio=20(opera=C3=A7=C3=A3?= =?UTF-8?q?o=20multi-cl=C3=ADnica)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/server.js | 43 ++++++++++ doc/PROVEDOR-PROTESE-DECISAO.md | 11 ++- frontend/App.tsx | 13 ++- frontend/components/Sidebar.tsx | 6 +- frontend/services/backend.ts | 10 +++ frontend/views/LabClientesView.tsx | 123 +++++++++++++++++++++++++++++ 6 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 frontend/views/LabClientesView.tsx diff --git a/backend/server.js b/backend/server.js index d2e2fd3..edd0b93 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3967,6 +3967,49 @@ app.get('/api/protese/bancada', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); +// FASE 3 — Clientes do laboratório: clínicas que enviam OS, com métricas operacionais e financeiras. +app.get('/api/protese/lab/clientes', authGuard, async (req, res) => { + try { + const uid = req.authUser.userId; + 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, + 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]); + 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)])); + 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) }; + })); + } 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) => { + try { + const uid = req.authUser.userId; + const labId = await labDoProtetico(uid); + const { rows } = await pool.query( + `SELECT p.id, p.valor, p.forma, p.data, p.recebido_nome, o.tipo, o.paciente_nome, + (SELECT nome_fantasia FROM clinicas WHERE id = o.clinica_id) AS clinica_nome + 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)) + ORDER BY p.data DESC, p.created_at DESC LIMIT 200`, [uid, labId]); + res.json(rows.map(r => ({ ...r, valor: parseFloat(r.valor || 0) }))); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + // Detalhe + histórico de eventos + fotos. app.get('/api/protese/os/:id', authGuard, async (req, res) => { try { diff --git a/doc/PROVEDOR-PROTESE-DECISAO.md b/doc/PROVEDOR-PROTESE-DECISAO.md index 1e57a15..7e7578a 100644 --- a/doc/PROVEDOR-PROTESE-DECISAO.md +++ b/doc/PROVEDOR-PROTESE-DECISAO.md @@ -72,8 +72,15 @@ Score/ranking/marketplace. Não codar indicador antes de existir dado. - Validado em DEV: componente `divergente`; ocorrência `parafuso_substituido` percorrendo todo o contraditório até `resolvida`; resolvida final (409). Indicadores ficam para a Fase 5. -### FASE 3 — Operação multi-clínica -Tela de Clientes (clínicas), Financeiro do laboratório, Histórico completo. +### ✅ FASE 3 — Operação multi-clínica (IMPLEMENTADA 24/jun) +- **Clientes** (tela `lab-clientes`): tabela das clínicas que enviam OS, com OS totais, ativas, + atrasadas, entregues, faturado e a-receber por cliente. Endpoint `GET /protese/lab/clientes`. +- **Financeiro do lab** (tela `lab-financeiro`): KPIs (faturado/recebido/a-receber) + extrato de + recebimentos (pagamentos lado lab) com clínica/paciente/forma/data. Endpoint `GET /protese/lab/pagamentos`. + Regra: faturado = custo das OS entregues (exceto garantia); a-receber = faturado − recebido. +- Menu do laboratório agora: Painel · Bancada · **Clientes** · **Financeiro** · Notificações · Config. +- Histórico completo: a Bancada já lista entregues/cancelados com filtro de status (mantida). +- Validado em DEV: agregação por clínica (faturado 600 / recebido 250 / a-receber 350) + extrato. ### FASE 4 — Empresa de verdade `pessoa_tipo` PF/PJ, CNPJ (expor `documento`), Equipe/Técnicos (estende `proteseOsAccesso` por vínculo). diff --git a/frontend/App.tsx b/frontend/App.tsx index 5e72a89..406cc6d 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -32,6 +32,7 @@ import { ComissoesView } from './views/ComissoesView.tsx'; import { GlosasView } from './views/GlosasView.tsx'; import { SalaHomeView } from './views/SalaHomeView.tsx'; import { LabHomeView } from './views/LabHomeView.tsx'; +import { LabClientesView } from './views/LabClientesView.tsx'; import { ConfiguracoesView } from './views/ConfiguracoesView.tsx'; import { OrcamentosView } from './views/OrcamentosView.tsx'; import { ContratosView } from './views/ContratosView.tsx'; @@ -87,6 +88,8 @@ export type ViewKey = | 'sala-home' | 'lab-home' | 'lab-bancada' + | 'lab-clientes' + | 'lab-financeiro' | 'marketplace-profissionais' | 'criar-clinica' | 'rx-config' @@ -139,6 +142,8 @@ const ROUTE_MAP: Record = { '/sala-home': 'sala-home', '/lab-home': 'lab-home', '/lab-bancada': 'lab-bancada', + '/lab-clientes': 'lab-clientes', + '/lab-financeiro': 'lab-financeiro', '/marketplace-profissionais': 'marketplace-profissionais', '/criar-clinica': 'criar-clinica', '/rx-config': 'rx-config', @@ -196,6 +201,8 @@ const VIEW_TO_ROUTE: Record = { 'sala-home': '/sala-home', 'lab-home': '/lab-home', 'lab-bancada': '/lab-bancada', + 'lab-clientes': '/lab-clientes', + 'lab-financeiro': '/lab-financeiro', 'marketplace-profissionais': '/marketplace-profissionais', 'criar-clinica': '/criar-clinica', 'rx-config': '/rx-config', @@ -295,7 +302,7 @@ const App: React.FC = () => { // Contexto de SALA: isola da clínica — só painel da sala/salas/conta/notificações. if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return ['sala-home', 'configuracoes', 'notificacoes', 'clinicas'].includes(view); // Contexto de LABORATÓRIO: área do protético — painel do lab + bancada/conta/notificações. - if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'configuracoes', 'notificacoes', 'clinicas'].includes(view); + if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'configuracoes', 'notificacoes', 'clinicas'].includes(view); // Catálogo de PLUGINS: EXCLUSIVO do superadmin (já retorna true acima); ninguém mais. if (view === 'plugins') return false; // Admin/donos têm acesso amplo, MAS as telas de superadmin são exclusivas do superadmin. @@ -314,7 +321,7 @@ const App: React.FC = () => { paciente: ['tratamentos', 'notificacoes', 'configuracoes'], dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'], biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'], - protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', 'lab-home', 'lab-bancada', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'], + protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', 'lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'], funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'proteses', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'], }; @@ -489,6 +496,8 @@ const App: React.FC = () => { case 'sala-home': return ; case 'lab-home': return ; case 'lab-bancada': return ; + case 'lab-clientes': return ; + case 'lab-financeiro': return ; case 'marketplace-profissionais': return ; case 'criar-clinica': return { window.location.reload(); }} />; case 'rx-config': return ; diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx index 0c23fa3..e49adb1 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/Sidebar.tsx @@ -63,6 +63,8 @@ export const Sidebar: React.FC = ({ activeTab, setActiveTab }) => { id: 'sala-home', label: 'PAINEL DA SALA', icon: DoorOpen }, { id: 'lab-home', label: 'PAINEL DO LAB', icon: LayoutDashboard }, { id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical }, + { id: 'lab-clientes', label: 'CLIENTES', icon: Building2 }, + { id: 'lab-financeiro', label: 'FINANCEIRO', icon: DollarSign }, { id: 'dashboard', label: 'VISÃO GERAL', icon: LayoutDashboard }, { id: 'agenda', label: 'AGENDA', icon: CalendarIcon }, { id: 'pacientes', label: 'PACIENTES', icon: Users }, @@ -100,7 +102,7 @@ export const Sidebar: React.FC = ({ activeTab, setActiveTab }) => // PAINEL DA SALA só existe quando o workspace ativo é uma sala. if (item.id === 'sala-home') return (activeWorkspace as any)?.tipo === 'sala'; // PAINEL DO LAB / BANCADA só existem no workspace de laboratório. - if (item.id === 'lab-home' || item.id === 'lab-bancada') return (activeWorkspace as any)?.tipo === 'laboratorio'; + if (['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro'].includes(item.id)) return (activeWorkspace as any)?.tipo === 'laboratorio'; // Contexto de SALA (workspace tipo='sala'): isola da clínica — menu base só conta/notificações. // (MINHAS SALAS / ALUGAR SALAS são itens de plugin, mostrados à parte.) @@ -108,7 +110,7 @@ export const Sidebar: React.FC = ({ activeTab, setActiveTab }) => // Contexto de LABORATÓRIO (workspace tipo='laboratorio'): área própria do protético — // painel do lab + bancada de produção, sem as telas clínicas da clínica. - if ((activeWorkspace as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'notificacoes', 'configuracoes'].includes(item.id); + if ((activeWorkspace as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'notificacoes', 'configuracoes'].includes(item.id); // plugins e gestão de tutores: exclusivo superadmin if (item.id === 'plugins' || item.id === 'admin-gestao-tutores') return false; diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index 7bb2eab..146b805 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -1086,6 +1086,16 @@ export const HybridBackend = { return await res.json().catch(() => ({ feitas: [], recebidas: [] })); }, + // Laboratório (Fase 3): clientes (clínicas) com métricas + extrato de recebimentos. + getLabClientes: async (): Promise => { + const res = await apiFetch(`${API_URL}/protese/lab/clientes`); + return await res.json().catch(() => []); + }, + getLabPagamentos: async (): Promise => { + const res = await apiFetch(`${API_URL}/protese/lab/pagamentos`); + return await res.json().catch(() => []); + }, + // Agenda de uma sala (reservas + bloqueios de manutenção) — usado pelo operador da unidade. getSalaReservas: async (salaId: string): Promise => { const res = await apiFetch(`${API_URL}/salas/${salaId}/reservas`); diff --git a/frontend/views/LabClientesView.tsx b/frontend/views/LabClientesView.tsx new file mode 100644 index 0000000..e9c4de7 --- /dev/null +++ b/frontend/views/LabClientesView.tsx @@ -0,0 +1,123 @@ +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([]); + const [pagamentos, setPagamentos] = 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 : []); + } 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 }) => ( +
+
+

{titulo}

+
{valor}
+
+ ); + + 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)}
+
+ ) + )} + + {aba === 'financeiro' && ( + <> +
+ + + +
+
+
+

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)}
+
+ )} +
+

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

+ + )} + + )} +
+ ); +};