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