From 6cd8c1f9e1dcbc7763f32ca20e33bd2f7a41f42b Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Wed, 24 Jun 2026 17:56:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(protese):=20marketplace=20transacional=20f?= =?UTF-8?q?atia=204=20=E2=80=94=20avalia=C3=A7=C3=A3o,=20satisfa=C3=A7?= =?UTF-8?q?=C3=A3o=20na=20nota=20e=20selo=20verificado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/server.js | 94 +++++++++++++------ doc/MARKETPLACE-TRANSACIONAL-PROTESE.md | 12 ++- frontend/services/backend.ts | 6 ++ frontend/views/LabIndicadoresView.tsx | 10 +- frontend/views/ProteseView.tsx | 29 +++++- .../views/plugins/ProfissionaisPlugin.tsx | 5 +- 6 files changed, 118 insertions(+), 38 deletions(-) diff --git a/backend/server.js b/backend/server.js index 46082b1..df470c8 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3848,27 +3848,8 @@ app.get('/api/protese/laboratorios', tenantGuard, async (req, res) => { GROUP BY u.id, u.nome, u.email ORDER BY interno DESC, u.nome`, [clinicaId]); // 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 })); + const notas = await notasScorePorProtetico(rows.map(r => r.id)); + const lista = rows.map(r => ({ ...r, interno: !!r.interno, nota: notas[r.id]?.nota ?? null, verificado: notas[r.id]?.verificado || false, avaliacoes: notas[r.id]?.avaliacoes || 0 })); // 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); @@ -4122,18 +4103,23 @@ async function calcularIndicadoresLab(uid, labId) { `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 { rows: av } = await pool.query( + `SELECT AVG(estrelas)::numeric AS media, COUNT(*)::int AS n FROM protese_avaliacao WHERE protetico_id = (SELECT owner_id FROM clinicas WHERE id=$1) OR protetico_id = $2`, [labId, uid]); 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); + const satisf = av[0].n > 0 ? parseFloat(av[0].media) : null; 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 operacional = Math.max(0, Math.min(10, 10 - (atraso_pct / 100 * 3 + retrab_pct / 100 * 4 + ocor_pct / 100 * 3))); + nota = satisf != null ? 0.6 * operacional + 0.4 * (satisf / 5 * 10) : operacional; + nota = Math.round(Math.max(0, Math.min(10, 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), + satisfacao: satisf != null ? r1(satisf) : null, avaliacoes: av[0].n, verificado: entregues >= SCORE_VERIF_MIN, nota, min_os: SCORE_MIN_OS, }; } @@ -4146,10 +4132,12 @@ app.get('/api/protese/lab/indicadores', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -// Nota ScoreOdonto por protético (mapa id→nota), p/ ranking no dropdown e no marketplace. +// Nota ScoreOdonto por protético (mapa id→{nota, avaliacoes, satisfacao, verificado}). +// A nota combina o operacional (atraso/retrabalho/ocorrências) com a satisfação da clínica (estrelas). +const SCORE_VERIF_MIN = 5; // entregas p/ selo "verificado" async function notasScorePorProtetico(ids) { - const notas = {}; - if (!ids || !ids.length) return notas; + const out = {}; + if (!ids || !ids.length) return out; const { rows: ag } = await pool.query( `SELECT protetico_id, COUNT(*) FILTER (WHERE status='entregue')::int AS entregues, @@ -4158,15 +4146,23 @@ async function notasScorePorProtetico(ids) { 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 { rows: avs } = await pool.query( + `SELECT protetico_id, AVG(estrelas)::numeric AS media, COUNT(*)::int AS n FROM protese_avaliacao WHERE protetico_id = ANY($1) GROUP BY protetico_id`, [ids]); const ocMap = Object.fromEntries(ocs.map(o => [o.protetico_id, o.n])); + const avMap = Object.fromEntries(avs.map(a => [a.protetico_id, { media: parseFloat(a.media), n: a.n }])); for (const g of ag) { + const av = avMap[g.protetico_id]; + const info = { nota: null, avaliacoes: av?.n || 0, satisfacao: av ? Math.round(av.media * 10) / 10 : null, verificado: g.entregues >= SCORE_VERIF_MIN }; 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 operacional = Math.max(0, Math.min(10, 10 - (pct(g.atrasados) / 100 * 3 + pct(g.garantia) / 100 * 4 + pct(ocMap[g.protetico_id] || 0) / 100 * 3))); + // Com avaliações: 60% operacional + 40% satisfação (estrelas→0-10). Sem: só operacional. + const nota = av && av.n > 0 ? 0.6 * operacional + 0.4 * (av.media / 5 * 10) : operacional; + info.nota = Math.round(Math.max(0, Math.min(10, nota)) * 10) / 10; } + out[g.protetico_id] = info; } - return notas; + return out; } // MODO 1 — gerar/recuperar o link seguro de acompanhamento da OS (clínica ou lab). @@ -4298,6 +4294,27 @@ app.post('/api/protese/rfq/:id/escolher', tenantGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); +// Fatia 4 — a clínica avalia o laboratório ao final da OS (1-5 estrelas + comentário). +app.post('/api/protese/os/:id/avaliar', authGuard, async (req, res) => { + try { + const uid = req.authUser.userId; + const acc = await carregaOSComAcesso(req.params.id, uid); + if (acc.error) return res.status(acc.status).json({ error: acc.error }); + if (!acc.isClinic) return res.status(403).json({ error: 'Apenas a clínica avalia.' }); + if (acc.os.status !== 'entregue') return res.status(409).json({ error: 'Avalie após a entrega.' }); + const estrelas = parseInt(req.body?.estrelas, 10); + if (!(estrelas >= 1 && estrelas <= 5)) return res.status(400).json({ error: 'Estrelas de 1 a 5.' }); + const nome = await actorNome(uid); + await pool.query( + `INSERT INTO protese_avaliacao (id, os_id, clinica_id, protetico_id, estrelas, comentario, autor_id, autor_nome) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + ON CONFLICT (os_id) DO UPDATE SET estrelas=$5, comentario=$6, autor_id=$7, autor_nome=$8, created_at=NOW()`, + [`pav_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, acc.os.id, acc.os.clinica_id, acc.os.protetico_id, estrelas, req.body?.comentario || null, uid, nome]); + await logEventoOS(acc.os, uid, `Laboratório avaliado: ${estrelas}★`, 'avaliacao'); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + // MODO 1 — leitura PÚBLICA da OS pelo token (sem login). Blindagem: sem CPF, sem valor do paciente. app.get('/api/p/:token', async (req, res) => { try { @@ -4409,6 +4426,7 @@ app.get('/api/protese/os/:id', authGuard, async (req, res) => { const { rows: ocorFotos } = await pool.query("SELECT id, ocorrencia_id, original_name FROM protese_os_foto WHERE os_id=$1 AND ocorrencia_id IS NOT NULL", [req.params.id]); const { rows: pagamentos } = await pool.query('SELECT id, lado, valor, forma, observacao, data, recebido_nome, created_at FROM protese_os_pagamento WHERE os_id=$1 ORDER BY data, created_at', [req.params.id]); const { rows: custodia } = await pool.query('SELECT id, de_local, para_local, etapa, observacao, actor_nome, created_at FROM protese_os_custodia WHERE os_id=$1 ORDER BY created_at', [req.params.id]); + const { rows: aval } = await pool.query('SELECT estrelas, comentario, autor_nome FROM protese_avaliacao WHERE os_id=$1', [req.params.id]); // Privacidade: o protético (lab que NÃO é da clínica) não vê o que o paciente paga/deve. const soLab = isLab && !isClinic; const pagsVisiveis = soLab ? pagamentos.filter(p => p.lado !== 'clinica') : pagamentos; @@ -4422,6 +4440,7 @@ app.get('/api/protese/os/:id', authGuard, async (req, res) => { componentes, pagamentos: pagsVisiveis.map(p => ({ ...p, valor: parseFloat(p.valor || 0) })), custodia, + avaliacao: aval[0] || null, ocorrencias: ocorrencias.map(o => ({ ...o, fotos: ocorFotos.filter(f => f.ocorrencia_id === o.id).map(f => ({ id: f.id, original_name: f.original_name, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })), @@ -6569,6 +6588,19 @@ async function runMigrations() { )`, `CREATE INDEX IF NOT EXISTS idx_protese_rfq_cot_rfq ON protese_rfq_cotacao (rfq_id)`, `CREATE INDEX IF NOT EXISTS idx_protese_rfq_cot_prot ON protese_rfq_cotacao (protetico_id, status)`, + // Fatia 4 — Avaliação da clínica ao final da OS (satisfação → alimenta a Nota; selo verificado). + `CREATE TABLE IF NOT EXISTS protese_avaliacao ( + id TEXT PRIMARY KEY, + os_id TEXT NOT NULL UNIQUE, + clinica_id TEXT, + protetico_id TEXT NOT NULL, + estrelas INTEGER NOT NULL, + comentario TEXT, + autor_id TEXT, + autor_nome TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_protese_aval_prot ON protese_avaliacao (protetico_id)`, `CREATE TABLE IF NOT EXISTS protese_produtos ( id TEXT PRIMARY KEY, protetico_id TEXT NOT NULL, -- dono (usuário protético) @@ -7331,7 +7363,9 @@ app.get('/api/profissionais/marketplace', authGuard, async (req, res) => { // Nota ScoreOdonto nos protéticos (score público no marketplace). Mantém a ordem por // proximidade; a nota é exibida no card para a clínica comparar. const notas = await notasScorePorProtetico(rows.filter(r => r.role === 'protetico').map(r => r.id)); - res.json(rows.map(r => ({ ...r, nota: r.role === 'protetico' ? (notas[r.id] ?? null) : undefined }))); + res.json(rows.map(r => r.role === 'protetico' + ? { ...r, nota: notas[r.id]?.nota ?? null, verificado: notas[r.id]?.verificado || false, avaliacoes: notas[r.id]?.avaliacoes || 0 } + : r)); } catch (err) { res.status(500).json({ error: err.message }); } }); diff --git a/doc/MARKETPLACE-TRANSACIONAL-PROTESE.md b/doc/MARKETPLACE-TRANSACIONAL-PROTESE.md index ac0c8bc..3d86ee1 100644 --- a/doc/MARKETPLACE-TRANSACIONAL-PROTESE.md +++ b/doc/MARKETPLACE-TRANSACIONAL-PROTESE.md @@ -41,9 +41,15 @@ Endpoints: `POST/GET /protese/rfq`, `GET /protese/rfq/recebidas`, `POST /protese clínica = botão **Cotações** na tela de Prótese (pedir multi-lab + comparar + escolher). Validado em DEV ponta a ponta (pedido → cotação R$420 → escolha → OS criada → RFQ fechada). -### Fatia 4 — Reputação pública e conversão -Avaliação da clínica ao final da OS (alimenta a Nota, hoje só operacional), selo "lab verificado", -ordenar marketplace por nota dentro do raio. +### ✅ Fatia 4 — Reputação (avaliação + selo) — IMPLEMENTADA +A clínica avalia o laboratório (1-5★ + comentário) **após a entrega** (`protese_avaliacao`, 1 por OS, +reavaliável). A satisfação **entra na Nota ScoreOdonto**: `0.6·operacional + 0.4·(estrelas/5·10)` (sem +avaliações = só operacional). **Selo "Verificado"** com ≥ 5 entregas. Exposto no marketplace (★nota · +nº avaliações · Verificado) e nos indicadores do lab (KPI Satisfação). Endpoint `POST +/protese/os/:id/avaliar`. Validado em DEV: 6 entregas + avaliações 5★/3★ → nota **9.2**, satisfação 4★, +verificado, em marketplace e indicadores. + +**Marketplace transacional concluído (Fatias 1-4).** ## Decisões de modelagem - **Catálogo público = catálogo atual** (`protese_produtos`); a vitrine só o exibe — sem duplicar. diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index 54a5495..4f013c3 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -1124,6 +1124,12 @@ export const HybridBackend = { const res = await apiFetch(`${API_URL}/protese/lab/indicadores`); return await res.json().catch(() => ({})); }, + // Fatia 4: a clínica avalia o laboratório ao final da OS + avaliarProteseOS: async (osId: string, body: { estrelas: number; comentario?: string }) => { + const res = await apiFetch(`${API_URL}/protese/os/${osId}/avaliar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao avaliar'); + return await res.json(); + }, // Modo 1: link seguro de acompanhamento da OS gerarProteseLink: async (osId: string) => { const res = await apiFetch(`${API_URL}/protese/os/${osId}/link`, { method: 'POST' }); diff --git a/frontend/views/LabIndicadoresView.tsx b/frontend/views/LabIndicadoresView.tsx index fab66ea..e7d48c1 100644 --- a/frontend/views/LabIndicadoresView.tsx +++ b/frontend/views/LabIndicadoresView.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { BarChart3, RefreshCw, Clock, AlertTriangle, RotateCcw, PackageX, Award, CheckCircle2 } from 'lucide-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'; @@ -41,9 +41,12 @@ export const LabIndicadoresView: React.FC = () => { {temNota ? ind.nota.toFixed(1) : '—'}
-

Nota ScoreOdonto

+
+

Nota ScoreOdonto

+ {ind.verificado && Verificado} +
{temNota ? ( -

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

+

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.

) : (

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

)} @@ -57,6 +60,7 @@ export const LabIndicadoresView: React.FC = () => { +
diff --git a/frontend/views/ProteseView.tsx b/frontend/views/ProteseView.tsx index d2bb1b1..71a0597 100644 --- a/frontend/views/ProteseView.tsx +++ b/frontend/views/ProteseView.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { FlaskConical, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check, Package, Wallet, Camera, CheckCircle2, Circle, History, Printer, MapPin, Building2, Share2 } from 'lucide-react'; +import { FlaskConical, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check, Package, Wallet, Camera, CheckCircle2, Circle, History, Printer, MapPin, Building2, Share2, FileText, Star } from 'lucide-react'; import { HybridBackend } from '../services/backend.ts'; import { useToast } from '../contexts/ToastContext.tsx'; import { useConfirm } from '../contexts/ConfirmContext.tsx'; @@ -372,6 +372,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose {naGarantia && modo === 'clinica' && ( )} + {os.status === 'entregue' && modo === 'clinica' && }
)} @@ -599,6 +600,32 @@ const CompartilharLinkSecao: React.FC<{ os: any }> = ({ os }) => { ); }; +// ── Seção: Avaliar o laboratório (após a entrega, lado clínica) ───────────── +const AvaliacaoSecao: React.FC<{ os: any; onChanged: () => void }> = ({ os, onChanged }) => { + const toast = useToast(); + const [estrelas, setEstrelas] = useState(os.avaliacao?.estrelas || 0); + const [coment, setComent] = useState(os.avaliacao?.comentario || ''); + const [busy, setBusy] = useState(false); + const salvar = async () => { + if (!estrelas) { toast.error('DÊ UMA NOTA DE 1 A 5.'); return; } + setBusy(true); + try { await HybridBackend.avaliarProteseOS(os.id, { estrelas, comentario: coment.trim() || undefined }); toast.success('AVALIAÇÃO ENVIADA.'); onChanged(); } + catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); } + }; + return ( +
+

Avaliar o laboratório

+
+ {[1, 2, 3, 4, 5].map(n => ( + + ))} +
+