feat(geo): busca por raio (PostGIS) em Salas e Profissionais

- geocoder de CEP com cache (cep_geo) + rotação: AwesomeAPI → Nominatim(CEP, c/ User-Agent) → ViaCEP+Nominatim(endereço)
- colunas geo geography(Point,4326) + índice GiST em salas e usuarios
- geocoda ao salvar (POST/PUT salas, PUT perfil) via setGeoByCep
- marketplaces aceitam ?cep=&raio= → ST_DWithin + dist_km, ordenado por distância
- frontend: filtro Meu CEP + Raio + badge ~X KM nos cards
- fix: clinicas.nome inexistente → nome_fantasia (salas/minhas + marketplace)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-13 23:18:49 +02:00
parent cb00d9f383
commit 503359ee97
3 changed files with 148 additions and 14 deletions
+11 -3
View File
@@ -37,6 +37,7 @@ interface Profissional {
especialidade: string;
raio_atuacao_km?: number | null;
bio_profissional?: string | null;
dist_km?: number | string | null;
}
interface Proposta {
@@ -138,6 +139,7 @@ export const ProfissionaisPlugin: React.FC = () => {
const [fCidade, setFCidade] = useState('');
const [fEspecialidade, setFEspecialidade] = useState('');
const [fCep, setFCep] = useState('');
const [fRaio, setFRaio] = useState('');
// perfil
const [perfil, setPerfil] = useState<any>(null);
const [savingPerfil, setSavingPerfil] = useState(false);
@@ -154,11 +156,12 @@ export const ProfissionaisPlugin: React.FC = () => {
if (fCidade) params.set('cidade', fCidade);
if (fEspecialidade) params.set('especialidade', fEspecialidade);
if (fCep) params.set('cep', fCep);
if (fCep && Number(fRaio) > 0) params.set('raio', fRaio);
const res = await apiFetch(`/api/profissionais/marketplace?${params}`);
if (res.ok) setLista(await res.json());
} catch { /* silently fail */ }
finally { setLoading(false); }
}, [fTipo, fEstado, fCidade, fEspecialidade, fCep]);
}, [fTipo, fEstado, fCidade, fEspecialidade, fCep, fRaio]);
const fetchPerfil = useCallback(async () => {
setLoading(true);
@@ -288,8 +291,12 @@ export const ProfissionaisPlugin: React.FC = () => {
<input className={`${inputCls} uppercase`} value={fEspecialidade} onChange={e => setFEspecialidade(e.target.value.toUpperCase())} placeholder="ORTODONTIA" />
</div>
<div>
<label className={labelCls}>CEP (região)</label>
<input className={inputCls} value={fCep} onChange={e => setFCep(e.target.value)} placeholder="79000" />
<label className={labelCls}>Meu CEP</label>
<input className={inputCls} value={fCep} onChange={e => setFCep(e.target.value)} placeholder="79000-000" />
</div>
<div>
<label className={labelCls}>Raio (km)</label>
<input type="number" min="1" className={inputCls} value={fRaio} onChange={e => setFRaio(e.target.value)} placeholder="20" />
</div>
</div>
<button onClick={fetchLista} disabled={loading}
@@ -331,6 +338,7 @@ export const ProfissionaisPlugin: React.FC = () => {
<div className="flex items-center gap-1.5 text-xs text-gray-500 font-medium">
<MapPin size={12} className="shrink-0 text-gray-400" />
<span>{[p.bairro, p.cidade, p.estado].filter(Boolean).join(' · ')}{p.raio_atuacao_km ? ` · ATENDE EM ATÉ ${p.raio_atuacao_km} KM` : ''}</span>
{p.dist_km != null && <span className="ml-auto shrink-0 text-[9px] font-black px-2 py-0.5 rounded-full bg-orange-100 text-orange-700">~{p.dist_km} KM</span>}
</div>
)}
<div className="flex items-center gap-1.5 text-xs text-gray-500 font-medium">
+15 -2
View File
@@ -45,6 +45,7 @@ interface Sala {
clinica_id?: string | null;
clinica_nome?: string | null;
reservas_pendentes?: number;
dist_km?: number | string | null;
}
interface Reserva {
@@ -377,6 +378,7 @@ const SalaCard: React.FC<{ sala: Sala; mine?: boolean; onReserve?: () => void; o
<div className="flex items-center gap-1.5 text-xs text-gray-500 font-medium mb-2">
<MapPin size={12} className="shrink-0 text-gray-400" />
<span>{[sala.endereco, sala.bairro, sala.cidade, sala.estado].filter(Boolean).join(' · ')}</span>
{sala.dist_km != null && <span className="ml-auto shrink-0 text-[9px] font-black px-2 py-0.5 rounded-full" style={{ backgroundColor: `${COLOR}15`, color: COLOR }}>~{sala.dist_km} KM</span>}
</div>
{sala.equipamentos && (
<p className="text-[10px] text-gray-400 font-bold uppercase mb-3">EQUIP.: {sala.equipamentos}</p>
@@ -448,6 +450,8 @@ export const SalasPlugin: React.FC = () => {
const [fTipo, setFTipo] = useState('');
const [fEstado, setFEstado] = useState('');
const [fCidade, setFCidade] = useState('');
const [fCep, setFCep] = useState('');
const [fRaio, setFRaio] = useState('');
// minhas
const [minhas, setMinhas] = useState<Sala[]>([]);
// reservas
@@ -463,11 +467,12 @@ 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); }
const res = await apiFetch(`/api/salas/marketplace?${params}`);
if (res.ok) setSalas(await res.json());
} catch { /* silently fail */ }
finally { setLoading(false); }
}, [fTipo, fEstado, fCidade]);
}, [fTipo, fEstado, fCidade, fCep, fRaio]);
const fetchMinhas = useCallback(async () => {
setLoading(true);
@@ -570,10 +575,18 @@ export const SalasPlugin: React.FC = () => {
{UF_LIST.map(uf => <option key={uf} value={uf}>{uf || 'Todos os estados'}</option>)}
</select>
</div>
<div className="col-span-2">
<div>
<label className={labelCls}>Cidade</label>
<input className={`${inputCls} uppercase`} value={fCidade} onChange={e => setFCidade(e.target.value.toUpperCase())} placeholder="CAMPO GRANDE" />
</div>
<div>
<label className={labelCls}>Meu CEP (busca por raio)</label>
<input className={inputCls} value={fCep} onChange={e => setFCep(e.target.value)} placeholder="00000-000" />
</div>
<div>
<label className={labelCls}>Raio (km)</label>
<input type="number" min="1" className={inputCls} value={fRaio} onChange={e => setFRaio(e.target.value)} placeholder="10" />
</div>
</div>
<button onClick={fetchMarketplace} disabled={loading}
className="flex items-center gap-2 px-5 py-2.5 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-colors disabled:opacity-60 hover:opacity-90" style={{ backgroundColor: COLOR }}>