343ca560dc
Backend: - Nova coluna salas.contrato (TEXT, migration ADD COLUMN IF NOT EXISTS). - INSERT/UPDATE de sala aceitam `contrato`; marketplace SELECT devolve s.contrato (minhas salas já via s.*). Frontend (SalasPlugin): - CONTRATO_MODELO: modelo padrão cuja cláusula 2 deixa TODOS os procedimentos sob inteira responsabilidade do locatário; o dono edita por sala. - SalaForm ganha textarea "Contrato de locação" pré-preenchida com o modelo + botão "Restaurar modelo". - Passo Pagamento: botão "Ler contrato de locação" abre modal com o texto (contrato da sala, ou o modelo) + checkbox de aceite. "Solicitar Reserva" só habilita após aceitar; o aceite é registrado nas observações da reserva. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1241 lines
76 KiB
TypeScript
1241 lines
76 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef } 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, FileText
|
||
} 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;
|
||
contrato?: 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: 'Período', valor: 'valor_turno', consulta: 'turno_consulta' },
|
||
{ id: 'diaria', label: 'Dia', valor: 'valor_diaria', consulta: 'diaria_consulta' },
|
||
{ id: 'semanal', label: 'Semana', valor: 'valor_semanal', consulta: 'semanal_consulta' },
|
||
{ id: 'mensal', label: 'Mês', 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);
|
||
const n = Math.max(1, qtd);
|
||
if (modelo === 'hora') {
|
||
const [h, m] = hora.split(':').map(Number); ini.setHours(h || 8, m || 0, 0, 0);
|
||
fim.setTime(ini.getTime() + n * 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() + (n * 7 - 1)); fim.setHours(18, 0, 0, 0);
|
||
} else if (modelo === 'mensal') {
|
||
ini.setHours(8, 0, 0, 0); fim.setMonth(fim.getMonth() + n); fim.setDate(fim.getDate() - 1); fim.setHours(18, 0, 0, 0);
|
||
} else { // diária — n dias consecutivos
|
||
ini.setHours(8, 0, 0, 0); fim.setDate(fim.getDate() + (n - 1)); fim.setHours(18, 0, 0, 0);
|
||
}
|
||
return { ini, fim };
|
||
};
|
||
|
||
// Quantidade máxima por modelo + rótulo da unidade.
|
||
const UNIDADE: Record<string, { sing: string; plur: string; max: number }> = {
|
||
hora: { sing: 'hora', plur: 'horas', max: 12 },
|
||
diaria: { sing: 'dia', plur: 'dias', max: 30 },
|
||
semanal: { sing: 'semana', plur: 'semanas', max: 8 },
|
||
mensal: { sing: 'mês', plur: 'meses', max: 12 },
|
||
};
|
||
|
||
// Modelo padrão de contrato de locação — o dono pode editar por sala; se em branco, vale este.
|
||
const CONTRATO_MODELO = `CONTRATO DE LOCAÇÃO DE ESPAÇO / SALA
|
||
|
||
1. OBJETO
|
||
A presente locação tem por objeto a cessão temporária do espaço/sala identificado nesta plataforma, para uso profissional durante o período reservado.
|
||
|
||
2. RESPONSABILIDADE
|
||
Todos os procedimentos, atendimentos e serviços realizados no espaço durante o período locado são de INTEIRA E EXCLUSIVA RESPONSABILIDADE DO LOCATÁRIO (quem aluga), abrangendo as esferas civil, criminal, ética, sanitária e trabalhista, perante pacientes, terceiros e órgãos de classe. O LOCADOR (dono da sala) não responde por qualquer ato praticado pelo locatário.
|
||
|
||
3. USO E CONSERVAÇÃO
|
||
O locatário compromete-se a utilizar o espaço e os equipamentos com zelo, devolvendo-os nas mesmas condições em que os recebeu. Eventuais danos serão integralmente ressarcidos pelo locatário.
|
||
|
||
4. REGULARIDADE PROFISSIONAL
|
||
O locatário declara possuir registro profissional ativo e todas as licenças e autorizações exigidas para o exercício de sua atividade, isentando o locador de qualquer responsabilidade a esse respeito.
|
||
|
||
5. PAGAMENTO
|
||
O valor e a forma de pagamento são os informados no pedido de reserva e combinados diretamente entre as partes.
|
||
|
||
6. CANCELAMENTO
|
||
Cancelamentos devem ser comunicados com antecedência razoável, conforme acordado entre as partes.
|
||
|
||
Ao aceitar, o LOCATÁRIO declara ter lido e concordado integralmente com os termos acima.`;
|
||
|
||
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,
|
||
contrato: sala?.contrato ?? CONTRATO_MODELO,
|
||
});
|
||
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">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-1.5"><FileText size={12} /> Contrato de locação</label>
|
||
<button type="button" onClick={() => set('contrato', CONTRATO_MODELO)} className="text-[9px] font-black uppercase text-teal-600 hover:text-teal-700">Restaurar modelo</button>
|
||
</div>
|
||
<textarea className={`${inputCls} font-medium normal-case`} rows={6} value={f.contrato} onChange={e => set('contrato', e.target.value)} placeholder="Termos do contrato que o locatário deverá aceitar ao reservar..." />
|
||
<p className="text-[9px] text-gray-400 font-bold uppercase mt-1">Exibido ao locatário no passo de pagamento; ele precisa aceitar para reservar. Já preenchemos um modelo — edite como quiser.</p>
|
||
</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 [diasMulti, setDiasMulti] = useState<Date[]>([]); // modelo "Dia": dias avulsos (1, 5, 20…)
|
||
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:00–21: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 ? computeRange(diaSel, f.modelo, hora, qtd, turno).fim : null;
|
||
const diasMultiSorted = [...diasMulti].sort((a, b) => a.getTime() - b.getTime());
|
||
const temSelecao = f.modelo === 'diaria' ? diasMulti.length > 0 : !!diaSel;
|
||
|
||
// ── Wizard (4 passos, formato slide) ──
|
||
const [step, setStep] = useState(0);
|
||
const [formaPagamento, setFormaPagamento] = useState('pix');
|
||
const [showContrato, setShowContrato] = useState(false);
|
||
const [contratoAceito, setContratoAceito] = useState(false);
|
||
const contratoTexto = (sala.contrato && sala.contrato.trim()) || CONTRATO_MODELO;
|
||
const STEPS = ['Dados', 'Calendário', 'Resumo', 'Pagamento'];
|
||
const FORMAS: Record<string, string> = { pix: 'PIX', dinheiro: 'Dinheiro', transferencia: 'Transferência', cartao: 'Cartão' };
|
||
const modeloLabel = MODELOS.find(m => m.id === f.modelo)?.label || '';
|
||
const total = (() => {
|
||
const m = MODELOS.find(x => x.id === f.modelo); const v = m ? Number(sala[m.valor]) : NaN;
|
||
if (!Number.isFinite(v)) return 0;
|
||
if (f.modelo === 'turno') return v;
|
||
if (f.modelo === 'diaria') return v * diasMulti.length;
|
||
return v * Math.max(1, qtd);
|
||
})();
|
||
|
||
// 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]);
|
||
// Ao trocar de modelo, volta a quantidade para 1 e limpa a multi-seleção de dias.
|
||
useEffect(() => { setQtd(1); setDiasMulti([]); }, [f.modelo]);
|
||
// Depois de escolher o(s) dia(s), revela os controles (que ficam abaixo do calendário).
|
||
const ctrlRef = useRef<HTMLDivElement>(null);
|
||
useEffect(() => { if (diaSel || diasMulti.length) ctrlRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, [diaSel, diasMulti.length, f.modelo]);
|
||
|
||
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 () => {
|
||
// Modelo "Dia" → uma reserva por dia avulso selecionado; demais → um único intervalo.
|
||
const ranges = f.modelo === 'diaria'
|
||
? diasMultiSorted.map(d => computeRange(d, 'diaria', hora, 1, turno))
|
||
: (f.inicio && f.fim ? [{ ini: new Date(f.inicio), fim: new Date(f.fim) }] : []);
|
||
if (!ranges.length) { toast.error(f.modelo === 'diaria' ? 'SELECIONE AO MENOS UM DIA.' : 'INFORME INÍCIO E FIM.'); return; }
|
||
if (!contratoAceito) { toast.error('É PRECISO ACEITAR O CONTRATO DE LOCAÇÃO.'); return; }
|
||
setSaving(true);
|
||
try {
|
||
const obs = [f.observacoes, `Forma de pagamento: ${FORMAS[formaPagamento] || formaPagamento}`, 'Contrato de locação: aceito pelo locatário'].filter(Boolean).join(' · ') || null;
|
||
const base = {
|
||
modelo: f.modelo, observacoes: obs,
|
||
clinicaId: comoUnidade && ws?.id ? ws.id : null,
|
||
profissionalUsuarioId: comoUnidade && profissionalId ? profissionalId : null,
|
||
};
|
||
let ok = 0; const erros: string[] = [];
|
||
for (const r of ranges) {
|
||
const res = await apiFetch(`/api/salas/${sala.id}/reservas`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ ...base, inicio: r.ini.toISOString(), fim: r.fim.toISOString() }),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (res.ok) ok++; else erros.push(data.error || 'erro');
|
||
}
|
||
if (!ok) throw new Error(erros[0] || 'Erro ao reservar.');
|
||
toast.success(ranges.length > 1 ? `${ok} RESERVAS SOLICITADAS! AGUARDE A CONFIRMAÇÃO DO LOCADOR.` : 'RESERVA SOLICITADA! AGUARDE A CONFIRMAÇÃO DO LOCADOR.');
|
||
if (erros.length) toast.error(`${erros.length} DIA(S) NÃO PUDERAM SER RESERVADOS (JÁ OCUPADOS?).`);
|
||
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-[1.5rem] w-full max-w-lg shadow-2xl overflow-hidden max-h-[92vh] flex flex-col">
|
||
{/* Header + stepper */}
|
||
<div className="px-6 pt-5 pb-4 border-b border-gray-100 shrink-0">
|
||
<div className="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="flex items-center gap-2 mt-4">
|
||
{STEPS.map((s, i) => (
|
||
<button key={s} type="button" onClick={() => i < step && setStep(i)} className="flex-1 flex flex-col items-center gap-1.5">
|
||
<div className={`w-full h-1.5 rounded-full transition-colors ${i <= step ? '' : 'bg-gray-100'}`} style={i <= step ? { backgroundColor: COLOR } : {}} />
|
||
<span className={`text-[8px] font-black uppercase tracking-wide ${i === step ? 'text-gray-700' : 'text-gray-300'}`}>{s}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Slides */}
|
||
<div className="flex-1 overflow-hidden min-h-[340px]">
|
||
<div className="flex h-full transition-transform duration-300 ease-out" style={{ transform: `translateX(-${step * 100}%)` }}>
|
||
|
||
{/* PASSO 1 — Dados */}
|
||
<div className="w-full shrink-0 overflow-y-auto 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>
|
||
<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>
|
||
<div>
|
||
<label className={labelCls}>Observações</label>
|
||
<textarea className={inputCls} rows={4} value={f.observacoes} onChange={e => setF(p => ({ ...p, observacoes: e.target.value }))} placeholder="Alguma informação para o locador…" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* PASSO 2 — Calendário */}
|
||
<div className="w-full shrink-0 overflow-y-auto p-6 space-y-4">
|
||
<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 = f.modelo === 'diaria'
|
||
? diasMulti.some(d => mesmoDia(d, dia))
|
||
: !!(diaSel && mesmoDia(dia, diaSel));
|
||
const emRange = !!(diaSel && rangeFim && (f.modelo === 'semanal' || f.modelo === 'mensal') && dia > diaSel && dia <= rangeFim);
|
||
const ocupado = diasOcupados.has(dia.toDateString());
|
||
const onPick = () => {
|
||
if (f.modelo === 'diaria') {
|
||
setDiasMulti(prev => prev.some(d => mesmoDia(d, dia)) ? prev.filter(d => !mesmoDia(d, dia)) : [...prev, dia]);
|
||
} else {
|
||
setDiaSel(dia);
|
||
}
|
||
};
|
||
return (
|
||
<button key={i} type="button" disabled={passado} onClick={onPick}
|
||
className={`relative h-9 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>
|
||
{temSelecao && (
|
||
<div ref={ctrlRef} className="rounded-2xl border-2 p-4 space-y-3" style={{ borderColor: `${COLOR}33`, backgroundColor: `${COLOR}0a` }}>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Selecionado</p>
|
||
<p className="text-xs font-black text-gray-800">
|
||
{f.modelo === 'diaria'
|
||
? `${diasMulti.length} ${diasMulti.length === 1 ? 'dia' : 'dias'}`
|
||
: `${diaSel?.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })}${(f.modelo === 'semanal' || f.modelo === 'mensal') && rangeFim ? ` → ${rangeFim.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })}` : ''}`}
|
||
</p>
|
||
</div>
|
||
<span className="text-[10px] font-black uppercase px-2.5 py-1 rounded-full" style={{ backgroundColor: `${COLOR}18`, color: COLOR }}>{modeloLabel}</span>
|
||
</div>
|
||
|
||
{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 bg-white 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 bg-white text-gray-500 font-black text-lg leading-none">+</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{f.modelo === 'turno' && (
|
||
<div>
|
||
<label className={labelCls}>Período</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-white 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>
|
||
)}
|
||
|
||
{(f.modelo === 'semanal' || f.modelo === 'mensal') && (
|
||
<div>
|
||
<label className={labelCls}>Quantidade de {UNIDADE[f.modelo]?.plur}</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 bg-white text-gray-500 font-black text-lg leading-none">−</button>
|
||
<span className="flex-1 text-center text-sm font-black text-gray-800">{qtd} {qtd === 1 ? UNIDADE[f.modelo]?.sing : UNIDADE[f.modelo]?.plur}</span>
|
||
<button type="button" onClick={() => setQtd(q => Math.min(UNIDADE[f.modelo]?.max || 12, q + 1))} className="w-9 h-9 rounded-lg border border-gray-200 bg-white text-gray-500 font-black text-lg leading-none">+</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{f.modelo === 'diaria' && (
|
||
<div>
|
||
<label className={labelCls}>Dias selecionados ({diasMulti.length})</label>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{diasMultiSorted.map((d, i) => {
|
||
const ocup = diasOcupados.has(d.toDateString());
|
||
return (
|
||
<button key={i} type="button" onClick={() => setDiasMulti(prev => prev.filter(x => !mesmoDia(x, d)))}
|
||
className="flex items-center gap-1 pl-2.5 pr-1.5 py-1 rounded-full text-[10px] font-black text-white" style={{ backgroundColor: ocup ? '#d97706' : COLOR }}>
|
||
{d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })}
|
||
<X size={11} />
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<p className="text-[9px] font-bold text-gray-400 uppercase mt-1.5">Toque no calendário para adicionar/remover dias · cada dia vira uma reserva (08h–18h)</p>
|
||
</div>
|
||
)}
|
||
|
||
{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} /> Já ocupado neste dia</p>
|
||
<div className="space-y-1 max-h-24 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>
|
||
|
||
{/* PASSO 3 — Resumo detalhado + históricos */}
|
||
<div className="w-full shrink-0 overflow-y-auto p-6 space-y-4">
|
||
<div className="rounded-2xl border border-gray-100 overflow-hidden">
|
||
{([
|
||
['Sala', sala.nome],
|
||
['Local', [sala.cidade, sala.estado].filter(Boolean).join(' · ') || '—'],
|
||
['Modelo', modeloLabel],
|
||
...(f.modelo === 'diaria'
|
||
? [
|
||
['Quantidade', `${diasMulti.length} ${diasMulti.length === 1 ? 'dia' : 'dias'}`],
|
||
['Dias', diasMultiSorted.map(d => d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })).join(', ') || '—'],
|
||
]
|
||
: [
|
||
...(UNIDADE[f.modelo] ? [['Quantidade', `${qtd} ${qtd === 1 ? UNIDADE[f.modelo].sing : UNIDADE[f.modelo].plur}`]] : []),
|
||
['Início', f.inicio ? fmtData(new Date(f.inicio).toISOString()) : '—'],
|
||
['Fim', f.fim ? fmtData(new Date(f.fim).toISOString()) : '—'],
|
||
]) as Array<[string, string]>,
|
||
['Reservar como', comoUnidade ? (ws?.nome || 'Unidade') : 'Pessoal'],
|
||
] as Array<[string, string]>).map(([k, v]) => (
|
||
<div key={k} className="flex items-center justify-between px-4 py-2.5 border-b border-gray-50 last:border-0">
|
||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{k}</span>
|
||
<span className="text-xs font-bold text-gray-800 text-right max-w-[62%] truncate">{v}</span>
|
||
</div>
|
||
))}
|
||
<div className="flex items-center justify-between px-4 py-3 bg-teal-50/60">
|
||
<span className="text-[10px] font-black text-teal-600 uppercase tracking-widest">Total estimado</span>
|
||
<span className="text-base font-black text-teal-700">{total > 0 ? fmtValor(String(total)) : 'Sob consulta'}</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 flex items-center gap-1"><Clock size={11} /> Histórico de reservas desta sala</p>
|
||
{ocupadas.length === 0 ? (
|
||
<p className="text-[11px] text-gray-300 font-bold uppercase py-2">Sem reservas registradas</p>
|
||
) : (
|
||
<div className="space-y-1.5 max-h-44 overflow-y-auto">
|
||
{ocupadas.slice().sort((a, b) => new Date(b.inicio).getTime() - new Date(a.inicio).getTime()).map(o => (
|
||
<div key={o.id} className="flex items-center justify-between text-[11px] bg-gray-50 rounded-lg px-3 py-1.5">
|
||
<span className="font-bold text-gray-600">{fmtData(o.inicio)} → {new Date(o.fim).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}</span>
|
||
<span className={`text-[9px] font-black uppercase px-1.5 py-0.5 rounded-full ${o.status === 'confirmada' ? 'bg-emerald-100 text-emerald-700' : o.status === 'pendente' ? 'bg-amber-100 text-amber-700' : 'bg-gray-200 text-gray-500'}`}>{o.status}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* PASSO 4 — Pagamento */}
|
||
<div className="w-full shrink-0 overflow-y-auto p-6 space-y-4">
|
||
<div className="rounded-2xl bg-gradient-to-br from-teal-50 to-white border border-teal-100 p-5 text-center">
|
||
<p className="text-[10px] font-black text-teal-600 uppercase tracking-widest">Total{modeloLabel ? ` · ${modeloLabel}` : ''}</p>
|
||
<p className="text-3xl font-black text-teal-700 mt-1">{total > 0 ? fmtValor(String(total)) : 'Sob consulta'}</p>
|
||
{total > 0 && f.modelo === 'diaria' && diasMulti.length > 1 && <p className="text-[10px] text-teal-500 font-bold mt-1">{diasMulti.length} dias × {fmtValor(String(Number(sala.valor_diaria)))}</p>}
|
||
{total > 0 && f.modelo !== 'diaria' && UNIDADE[f.modelo] && qtd > 1 && <p className="text-[10px] text-teal-500 font-bold mt-1">{qtd} {UNIDADE[f.modelo].plur} × {fmtValor(String(Number(sala[MODELOS.find(m => m.id === f.modelo)!.valor])))}</p>}
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>Forma de pagamento</label>
|
||
<div className="grid grid-cols-2 gap-2 mt-1">
|
||
{Object.entries(FORMAS).map(([k, l]) => (
|
||
<button key={k} type="button" onClick={() => setFormaPagamento(k)}
|
||
className={`py-2.5 rounded-xl text-[11px] font-black uppercase border transition-all ${formaPagamento === k ? 'text-white border-transparent' : 'bg-gray-50 text-gray-500 border-gray-200 hover:bg-gray-100'}`}
|
||
style={formaPagamento === k ? { backgroundColor: COLOR } : {}}>{l}</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>Contrato de locação</label>
|
||
<button type="button" onClick={() => setShowContrato(true)}
|
||
className="w-full flex items-center justify-between rounded-xl border p-3.5 transition-colors hover:bg-gray-50"
|
||
style={{ borderColor: contratoAceito ? '#16a34a55' : `${COLOR}44`, backgroundColor: contratoAceito ? '#16a34a0d' : `${COLOR}0a` }}>
|
||
<span className="flex items-center gap-2 text-xs font-black text-gray-700 uppercase"><FileText size={15} style={{ color: contratoAceito ? '#16a34a' : COLOR }} /> Ler contrato de locação</span>
|
||
<span className="text-[10px] font-black uppercase flex items-center gap-1" style={{ color: contratoAceito ? '#16a34a' : COLOR }}>
|
||
{contratoAceito ? <><CheckCircle2 size={13} /> Aceito</> : 'Abrir'}
|
||
</span>
|
||
</button>
|
||
<label className="flex items-start gap-2 mt-2 px-1 cursor-pointer">
|
||
<input type="checkbox" checked={contratoAceito} onChange={e => setContratoAceito(e.target.checked)} className="mt-0.5 w-4 h-4 accent-teal-600 shrink-0" />
|
||
<span className="text-[11px] font-bold text-gray-600 leading-snug">Li e concordo com o contrato de locação — os procedimentos realizados são de minha inteira responsabilidade.</span>
|
||
</label>
|
||
</div>
|
||
<div className="rounded-xl bg-gray-50 border border-gray-100 p-3.5 text-[11px] text-gray-500 leading-relaxed">
|
||
<span className="font-black text-gray-600 uppercase">Como funciona: </span>ao solicitar, o locador recebe seu pedido e confirma. O pagamento é combinado diretamente com ele após a confirmação — sua preferência ({FORMAS[formaPagamento]}) vai junto no pedido.
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer nav */}
|
||
<div className="px-6 py-4 border-t border-gray-100 flex gap-3 shrink-0">
|
||
<button onClick={() => (step === 0 ? onClose() : setStep(s => s - 1))} 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">
|
||
{step === 0 ? 'Cancelar' : 'Voltar'}
|
||
</button>
|
||
{step < 3 ? (
|
||
<button onClick={() => setStep(s => s + 1)} disabled={step === 1 && !temSelecao}
|
||
className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase transition-opacity hover:opacity-90 disabled:opacity-40 flex items-center justify-center gap-2" style={{ backgroundColor: COLOR }}>
|
||
Próximo <ChevronRight size={14} />
|
||
</button>
|
||
) : (
|
||
<button onClick={handleReserve} disabled={saving || !temSelecao || !contratoAceito}
|
||
className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase transition-opacity hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2" style={{ backgroundColor: COLOR }}>
|
||
{saving ? <RefreshCw size={14} className="animate-spin" /> : <CalendarClock size={14} />} Solicitar Reserva
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Modal: contrato de locação */}
|
||
{showContrato && (
|
||
<div className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowContrato(false)}>
|
||
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl max-h-[85vh] flex flex-col overflow-hidden" onClick={e => e.stopPropagation()}>
|
||
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between shrink-0">
|
||
<h4 className="text-sm font-black text-gray-900 uppercase flex items-center gap-2"><FileText size={16} style={{ color: COLOR }} /> Contrato de locação</h4>
|
||
<button onClick={() => setShowContrato(false)} className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-500"><X size={16} /></button>
|
||
</div>
|
||
<div className="p-5 overflow-y-auto text-[11px] text-gray-600 leading-relaxed whitespace-pre-wrap font-medium">{contratoTexto}</div>
|
||
<div className="p-4 border-t border-gray-100 shrink-0 flex gap-2">
|
||
<button onClick={() => setShowContrato(false)} className="flex-1 py-3 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-500 hover:bg-gray-50">Fechar</button>
|
||
<button onClick={() => { setContratoAceito(true); setShowContrato(false); }} className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase hover:opacity-90 flex items-center justify-center gap-2" style={{ backgroundColor: COLOR }}><CheckCircle2 size={14} /> Li e concordo</button>
|
||
</div>
|
||
</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>
|
||
);
|
||
};
|