Files
scoreodonto.com/frontend/views/plugins/SalasPlugin.tsx
T
VPS 4 Builder dab74de207 feat(salas): modal de reserva com calendário mensal + seleção de modelo por cartão
Substitui os dois datetime-local crus por um calendário mensal profissional
(navegação de mês, dia selecionável, dias passados desabilitados, dias ocupados
com marcador, destaque de intervalo p/ semanal/mensal). Modelo de locação vira
grade de cartões com o preço de cada (Hora/Turno/Diária/Semanal/Mensal, só os que
a sala oferece). Controles conforme o modelo: hora (início + duração), turno
(manhã/tarde/noite); diária/semanal/mensal calculam o período. Resumo do período
+ ocupados do dia selecionado. início/fim derivados do calendário (backend igual).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 03:34:36 +02:00

985 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback } from 'react';
import {
DoorOpen, MapPin, Search, RefreshCw, Plus, Pencil, Trash2, X, Save,
CalendarClock, CheckCircle2, XCircle, Clock, Building2, Briefcase, Home, Inbox, Send, Eraser, ChevronUp, ChevronLeft, ChevronRight
} from 'lucide-react';
import { PageHeader } from '../../components/PageHeader.tsx';
import { useToast } from '../../contexts/ToastContext.tsx';
import { useConfirm } from '../../contexts/ConfirmContext.tsx';
import { HybridBackend } from '../../services/backend.ts';
import { buscarCep } from '../../services/viacep.ts';
const API_URL = (window as any).__API_URL__ || '';
const UF_LIST = ['', 'AC', 'AL', 'AM', 'AP', 'BA', 'CE', 'DF', 'ES', 'GO', 'MA', 'MG', 'MS', 'MT', 'PA', 'PB', 'PE', 'PI', 'PR', 'RJ', 'RN', 'RO', 'RR', 'RS', 'SC', 'SE', 'SP', 'TO'];
const COLOR = '#0d9488'; // teal — identidade do plugin
const apiFetch = (path: string, opts: RequestInit = {}) =>
fetch(`${API_URL}${path}`, {
...opts,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}`,
...(opts.headers || {}),
},
});
// ─── Types ───────────────────────────────────────────────────────────────────
interface Sala {
id: string;
nome: string;
tipo: 'clinica' | 'consultorio' | 'independente';
descricao: string | null;
equipamentos: string | null;
endereco: string | null;
bairro: string | null;
cidade: string;
estado: string;
cep: string | null;
valor_hora: string | null;
valor_turno: string | null;
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;
clinica_nome?: string | null;
reservas_pendentes?: number;
dist_km?: number | string | null;
}
interface Reserva {
id: string;
sala_id: string;
sala_nome?: string;
solicitante_nome?: string;
owner_nome?: string;
cidade?: string;
estado?: string;
inicio: string;
fim: string;
modelo: string;
valor?: number | string | null;
status: 'pendente' | 'confirmada' | 'recusada' | 'cancelada';
observacoes?: string | null;
clinica_nome?: string | null;
profissional_nome?: string | null;
}
const TIPO_META: Record<string, { label: string; icon: any }> = {
clinica: { label: 'Sala Clínica', icon: Building2 },
consultorio: { label: 'Sala Consultório', icon: Briefcase },
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',
recusada: 'bg-red-100 text-red-600',
cancelada: 'bg-gray-100 text-gray-500',
};
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): 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]);
};
// ── Calendário de reserva ────────────────────────────────────────────────────
const MODELOS: Array<{ id: string; label: string; valor: keyof Sala; consulta: keyof Sala }> = [
{ id: 'hora', label: 'Hora', valor: 'valor_hora', consulta: 'hora_consulta' },
{ id: 'turno', label: 'Turno', valor: 'valor_turno', consulta: 'turno_consulta' },
{ id: 'diaria', label: 'Diária', valor: 'valor_diaria', consulta: 'diaria_consulta' },
{ id: 'semanal', label: 'Semanal', valor: 'valor_semanal', consulta: 'semanal_consulta' },
{ id: 'mensal', label: 'Mensal', valor: 'valor_mensal', consulta: 'mensal_consulta' },
];
const TURNOS: Record<string, { label: string; ini: number; fim: number }> = {
manha: { label: 'Manhã', ini: 8, fim: 12 },
tarde: { label: 'Tarde', ini: 13, fim: 18 },
noite: { label: 'Noite', ini: 18, fim: 22 },
};
const WEEKDAYS = ['D', 'S', 'T', 'Q', 'Q', 'S', 'S'];
const MESES = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'];
const pad2 = (n: number) => String(n).padStart(2, '0');
const toLocalInput = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
const mesmoDia = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
// Células do mês (com padding p/ alinhar ao dia da semana).
const monthGrid = (ref: Date): (Date | null)[] => {
const first = new Date(ref.getFullYear(), ref.getMonth(), 1);
const dias = new Date(ref.getFullYear(), ref.getMonth() + 1, 0).getDate();
const cells: (Date | null)[] = [];
for (let i = 0; i < first.getDay(); i++) cells.push(null);
for (let d = 1; d <= dias; d++) cells.push(new Date(ref.getFullYear(), ref.getMonth(), d));
return cells;
};
// Início/fim conforme o modelo escolhido.
const computeRange = (dia: Date, modelo: string, hora: string, qtd: number, turno: string): { ini: Date; fim: Date } => {
const b = new Date(dia); b.setHours(0, 0, 0, 0);
const ini = new Date(b), fim = new Date(b);
if (modelo === 'hora') {
const [h, m] = hora.split(':').map(Number); ini.setHours(h || 8, m || 0, 0, 0);
fim.setTime(ini.getTime() + Math.max(1, qtd) * 3600_000);
} else if (modelo === 'turno') {
const t = TURNOS[turno] || TURNOS.manha; ini.setHours(t.ini, 0, 0, 0); fim.setHours(t.fim, 0, 0, 0);
} else if (modelo === 'semanal') {
ini.setHours(8, 0, 0, 0); fim.setDate(fim.getDate() + 6); fim.setHours(18, 0, 0, 0);
} else if (modelo === 'mensal') {
ini.setHours(8, 0, 0, 0); fim.setDate(fim.getDate() + 29); fim.setHours(18, 0, 0, 0);
} else { // diária
ini.setHours(8, 0, 0, 0); fim.setHours(18, 0, 0, 0);
}
return { ini, fim };
};
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';
// Estilo padrão de filtros — fonte única em components/filterStyles.
import { inputNew, labelNew } from '../../components/filterStyles.tsx';
// ─── Modal: cadastrar / editar sala ──────────────────────────────────────────
const SalaFormModal: React.FC<{ sala: Sala | null; onClose: () => void; onSaved: () => void }> = ({ sala, onClose, onSaved }) => {
const toast = useToast();
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 || '', 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 }));
const handleSave = async () => {
if (!f.nome || !f.cidade || !f.estado) { toast.error('NOME, CIDADE E ESTADO SÃO OBRIGATÓRIOS.'); return; }
setSaving(true);
try {
const body = JSON.stringify({
...f,
clinicaId: vincularUnidade && ws?.id ? ws.id : null,
valorHora: f.valorHora === '' ? null : +f.valorHora, valorTurno: f.valorTurno === '' ? null : +f.valorTurno,
valorDiaria: f.valorDiaria === '' ? null : +f.valorDiaria, valorSemanal: f.valorSemanal === '' ? null : +f.valorSemanal,
valorMensal: f.valorMensal === '' ? null : +f.valorMensal,
});
const res = sala
? await apiFetch(`/api/salas/${sala.id}`, { method: 'PUT', body })
: await apiFetch('/api/salas', { method: 'POST', body });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Erro ao salvar.');
toast.success(sala ? 'SALA ATUALIZADA!' : 'SALA CADASTRADA!');
// Sala é workspace próprio: atualiza o seletor. Se for a 1ª (sem workspace ativo), recarrega p/ assumir contexto.
const semWs = !HybridBackend.getActiveWorkspace();
await HybridBackend.refreshWorkspaces();
if (!sala && semWs) { window.location.reload(); return; }
onSaved();
onClose();
} catch (e: any) { toast.error(e.message.toUpperCase()); }
finally { setSaving(false); }
};
return (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl w-full max-w-2xl shadow-2xl overflow-hidden max-h-[92vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between sticky top-0 bg-white">
<h3 className="text-sm font-black text-gray-900 uppercase">{sala ? 'EDITAR SALA' : 'CADASTRAR SALA'}</h3>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl transition-colors"><X size={18} /></button>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="md:col-span-2">
<label className={labelCls}>Nome da sala *</label>
<input className={inputCls} value={f.nome} onChange={e => set('nome', e.target.value)} placeholder="EX: SALA 2 — EQUIPADA P/ HOF" />
</div>
<div>
<label className={labelCls}>Tipo</label>
<select className={inputCls} value={f.tipo} onChange={e => set('tipo', e.target.value)}>
<option value="clinica">SALA CLÍNICA</option>
<option value="consultorio">SALA CONSULTÓRIO</option>
<option value="independente">SALA INDEPENDENTE</option>
</select>
</div>
<div>
<label className={labelCls}>Disponível p/ locação</label>
<select className={inputCls} value={f.disponivel ? '1' : '0'} onChange={e => set('disponivel', e.target.value === '1')}>
<option value="1">SIM</option>
<option value="0">NÃO</option>
</select>
</div>
<div className="md:col-span-2">
<label className={labelCls}>Descrição</label>
<textarea className={inputCls} rows={2} value={f.descricao} onChange={e => set('descricao', e.target.value)} placeholder="Detalhes da sala, regras de uso..." />
</div>
<div className="md:col-span-2">
<label className={labelCls}>Equipamentos</label>
<input className={inputCls} value={f.equipamentos} onChange={e => set('equipamentos', e.target.value)} placeholder="CADEIRA, FOTOPOLIMERIZADOR, RX..." />
</div>
<div className="md:col-span-2">
<label className={labelCls}>Endereço</label>
<input className={inputCls} value={f.endereco} onChange={e => set('endereco', e.target.value)} placeholder="RUA / NÚMERO / COMPLEMENTO" />
</div>
<div>
<label className={labelCls}>Bairro</label>
<input className={`${inputCls} uppercase`} value={f.bairro} onChange={e => set('bairro', e.target.value.toUpperCase())} />
</div>
<div>
<label className={labelCls}>CEP</label>
<input className={inputCls} value={f.cep} onChange={async e => {
const v = e.target.value;
set('cep', v);
if (v.replace(/\D/g, '').length === 8) {
const r = await buscarCep(v);
if (r) { setF(p => ({ ...p, endereco: r.logradouro, bairro: r.bairro, cidade: r.cidade, estado: r.estado })); toast.success('ENDEREÇO PREENCHIDO PELO CEP!'); }
else toast.error('CEP NÃO ENCONTRADO.');
}
}} placeholder="79000-000" />
</div>
<div>
<label className={labelCls}>Cidade *</label>
<input className={`${inputCls} uppercase`} value={f.cidade} onChange={e => set('cidade', e.target.value.toUpperCase())} />
</div>
<div>
<label className={labelCls}>Estado *</label>
<select className={inputCls} value={f.estado} onChange={e => set('estado', e.target.value)}>
{UF_LIST.map(uf => <option key={uf} value={uf}>{uf || 'SELECIONE'}</option>)}
</select>
</div>
</div>
{ws?.id && (
<label className="flex items-center gap-3 p-3 rounded-xl border border-gray-100 bg-gray-50/60 cursor-pointer">
<input type="checkbox" checked={vincularUnidade} onChange={e => setVincularUnidade(e.target.checked)} className="w-4 h-4 accent-teal-600" />
<span className="text-[11px] font-black text-gray-600 uppercase">Anunciar pela unidade: {ws.nome}</span>
</label>
)}
<div>
<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>
</div>
</div>
<div className="px-6 pb-6 flex gap-3">
<button onClick={onClose} className="flex-1 py-3 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-500 hover:bg-gray-50 transition-colors">CANCELAR</button>
<button onClick={handleSave} disabled={saving} className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase transition-colors flex items-center justify-center gap-2 disabled:opacity-60" style={{ backgroundColor: COLOR }}>
{saving ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />} SALVAR
</button>
</div>
</div>
</div>
);
};
// ─── Modal: reservar sala ────────────────────────────────────────────────────
const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () => void }> = ({ sala, onClose, onReserved }) => {
const toast = useToast();
const ws = HybridBackend.getActiveWorkspace();
const [saving, setSaving] = useState(false);
const [ocupadas, setOcupadas] = useState<Reserva[]>([]);
const [f, setF] = useState({ inicio: '', fim: '', modelo: 'hora', observacoes: '' });
// Reserva institucional: em nome da unidade ativa, com profissional designado opcional.
const [comoUnidade, setComoUnidade] = useState(false);
const [profissionais, setProfissionais] = useState<Array<{ usuario_id: string; nome: string }>>([]);
const [profissionalId, setProfissionalId] = useState('');
// ── Calendário de reserva ──
const hoje0 = new Date(); hoje0.setHours(0, 0, 0, 0);
const [mesRef, setMesRef] = useState(new Date(hoje0.getFullYear(), hoje0.getMonth(), 1));
const [diaSel, setDiaSel] = useState<Date | null>(null);
const [hora, setHora] = useState('08:00');
const [qtd, setQtd] = useState(1);
const [turno, setTurno] = useState('manha');
const HORAS = Array.from({ length: 29 }, (_, i) => `${pad2(7 + Math.floor(i / 2))}:${i % 2 ? '30' : '00'}`); // 07:0021:00
const modelosDisp = MODELOS.filter(m => fmtValor(sala[m.valor] as any) || sala[m.consulta]);
const diasOcupados = new Set(ocupadas.map(o => new Date(o.inicio).toDateString()));
const ocupadasDoDia = diaSel ? ocupadas.filter(o => new Date(o.inicio).toDateString() === diaSel.toDateString()) : [];
const rangeFim = diaSel ? (() => { const d = new Date(diaSel); d.setDate(d.getDate() + (f.modelo === 'mensal' ? 29 : 6)); return d; })() : null;
// Modelo padrão = 1º disponível.
useEffect(() => { if (modelosDisp.length && !modelosDisp.some(m => m.id === f.modelo)) setF(p => ({ ...p, modelo: modelosDisp[0].id })); /* eslint-disable-next-line */ }, []);
// Recalcula início/fim conforme dia + modelo + horário.
useEffect(() => {
if (!diaSel) return;
const { ini, fim } = computeRange(diaSel, f.modelo, hora, qtd, turno);
setF(p => ({ ...p, inicio: toLocalInput(ini), fim: toLocalInput(fim) }));
}, [diaSel, f.modelo, hora, qtd, turno]);
useEffect(() => {
apiFetch(`/api/salas/${sala.id}/reservas`)
.then(r => (r.ok ? r.json() : []))
.then(setOcupadas)
.catch(() => {});
}, [sala.id]);
useEffect(() => {
if (!comoUnidade || !ws?.id || profissionais.length) return;
apiFetch(`/api/dentistas?clinicaId=${encodeURIComponent(ws.id)}`)
.then(r => (r.ok ? r.json() : []))
.then((ds: any[]) => setProfissionais(ds.filter(d => d.usuario_id).map(d => ({ usuario_id: d.usuario_id, nome: d.nome }))))
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [comoUnidade]);
const handleReserve = async () => {
if (!f.inicio || !f.fim) { toast.error('INFORME INÍCIO E FIM.'); return; }
setSaving(true);
try {
const res = await apiFetch(`/api/salas/${sala.id}/reservas`, {
method: 'POST',
body: JSON.stringify({
inicio: new Date(f.inicio).toISOString(), fim: new Date(f.fim).toISOString(), modelo: f.modelo, observacoes: f.observacoes || null,
clinicaId: comoUnidade && ws?.id ? ws.id : null,
profissionalUsuarioId: comoUnidade && profissionalId ? profissionalId : null,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Erro ao reservar.');
toast.success('RESERVA SOLICITADA! AGUARDE A CONFIRMAÇÃO DO LOCADOR.');
onReserved();
onClose();
} catch (e: any) { toast.error(e.message.toUpperCase()); }
finally { setSaving(false); }
};
return (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden max-h-[92vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<div>
<h3 className="text-sm font-black text-gray-900 uppercase">RESERVAR {sala.nome}</h3>
<p className="text-[10px] font-bold uppercase tracking-widest" style={{ color: COLOR }}>{[sala.cidade, sala.estado].filter(Boolean).join(' · ')}</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl transition-colors"><X size={18} /></button>
</div>
<div className="p-6 space-y-4">
{ws?.id && (
<div>
<label className={labelCls}>Reservar como</label>
<div className="flex gap-2">
{[{ v: false, l: 'PESSOAL' }, { v: true, l: ws.nome?.toUpperCase() || 'UNIDADE' }].map(o => (
<button key={String(o.v)} type="button" onClick={() => setComoUnidade(o.v)}
className={`flex-1 py-2.5 rounded-xl text-[10px] font-black uppercase border transition-all truncate px-2 ${comoUnidade === o.v ? 'text-white border-transparent' : 'bg-gray-50 text-gray-500 border-gray-200 hover:bg-gray-100'}`}
style={comoUnidade === o.v ? { backgroundColor: COLOR } : {}}>
{o.l}
</button>
))}
</div>
</div>
)}
{comoUnidade && (
<div>
<label className={labelCls}>Profissional que vai usar a sala (opcional)</label>
<select className={inputCls} value={profissionalId} onChange={e => setProfissionalId(e.target.value)}>
<option value="">EU MESMO ({HybridBackend.getCurrentUserName()?.toUpperCase() || 'SOLICITANTE'})</option>
{profissionais.map(p => <option key={p.usuario_id} value={p.usuario_id}>{p.nome?.toUpperCase()}</option>)}
</select>
<p className="text-[9px] text-gray-400 font-bold uppercase mt-1">O anti-conflito verifica a agenda do profissional selecionado</p>
</div>
)}
{/* Modelo de locação — cartões com preço */}
<div>
<label className={labelCls}>Modelo de locação</label>
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2 mt-1">
{(modelosDisp.length ? modelosDisp : [MODELOS[0]]).map(m => {
const ativo = f.modelo === m.id;
const preco = fmtValor(sala[m.valor] as any) || (sala[m.consulta] ? 'sob consulta' : '—');
return (
<button key={m.id} type="button" onClick={() => setF(p => ({ ...p, modelo: m.id }))}
className={`p-2.5 rounded-xl border text-center transition-all ${ativo ? 'text-white border-transparent shadow-sm' : 'bg-gray-50 text-gray-600 border-gray-200 hover:bg-gray-100'}`}
style={ativo ? { backgroundColor: COLOR } : {}}>
<div className="text-[11px] font-black uppercase leading-none">{m.label}</div>
<div className={`text-[9px] font-bold mt-1 leading-none ${ativo ? 'text-white/80' : 'text-gray-400'}`}>{preco}</div>
</button>
);
})}
</div>
</div>
{/* Calendário mensal */}
<div className="rounded-2xl border border-gray-100 p-4">
<div className="flex items-center justify-between mb-3">
<button type="button" onClick={() => setMesRef(m => new Date(m.getFullYear(), m.getMonth() - 1, 1))} className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-500"><ChevronLeft size={18} /></button>
<span className="text-xs font-black text-gray-800 uppercase tracking-wide">{MESES[mesRef.getMonth()]} {mesRef.getFullYear()}</span>
<button type="button" onClick={() => setMesRef(m => new Date(m.getFullYear(), m.getMonth() + 1, 1))} className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-500"><ChevronRight size={18} /></button>
</div>
<div className="grid grid-cols-7 gap-1 mb-1">
{WEEKDAYS.map((w, i) => <div key={i} className="text-center text-[9px] font-black text-gray-300 uppercase py-1">{w}</div>)}
</div>
<div className="grid grid-cols-7 gap-1">
{monthGrid(mesRef).map((dia, i) => {
if (!dia) return <div key={i} />;
const passado = dia < hoje0;
const sel = !!(diaSel && mesmoDia(dia, diaSel));
const emRange = !!(diaSel && rangeFim && (f.modelo === 'semanal' || f.modelo === 'mensal') && dia > diaSel && dia <= rangeFim);
const ocupado = diasOcupados.has(dia.toDateString());
return (
<button key={i} type="button" disabled={passado} onClick={() => setDiaSel(dia)}
className={`relative aspect-square rounded-lg text-xs font-bold flex items-center justify-center transition-all ${passado ? 'text-gray-200 cursor-not-allowed' : sel ? 'text-white shadow' : emRange ? 'text-teal-700' : 'text-gray-600 hover:bg-gray-100'}`}
style={sel ? { backgroundColor: COLOR } : emRange ? { backgroundColor: `${COLOR}22` } : {}}>
{dia.getDate()}
{ocupado && !sel && <span className="absolute bottom-1 w-1 h-1 rounded-full bg-amber-400" />}
</button>
);
})}
</div>
</div>
{/* Controles de horário (conforme o modelo) */}
{diaSel && f.modelo === 'hora' && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>Início</label>
<select className={inputCls} value={hora} onChange={e => setHora(e.target.value)}>
{HORAS.map(h => <option key={h} value={h}>{h}</option>)}
</select>
</div>
<div>
<label className={labelCls}>Duração</label>
<div className="flex items-center gap-2">
<button type="button" onClick={() => setQtd(q => Math.max(1, q - 1))} className="w-9 h-9 rounded-lg border border-gray-200 text-gray-500 font-black text-lg leading-none"></button>
<span className="flex-1 text-center text-sm font-black text-gray-800">{qtd}h</span>
<button type="button" onClick={() => setQtd(q => Math.min(12, q + 1))} className="w-9 h-9 rounded-lg border border-gray-200 text-gray-500 font-black text-lg leading-none">+</button>
</div>
</div>
</div>
)}
{diaSel && f.modelo === 'turno' && (
<div>
<label className={labelCls}>Turno</label>
<div className="grid grid-cols-3 gap-2">
{Object.entries(TURNOS).map(([k, t]) => (
<button key={k} type="button" onClick={() => setTurno(k)}
className={`py-2 rounded-xl text-[11px] font-black uppercase border transition-all ${turno === k ? 'text-white border-transparent' : 'bg-gray-50 text-gray-500 border-gray-200 hover:bg-gray-100'}`}
style={turno === k ? { backgroundColor: COLOR } : {}}>
{t.label}<div className="text-[8px] font-bold opacity-70">{pad2(t.ini)}h{pad2(t.fim)}h</div>
</button>
))}
</div>
</div>
)}
{/* Resumo do período */}
{f.inicio && f.fim && (
<div className="rounded-2xl bg-teal-50 border border-teal-100 p-3.5 flex items-center gap-3">
<CalendarClock size={18} className="text-teal-600 shrink-0" />
<div className="text-xs font-bold text-teal-800 leading-snug">
{fmtData(new Date(f.inicio).toISOString())} <span className="text-teal-400"></span> {fmtData(new Date(f.fim).toISOString())}
</div>
</div>
)}
<div>
<label className={labelCls}>Observações</label>
<textarea className={inputCls} rows={2} value={f.observacoes} onChange={e => setF(p => ({ ...p, observacoes: e.target.value }))} />
</div>
{diaSel && ocupadasDoDia.length > 0 && (
<div className="p-3 rounded-xl bg-amber-50 border border-amber-100">
<p className="text-[9px] font-black text-amber-600 uppercase tracking-widest mb-1.5 flex items-center gap-1"><Clock size={11} /> ocupado neste dia</p>
<div className="space-y-1 max-h-28 overflow-y-auto">
{ocupadasDoDia.map(o => (
<p key={o.id} className="text-[10px] font-bold text-amber-700">
{new Date(o.inicio).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}{new Date(o.fim).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })} ({o.status})
</p>
))}
</div>
</div>
)}
</div>
<div className="px-6 pb-6 flex gap-3">
<button onClick={onClose} className="flex-1 py-3 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-500 hover:bg-gray-50 transition-colors">CANCELAR</button>
<button onClick={handleReserve} disabled={saving} className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase transition-colors flex items-center justify-center gap-2 disabled:opacity-60" style={{ backgroundColor: COLOR }}>
{saving ? <RefreshCw size={14} className="animate-spin" /> : <CalendarClock size={14} />} SOLICITAR RESERVA
</button>
</div>
</div>
</div>
);
};
// ─── Card de sala (marketplace + minhas) ────────────────────────────────────
const SalaCard: React.FC<{ sala: Sala; mine?: boolean; onReserve?: () => void; onEdit?: () => void; onDelete?: () => void }> = ({ sala, mine, onReserve, onEdit, onDelete }) => {
const meta = TIPO_META[sala.tipo] || TIPO_META.independente;
const Icon = meta.icon;
return (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 hover:shadow-md transition-all">
<div className="flex items-start gap-3 mb-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ backgroundColor: `${COLOR}15` }}>
<Icon size={18} style={{ color: COLOR }} />
</div>
<div className="flex-1 min-w-0">
<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>
{!mine && (sala.clinica_nome || sala.owner_nome) && <p className="text-[10px] text-gray-400 font-bold uppercase mt-0.5">POR {sala.clinica_nome || sala.owner_nome}</p>}
{mine && sala.clinica_nome && <p className="text-[10px] text-gray-400 font-bold uppercase mt-0.5">ANUNCIADA POR {sala.clinica_nome}</p>}
</div>
</div>
{sala.descricao && <p className="text-xs text-gray-500 font-medium mb-2 line-clamp-2">{sala.descricao}</p>}
<div className="flex items-center gap-1.5 text-xs text-gray-500 font-medium mb-2">
<MapPin size={12} className="shrink-0 text-gray-400" />
<span>{[sala.endereco, sala.bairro, sala.cidade, sala.estado].filter(Boolean).join(' · ')}</span>
{sala.dist_km != null && <span className="ml-auto shrink-0 text-[9px] font-black px-2 py-0.5 rounded-full" style={{ backgroundColor: `${COLOR}15`, color: COLOR }}>~{sala.dist_km} KM</span>}
</div>
{sala.equipamentos && (
<p className="text-[10px] text-gray-400 font-bold uppercase mb-3">EQUIP.: {sala.equipamentos}</p>
)}
<div className="flex flex-wrap gap-1.5 mb-4">
{precos(sala).map(([l, v]) => (
<span key={l} className="text-[9px] font-black px-2 py-1 rounded-lg bg-gray-50 border border-gray-100 text-gray-600 uppercase">{l}: {fmtValor(v)}</span>
))}
{precos(sala).length === 0 && <span className="text-[9px] font-bold text-gray-300 uppercase">Valores a combinar</span>}
</div>
<div className="flex gap-2 pt-2 border-t border-gray-50">
{mine ? (
<>
<button onClick={onEdit} className="flex-1 py-2.5 rounded-xl border border-gray-200 text-gray-500 hover:bg-gray-50 text-[10px] font-black uppercase flex items-center justify-center gap-1.5 transition-colors"><Pencil size={12} /> EDITAR</button>
<button onClick={onDelete} className="px-4 py-2.5 rounded-xl border border-red-100 text-red-400 hover:bg-red-50 transition-colors"><Trash2 size={14} /></button>
</>
) : (
<button onClick={onReserve} className="flex-1 py-2.5 rounded-xl text-white text-[10px] font-black uppercase flex items-center justify-center gap-1.5 transition-colors hover:opacity-90" style={{ backgroundColor: COLOR }}>
<CalendarClock size={13} /> RESERVAR
</button>
)}
</div>
</div>
);
};
// ─── Lista de reservas ───────────────────────────────────────────────────────
const ReservaRow: React.FC<{ r: Reserva; recebida?: boolean; onAction: (acao: 'confirmar' | 'recusar' | 'cancelar') => void }> = ({ r, recebida, onAction }) => (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex flex-col md:flex-row md:items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-black text-gray-900 text-xs uppercase">{r.sala_nome || r.sala_id}</span>
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${STATUS_STYLE[r.status] || STATUS_STYLE.cancelada}`}>{r.status}</span>
<span className="text-[9px] font-black px-2 py-0.5 rounded-full uppercase bg-gray-50 text-gray-500">{r.modelo}</span>
</div>
<p className="text-[11px] text-gray-500 font-bold mt-1">{fmtData(r.inicio)} {fmtData(r.fim)}</p>
<p className="text-[10px] text-gray-400 font-medium uppercase mt-0.5">
{recebida
? <>SOLICITANTE: {r.clinica_nome ? `${r.clinica_nome} (${r.solicitante_nome || '—'})` : (r.solicitante_nome || '—')}</>
: <>LOCADOR: {r.owner_nome || '—'} · {[r.cidade, r.estado].filter(Boolean).join('/')}{r.clinica_nome ? <> · EM NOME DE {r.clinica_nome}</> : null}</>}
</p>
{r.profissional_nome && <p className="text-[10px] text-gray-400 font-medium uppercase mt-0.5">PROFISSIONAL: {r.profissional_nome}</p>}
{r.observacoes && <p className="text-[10px] text-gray-400 mt-0.5">OBS: {r.observacoes}</p>}
</div>
{['pendente', 'confirmada'].includes(r.status) && (
<div className="flex gap-2 shrink-0">
{recebida && r.status === 'pendente' && (
<>
<button onClick={() => onAction('confirmar')} className="px-3 py-2 rounded-xl bg-emerald-500 text-white text-[10px] font-black uppercase flex items-center gap-1 hover:bg-emerald-600 transition-colors"><CheckCircle2 size={12} /> CONFIRMAR</button>
<button onClick={() => onAction('recusar')} className="px-3 py-2 rounded-xl border border-red-200 text-red-500 text-[10px] font-black uppercase flex items-center gap-1 hover:bg-red-50 transition-colors"><XCircle size={12} /> RECUSAR</button>
</>
)}
<button onClick={() => onAction('cancelar')} className="px-3 py-2 rounded-xl border border-gray-200 text-gray-400 text-[10px] font-black uppercase hover:bg-gray-50 transition-colors">CANCELAR</button>
</div>
)}
</div>
);
// ─── Main view ───────────────────────────────────────────────────────────────
type Tab = 'marketplace' | 'minhas' | 'reservas';
// Bloqueio quando falta endereço/CEP da origem (clínica ou perfil) — a busca é por proximidade.
const GateEndereco: React.FC<{ isClinic: boolean }> = ({ isClinic }) => (
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm p-8 text-center">
<div className="w-14 h-14 rounded-2xl bg-amber-50 flex items-center justify-center mx-auto mb-4">
<MapPin size={26} className="text-amber-500" />
</div>
<h3 className="text-base font-black uppercase tracking-tight text-gray-900">Complete seu endereço</h3>
<p className="text-sm text-gray-500 mt-2 max-w-md mx-auto leading-relaxed">
A busca de salas é por proximidade. Preencha {isClinic
? 'a localização e o CEP da sua clínica/consultório (em Configurações)'
: 'a localização e o CEP do seu perfil (em Profissionais → Meu Perfil)'} para continuar.
</p>
<button onClick={() => window.location.assign(isClinic ? '/configuracoes' : '/marketplace-profissionais')}
className="mt-5 inline-flex items-center gap-2 px-5 py-3 rounded-xl text-white text-xs font-black uppercase tracking-widest hover:opacity-90 transition-opacity"
style={{ backgroundColor: COLOR }}>
{isClinic ? 'Ir para Configurações' : 'Completar meu perfil'}
</button>
</div>
);
export const SalasPlugin: React.FC<{ view?: 'minhas' | 'alugar' }> = ({ view }) => {
const toast = useToast();
const confirm = useConfirm();
const [tab, setTab] = useState<Tab>(view === 'minhas' ? 'minhas' : 'marketplace');
const [loading, setLoading] = useState(false);
// marketplace
const [salas, setSalas] = useState<Sala[]>([]);
const [fTipo, setFTipo] = useState('');
const [fEstado, setFEstado] = useState('');
const [fCidade, setFCidade] = useState('');
const [fCep, setFCep] = useState('');
const [fRaio, setFRaio] = useState('');
const [fArea, setFArea] = useState('');
const [fBusca, setFBusca] = useState('');
const [showFiltros, setShowFiltros] = useState(true);
const [areas, setAreas] = useState<{ slug: string; nome: string }[]>([]);
useEffect(() => { HybridBackend.getAreas().then(setAreas); }, []);
const limparFiltros = () => { setFTipo(''); setFEstado(''); setFCidade(''); setFCep(''); setFRaio(''); setFArea(''); setFBusca(''); };
// Busca por texto (nome/cidade/bairro) aplicada sobre o resultado do backend.
const salasFiltradas = fBusca.trim()
? salas.filter(s => `${s.nome} ${(s as any).cidade || ''} ${(s as any).bairro || ''}`.toLowerCase().includes(fBusca.trim().toLowerCase()))
: salas;
// minhas
const [minhas, setMinhas] = useState<Sala[]>([]);
// reservas
const [reservas, setReservas] = useState<{ feitas: Reserva[]; recebidas: Reserva[] }>({ feitas: [], recebidas: [] });
const [editingSala, setEditingSala] = useState<Sala | null | 'new'>(null);
const [reservingSala, setReservingSala] = useState<Sala | null>(null);
// Endereço da origem (clínica ou perfil) preenchido? null = checando.
const role = HybridBackend.getCurrentRole();
const ws = HybridBackend.getActiveWorkspace();
const isProfissional = ['dentista', 'biomedico', 'protetico'].includes(role);
const [enderecoOk, setEnderecoOk] = useState<boolean | null>(null);
const checkEndereco = useCallback(async () => {
try {
if (isProfissional) {
const res = await apiFetch('/api/profissionais/perfil');
const p = res.ok ? await res.json() : {};
setEnderecoOk(!!(p?.cep && p?.cidade && p?.estado));
} else if (ws?.id) {
const r = await HybridBackend.getClinicaProfile(ws.id);
const c: any = r?.clinica || {};
setEnderecoOk(!!(c.cep && c.cidade && c.estado));
} else {
setEnderecoOk(true);
}
} catch { setEnderecoOk(true); }
}, [isProfissional, ws?.id]);
useEffect(() => { checkEndereco(); }, [checkEndereco]);
const fetchMarketplace = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
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);
if (fCep) params.set('cep', fCep); // CEP digitado sobrepõe a origem automática
if (fCep && Number(fRaio) > 0) params.set('raio', fRaio);
const res = await apiFetch(`/api/salas/marketplace?${params}`);
if (res.ok) setSalas(await res.json());
} catch { /* silently fail */ }
finally { setLoading(false); }
}, [fTipo, fEstado, fCidade, fCep, fRaio, fArea]);
const fetchMinhas = useCallback(async () => {
setLoading(true);
try { const res = await apiFetch('/api/salas/minhas'); if (res.ok) setMinhas(await res.json()); }
catch { /* silently fail */ }
finally { setLoading(false); }
}, []);
const fetchReservas = useCallback(async () => {
setLoading(true);
try {
// Inclui as reservas da unidade ativa (feitas por qualquer membro), além das pessoais.
const wsId = HybridBackend.getActiveWorkspace()?.id;
const res = await apiFetch(`/api/sala-reservas/minhas${wsId ? `?clinicaId=${encodeURIComponent(wsId)}` : ''}`);
if (res.ok) setReservas(await res.json());
}
catch { /* silently fail */ }
finally { setLoading(false); }
}, []);
// Trocar de rota (/alugar-salas ↔ /minhas-salas) reaproveita este componente e
// só muda a prop `view` — o tab do useState não reage sozinho. Reseta a aba p/ a
// do contexto novo (senão a URL muda mas o conteúdo continua na aba anterior).
useEffect(() => { setTab(view === 'minhas' ? 'minhas' : 'marketplace'); }, [view]);
useEffect(() => {
if (tab === 'marketplace') fetchMarketplace();
if (tab === 'minhas') fetchMinhas();
if (tab === 'reservas') fetchReservas();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab]);
const handleDelete = async (sala: Sala) => {
if (!await confirm(`EXCLUIR A SALA "${sala.nome}"?`)) return;
try {
const res = await apiFetch(`/api/salas/${sala.id}`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Erro ao excluir.');
toast.success('SALA EXCLUÍDA.');
// Se a sala excluída era o workspace ativo, sai do contexto dela.
if (HybridBackend.getActiveWorkspace()?.id === sala.id) { localStorage.removeItem('SCOREODONTO_ACTIVE_WORKSPACE'); await HybridBackend.refreshWorkspaces(); window.location.reload(); return; }
await HybridBackend.refreshWorkspaces();
fetchMinhas();
} catch (e: any) { toast.error(e.message.toUpperCase()); }
};
const handleReservaAction = async (r: Reserva, acao: 'confirmar' | 'recusar' | 'cancelar') => {
if (acao === 'cancelar' && !await confirm('CANCELAR ESTA RESERVA?')) return;
try {
const res = await apiFetch(`/api/sala-reservas/${r.id}`, { method: 'PUT', body: JSON.stringify({ acao }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Erro.');
toast.success(`RESERVA ${data.status?.toUpperCase()}.`);
fetchReservas();
} catch (e: any) { toast.error(e.message.toUpperCase()); }
};
// Dois modos: 'minhas' (administrar/publicar) e 'alugar' (marketplace). Sem prop = tudo (legado).
const ALL_TABS: Array<{ id: Tab; label: string; icon: any }> = [
{ id: 'marketplace', label: 'MARKETPLACE', icon: Search },
{ id: 'minhas', label: 'MINHAS SALAS', icon: DoorOpen },
{ id: 'reservas', label: 'RESERVAS', icon: CalendarClock },
];
const TABS = view === 'minhas' ? ALL_TABS.filter(t => t.id === 'minhas' || t.id === 'reservas')
: view === 'alugar' ? ALL_TABS.filter(t => t.id === 'marketplace' || t.id === 'reservas')
: ALL_TABS;
const pendentesRecebidas = reservas.recebidas.filter(r => r.status === 'pendente').length;
return (
<div className="space-y-8 animate-in fade-in duration-500">
<PageHeader
title={view === 'minhas' ? 'MINHAS SALAS' : view === 'alugar' ? 'ALUGAR SALAS' : 'LOCAÇÃO DE SALAS'}
description={view === 'minhas' ? 'Publique e administre suas salas, preços, disponibilidade e reservas recebidas.'
: view === 'alugar' ? 'Busque e reserve salas equipadas de outros profissionais.'
: 'Alugue salas equipadas ou disponibilize as suas para outros profissionais.'}
/>
{/* Tabs — segmented control: no mobile ocupa a largura toda e distribui igual;
no desktop encolhe ao conteúdo. */}
{TABS.length > 1 && (
<div className="flex gap-1 bg-gray-100/80 p-1 rounded-2xl w-full sm:w-fit">
{TABS.map(t => {
const Icon = t.icon;
const active = tab === t.id;
return (
<button key={t.id} onClick={() => setTab(t.id)}
className={`flex-1 sm:flex-none flex items-center justify-center gap-2 px-3 sm:px-5 py-2.5 rounded-xl text-[11px] font-black uppercase tracking-wider transition-all ${active ? 'bg-white shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
style={active ? { color: COLOR } : {}}>
<Icon size={14} className="shrink-0" /> <span className="truncate">{t.label}</span>
{t.id === 'reservas' && pendentesRecebidas > 0 && (
<span className="text-[9px] font-black px-1.5 py-0.5 rounded-full text-white" style={{ backgroundColor: COLOR }}>{pendentesRecebidas}</span>
)}
</button>
);
})}
</div>
)}
{/* MARKETPLACE */}
{tab === 'marketplace' && enderecoOk === false && (
<GateEndereco isClinic={!isProfissional} />
)}
{tab === 'marketplace' && enderecoOk !== false && (
<>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
{/* Busca por nome/cidade/bairro (sobre o resultado) */}
<div className="relative mb-5">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
<input
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-200 rounded-2xl text-sm font-medium text-gray-700 outline-none focus:border-teal-400 focus:bg-white transition-colors"
value={fBusca} onChange={e => setFBusca(e.target.value)}
placeholder="Buscar sala por nome, cidade ou bairro..." />
</div>
{showFiltros && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className={labelNew}>Área</label>
<select className={inputNew} 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={labelNew}>Tipo</label>
<select className={inputNew} value={fTipo} onChange={e => setFTipo(e.target.value)}>
<option value="">Todos</option>
<option value="clinica">Sala Clínica</option>
<option value="consultorio">Sala Consultório</option>
<option value="independente">Sala Independente</option>
</select>
</div>
<div>
<label className={labelNew}>Estado</label>
<select className={inputNew} value={fEstado} onChange={e => setFEstado(e.target.value)}>
{UF_LIST.map(uf => <option key={uf} value={uf}>{uf || 'Todos'}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:items-end">
<div>
<label className={labelNew}>Cidade</label>
<input className={`${inputNew} uppercase`} value={fCidade} onChange={e => setFCidade(e.target.value.toUpperCase())} placeholder="CAMPO GRANDE" />
</div>
<div>
<label className={labelNew}>Meu CEP</label>
<input className={inputNew} value={fCep} onChange={e => setFCep(e.target.value)} placeholder="79000-000" />
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className={`${labelNew} mb-0`}>Raio de Busca</label>
<span className="text-[10px] font-black px-2 py-0.5 rounded-full bg-teal-100 text-teal-700">{fRaio || 0} KM</span>
</div>
<input type="range" min="0" max="200" step="5" value={fRaio || 0}
onChange={e => setFRaio(e.target.value === '0' ? '' : e.target.value)}
className="w-full accent-teal-600 cursor-pointer" />
{!fCep && <p className="text-[10px] text-gray-400 mt-1">Informe o CEP para filtrar por raio.</p>}
</div>
</div>
</div>
)}
<div className="flex items-center justify-between mt-5 pt-4 border-t border-gray-50">
<button onClick={() => setShowFiltros(s => !s)}
className="flex items-center gap-1.5 text-xs font-bold text-gray-500 hover:text-gray-700 transition-colors">
<ChevronUp size={14} className={`transition-transform ${showFiltros ? '' : 'rotate-180'}`} />
{showFiltros ? 'Ocultar Filtros' : 'Mostrar Filtros'}
</button>
<div className="flex items-center gap-4">
<button onClick={limparFiltros} className="flex items-center gap-1.5 text-xs font-bold text-gray-500 hover:text-gray-700 transition-colors">
<Eraser size={13} /> Limpar Filtros
</button>
<button onClick={fetchMarketplace} disabled={loading}
className="flex items-center gap-2 px-6 py-3 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-opacity disabled:opacity-60 hover:opacity-90" style={{ backgroundColor: COLOR }}>
{loading ? <RefreshCw size={14} className="animate-spin" /> : <Search size={14} />} Buscar
</button>
</div>
</div>
</div>
{loading ? (
<div className="flex justify-center py-12"><RefreshCw size={24} className="animate-spin" style={{ color: COLOR }} /></div>
) : salasFiltradas.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<DoorOpen size={40} className="mx-auto mb-3 opacity-40" />
<p className="font-bold text-sm uppercase">Nenhuma sala encontrada</p>
<p className="text-xs mt-1">Ajuste os filtros ou aguarde novos anúncios</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{salasFiltradas.map(s => <SalaCard key={s.id} sala={s} onReserve={() => setReservingSala(s)} />)}
</div>
)}
</>
)}
{/* MINHAS SALAS */}
{tab === 'minhas' && (
<>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight">
{minhas.length > 0 ? `${minhas.length} sala${minhas.length > 1 ? 's' : ''} cadastrada${minhas.length > 1 ? 's' : ''}` : 'Suas salas'}
</h3>
<button onClick={() => setEditingSala('new')}
className="w-full sm:w-auto flex items-center justify-center gap-2 px-5 py-2.5 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-opacity hover:opacity-90 shadow-sm" style={{ backgroundColor: COLOR }}>
<Plus size={16} /> CADASTRAR SALA
</button>
</div>
{loading ? (
<div className="flex justify-center py-12"><RefreshCw size={24} className="animate-spin" style={{ color: COLOR }} /></div>
) : minhas.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<DoorOpen size={40} className="mx-auto mb-3 opacity-40" />
<p className="font-bold text-sm uppercase">Você ainda não cadastrou salas</p>
<p className="text-xs mt-1">Disponibilize uma sala e receba solicitações de reserva</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{minhas.map(s => <SalaCard key={s.id} sala={s} mine onEdit={() => setEditingSala(s)} onDelete={() => handleDelete(s)} />)}
</div>
)}
</>
)}
{/* RESERVAS */}
{tab === 'reservas' && (
loading ? (
<div className="flex justify-center py-12"><RefreshCw size={24} className="animate-spin" style={{ color: COLOR }} /></div>
) : (
<div className="space-y-8">
{/* Receita de locação (reservas confirmadas viram recebível no financeiro da sala) */}
{(() => {
const conf = reservas.recebidas.filter(r => r.status === 'confirmada' && Number(r.valor) > 0);
const total = conf.reduce((s, r) => s + Number(r.valor), 0);
if (conf.length === 0) return null;
return (
<div className="bg-teal-50 border border-teal-100 rounded-2xl px-5 py-3 flex items-center justify-between">
<span className="text-[11px] font-black text-teal-700 uppercase tracking-wide">Receita de locação (confirmadas)</span>
<span className="text-lg font-black text-teal-700">R$ {total.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}<span className="text-[10px] font-bold text-teal-500 ml-2">{conf.length} reserva(s) financeiro</span></span>
</div>
);
})()}
<div>
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3 flex items-center gap-1.5"><Inbox size={12} /> RECEBIDAS (MINHAS SALAS)</p>
{reservas.recebidas.length === 0
? <p className="text-xs text-gray-300 font-bold uppercase">Nenhuma reserva recebida</p>
: <div className="space-y-3">{reservas.recebidas.map(r => <ReservaRow key={r.id} r={r} recebida onAction={a => handleReservaAction(r, a)} />)}</div>}
</div>
<div>
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3 flex items-center gap-1.5"><Send size={12} /> FEITAS POR MIM</p>
{reservas.feitas.length === 0
? <p className="text-xs text-gray-300 font-bold uppercase">Você ainda não solicitou reservas</p>
: <div className="space-y-3">{reservas.feitas.map(r => <ReservaRow key={r.id} r={r} onAction={a => handleReservaAction(r, a)} />)}</div>}
</div>
</div>
)
)}
{editingSala && <SalaFormModal sala={editingSala === 'new' ? null : editingSala} onClose={() => setEditingSala(null)} onSaved={fetchMinhas} />}
{reservingSala && <ReservaModal sala={reservingSala} onClose={() => setReservingSala(null)} onReserved={() => setTab('reservas')} />}
</div>
);
};