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:
+60
-8
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user