feat(protese): Fase 5 — indicadores + Nota ScoreOdonto + ranking no marketplace
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 55s

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 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-24 05:49:32 +02:00
parent ae48feaa8a
commit 84f4a797d2
8 changed files with 166 additions and 10 deletions
+66 -1
View File
@@ -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 {
+15 -3
View File
@@ -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 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.
+7 -2
View File
@@ -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<string, ViewKey> = {
'/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<ViewKey, string> = {
'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 <LabClientesView tab="clientes" />;
case 'lab-financeiro': return <LabClientesView tab="financeiro" />;
case 'lab-equipe': return <LabEquipeView />;
case 'lab-indicadores': return <LabIndicadoresView />;
case 'marketplace-profissionais': return <ProfissionaisPlugin />;
case 'criar-clinica': return <CreateClinicView onCreated={() => { window.location.reload(); }} />;
case 'rx-config': return <RxConfigView />;
+3 -2
View File
@@ -65,6 +65,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ 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<SidebarProps> = ({ 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<SidebarProps> = ({ 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
+5
View File
@@ -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<any> => {
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<any[]> => {
+68
View File
@@ -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<any>(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 }) => (
<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>
{sub && <p className="mt-2 text-[10px] font-bold text-gray-400 uppercase">{sub}</p>}
</div>
);
if (loading || !ind) return <div className="flex justify-center py-20"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>;
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 (
<div className="space-y-6 pb-10">
<PageHeader title="INDICADORES 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 shadow-sm"><RefreshCw size={16} /></button>
</PageHeader>
{/* Nota ScoreOdonto */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 flex items-center gap-5">
<div className={`w-20 h-20 rounded-2xl border-4 flex flex-col items-center justify-center shrink-0 ${temNota ? 'border-current ' + notaCor : 'border-gray-200 text-gray-300'}`}>
<Award size={18} />
<span className="text-2xl font-black leading-none mt-0.5">{temNota ? ind.nota.toFixed(1) : '—'}</span>
</div>
<div className="min-w-0">
<h3 className="text-sm font-black text-gray-800 uppercase">Nota ScoreOdonto</h3>
{temNota ? (
<p className="text-[11px] text-gray-400 font-bold uppercase mt-1">Calculada sobre {ind.entregues} entrega(s). Penaliza atraso, retrabalho e ocorrências resolvidas.</p>
) : (
<p className="text-[11px] text-amber-500 font-bold uppercase mt-1">Score em formação mínimo de {ind.min_os} entregas para gerar a nota.</p>
)}
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<KPI titulo="Entregues" valor={String(ind.entregues)} cor="text-green-600" Icon={CheckCircle2} sub={`${ind.total} OS no total`} />
<KPI titulo="Tempo médio" valor={`${ind.dias_medio} d`} cor="text-blue-600" Icon={Clock} sub="Solicitado → entregue" />
<KPI titulo="Atrasos" valor={`${ind.atraso_pct}%`} cor="text-amber-500" Icon={AlertTriangle} sub={`${ind.atrasados} entrega(s)`} />
<KPI titulo="Retrabalho" valor={`${ind.retrabalho_pct}%`} cor="text-orange-500" Icon={RotateCcw} sub={`${ind.garantia} garantia(s)`} />
<KPI titulo="Ocorrências" valor={`${ind.ocorrencia_pct}%`} cor="text-red-500" Icon={AlertTriangle} sub={`${ind.ocorrencias_resolvidas} resolvida(s)`} />
<KPI titulo="Componentes perdidos" valor={String(ind.componentes_ausentes)} cor="text-red-600" Icon={PackageX} sub="Conferidos como ausentes" />
</div>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2 mb-2"><BarChart3 size={16} className="text-teal-600" /> Como a nota é calculada</h3>
<p className="text-xs text-gray-500">Base 10, descontando proporcionalmente: <b>atraso</b> (peso 3), <b>retrabalho/garantia</b> (peso 4) e <b>ocorrências resolvidas</b> (peso 3). ocorrências <b>resolvidas</b> (após contraditório) entram abertas/contestadas não pesam. A nota aparece para as clínicas no marketplace ao escolherem o laboratório.</p>
</div>
</div>
);
};
+1 -1
View File
@@ -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"
>
<option value="">ESCOLHA O LABORATÓRIO / PROTÉTICO...</option>
{laboratorios.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (INTERNO)' : ''}</option>)}
{laboratorios.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (INTERNO)' : ''}{l.nota != null ? ` · ★ ${l.nota}` : ''}</option>)}
</select>
{selectedLab && (labProdutos.length === 0 ? (
+1 -1
View File
@@ -121,7 +121,7 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o
<label className="text-[10px] font-black text-gray-500 uppercase">Laboratório / Protético *</label>
<select value={form.protetico_id} onChange={e => set('protetico_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
<option value="">Selecione...</option>
{labs.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (interno)' : ''}</option>)}
{labs.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (interno)' : ''}{l.nota != null ? ` · ★ ${l.nota}` : ''}</option>)}
</select>
{labs.length === 0 && <p className="text-[10px] text-gray-400 mt-1">Nenhum protético com vínculo ou no diretório. Convide um na Equipe ou no marketplace de Profissionais.</p>}
</div>