84f4a797d2
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>
69 lines
4.6 KiB
TypeScript
69 lines
4.6 KiB
TypeScript
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). Só 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>
|
|
);
|
|
};
|