feat(protese): seletor de protético em modal (busca, favorito/padrão, vincular por e-mail)

O dropdown só listava protéticos internos (vinculados) ou do diretório público — um protético com
conta fora do diretório (ex.: claudemir) não aparecia para a clínica. Reformulado:

- Componente ProteticoPicker (modal): busca por nome/e-mail; estrela de FAVORITO; marcar PADRÃO
  (pré-selecionado na próxima OS/GTO, só um por clínica); e "Adicionar por e-mail", que VINCULA um
  protético existente como interno — resolve o caso de não estar no diretório, sem expô-lo publicamente.
- Backend: tabela protese_protetico_pref (favorito/padrão por clínica); GET /protese/laboratorios
  passa a retornar favorito/padrao e ordena padrão→favorito→interno→nota→nome; POST .../:id/pref e
  POST /protese/laboratorios/vincular.
- Picker aplicado no LancarGTO (avaliação/GTO) e na NovaOSModal; ProteticoInternoInline (protético
  SEM conta) mantido ao lado.

Validado em DEV na CONSULTT CLINIC: claudemir ausente → vinculado por e-mail → aparece interno e,
como favorito+padrão, no topo da lista.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-25 07:18:32 +02:00
parent 8353a9d897
commit e6d8b532e3
5 changed files with 205 additions and 30 deletions
+60 -8
View File
@@ -3839,23 +3839,66 @@ app.get('/api/protese/laboratorios', tenantGuard, async (req, res) => {
try {
const clinicaId = req.clinicaId;
const { rows } = await pool.query(
`SELECT u.id, u.nome, u.email,
BOOL_OR(v.clinica_id = $1) AS interno
`SELECT u.id, u.nome, u.email, u.celular,
BOOL_OR(v.clinica_id = $1) AS interno,
BOOL_OR(pf.favorito) AS favorito,
BOOL_OR(pf.padrao) AS padrao
FROM usuarios u
LEFT JOIN vinculos v ON v.usuario_id = u.id
LEFT JOIN protese_protetico_pref pf ON pf.protetico_id = u.id AND pf.clinica_id = $1
WHERE u.role = 'protetico'
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]);
AND (v.clinica_id = $1 OR u.disponivel_diretorio = true OR pf.clinica_id = $1)
GROUP BY u.id, u.nome, u.email, u.celular
ORDER BY u.nome`, [clinicaId]);
// Nota ScoreOdonto por protético (ranking no marketplace) — mesma fórmula da tela do lab.
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));
const lista = rows.map(r => ({ ...r, interno: !!r.interno, favorito: !!r.favorito, padrao: !!r.padrao, nota: notas[r.id]?.nota ?? null, verificado: notas[r.id]?.verificado || false, avaliacoes: notas[r.id]?.avaliacoes || 0 }));
// Ranking: padrão → favorito → interno → maior nota nome.
lista.sort((a, b) => (b.padrao ? 1 : 0) - (a.padrao ? 1 : 0) || (b.favorito ? 1 : 0) - (a.favorito ? 1 : 0) || (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 }); }
});
// Marcar protético como FAVORITO (★) e/ou PADRÃO (pré-selecionado) na clínica ativa.
app.post('/api/protese/laboratorios/:proteticoId/pref', tenantGuard, async (req, res) => {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
try {
const protId = req.params.proteticoId;
const { rows: pr } = await pool.query("SELECT 1 FROM usuarios WHERE id=$1 AND role='protetico'", [protId]);
if (!pr.length) return res.status(404).json({ error: 'Protético não encontrado.' });
const favorito = req.body?.favorito;
const padrao = req.body?.padrao;
// Só pode haver UM padrão por clínica.
if (padrao === true) await pool.query('UPDATE protese_protetico_pref SET padrao=false WHERE clinica_id=$1', [req.clinicaId]);
// COALESCE(EXCLUDED...) preserva o outro flag quando só um é enviado.
await pool.query(
`INSERT INTO protese_protetico_pref (clinica_id, protetico_id, favorito, padrao)
VALUES ($1,$2,$3,$4)
ON CONFLICT (clinica_id, protetico_id) DO UPDATE
SET favorito = COALESCE(EXCLUDED.favorito, protese_protetico_pref.favorito),
padrao = COALESCE(EXCLUDED.padrao, protese_protetico_pref.padrao)`,
[req.clinicaId, protId, favorito ?? null, padrao ?? null]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Vincular um protético EXISTENTE (com conta) como interno da clínica — resolve "não aparece no
// dropdown" sem precisar do diretório público. Busca por e-mail exato.
app.post('/api/protese/laboratorios/vincular', tenantGuard, async (req, res) => {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
try {
const email = (req.body?.email || '').toLowerCase().trim();
if (!email) return res.status(400).json({ error: 'E-mail obrigatório.' });
const { rows } = await pool.query("SELECT id, nome, email FROM usuarios WHERE lower(email)=$1 AND role='protetico'", [email]);
if (!rows.length) return res.status(404).json({ error: 'Nenhum protético com este e-mail. Confira ou cadastre um protético interno.' });
const p = rows[0];
await pool.query(
"INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1,$2,$3,'protetico') ON CONFLICT (usuario_id, clinica_id) DO NOTHING",
[`v_${p.id}_${req.clinicaId}`, p.id, req.clinicaId]);
res.json({ success: true, protetico: { id: p.id, nome: p.nome, email: p.email } });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Protético INTERNO (sem conta): a clínica registra um protético que não usa a plataforma.
// Cria um usuário "mudo" (e-mail placeholder, senha-fantasma) + vínculo com a clínica (interno,
// fora do diretório). A partir daí já recebe OS e o link /p/TOKEN.
@@ -6858,6 +6901,15 @@ async function runMigrations() {
)`,
`CREATE INDEX IF NOT EXISTS idx_protese_coleta_lab ON protese_coleta (protetico_id, data_hora)`,
`CREATE INDEX IF NOT EXISTS idx_protese_coleta_os ON protese_coleta (os_id)`,
// Preferência de protético por clínica: favorito (★) e padrão (pré-selecionado na nova OS/GTO).
`CREATE TABLE IF NOT EXISTS protese_protetico_pref (
clinica_id TEXT NOT NULL,
protetico_id TEXT NOT NULL,
favorito BOOLEAN DEFAULT false,
padrao BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (clinica_id, protetico_id)
)`,
`CREATE TABLE IF NOT EXISTS protese_produtos (
id TEXT PRIMARY KEY,
protetico_id TEXT NOT NULL, -- dono (usuário protético)