feat(salas): filtros do alugar-salas iguais ao marketplace de profissionais + fix própria sala

- Área de filtros de /alugar-salas restilizada igual ao marketplace de
  profissionais: card com busca no topo, filtros recolhíveis (Ocultar/Mostrar),
  slider de raio com badge KM, barra de ações (Limpar + Buscar) e busca por
  texto (nome/cidade/bairro) sobre o resultado.
- Fix: o marketplace listava as PRÓPRIAS salas (e as da unidade ativa) — agora
  exclui owner_usuario_id do usuário e clinica_id da unidade ativa. Não se aluga
  a própria sala.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-07 02:26:22 +02:00
parent 041803ebb3
commit e0b37797b9
2 changed files with 87 additions and 40 deletions
+3
View File
@@ -7662,6 +7662,9 @@ app.get('/api/salas/marketplace', authGuard, async (req, res) => {
const params = [];
let where = `WHERE s.disponivel = true`;
// Não listar as PRÓPRIAS salas (não se aluga a própria) nem as da unidade ativa.
params.push(req.authUser.userId); where += ` AND s.owner_usuario_id <> $${params.length}`;
if (clinicaId) { params.push(String(clinicaId)); where += ` AND (s.clinica_id IS NULL OR s.clinica_id <> $${params.length})`; }
if (tipo && SALA_TIPOS.includes(tipo)) { params.push(tipo); where += ` AND s.tipo = $${params.length}`; }
if (estado) { params.push(String(estado).toUpperCase()); where += ` AND s.estado = $${params.length}`; }
if (cidade) { params.push(`%${String(cidade).toUpperCase()}%`); where += ` AND s.cidade ILIKE $${params.length}`; }
+84 -40
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
DoorOpen, MapPin, Search, RefreshCw, Plus, Pencil, Trash2, X, Save,
CalendarClock, CheckCircle2, XCircle, Clock, Building2, Briefcase, Home, Inbox, Send
CalendarClock, CheckCircle2, XCircle, Clock, Building2, Briefcase, Home, Inbox, Send, Eraser, ChevronUp
} from 'lucide-react';
import { PageHeader } from '../../components/PageHeader.tsx';
import { useToast } from '../../contexts/ToastContext.tsx';
@@ -108,6 +108,9 @@ const precos = (s: Sala): Array<[string, string]> => {
const inputCls = 'w-full px-3 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-xs font-bold text-gray-700 outline-none focus:border-teal-400';
const labelCls = 'text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1';
// Estilo da área de filtros (mesmo do marketplace de profissionais).
const inputNew = 'w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm font-semibold text-gray-700 outline-none focus:border-teal-400 focus:bg-white transition-colors';
const labelNew = 'text-[11px] font-bold text-gray-500 mb-1.5 block';
// ─── Modal: cadastrar / editar sala ──────────────────────────────────────────
const SalaFormModal: React.FC<{ sala: Sala | null; onClose: () => void; onSaved: () => void }> = ({ sala, onClose, onSaved }) => {
@@ -516,8 +519,15 @@ export const SalasPlugin: React.FC<{ view?: 'minhas' | 'alugar' }> = ({ view })
const [fCep, setFCep] = useState('');
const [fRaio, setFRaio] = useState('');
const [fArea, setFArea] = useState('');
const [fBusca, setFBusca] = useState('');
const [showFiltros, setShowFiltros] = useState(true);
const [areas, setAreas] = useState<{ slug: string; nome: string }[]>([]);
useEffect(() => { HybridBackend.getAreas().then(setAreas); }, []);
const limparFiltros = () => { setFTipo(''); setFEstado(''); setFCidade(''); setFCep(''); setFRaio(''); setFArea(''); setFBusca(''); };
// Busca por texto (nome/cidade/bairro) aplicada sobre o resultado do backend.
const salasFiltradas = fBusca.trim()
? salas.filter(s => `${s.nome} ${(s as any).cidade || ''} ${(s as any).bairro || ''}`.toLowerCase().includes(fBusca.trim().toLowerCase()))
: salas;
// minhas
const [minhas, setMinhas] = useState<Sala[]>([]);
// reservas
@@ -669,50 +679,84 @@ export const SalasPlugin: React.FC<{ view?: 'minhas' | 'alugar' }> = ({ view })
{tab === 'marketplace' && enderecoOk !== false && (
<>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-4">
<div>
<label className={labelCls}>Área</label>
<select className={inputCls} value={fArea} onChange={e => setFArea(e.target.value)}>
<option value="">Todas</option>
{areas.map(a => <option key={a.slug} value={a.slug}>{a.nome}</option>)}
</select>
{/* Busca por nome/cidade/bairro (sobre o resultado) */}
<div className="relative mb-5">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
<input
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-200 rounded-2xl text-sm font-medium text-gray-700 outline-none focus:border-teal-400 focus:bg-white transition-colors"
value={fBusca} onChange={e => setFBusca(e.target.value)}
placeholder="Buscar sala por nome, cidade ou bairro..." />
</div>
{showFiltros && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className={labelNew}>Área</label>
<select className={inputNew} value={fArea} onChange={e => setFArea(e.target.value)}>
<option value="">Todas</option>
{areas.map(a => <option key={a.slug} value={a.slug}>{a.nome}</option>)}
</select>
</div>
<div>
<label className={labelNew}>Tipo</label>
<select className={inputNew} value={fTipo} onChange={e => setFTipo(e.target.value)}>
<option value="">Todos</option>
<option value="clinica">Sala Clínica</option>
<option value="consultorio">Sala Consultório</option>
<option value="independente">Sala Independente</option>
</select>
</div>
<div>
<label className={labelNew}>Estado</label>
<select className={inputNew} value={fEstado} onChange={e => setFEstado(e.target.value)}>
{UF_LIST.map(uf => <option key={uf} value={uf}>{uf || 'Todos'}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:items-end">
<div>
<label className={labelNew}>Cidade</label>
<input className={`${inputNew} uppercase`} value={fCidade} onChange={e => setFCidade(e.target.value.toUpperCase())} placeholder="CAMPO GRANDE" />
</div>
<div>
<label className={labelNew}>Meu CEP</label>
<input className={inputNew} value={fCep} onChange={e => setFCep(e.target.value)} placeholder="79000-000" />
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className={`${labelNew} mb-0`}>Raio de Busca</label>
<span className="text-[10px] font-black px-2 py-0.5 rounded-full bg-teal-100 text-teal-700">{fRaio || 0} KM</span>
</div>
<input type="range" min="0" max="200" step="5" value={fRaio || 0}
onChange={e => setFRaio(e.target.value === '0' ? '' : e.target.value)}
className="w-full accent-teal-600 cursor-pointer" />
{!fCep && <p className="text-[10px] text-gray-400 mt-1">Informe o CEP para filtrar por raio.</p>}
</div>
</div>
</div>
<div>
<label className={labelCls}>Tipo</label>
<select className={inputCls} value={fTipo} onChange={e => setFTipo(e.target.value)}>
<option value="">Todos</option>
<option value="clinica">SALA CLÍNICA</option>
<option value="consultorio">SALA CONSULTÓRIO</option>
<option value="independente">SALA INDEPENDENTE</option>
</select>
</div>
<div>
<label className={labelCls}>Estado</label>
<select className={inputCls} value={fEstado} onChange={e => setFEstado(e.target.value)}>
{UF_LIST.map(uf => <option key={uf} value={uf}>{uf || 'Todos os estados'}</option>)}
</select>
</div>
<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 className="flex items-center justify-between mt-5 pt-4 border-t border-gray-50">
<button onClick={() => setShowFiltros(s => !s)}
className="flex items-center gap-1.5 text-xs font-bold text-gray-500 hover:text-gray-700 transition-colors">
<ChevronUp size={14} className={`transition-transform ${showFiltros ? '' : 'rotate-180'}`} />
{showFiltros ? 'Ocultar Filtros' : 'Mostrar Filtros'}
</button>
<div className="flex items-center gap-4">
<button onClick={limparFiltros} className="flex items-center gap-1.5 text-xs font-bold text-gray-500 hover:text-gray-700 transition-colors">
<Eraser size={13} /> Limpar Filtros
</button>
<button onClick={fetchMarketplace} disabled={loading}
className="flex items-center gap-2 px-6 py-3 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-opacity disabled:opacity-60 hover:opacity-90" style={{ backgroundColor: COLOR }}>
{loading ? <RefreshCw size={14} className="animate-spin" /> : <Search size={14} />} Buscar
</button>
</div>
</div>
<button onClick={fetchMarketplace} disabled={loading}
className="w-full sm:w-auto flex items-center justify-center gap-2 px-6 py-2.5 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-opacity disabled:opacity-60 hover:opacity-90 shadow-sm" style={{ backgroundColor: COLOR }}>
{loading ? <RefreshCw size={14} className="animate-spin" /> : <Search size={14} />} BUSCAR
</button>
</div>
{loading ? (
<div className="flex justify-center py-12"><RefreshCw size={24} className="animate-spin" style={{ color: COLOR }} /></div>
) : salas.length === 0 ? (
) : salasFiltradas.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<DoorOpen size={40} className="mx-auto mb-3 opacity-40" />
<p className="font-bold text-sm uppercase">Nenhuma sala encontrada</p>
@@ -720,7 +764,7 @@ export const SalasPlugin: React.FC<{ view?: 'minhas' | 'alugar' }> = ({ view })
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{salas.map(s => <SalaCard key={s.id} sala={s} onReserve={() => setReservingSala(s)} />)}
{salasFiltradas.map(s => <SalaCard key={s.id} sala={s} onReserve={() => setReservingSala(s)} />)}
</div>
)}
</>