feat(protese): marketplace transacional fatia 4 — avaliação, satisfação na nota e selo verificado
- 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>
This commit is contained in:
+64
-30
@@ -3848,27 +3848,8 @@ app.get('/api/protese/laboratorios', tenantGuard, async (req, res) => {
|
|||||||
GROUP BY u.id, u.nome, u.email
|
GROUP BY u.id, u.nome, u.email
|
||||||
ORDER BY interno DESC, u.nome`, [clinicaId]);
|
ORDER BY interno DESC, u.nome`, [clinicaId]);
|
||||||
// Nota ScoreOdonto por protético (ranking no marketplace) — mesma fórmula da tela do lab.
|
// Nota ScoreOdonto por protético (ranking no marketplace) — mesma fórmula da tela do lab.
|
||||||
const ids = rows.map(r => r.id);
|
const notas = await notasScorePorProtetico(rows.map(r => r.id));
|
||||||
const notas = {};
|
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 }));
|
||||||
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.
|
// 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));
|
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);
|
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]);
|
`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(
|
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]);
|
`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 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 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;
|
let nota = null;
|
||||||
if (entregues >= SCORE_MIN_OS) {
|
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)));
|
const operacional = 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;
|
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;
|
const r1 = n => Math.round(Number(n) * 10) / 10;
|
||||||
return {
|
return {
|
||||||
total: r.total, entregues, garantia: r.garantia, atrasados: r.atrasados,
|
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),
|
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),
|
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,
|
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 }); }
|
} 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) {
|
async function notasScorePorProtetico(ids) {
|
||||||
const notas = {};
|
const out = {};
|
||||||
if (!ids || !ids.length) return notas;
|
if (!ids || !ids.length) return out;
|
||||||
const { rows: ag } = await pool.query(
|
const { rows: ag } = await pool.query(
|
||||||
`SELECT protetico_id,
|
`SELECT protetico_id,
|
||||||
COUNT(*) FILTER (WHERE status='entregue')::int AS entregues,
|
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]);
|
FROM protese_os WHERE protetico_id = ANY($1) AND deleted_at IS NULL GROUP BY protetico_id`, [ids]);
|
||||||
const { rows: ocs } = await pool.query(
|
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]);
|
`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 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) {
|
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) {
|
if (g.entregues >= SCORE_MIN_OS) {
|
||||||
const pct = n => g.entregues > 0 ? n / g.entregues * 100 : 0;
|
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);
|
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)));
|
||||||
notas[g.protetico_id] = Math.round(Math.max(0, Math.min(10, nota)) * 10) / 10;
|
// 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).
|
// 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 }); }
|
} 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.
|
// 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) => {
|
app.get('/api/p/:token', async (req, res) => {
|
||||||
try {
|
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: 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: 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: 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.
|
// 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 soLab = isLab && !isClinic;
|
||||||
const pagsVisiveis = soLab ? pagamentos.filter(p => p.lado !== 'clinica') : pagamentos;
|
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,
|
componentes,
|
||||||
pagamentos: pagsVisiveis.map(p => ({ ...p, valor: parseFloat(p.valor || 0) })),
|
pagamentos: pagsVisiveis.map(p => ({ ...p, valor: parseFloat(p.valor || 0) })),
|
||||||
custodia,
|
custodia,
|
||||||
|
avaliacao: aval[0] || null,
|
||||||
ocorrencias: ocorrencias.map(o => ({
|
ocorrencias: ocorrencias.map(o => ({
|
||||||
...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)}` })),
|
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_rfq ON protese_rfq_cotacao (rfq_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_protese_rfq_cot_prot ON protese_rfq_cotacao (protetico_id, status)`,
|
`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 (
|
`CREATE TABLE IF NOT EXISTS protese_produtos (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
protetico_id TEXT NOT NULL, -- dono (usuário protético)
|
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
|
// 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.
|
// 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));
|
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 }); }
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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).
|
DEV ponta a ponta (pedido → cotação R$420 → escolha → OS criada → RFQ fechada).
|
||||||
|
|
||||||
### Fatia 4 — Reputação pública e conversão
|
### ✅ Fatia 4 — Reputação (avaliação + selo) — IMPLEMENTADA
|
||||||
Avaliação da clínica ao final da OS (alimenta a Nota, hoje só operacional), selo "lab verificado",
|
A clínica avalia o laboratório (1-5★ + comentário) **após a entrega** (`protese_avaliacao`, 1 por OS,
|
||||||
ordenar marketplace por nota dentro do raio.
|
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
|
## Decisões de modelagem
|
||||||
- **Catálogo público = catálogo atual** (`protese_produtos`); a vitrine só o exibe — sem duplicar.
|
- **Catálogo público = catálogo atual** (`protese_produtos`); a vitrine só o exibe — sem duplicar.
|
||||||
|
|||||||
@@ -1124,6 +1124,12 @@ export const HybridBackend = {
|
|||||||
const res = await apiFetch(`${API_URL}/protese/lab/indicadores`);
|
const res = await apiFetch(`${API_URL}/protese/lab/indicadores`);
|
||||||
return await res.json().catch(() => ({}));
|
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
|
// Modo 1: link seguro de acompanhamento da OS
|
||||||
gerarProteseLink: async (osId: string) => {
|
gerarProteseLink: async (osId: string) => {
|
||||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/link`, { method: 'POST' });
|
const res = await apiFetch(`${API_URL}/protese/os/${osId}/link`, { method: 'POST' });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
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 { HybridBackend } from '../services/backend.ts';
|
||||||
import { PageHeader } from '../components/PageHeader.tsx';
|
import { PageHeader } from '../components/PageHeader.tsx';
|
||||||
|
|
||||||
@@ -41,9 +41,12 @@ export const LabIndicadoresView: React.FC = () => {
|
|||||||
<span className="text-2xl font-black leading-none mt-0.5">{temNota ? ind.nota.toFixed(1) : '—'}</span>
|
<span className="text-2xl font-black leading-none mt-0.5">{temNota ? ind.nota.toFixed(1) : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<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>
|
<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 ? (
|
{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-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>
|
<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>
|
||||||
)}
|
)}
|
||||||
@@ -57,6 +60,7 @@ export const LabIndicadoresView: React.FC = () => {
|
|||||||
<KPI titulo="Retrabalho" valor={`${ind.retrabalho_pct}%`} cor="text-orange-500" Icon={RotateCcw} sub={`${ind.garantia} garantia(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="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="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>
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
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 { HybridBackend } from '../services/backend.ts';
|
||||||
import { useToast } from '../contexts/ToastContext.tsx';
|
import { useToast } from '../contexts/ToastContext.tsx';
|
||||||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||||||
@@ -372,6 +372,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
|||||||
{naGarantia && modo === 'clinica' && (
|
{naGarantia && modo === 'clinica' && (
|
||||||
<button disabled={busy} onClick={acionarGarantia} className="w-full py-2.5 rounded-xl bg-emerald-600 text-white font-black text-sm uppercase disabled:opacity-50 flex items-center justify-center gap-2"><ShieldCheck size={16} /> Acionar garantia (refazer sem custo)</button>
|
<button disabled={busy} onClick={acionarGarantia} className="w-full py-2.5 rounded-xl bg-emerald-600 text-white font-black text-sm uppercase disabled:opacity-50 flex items-center justify-center gap-2"><ShieldCheck size={16} /> Acionar garantia (refazer sem custo)</button>
|
||||||
)}
|
)}
|
||||||
|
{os.status === 'entregue' && modo === 'clinica' && <AvaliacaoSecao os={os} onChanged={carregar} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -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<number>(os.avaliacao?.estrelas || 0);
|
||||||
|
const [coment, setComent] = useState<string>(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 (
|
||||||
|
<div className="pt-3 border-t border-gray-100">
|
||||||
|
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Star size={12} /> Avaliar o laboratório</p>
|
||||||
|
<div className="flex gap-1 mb-2">
|
||||||
|
{[1, 2, 3, 4, 5].map(n => (
|
||||||
|
<button key={n} onClick={() => setEstrelas(n)} className={n <= estrelas ? 'text-amber-400' : 'text-gray-200'}><Star size={26} fill={n <= estrelas ? 'currentColor' : 'none'} /></button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<textarea value={coment} onChange={e => setComent(e.target.value)} rows={2} placeholder="Comentário (opcional)" className="w-full p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-amber-400 resize-none" />
|
||||||
|
<button disabled={busy} onClick={salvar} className="mt-2 px-3 py-2 rounded-lg bg-amber-500 text-white text-[11px] font-black uppercase disabled:opacity-50">{os.avaliacao ? 'Atualizar avaliação' : 'Enviar avaliação'}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Seção: Monitoramento de custódia (onde o trabalho está) ─────────────────
|
// ── Seção: Monitoramento de custódia (onde o trabalho está) ─────────────────
|
||||||
const MonitoramentoSecao: React.FC<{ os: any; podeEditar: boolean; busy: boolean; onMover: (p: 'clinica' | 'laboratorio') => void }> = ({ os, podeEditar, busy, onMover }) => {
|
const MonitoramentoSecao: React.FC<{ os: any; podeEditar: boolean; busy: boolean; onMover: (p: 'clinica' | 'laboratorio') => void }> = ({ os, podeEditar, busy, onMover }) => {
|
||||||
const noLab = (os.local_atual || 'clinica') === 'laboratorio';
|
const noLab = (os.local_atual || 'clinica') === 'laboratorio';
|
||||||
|
|||||||
@@ -187,6 +187,8 @@ interface Profissional {
|
|||||||
bio_profissional?: string | null;
|
bio_profissional?: string | null;
|
||||||
dist_km?: number | string | null;
|
dist_km?: number | string | null;
|
||||||
nota?: number | null; // Nota ScoreOdonto (só protéticos com volume mínimo)
|
nota?: number | null; // Nota ScoreOdonto (só protéticos com volume mínimo)
|
||||||
|
verificado?: boolean; // selo (≥ 5 entregas)
|
||||||
|
avaliacoes?: number; // nº de avaliações da clínica
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Proposta {
|
interface Proposta {
|
||||||
@@ -843,7 +845,8 @@ export const ProfissionaisPlugin: React.FC = () => {
|
|||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="font-black text-gray-900 text-sm uppercase truncate">{p.nome}</span>
|
<span className="font-black text-gray-900 text-sm uppercase truncate">{p.nome}</span>
|
||||||
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${meta.cls}`}>{meta.label}</span>
|
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${meta.cls}`}>{meta.label}</span>
|
||||||
{p.nota != null && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 uppercase" title="Nota ScoreOdonto">★ {p.nota}</span>}
|
{p.nota != null && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 uppercase" title="Nota ScoreOdonto">★ {p.nota}{p.avaliacoes ? ` · ${p.avaliacoes}` : ''}</span>}
|
||||||
|
{p.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" title="Laboratório verificado"><BadgeCheck size={10} /> Verificado</span>}
|
||||||
</div>
|
</div>
|
||||||
{p.especialidade && <p className="text-xs text-gray-500 font-medium mt-0.5 uppercase">{p.especialidade}</p>}
|
{p.especialidade && <p className="text-xs text-gray-500 font-medium mt-0.5 uppercase">{p.especialidade}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user