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:
@@ -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