From e6d8b532e330516c3d8e462298b445e75e96c426 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Thu, 25 Jun 2026 07:18:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(protese):=20seletor=20de=20prot=C3=A9tico?= =?UTF-8?q?=20em=20modal=20(busca,=20favorito/padr=C3=A3o,=20vincular=20po?= =?UTF-8?q?r=20e-mail)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/server.js | 68 +++++++++++-- frontend/components/ProteticoPicker.tsx | 127 ++++++++++++++++++++++++ frontend/services/backend.ts | 12 +++ frontend/views/LancarGTO.tsx | 17 +--- frontend/views/ProteseView.tsx | 11 +- 5 files changed, 205 insertions(+), 30 deletions(-) create mode 100644 frontend/components/ProteticoPicker.tsx diff --git a/backend/server.js b/backend/server.js index 4bb7cde..203a303 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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) diff --git a/frontend/components/ProteticoPicker.tsx b/frontend/components/ProteticoPicker.tsx new file mode 100644 index 0000000..53a7e25 --- /dev/null +++ b/frontend/components/ProteticoPicker.tsx @@ -0,0 +1,127 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Search, X, Star, Check, ShieldCheck, UserPlus, Building2 } from 'lucide-react'; +import { HybridBackend } from '../services/backend.ts'; +import { useToast } from '../contexts/ToastContext.tsx'; + +// Seletor de protético em modal: busca por nome/e-mail, favorito (★), padrão (pré-selecionado) +// e "adicionar por e-mail" (vincula como interno — resolve o protético que não está no diretório). +export const ProteticoPicker: React.FC<{ value: string; onChange: (id: string, lab?: any) => void; className?: string }> = ({ value, onChange, className }) => { + const toast = useToast(); + const [open, setOpen] = useState(false); + const [labs, setLabs] = useState([]); + const [busca, setBusca] = useState(''); + const [addEmail, setAddEmail] = useState(''); + const [showAdd, setShowAdd] = useState(false); + const [busy, setBusy] = useState(false); + const preselected = useRef(false); + + const carregar = useCallback(async (): Promise => { + try { const r = await HybridBackend.getProteseLaboratorios(); const arr = Array.isArray(r) ? r : []; setLabs(arr); return arr; } + catch { setLabs([]); return []; } + }, []); + useEffect(() => { carregar(); }, [carregar]); + + // Pré-seleciona o protético PADRÃO uma única vez, quando nada foi escolhido ainda. + useEffect(() => { + if (preselected.current || value) return; + const pad = labs.find(l => l.padrao); + if (pad) { preselected.current = true; onChange(pad.id, pad); } + }, [labs, value]); // eslint-disable-line react-hooks/exhaustive-deps + + const sel = labs.find(l => l.id === value); + const q = busca.toLowerCase(); + const filtrados = labs.filter(l => !q || (l.nome || '').toLowerCase().includes(q) || (l.email || '').toLowerCase().includes(q)); + + const toggleFav = async (l: any, e: React.MouseEvent) => { + e.stopPropagation(); + try { await HybridBackend.setProteticoPref(l.id, { favorito: !l.favorito }); await carregar(); } + catch { toast.error('ERRO'); } + }; + const tornarPadrao = async (l: any, e: React.MouseEvent) => { + e.stopPropagation(); + try { await HybridBackend.setProteticoPref(l.id, { padrao: !l.padrao }); await carregar(); toast.success(l.padrao ? 'PADRÃO REMOVIDO.' : 'DEFINIDO COMO PADRÃO.'); } + catch { toast.error('ERRO'); } + }; + const selecionar = (l: any) => { onChange(l.id, l); setOpen(false); setBusca(''); }; + const adicionar = async () => { + if (!addEmail.trim()) return; + setBusy(true); + try { + const r = await HybridBackend.vincularProteticoEmail(addEmail.trim()); + if (r?.error) throw new Error(r.error); + toast.success('PROTÉTICO VINCULADO.'); + setAddEmail(''); setShowAdd(false); + const novos = await carregar(); + const novo = novos.find((x: any) => x.id === r.protetico?.id); + if (novo) selecionar(novo); + } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); } + }; + + return ( + <> + + + {open && ( +
setOpen(false)}> +
e.stopPropagation()}> +
+

Escolher protético

+ +
+
+
+ + setBusca(e.target.value)} placeholder="Buscar por nome ou e-mail…" + className="w-full pl-9 pr-3 py-2.5 rounded-xl border border-gray-200 text-sm outline-none focus:border-teal-400" /> +
+
+
+ {filtrados.length === 0 ? ( +

Nenhum protético encontrado.

+ ) : filtrados.map(l => ( +
selecionar(l)} + className={`flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer ${l.id === value ? 'bg-teal-50 border border-teal-200' : 'hover:bg-gray-50'}`}> + +
+

+ {l.nome}{l.verificado && } +

+

+ {l.interno ? 'Interno' : 'Diretório'} + {l.nota != null && · ★ {l.nota}} + {l.padrao && · Padrão} +

+
+ + {l.id === value && } +
+ ))} +
+
+ {showAdd ? ( +
+ setAddEmail(e.target.value)} type="email" placeholder="e-mail do protético" + className="flex-1 px-3 py-2 rounded-lg border border-gray-200 text-sm outline-none focus:border-teal-400" /> + +
+ ) : ( + + )} +
+
+
+ )} + + ); +}; diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index e18213a..31d8e58 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -748,6 +748,18 @@ export const HybridBackend = { const res = await apiFetch(`${API_URL}/protese/laboratorios?clinicaId=${encodeURIComponent(clinicaId)}`); return await res.json(); }, + // Favorito (★) / padrão do protético na clínica ativa. + setProteticoPref: async (proteticoId: string, body: { favorito?: boolean; padrao?: boolean }) => { + const clinicaId = HybridBackend.getActiveWorkspace()?.id || ''; + const res = await apiFetch(`${API_URL}/protese/laboratorios/${proteticoId}/pref?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + return await res.json().catch(() => ({ error: 'falha' })); + }, + // Vincular um protético existente (com conta) como interno, por e-mail. + vincularProteticoEmail: async (email: string) => { + const clinicaId = HybridBackend.getActiveWorkspace()?.id || ''; + const res = await apiFetch(`${API_URL}/protese/laboratorios/vincular?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) }); + return await res.json().catch(() => ({ error: 'falha' })); + }, getProteseOS: async (opts: { status?: string; paciente_id?: string } = {}) => { const clinicaId = HybridBackend.getActiveWorkspace()?.id || ''; const qs = new URLSearchParams({ clinicaId }); diff --git a/frontend/views/LancarGTO.tsx b/frontend/views/LancarGTO.tsx index 37bb5c9..026cfa9 100644 --- a/frontend/views/LancarGTO.tsx +++ b/frontend/views/LancarGTO.tsx @@ -13,6 +13,7 @@ import { X } from 'lucide-react'; import { PageHeader } from '../components/PageHeader.tsx'; +import { ProteticoPicker } from '../components/ProteticoPicker.tsx'; import { HybridBackend } from '../services/backend.ts'; import { Paciente, Dentista, Procedimento, Especialidade } from '../types.ts'; import { useToast } from '../contexts/ToastContext.tsx'; @@ -123,7 +124,6 @@ export const LancarGTO: React.FC = () => { const [patientSearchTerm, setPatientSearchTerm] = useState(''); // --- PRÓTESE (Fase 1): catálogo do protético entra como "procedimentos" do GTO --- - const [laboratorios, setLaboratorios] = useState([]); const [selectedLab, setSelectedLab] = useState(''); const [labProdutos, setLabProdutos] = useState([]); const [selectedProdutoId, setSelectedProdutoId] = useState(''); @@ -162,12 +162,6 @@ export const LancarGTO: React.FC = () => { [labProdutos, selectedProdutoId] ); - // Carrega os laboratórios acessíveis assim que a especialidade de prótese é escolhida. - useEffect(() => { - if (!isProtese || laboratorios.length) return; - HybridBackend.getProteseLaboratorios().then(l => setLaboratorios(Array.isArray(l) ? l : [])).catch(() => {}); - }, [isProtese, laboratorios.length]); - // Catálogo do laboratório selecionado. useEffect(() => { if (!selectedLab) { setLabProdutos([]); return; } @@ -482,14 +476,7 @@ export const LancarGTO: React.FC = () => { {selectedEsp && isProtese && (
- + { setSelectedLab(id); setSelectedProdutoId(''); setProtValor(''); }} /> {selectedLab && (labProdutos.length === 0 ? (
diff --git a/frontend/views/ProteseView.tsx b/frontend/views/ProteseView.tsx index 5fc25ae..0806a44 100644 --- a/frontend/views/ProteseView.tsx +++ b/frontend/views/ProteseView.tsx @@ -5,6 +5,7 @@ import { useToast } from '../contexts/ToastContext.tsx'; import { useConfirm } from '../contexts/ConfirmContext.tsx'; import { PageHeader } from '../components/PageHeader.tsx'; import { rotulosPaciente } from '../utils/pacienteLabel.ts'; +import { ProteticoPicker } from '../components/ProteticoPicker.tsx'; const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 }); const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/'); @@ -63,7 +64,7 @@ const ProteticoInternoInline: React.FC<{ onCriado: (id: string) => void }> = ({ // ── Modal: Nova OS (lado clínica) ─────────────────────────────────────────── const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => { const toast = useToast(); - const [labs, setLabs] = useState([]); + const [pickerKey, setPickerKey] = useState(0); const [dentistas, setDentistas] = useState([]); const [busca, setBusca] = useState(''); const [pacientes, setPacientes] = useState([]); @@ -74,7 +75,6 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o const ws = HybridBackend.getActiveWorkspace(); useEffect(() => { - HybridBackend.getProteseLaboratorios().then(r => setLabs(Array.isArray(r) ? r : [])).catch(() => setLabs([])); HybridBackend.getDentistas(ws?.id).then(r => setDentistas(Array.isArray(r) ? r : [])).catch(() => setDentistas([])); }, [ws?.id]); @@ -152,11 +152,8 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o {/* Laboratório */}
- - { HybridBackend.getProteseLaboratorios().then(r => { setLabs(Array.isArray(r) ? r : []); set('protetico_id', id); }); }} /> +
set('protetico_id', id)} />
+ { set('protetico_id', id); setPickerKey(k => k + 1); }} />
{/* Produto (catálogo do laboratório — preço fixo) */}