diff --git a/backend/server.js b/backend/server.js index fded115..c791aba 100644 --- a/backend/server.js +++ b/backend/server.js @@ -639,6 +639,7 @@ app.put('/api/clinicas/:clinicaId', authGuard, async (req, res) => { const val = (c) => (UPPER.has(c) && typeof b[c] === 'string') ? b[c].trim().toUpperCase() : b[c]; const set = keys.map((c, i) => `${c} = $${i + 1}`).join(', '); await pool.query(`UPDATE clinicas SET ${set} WHERE id = $${keys.length + 1}`, [...keys.map(val), req.params.clinicaId]); + if (b.cep !== undefined) await setGeoByCep('clinicas', req.params.clinicaId, b.cep); res.json({ success: true, nome: b.nome_fantasia ? String(b.nome_fantasia).trim().toUpperCase() : undefined }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); @@ -4010,8 +4011,10 @@ async function runMigrations() { // Coluna geográfica (ponto WGS84) p/ busca por raio em salas e profissionais. `ALTER TABLE salas ADD COLUMN IF NOT EXISTS geo geography(Point,4326)`, `ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS geo geography(Point,4326)`, + `ALTER TABLE clinicas ADD COLUMN IF NOT EXISTS geo geography(Point,4326)`, `CREATE INDEX IF NOT EXISTS idx_salas_geo ON salas USING GIST (geo)`, `CREATE INDEX IF NOT EXISTS idx_usuarios_geo ON usuarios USING GIST (geo)`, + `CREATE INDEX IF NOT EXISTS idx_clinicas_geo ON clinicas USING GIST (geo)`, // ── PLUGIN: Marketplace de Profissionais ───────────────────────────── `ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS cep TEXT`, `ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS raio_atuacao_km INTEGER`, @@ -4228,6 +4231,21 @@ async function setGeoByCep(table, id, cep) { } catch (e) { console.error('[setGeoByCep]', table, e.message); return null; } } +// Resolve a ORIGEM da busca (de onde medir distância): CEP digitado → clínica ativa +// (geo da clínica) → fallback no geo do próprio usuário logado. Retorna {lat,lng} ou null. +async function resolveOrigem(req, cep, clinicaId) { + if (cep) return await geocodarCep(cep); + try { + if (clinicaId) { + const r = await pool.query('SELECT ST_Y(geo::geometry) AS lat, ST_X(geo::geometry) AS lng FROM clinicas WHERE id=$1 AND geo IS NOT NULL', [clinicaId]); + if (r.rows.length) return r.rows[0]; + } + const u = await pool.query('SELECT ST_Y(geo::geometry) AS lat, ST_X(geo::geometry) AS lng FROM usuarios WHERE id=$1 AND geo IS NOT NULL', [req.authUser.userId]); + if (u.rows.length) return u.rows[0]; + } catch (e) { console.error('[resolveOrigem]', e.message); } + return null; +} + // Minhas salas (como locador) app.get('/api/salas/minhas', authGuard, async (req, res) => { try { @@ -4299,10 +4317,10 @@ app.delete('/api/salas/:id', authGuard, async (req, res) => { // Marketplace de salas (qualquer usuário autenticado) app.get('/api/salas/marketplace', authGuard, async (req, res) => { try { - const { tipo, estado, cidade, cep, raio } = req.query; - // Busca por raio: CEP do buscador → coordenada (cache). Se geocodar falhar, cai no filtro normal. + const { tipo, estado, cidade, cep, raio, clinicaId } = req.query; const raioKm = Number(raio); - const origin = (cep && Number.isFinite(raioKm) && raioKm > 0) ? await geocodarCep(cep) : null; + // Origem = CEP digitado → clínica ativa → próprio usuário (proximidade automática). + const origin = await resolveOrigem(req, cep, clinicaId); const params = []; let where = `WHERE s.disponivel = true`; @@ -4315,11 +4333,10 @@ app.get('/api/salas/marketplace', authGuard, async (req, res) => { if (origin) { params.push(origin.lng); const pLng = params.length; params.push(origin.lat); const pLat = params.length; - params.push(raioKm * 1000); const pRaio = params.length; const pt = `ST_SetSRID(ST_MakePoint($${pLng},$${pLat}),4326)::geography`; distSelect = `ROUND((ST_Distance(s.geo, ${pt})/1000)::numeric, 1) AS dist_km`; - where += ` AND s.geo IS NOT NULL AND ST_DWithin(s.geo, ${pt}, $${pRaio})`; - order = `ORDER BY ST_Distance(s.geo, ${pt})`; + order = `ORDER BY ST_Distance(s.geo, ${pt}) NULLS LAST`; + if (Number.isFinite(raioKm) && raioKm > 0) { params.push(raioKm * 1000); where += ` AND s.geo IS NOT NULL AND ST_DWithin(s.geo, ${pt}, $${params.length})`; } } const { rows } = await pool.query( `SELECT s.id, s.nome, s.tipo, s.descricao, s.equipamentos, s.endereco, s.bairro, s.cidade, s.estado, s.cep, @@ -4519,9 +4536,10 @@ app.put('/api/profissionais/perfil', authGuard, async (req, res) => { // Busca de profissionais (inclui biomédicos; supera o diretório antigo) app.get('/api/profissionais/marketplace', authGuard, async (req, res) => { - const { tipo, estado, cidade, especialidade, cep, raio } = req.query; + const { tipo, estado, cidade, especialidade, cep, raio, clinicaId } = req.query; const raioKm = Number(raio); - const origin = (cep && Number.isFinite(raioKm) && raioKm > 0) ? await geocodarCep(cep) : null; + // Origem = CEP digitado → clínica ativa → próprio usuário (mostra os mais próximos primeiro). + const origin = await resolveOrigem(req, cep, clinicaId); let where = `WHERE disponivel_diretorio = true AND COALESCE(ativo, true) = true AND role = ANY($1::text[])`; const params = [tipo && MARKETPLACE_ROLES.includes(tipo) ? [tipo] : MARKETPLACE_ROLES]; @@ -4534,15 +4552,10 @@ app.get('/api/profissionais/marketplace', authGuard, async (req, res) => { if (origin) { params.push(origin.lng); const pLng = params.length; params.push(origin.lat); const pLat = params.length; - params.push(raioKm * 1000); const pRaio = params.length; const pt = `ST_SetSRID(ST_MakePoint($${pLng},$${pLat}),4326)::geography`; distSelect = `ROUND((ST_Distance(geo, ${pt})/1000)::numeric, 1) AS dist_km`; - where += ` AND geo IS NOT NULL AND ST_DWithin(geo, ${pt}, $${pRaio})`; - order = `ORDER BY ST_Distance(geo, ${pt})`; - } else if (cep) { - // Sem raio: mantém o filtro antigo por prefixo de CEP (compatível). - params.push(`${String(cep).replace(/\D/g, '').slice(0, 5)}%`); - where += ` AND regexp_replace(COALESCE(cep,''), '\\D', '', 'g') LIKE $${params.length}`; + order = `ORDER BY ST_Distance(geo, ${pt}) NULLS LAST`; + if (Number.isFinite(raioKm) && raioKm > 0) { params.push(raioKm * 1000); where += ` AND geo IS NOT NULL AND ST_DWithin(geo, ${pt}, $${params.length})`; } } try { const { rows } = await pool.query( diff --git a/frontend/views/plugins/ProfissionaisPlugin.tsx b/frontend/views/plugins/ProfissionaisPlugin.tsx index d569c7c..53c108e 100644 --- a/frontend/views/plugins/ProfissionaisPlugin.tsx +++ b/frontend/views/plugins/ProfissionaisPlugin.tsx @@ -155,7 +155,10 @@ export const ProfissionaisPlugin: React.FC = () => { if (fEstado) params.set('estado', fEstado); if (fCidade) params.set('cidade', fCidade); if (fEspecialidade) params.set('especialidade', fEspecialidade); - if (fCep) params.set('cep', fCep); + // Origem automática: clínica ativa (backend cai no geo do usuário se faltar) → mais próximos primeiro. + const wsId = HybridBackend.getActiveWorkspace()?.id; + if (wsId) params.set('clinicaId', wsId); + if (fCep) params.set('cep', fCep); // CEP digitado sobrepõe a origem automática if (fCep && Number(fRaio) > 0) params.set('raio', fRaio); const res = await apiFetch(`/api/profissionais/marketplace?${params}`); if (res.ok) setLista(await res.json()); diff --git a/frontend/views/plugins/SalasPlugin.tsx b/frontend/views/plugins/SalasPlugin.tsx index 289aadf..e8c7969 100644 --- a/frontend/views/plugins/SalasPlugin.tsx +++ b/frontend/views/plugins/SalasPlugin.tsx @@ -467,7 +467,11 @@ export const SalasPlugin: React.FC = () => { if (fTipo) params.set('tipo', fTipo); if (fEstado) params.set('estado', fEstado); if (fCidade) params.set('cidade', fCidade); - if (fCep && Number(fRaio) > 0) { params.set('cep', fCep); params.set('raio', fRaio); } + // Origem automática: clínica/consultório ativo (backend cai no geo do usuário se faltar). + const wsId = HybridBackend.getActiveWorkspace()?.id; + if (wsId) params.set('clinicaId', wsId); + if (fCep) params.set('cep', fCep); // CEP digitado sobrepõe a origem automática + if (fCep && Number(fRaio) > 0) params.set('raio', fRaio); const res = await apiFetch(`/api/salas/marketplace?${params}`); if (res.ok) setSalas(await res.json()); } catch { /* silently fail */ }