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:
@@ -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}`; }
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user