From 92a0ec391743cf7dd87e2b2b113b8a9bdb6d9c98 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Sun, 14 Jun 2026 07:36:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(salas):=20=C3=A1rea=20por=20sala=20+=20pre?= =?UTF-8?q?=C3=A7o=20por=20modalidade=20(fixo=20ou=20sob=20consulta)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - salas.area + filtro por área no marketplace + badge no card + seletor no cadastro - preço por modalidade: hora/turno/diária/semanal/mensal podem ser FIXO (valor) ou SOB CONSULTA (flags *_consulta) — precos() mostra 'Sob consulta' - backfill de área das salas pelo papel do dono - POST/PUT salas aceitam area + flags; marketplace retorna/filtra por área Co-Authored-By: Claude Opus 4.8 --- backend/server.js | 28 +++++++--- frontend/views/plugins/SalasPlugin.tsx | 71 +++++++++++++++++++++----- 2 files changed, 81 insertions(+), 18 deletions(-) diff --git a/backend/server.js b/backend/server.js index 6fec4b0..0c15c28 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4065,6 +4065,15 @@ 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 salas ADD COLUMN IF NOT EXISTS area TEXT`, + `ALTER TABLE salas ADD COLUMN IF NOT EXISTS hora_consulta BOOLEAN DEFAULT false`, + `ALTER TABLE salas ADD COLUMN IF NOT EXISTS turno_consulta BOOLEAN DEFAULT false`, + `ALTER TABLE salas ADD COLUMN IF NOT EXISTS diaria_consulta BOOLEAN DEFAULT false`, + `ALTER TABLE salas ADD COLUMN IF NOT EXISTS semanal_consulta BOOLEAN DEFAULT false`, + `ALTER TABLE salas ADD COLUMN IF NOT EXISTS mensal_consulta BOOLEAN DEFAULT false`, + `UPDATE salas s SET area = CASE u.role WHEN 'biomedico' THEN 'biomedicina' WHEN 'protetico' THEN 'protese' ELSE 'odontologia' END + FROM usuarios u WHERE u.id = s.owner_usuario_id AND s.area IS NULL`, + `UPDATE salas SET area = 'odontologia' WHERE area IS NULL`, `ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS geo geography(Point,4326)`, `ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS logradouro TEXT`, `ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS numero TEXT`, @@ -4426,15 +4435,19 @@ app.post('/api/salas', authGuard, async (req, res) => { const b = req.body || {}; if (!b.nome || !b.cidade || !b.estado) return res.status(400).json({ error: 'Nome, cidade e estado são obrigatórios.' }); const id = `sala_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + let area = b.area; + if (!area) { const { rows: ur } = await pool.query('SELECT role FROM usuarios WHERE id=$1', [req.authUser.userId]); area = roleToArea(ur[0]?.role); } await pool.query( `INSERT INTO salas (id, owner_usuario_id, clinica_id, nome, tipo, descricao, equipamentos, endereco, bairro, cidade, estado, cep, - valor_hora, valor_turno, valor_diaria, valor_semanal, valor_mensal, disponivel) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`, + valor_hora, valor_turno, valor_diaria, valor_semanal, valor_mensal, disponivel, area, + hora_consulta, turno_consulta, diaria_consulta, semanal_consulta, mensal_consulta) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24)`, [id, req.authUser.userId, b.clinicaId || null, b.nome, SALA_TIPOS.includes(b.tipo) ? b.tipo : 'independente', b.descricao || null, b.equipamentos || null, b.endereco || null, b.bairro?.toUpperCase() || null, String(b.cidade).toUpperCase(), String(b.estado).toUpperCase(), b.cep || null, b.valorHora ?? null, b.valorTurno ?? null, b.valorDiaria ?? null, b.valorSemanal ?? null, b.valorMensal ?? null, - b.disponivel !== false]); + b.disponivel !== false, area, + b.horaConsulta === true, b.turnoConsulta === true, b.diariaConsulta === true, b.semanalConsulta === true, b.mensalConsulta === true]); await setGeoByCep('salas', id, b.cep); await registrarAudit(pool, { clinicaId: b.clinicaId || null, entidade: 'sala', entidadeId: id, acao: 'criou', actorId: req.authUser.userId, actorNome: await actorNome(req.authUser.userId), detalhes: { nome: b.nome } }); res.json({ success: true, id }); @@ -4451,7 +4464,8 @@ app.put('/api/salas/:id', authGuard, async (req, res) => { endereco: b.endereco, bairro: b.bairro?.toUpperCase(), cidade: b.cidade ? String(b.cidade).toUpperCase() : undefined, estado: b.estado ? String(b.estado).toUpperCase() : undefined, cep: b.cep, valor_hora: b.valorHora, valor_turno: b.valorTurno, valor_diaria: b.valorDiaria, valor_semanal: b.valorSemanal, valor_mensal: b.valorMensal, - disponivel: b.disponivel }; + area: b.area, disponivel: b.disponivel, + hora_consulta: b.horaConsulta, turno_consulta: b.turnoConsulta, diaria_consulta: b.diariaConsulta, semanal_consulta: b.semanalConsulta, mensal_consulta: b.mensalConsulta }; const keys = Object.keys(COLS).filter(k => COLS[k] !== undefined); if (!keys.length) return res.json({ success: true }); const set = keys.map((k, i) => `${k} = $${i + 1}`).join(', '); @@ -4479,7 +4493,7 @@ 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, clinicaId } = req.query; + const { tipo, estado, cidade, cep, raio, clinicaId, area } = req.query; const raioKm = Number(raio); // Origem = CEP digitado → clínica ativa → próprio usuário (proximidade automática). const origin = await resolveOrigem(req, cep, clinicaId); @@ -4489,6 +4503,7 @@ app.get('/api/salas/marketplace', authGuard, async (req, res) => { 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}`; } + if (area) { params.push(String(area)); where += ` AND s.area = $${params.length}`; } let distSelect = 'NULL::float AS dist_km'; let order = 'ORDER BY s.estado, s.cidade, s.nome'; @@ -4501,8 +4516,9 @@ app.get('/api/salas/marketplace', authGuard, async (req, res) => { 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, + `SELECT s.id, s.nome, s.tipo, s.descricao, s.equipamentos, s.endereco, s.bairro, s.cidade, s.estado, s.cep, s.area, s.valor_hora, s.valor_turno, s.valor_diaria, s.valor_semanal, s.valor_mensal, + s.hora_consulta, s.turno_consulta, s.diaria_consulta, s.semanal_consulta, s.mensal_consulta, s.owner_usuario_id, u.nome AS owner_nome, c.nome_fantasia AS clinica_nome, ${distSelect} FROM salas s LEFT JOIN usuarios u ON u.id = s.owner_usuario_id LEFT JOIN clinicas c ON c.id = s.clinica_id ${where} ${order} LIMIT 200`, params); diff --git a/frontend/views/plugins/SalasPlugin.tsx b/frontend/views/plugins/SalasPlugin.tsx index 6312a5e..74f0e28 100644 --- a/frontend/views/plugins/SalasPlugin.tsx +++ b/frontend/views/plugins/SalasPlugin.tsx @@ -40,6 +40,12 @@ interface Sala { valor_diaria: string | null; valor_semanal: string | null; valor_mensal: string | null; + hora_consulta?: boolean; + turno_consulta?: boolean; + diaria_consulta?: boolean; + semanal_consulta?: boolean; + mensal_consulta?: boolean; + area?: string; disponivel?: boolean; owner_nome?: string; clinica_id?: string | null; @@ -71,6 +77,12 @@ const TIPO_META: Record = { independente: { label: 'Sala Independente', icon: Home }, }; +const AREA_META: Record = { + odontologia: { nome: 'Odontologia', cor: '#2563eb' }, + biomedicina: { nome: 'Biomedicina', cor: '#db2777' }, + protese: { nome: 'Prótese', cor: '#7c3aed' }, +}; + const STATUS_STYLE: Record = { pendente: 'bg-amber-100 text-amber-700', confirmada: 'bg-emerald-100 text-emerald-700', @@ -81,9 +93,16 @@ const STATUS_STYLE: Record = { const fmtValor = (v: string | null) => (v == null || v === '' ? null : Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })); const fmtData = (iso: string) => new Date(iso).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }); -const precos = (s: Sala) => [ - ['HORA', s.valor_hora], ['TURNO', s.valor_turno], ['DIÁRIA', s.valor_diaria], ['SEMANA', s.valor_semanal], ['MÊS', s.valor_mensal], -].filter(([, v]) => fmtValor(v as string | null)) as Array<[string, string]>; +const precos = (s: Sala): Array<[string, string]> => { + const mods: Array<[string, string | null, boolean | undefined]> = [ + ['HORA', s.valor_hora, s.hora_consulta], ['TURNO', s.valor_turno, s.turno_consulta], + ['DIÁRIA', s.valor_diaria, s.diaria_consulta], ['SEMANA', s.valor_semanal, s.semanal_consulta], + ['MÊS', s.valor_mensal, s.mensal_consulta], + ]; + return mods + .filter(([, v, c]) => fmtValor(v as string | null) || c) + .map(([l, v]) => [l, fmtValor(v as string | null) || 'Sob consulta'] as [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'; @@ -94,12 +113,17 @@ const SalaFormModal: React.FC<{ sala: Sala | null; onClose: () => void; onSaved: const ws = HybridBackend.getActiveWorkspace(); const [saving, setSaving] = useState(false); const [vincularUnidade, setVincularUnidade] = useState(sala ? !!sala.clinica_id : !!ws?.id); + const [areas, setAreas] = useState<{ slug: string; nome: string }[]>([]); + useEffect(() => { HybridBackend.getAreas().then(setAreas); }, []); const [f, setF] = useState({ nome: sala?.nome || '', tipo: sala?.tipo || 'independente', descricao: sala?.descricao || '', equipamentos: sala?.equipamentos || '', endereco: sala?.endereco || '', bairro: sala?.bairro || '', - cidade: sala?.cidade || '', estado: sala?.estado || '', cep: sala?.cep || '', + cidade: sala?.cidade || '', estado: sala?.estado || '', cep: sala?.cep || '', area: sala?.area || '', valorHora: sala?.valor_hora || '', valorTurno: sala?.valor_turno || '', valorDiaria: sala?.valor_diaria || '', valorSemanal: sala?.valor_semanal || '', valorMensal: sala?.valor_mensal || '', + horaConsulta: sala?.hora_consulta === true, turnoConsulta: sala?.turno_consulta === true, + diariaConsulta: sala?.diaria_consulta === true, semanalConsulta: sala?.semanal_consulta === true, + mensalConsulta: sala?.mensal_consulta === true, disponivel: sala?.disponivel !== false, }); const set = (k: string, v: any) => setF(p => ({ ...p, [k]: v })); @@ -201,12 +225,23 @@ const SalaFormModal: React.FC<{ sala: Sala | null; onClose: () => void; onSaved: )}
-

Valores de locação (R$) — preencha os modelos oferecidos

-
- {([['valorHora', 'HORA'], ['valorTurno', 'TURNO'], ['valorDiaria', 'DIÁRIA'], ['valorSemanal', 'SEMANAL'], ['valorMensal', 'MENSAL']] as const).map(([k, l]) => ( -
- - set(k, e.target.value)} placeholder="—" /> + + +
+
+

Valores de locação — fixo (R$) ou "sob consulta" por modalidade

+
+ {([['valorHora', 'horaConsulta', 'HORA'], ['valorTurno', 'turnoConsulta', 'TURNO'], ['valorDiaria', 'diariaConsulta', 'DIÁRIA'], ['valorSemanal', 'semanalConsulta', 'SEMANAL'], ['valorMensal', 'mensalConsulta', 'MENSAL']] as const).map(([vk, ck, l]) => ( +
+ + set(vk, e.target.value)} + className={`${inputCls} flex-1 ${(f as any)[ck] ? 'opacity-40' : ''}`} placeholder={(f as any)[ck] ? 'sob consulta' : '—'} /> +
))}
@@ -367,6 +402,7 @@ const SalaCard: React.FC<{ sala: Sala; mine?: boolean; onReserve?: () => void; o
{sala.nome} {meta.label} + {sala.area && AREA_META[sala.area] && {AREA_META[sala.area].nome}} {mine && sala.disponivel === false && OCULTA} {mine && (sala.reservas_pendentes || 0) > 0 && {sala.reservas_pendentes} PENDENTE{sala.reservas_pendentes! > 1 ? 'S' : ''}}
@@ -472,6 +508,9 @@ export const SalasPlugin: React.FC = () => { const [fCidade, setFCidade] = useState(''); const [fCep, setFCep] = useState(''); const [fRaio, setFRaio] = useState(''); + const [fArea, setFArea] = useState(''); + const [areas, setAreas] = useState<{ slug: string; nome: string }[]>([]); + useEffect(() => { HybridBackend.getAreas().then(setAreas); }, []); // minhas const [minhas, setMinhas] = useState([]); // reservas @@ -511,6 +550,7 @@ export const SalasPlugin: React.FC = () => { if (fTipo) params.set('tipo', fTipo); if (fEstado) params.set('estado', fEstado); if (fCidade) params.set('cidade', fCidade); + if (fArea) params.set('area', fArea); // 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); @@ -520,7 +560,7 @@ export const SalasPlugin: React.FC = () => { if (res.ok) setSalas(await res.json()); } catch { /* silently fail */ } finally { setLoading(false); } - }, [fTipo, fEstado, fCidade, fCep, fRaio]); + }, [fTipo, fEstado, fCidade, fCep, fRaio, fArea]); const fetchMinhas = useCallback(async () => { setLoading(true); @@ -610,7 +650,14 @@ export const SalasPlugin: React.FC = () => { {tab === 'marketplace' && enderecoOk !== false && ( <>
-
+
+
+ + +