Files
scoreodonto.com/frontend/views/plugins/SalasPlugin.tsx
T
VPS 4 Builder 17f83e5c86 feat(marketplace): exigir endereço/CEP da origem antes da busca por proximidade
- Profissionais e Salas: aba de busca bloqueia se a origem não tiver cep+cidade+estado
- clínica/consultório (donoclinica/donoconsultorio/admin/funcionário): checa endereço
  da clínica ativa → CTA p/ Configurações
- dentista/biomédico/protético: checa o próprio perfil → CTA p/ completar perfil
- fail-safe: em erro de checagem não bloqueia

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 03:23:32 +02:00

713 lines
38 KiB
TypeScript

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
} from 'lucide-react';
import { PageHeader } from '../../components/PageHeader.tsx';
import { useToast } from '../../contexts/ToastContext.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;
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;
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 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) => [
['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 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';
// ─── 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 [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 || '',
valorHora: sala?.valor_hora || '', valorTurno: sala?.valor_turno || '', valorDiaria: sala?.valor_diaria || '',
valorSemanal: sala?.valor_semanal || '', valorMensal: sala?.valor_mensal || '',
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!');
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>
<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="—" />
</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('');
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-md 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>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>Início *</label>
<input type="datetime-local" className={inputCls} value={f.inicio} onChange={e => setF(p => ({ ...p, inicio: e.target.value }))} />
</div>
<div>
<label className={labelCls}>Fim *</label>
<input type="datetime-local" className={inputCls} value={f.fim} onChange={e => setF(p => ({ ...p, fim: e.target.value }))} />
</div>
</div>
<div>
<label className={labelCls}>Modelo de locação</label>
<select className={inputCls} value={f.modelo} onChange={e => setF(p => ({ ...p, modelo: e.target.value }))}>
{precos(sala).length === 0 && <option value="hora">HORA</option>}
{precos(sala).map(([l]) => {
const map: Record<string, string> = { HORA: 'hora', TURNO: 'turno', 'DIÁRIA': 'diaria', SEMANA: 'semanal', 'MÊS': 'mensal' };
return <option key={l} value={map[l]}>{l}</option>;
})}
</select>
</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>
{ocupadas.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} /> Horários ocupados</p>
<div className="space-y-1 max-h-28 overflow-y-auto">
{ocupadas.map(o => (
<p key={o.id} className="text-[10px] font-bold text-amber-700">{fmtData(o.inicio)} {fmtData(o.fim)} ({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>
{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 = () => {
const toast = useToast();
const [tab, setTab] = useState<Tab>('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('');
// 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);
// 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]);
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); }
}, []);
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 (!window.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.');
fetchMinhas();
} catch (e: any) { toast.error(e.message.toUpperCase()); }
};
const handleReservaAction = async (r: Reserva, acao: 'confirmar' | 'recusar' | 'cancelar') => {
if (acao === 'cancelar' && !window.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()); }
};
const 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 pendentesRecebidas = reservas.recebidas.filter(r => r.status === 'pendente').length;
return (
<div className="space-y-8 animate-in fade-in duration-500">
<PageHeader
title="LOCAÇÃO DE SALAS"
description="Alugue salas equipadas ou disponibilize as suas para outros profissionais."
/>
{/* Tabs */}
<div className="flex gap-2 flex-wrap">
{TABS.map(t => {
const Icon = t.icon;
const active = tab === t.id;
return (
<button key={t.id} onClick={() => setTab(t.id)}
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-[11px] font-black uppercase tracking-wider transition-all ${active ? 'text-white shadow-md' : 'bg-white text-gray-500 border border-gray-200 hover:bg-gray-50'}`}
style={active ? { backgroundColor: COLOR } : {}}>
<Icon size={14} /> {t.label}
{t.id === 'reservas' && pendentesRecebidas > 0 && (
<span className={`text-[9px] font-black px-1.5 py-0.5 rounded-full ${active ? 'bg-white/25' : 'bg-amber-100 text-amber-700'}`}>{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">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div>
<label className={labelCls}>Tipo</label>
<select className={inputCls} 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={labelCls}>Estado</label>
<select className={inputCls} value={fEstado} onChange={e => setFEstado(e.target.value)}>
{UF_LIST.map(uf => <option key={uf} value={uf}>{uf || 'Todos os estados'}</option>)}
</select>
</div>
<div>
<label className={labelCls}>Cidade</label>
<input className={`${inputCls} uppercase`} value={fCidade} onChange={e => setFCidade(e.target.value.toUpperCase())} placeholder="CAMPO GRANDE" />
</div>
<div>
<label className={labelCls}>Meu CEP (busca por raio)</label>
<input className={inputCls} value={fCep} onChange={e => setFCep(e.target.value)} placeholder="00000-000" />
</div>
<div>
<label className={labelCls}>Raio (km)</label>
<input type="number" min="1" className={inputCls} value={fRaio} onChange={e => setFRaio(e.target.value)} placeholder="10" />
</div>
</div>
<button onClick={fetchMarketplace} disabled={loading}
className="flex items-center gap-2 px-5 py-2.5 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-colors disabled:opacity-60 hover:opacity-90" style={{ backgroundColor: COLOR }}>
{loading ? <RefreshCw size={14} className="animate-spin" /> : <Search size={14} />} BUSCAR
</button>
</div>
{loading ? (
<div className="flex justify-center py-12"><RefreshCw size={24} className="animate-spin" style={{ color: COLOR }} /></div>
) : salas.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 gap-4">
{salas.map(s => <SalaCard key={s.id} sala={s} onReserve={() => setReservingSala(s)} />)}
</div>
)}
</>
)}
{/* MINHAS SALAS */}
{tab === 'minhas' && (
<>
<button onClick={() => setEditingSala('new')}
className="flex items-center gap-2 px-5 py-2.5 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-colors hover:opacity-90" style={{ backgroundColor: COLOR }}>
<Plus size={14} /> CADASTRAR SALA
</button>
{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 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">
<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>
);
};