6cd8c1f9e1
- A clínica avalia o laboratório (1-5★ + comentário) após a entrega (protese_avaliacao, 1 por OS, reavaliável). POST /protese/os/:id/avaliar; avaliação volta no GET detalhe. - Nota ScoreOdonto passa a combinar 0.6·operacional + 0.4·satisfação (estrelas) quando há avaliações. Selo "Verificado" (≥5 entregas). Exposto no marketplace (★nota · nº avaliações · Verificado) e nos indicadores do lab (KPI Satisfação). Helper notasScorePorProtetico unificado (dropdown + marketplace). - UI: seção "Avaliar o laboratório" na OS entregue (lado clínica); selo/avaliações no card do marketplace. - Correção de passagem: FileText/Star importados em ProteseView (FileText faltava desde a fatia 3). Validado em DEV: 6 entregas + 5★/3★ → nota 9.2, satisfação 4★, verificado (marketplace e indicadores). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
73 lines
5.2 KiB
TypeScript
73 lines
5.2 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { BarChart3, RefreshCw, Clock, AlertTriangle, RotateCcw, PackageX, Award, CheckCircle2, BadgeCheck, Star } 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">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<h3 className="text-sm font-black text-gray-800 uppercase">Nota ScoreOdonto</h3>
|
|
{ind.verificado && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-teal-100 text-teal-700 uppercase flex items-center gap-0.5"><BadgeCheck size={11} /> Verificado</span>}
|
|
</div>
|
|
{temNota ? (
|
|
<p className="text-[11px] text-gray-400 font-bold uppercase mt-1">Sobre {ind.entregues} entrega(s){ind.avaliacoes ? ` · ${ind.satisfacao}★ de ${ind.avaliacoes} avaliações` : ''}. Combina desempenho (atraso/retrabalho/ocorrências) e satisfação da clínica.</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" />
|
|
<KPI titulo="Satisfação" valor={ind.satisfacao != null ? `${ind.satisfacao}★` : '—'} cor="text-amber-500" Icon={Star} sub={`${ind.avaliacoes || 0} avaliação(ões)`} />
|
|
</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>
|
|
);
|
|
};
|