From 84f4a797d27be00960f78a2ddba63b6ec1d072d3 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Wed, 24 Jun 2026 05:49:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(protese):=20Fase=205=20=E2=80=94=20indicad?= =?UTF-8?q?ores=20+=20Nota=20ScoreOdonto=20+=20ranking=20no=20marketplace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provedor de Prótese, Fase 5 (doc/PROVEDOR-PROTESE-DECISAO.md) — sobre a captura das fases 2-4: - Indicadores do lab (GET /protese/lab/indicadores, só dono): entregues, tempo médio, atraso %, retrabalho % (garantia), ocorrências resolvidas %, componentes ausentes. Tela lab-indicadores. - Nota ScoreOdonto (0-10) = 10 − (atraso%×3 + retrabalho%×4 + ocorrência%×3), só com ≥3 entregas ("score em formação" abaixo disso). Apenas ocorrências RESOLVIDAS pesam (contraditório respeitado). - Ranking: getProteseLaboratorios devolve a nota por protético e ordena internos→nota→nome; a clínica vê ★ nota no dropdown de Nova OS e Lançar GTO. Roadmap do Provedor de Prótese concluído (Fases 1-5). Marketplace transacional e Modo 1 (link seguro sem conta) ficam como evolução futura. Co-Authored-By: Claude Opus 4.8 --- backend/server.js | 67 +++++++++++++++++++++++++- doc/PROVEDOR-PROTESE-DECISAO.md | 18 +++++-- frontend/App.tsx | 9 +++- frontend/components/Sidebar.tsx | 5 +- frontend/services/backend.ts | 5 ++ frontend/views/LabIndicadoresView.tsx | 68 +++++++++++++++++++++++++++ frontend/views/LancarGTO.tsx | 2 +- frontend/views/ProteseView.tsx | 2 +- 8 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 frontend/views/LabIndicadoresView.tsx diff --git a/backend/server.js b/backend/server.js index 4e10bd7..9d55d2c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3847,7 +3847,31 @@ app.get('/api/protese/laboratorios', tenantGuard, async (req, res) => { AND (v.clinica_id = $1 OR u.disponivel_diretorio = true) GROUP BY u.id, u.nome, u.email ORDER BY interno DESC, u.nome`, [clinicaId]); - res.json(rows.map(r => ({ ...r, interno: !!r.interno }))); + // Nota ScoreOdonto por protético (ranking no marketplace) — mesma fórmula da tela do lab. + const ids = rows.map(r => r.id); + const notas = {}; + if (ids.length) { + const { rows: ag } = await pool.query( + `SELECT protetico_id, + COUNT(*) FILTER (WHERE status='entregue')::int AS entregues, + COUNT(*) FILTER (WHERE status='entregue' AND tipo_os='garantia')::int AS garantia, + COUNT(*) FILTER (WHERE status='entregue' AND prazo_entrega IS NOT NULL AND updated_at::date > prazo_entrega)::int AS atrasados + FROM protese_os WHERE protetico_id = ANY($1) AND deleted_at IS NULL GROUP BY protetico_id`, [ids]); + const { rows: ocs } = await pool.query( + `SELECT o.protetico_id, COUNT(DISTINCT oc.os_id)::int AS n FROM protese_os_ocorrencia oc JOIN protese_os o ON o.id = oc.os_id WHERE oc.status='resolvida' AND o.protetico_id = ANY($1) GROUP BY o.protetico_id`, [ids]); + const ocMap = Object.fromEntries(ocs.map(o => [o.protetico_id, o.n])); + for (const g of ag) { + if (g.entregues >= SCORE_MIN_OS) { + const pct = n => g.entregues > 0 ? n / g.entregues * 100 : 0; + const nota = 10 - (pct(g.atrasados) / 100 * 3 + pct(g.garantia) / 100 * 4 + pct(ocMap[g.protetico_id] || 0) / 100 * 3); + notas[g.protetico_id] = Math.round(Math.max(0, Math.min(10, nota)) * 10) / 10; + } + } + } + const lista = rows.map(r => ({ ...r, interno: !!r.interno, nota: notas[r.id] ?? null })); + // Ranking: internos primeiro, depois maior nota, depois nome. + lista.sort((a, b) => (b.interno ? 1 : 0) - (a.interno ? 1 : 0) || (b.nota ?? -1) - (a.nota ?? -1) || a.nome.localeCompare(b.nome)); + res.json(lista); } catch (err) { res.status(500).json({ error: err.message }); } }); @@ -4081,6 +4105,47 @@ app.delete('/api/protese/lab/equipe/:userId', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); +// FASE 5 — Indicadores derivados da captura (Fases 2-4) + Nota ScoreOdonto. +// Só dados que JÁ existem: entregas/prazo, garantia (retrabalho), ocorrências RESOLVIDAS +// (contraditório respeitado), componentes ausentes, tempo médio. Nota só com volume mínimo. +const SCORE_MIN_OS = 3; +async function calcularIndicadoresLab(uid, labId) { + const esc = `(o.laboratorio_id = $2 OR (o.laboratorio_id IS NULL AND o.protetico_id = $1)) AND o.deleted_at IS NULL`; + const { rows: a } = await pool.query( + `SELECT COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE status='entregue')::int AS entregues, + COUNT(*) FILTER (WHERE status='entregue' AND tipo_os='garantia')::int AS garantia, + COUNT(*) FILTER (WHERE status='entregue' AND prazo_entrega IS NOT NULL AND updated_at::date > prazo_entrega)::int AS atrasados, + COALESCE(AVG(EXTRACT(EPOCH FROM (updated_at - created_at))/86400) FILTER (WHERE status='entregue' AND updated_at IS NOT NULL), 0) AS dias_medio + FROM protese_os o WHERE ${esc}`, [uid, labId]); + const { rows: oc } = await pool.query( + `SELECT COUNT(DISTINCT oc.os_id)::int AS n FROM protese_os_ocorrencia oc JOIN protese_os o ON o.id = oc.os_id WHERE oc.status='resolvida' AND ${esc}`, [uid, labId]); + const { rows: cp } = await pool.query( + `SELECT COUNT(*)::int AS n FROM protese_os_componente c JOIN protese_os o ON o.id = c.os_id WHERE c.status_conferencia='ausente' AND ${esc}`, [uid, labId]); + const r = a[0], entregues = r.entregues, pct = n => entregues > 0 ? (n / entregues * 100) : 0; + const atraso_pct = pct(r.atrasados), retrab_pct = pct(r.garantia), ocor_pct = pct(oc[0].n); + let nota = null; + if (entregues >= SCORE_MIN_OS) { + nota = Math.max(0, Math.min(10, 10 - (atraso_pct / 100 * 3 + retrab_pct / 100 * 4 + ocor_pct / 100 * 3))); + nota = Math.round(nota * 10) / 10; + } + const r1 = n => Math.round(Number(n) * 10) / 10; + return { + total: r.total, entregues, garantia: r.garantia, atrasados: r.atrasados, + ocorrencias_resolvidas: oc[0].n, componentes_ausentes: cp[0].n, dias_medio: r1(r.dias_medio), + atraso_pct: r1(atraso_pct), retrabalho_pct: r1(retrab_pct), ocorrencia_pct: r1(ocor_pct), + nota, min_os: SCORE_MIN_OS, + }; +} +app.get('/api/protese/lab/indicadores', authGuard, async (req, res) => { + try { + const uid = req.authUser.userId; + const labId = await labDoProtetico(uid); + if (!labId) return res.status(403).json({ error: 'Apenas o dono vê os indicadores.' }); + res.json(await calcularIndicadoresLab(uid, labId)); + } 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 bde08d5..7c190ea 100644 --- a/doc/PROVEDOR-PROTESE-DECISAO.md +++ b/doc/PROVEDOR-PROTESE-DECISAO.md @@ -93,7 +93,19 @@ Score/ranking/marketplace. Não codar indicador antes de existir dado. - Validado em DEV: dados PJ+CNPJ; técnico convidado acessa a bancada (vê clínica de origem), não vê clientes (0) e leva 403 ao tentar gerenciar equipe. -### FASE 5 — Ecossistema -Marketplace transacional, Score, indicadores, ranking (sobre os dados das fases 2-4). +### ✅ FASE 5 — Indicadores & Score (IMPLEMENTADA 24/jun) +- **Indicadores derivados** (`GET /protese/lab/indicadores`, só dono) calculados sobre a captura das + fases 2-4: entregues, tempo médio (solicitado→entregue), atraso %, retrabalho % (garantia), + ocorrências resolvidas %, componentes ausentes. Tela `lab-indicadores`. +- **Nota ScoreOdonto** (0-10): `10 − (atraso%×3 + retrabalho%×4 + ocorrência%×3)`, só com **mínimo de + 3 entregas** (senão "score em formação"). Só ocorrências **resolvidas** entram (contraditório respeitado). +- **Ranking no marketplace**: `getProteseLaboratorios` passa a devolver a `nota` por protético e ordena + internos→maior nota→nome; a clínica vê `★ nota` no dropdown de Nova OS e do Lançar GTO. +- Validado em DEV: 4 entregas (1 atrasada, 1 garantia) → atraso 25% / retrab 25% / **nota 8.3**; a + clínica vê a nota no dropdown; "score em formação" abaixo do mínimo. -> Acoplamentos: PF/PJ só com a Fase 4 (equipe), senão é campo morto. Score só na Fase 5 (precisa volume). +> Acoplamentos respeitados: PF/PJ veio com a equipe (Fase 4); Score só após a captura (Fases 2-4). + +### Fora de escopo (futuro) +Marketplace **transacional** (propostas/contratação dentro da plataforma) e o Modo 1 (protético sem +conta via link seguro `/p/TOKEN`) seguem como evolução — a base de dados/score já os suporta. diff --git a/frontend/App.tsx b/frontend/App.tsx index e726364..7da5916 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -34,6 +34,7 @@ import { SalaHomeView } from './views/SalaHomeView.tsx'; import { LabHomeView } from './views/LabHomeView.tsx'; import { LabClientesView } from './views/LabClientesView.tsx'; import { LabEquipeView } from './views/LabEquipeView.tsx'; +import { LabIndicadoresView } from './views/LabIndicadoresView.tsx'; import { ConfiguracoesView } from './views/ConfiguracoesView.tsx'; import { OrcamentosView } from './views/OrcamentosView.tsx'; import { ContratosView } from './views/ContratosView.tsx'; @@ -92,6 +93,7 @@ export type ViewKey = | 'lab-clientes' | 'lab-financeiro' | 'lab-equipe' + | 'lab-indicadores' | 'marketplace-profissionais' | 'criar-clinica' | 'rx-config' @@ -147,6 +149,7 @@ const ROUTE_MAP: Record = { '/lab-clientes': 'lab-clientes', '/lab-financeiro': 'lab-financeiro', '/lab-equipe': 'lab-equipe', + '/lab-indicadores': 'lab-indicadores', '/marketplace-profissionais': 'marketplace-profissionais', '/criar-clinica': 'criar-clinica', '/rx-config': 'rx-config', @@ -207,6 +210,7 @@ const VIEW_TO_ROUTE: Record = { 'lab-clientes': '/lab-clientes', 'lab-financeiro': '/lab-financeiro', 'lab-equipe': '/lab-equipe', + 'lab-indicadores': '/lab-indicadores', 'marketplace-profissionais': '/marketplace-profissionais', 'criar-clinica': '/criar-clinica', 'rx-config': '/rx-config', @@ -306,7 +310,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', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'configuracoes', 'notificacoes', 'clinicas'].includes(view); + if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', '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. @@ -325,7 +329,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', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'], + protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', 'lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', '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'], }; @@ -503,6 +507,7 @@ const App: React.FC = () => { case 'lab-clientes': return ; case 'lab-financeiro': return ; case 'lab-equipe': return ; + case 'lab-indicadores': 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 27ce809..30c052f 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/Sidebar.tsx @@ -65,6 +65,7 @@ export const Sidebar: React.FC = ({ activeTab, setActiveTab }) => { id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical }, { id: 'lab-clientes', label: 'CLIENTES', icon: Building2 }, { id: 'lab-financeiro', label: 'FINANCEIRO', icon: DollarSign }, + { id: 'lab-indicadores', label: 'INDICADORES', icon: BarChart3 }, { id: 'lab-equipe', label: 'EQUIPE', icon: UsersRound }, { id: 'dashboard', label: 'VISÃO GERAL', icon: LayoutDashboard }, { id: 'agenda', label: 'AGENDA', icon: CalendarIcon }, @@ -103,7 +104,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 (['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe'].includes(item.id)) return (activeWorkspace as any)?.tipo === 'laboratorio'; + if (['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores'].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.) @@ -116,7 +117,7 @@ export const Sidebar: React.FC = ({ activeTab, setActiveTab }) => const ehTecnico = (activeWorkspace as any)?.role === 'tecnico_protese'; return ehTecnico ? ['lab-home', 'lab-bancada', 'notificacoes', 'configuracoes'].includes(item.id) - : ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'notificacoes', 'configuracoes'].includes(item.id); + : ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-indicadores', 'lab-equipe', 'notificacoes', 'configuracoes'].includes(item.id); } // plugins e gestão de tutores: exclusivo superadmin diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index c4ccb5e..aef0cb5 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -1119,6 +1119,11 @@ export const HybridBackend = { if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover'); return await res.json(); }, + // Fase 5: indicadores do laboratório (Nota ScoreOdonto + KPIs derivados) + getLabIndicadores: async (): Promise => { + const res = await apiFetch(`${API_URL}/protese/lab/indicadores`); + return await res.json().catch(() => ({})); + }, // Agenda de uma sala (reservas + bloqueios de manutenção) — usado pelo operador da unidade. getSalaReservas: async (salaId: string): Promise => { diff --git a/frontend/views/LabIndicadoresView.tsx b/frontend/views/LabIndicadoresView.tsx new file mode 100644 index 0000000..fab66ea --- /dev/null +++ b/frontend/views/LabIndicadoresView.tsx @@ -0,0 +1,68 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { BarChart3, RefreshCw, Clock, AlertTriangle, RotateCcw, PackageX, Award, CheckCircle2 } from 'lucide-react'; +import { HybridBackend } from '../services/backend.ts'; +import { PageHeader } from '../components/PageHeader.tsx'; + +// Área do Laboratório (Fase 5): indicadores derivados da captura + Nota ScoreOdonto. +export const LabIndicadoresView: React.FC = () => { + const [ind, setInd] = useState(null); + const [loading, setLoading] = useState(false); + + const carregar = useCallback(async () => { + setLoading(true); + try { setInd(await HybridBackend.getLabIndicadores()); } catch { /* silent */ } finally { setLoading(false); } + }, []); + useEffect(() => { carregar(); }, [carregar]); + + const KPI: React.FC<{ titulo: string; valor: string; cor: string; Icon: any; sub?: string }> = ({ titulo, valor, cor, Icon, sub }) => ( +
+
+

{titulo}

+
{valor}
+ {sub &&

{sub}

} +
+ ); + + if (loading || !ind) return
; + + const temNota = ind.nota !== null && ind.nota !== undefined; + const notaCor = ind.nota >= 9 ? 'text-green-600' : ind.nota >= 7 ? 'text-teal-600' : ind.nota >= 5 ? 'text-amber-500' : 'text-red-500'; + + return ( +
+ + + + + {/* Nota ScoreOdonto */} +
+
+ + {temNota ? ind.nota.toFixed(1) : '—'} +
+
+

Nota ScoreOdonto

+ {temNota ? ( +

Calculada sobre {ind.entregues} entrega(s). Penaliza atraso, retrabalho e ocorrências resolvidas.

+ ) : ( +

Score em formação — mínimo de {ind.min_os} entregas para gerar a nota.

+ )} +
+
+ +
+ + + + + + +
+ +
+

Como a nota é calculada

+

Base 10, descontando proporcionalmente: atraso (peso 3), retrabalho/garantia (peso 4) e ocorrências resolvidas (peso 3). Só ocorrências resolvidas (após contraditório) entram — abertas/contestadas não pesam. A nota aparece para as clínicas no marketplace ao escolherem o laboratório.

+
+
+ ); +}; diff --git a/frontend/views/LancarGTO.tsx b/frontend/views/LancarGTO.tsx index 8737037..37bb5c9 100644 --- a/frontend/views/LancarGTO.tsx +++ b/frontend/views/LancarGTO.tsx @@ -488,7 +488,7 @@ export const LancarGTO: React.FC = () => { className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase" > - {laboratorios.map(l => )} + {laboratorios.map(l => )} {selectedLab && (labProdutos.length === 0 ? ( diff --git a/frontend/views/ProteseView.tsx b/frontend/views/ProteseView.tsx index 6f4ccf0..91fdb0d 100644 --- a/frontend/views/ProteseView.tsx +++ b/frontend/views/ProteseView.tsx @@ -121,7 +121,7 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o {labs.length === 0 &&

Nenhum protético com vínculo ou no diretório. Convide um na Equipe ou no marketplace de Profissionais.

}