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
+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)}>