feat(salas): área por sala + preço por modalidade (fixo ou sob consulta)

- 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 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-14 07:36:35 +02:00
parent 97783c1e34
commit 92a0ec3917
2 changed files with 81 additions and 18 deletions
+22 -6
View File
@@ -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);
+59 -12
View File
@@ -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<string, { label: string; icon: any }> = {
independente: { label: 'Sala Independente', icon: Home },
};
const AREA_META: Record<string, { nome: string; cor: string }> = {
odontologia: { nome: 'Odontologia', cor: '#2563eb' },
biomedicina: { nome: 'Biomedicina', cor: '#db2777' },
protese: { nome: 'Prótese', cor: '#7c3aed' },
};
const STATUS_STYLE: Record<string, string> = {
pendente: 'bg-amber-100 text-amber-700',
confirmada: 'bg-emerald-100 text-emerald-700',
@@ -81,9 +93,16 @@ const STATUS_STYLE: Record<string, string> = {
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:
</label>
)}
<div>
<p className={labelCls}>Valores de locação (R$) preencha os modelos oferecidos</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{([['valorHora', 'HORA'], ['valorTurno', 'TURNO'], ['valorDiaria', 'DIÁRIA'], ['valorSemanal', 'SEMANAL'], ['valorMensal', 'MENSAL']] as const).map(([k, l]) => (
<div key={k}>
<label className={labelCls}>{l}</label>
<input type="number" min="0" className={inputCls} value={(f as any)[k]} onChange={e => set(k, e.target.value)} placeholder="—" />
<label className={labelCls}>Área da sala</label>
<select className={inputCls} value={f.area} onChange={e => set('area', e.target.value)}>
<option value=""> Área (padrão pelo seu perfil) </option>
{areas.map(a => <option key={a.slug} value={a.slug}>{a.nome}</option>)}
</select>
</div>
<div>
<p className={labelCls}>Valores de locação fixo (R$) ou "sob consulta" por modalidade</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{([['valorHora', 'horaConsulta', 'HORA'], ['valorTurno', 'turnoConsulta', 'TURNO'], ['valorDiaria', 'diariaConsulta', 'DIÁRIA'], ['valorSemanal', 'semanalConsulta', 'SEMANAL'], ['valorMensal', 'mensalConsulta', 'MENSAL']] as const).map(([vk, ck, l]) => (
<div key={vk} className="flex items-center gap-2">
<label className={`${labelCls} w-16 mb-0 shrink-0`}>{l}</label>
<input type="number" min="0" disabled={(f as any)[ck]} value={(f as any)[ck] ? '' : (f as any)[vk]} onChange={e => set(vk, e.target.value)}
className={`${inputCls} flex-1 ${(f as any)[ck] ? 'opacity-40' : ''}`} placeholder={(f as any)[ck] ? 'sob consulta' : '—'} />
<label className="flex items-center gap-1 text-[9px] font-bold text-gray-500 uppercase cursor-pointer whitespace-nowrap">
<input type="checkbox" checked={(f as any)[ck]} onChange={e => set(ck, e.target.checked)} className="w-3.5 h-3.5 accent-teal-600" /> sob consulta
</label>
</div>
))}
</div>
@@ -367,6 +402,7 @@ const SalaCard: React.FC<{ sala: Sala; mine?: boolean; onReserve?: () => void; o
<div className="flex items-center gap-2 flex-wrap">
<span className="font-black text-gray-900 text-sm uppercase truncate">{sala.nome}</span>
<span className="text-[9px] font-black px-2 py-0.5 rounded-full uppercase" style={{ backgroundColor: `${COLOR}15`, color: COLOR }}>{meta.label}</span>
{sala.area && AREA_META[sala.area] && <span className="text-[9px] font-black px-2 py-0.5 rounded-full uppercase text-white" style={{ backgroundColor: AREA_META[sala.area].cor }}>{AREA_META[sala.area].nome}</span>}
{mine && sala.disponivel === false && <span className="text-[9px] font-black px-2 py-0.5 rounded-full uppercase bg-gray-100 text-gray-500">OCULTA</span>}
{mine && (sala.reservas_pendentes || 0) > 0 && <span className="text-[9px] font-black px-2 py-0.5 rounded-full uppercase bg-amber-100 text-amber-700">{sala.reservas_pendentes} PENDENTE{sala.reservas_pendentes! > 1 ? 'S' : ''}</span>}
</div>
@@ -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<Sala[]>([]);
// 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 && (
<>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div className="grid grid-cols-2 md:grid-cols-5 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>
</div>
<div>
<label className={labelCls}>Tipo</label>
<select className={inputCls} value={fTipo} onChange={e => setFTipo(e.target.value)}>