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 = { clinica: { label: 'Sala Clínica', icon: Building2 }, consultorio: { label: 'Sala Consultório', icon: Briefcase }, independente: { label: 'Sala Independente', icon: Home }, }; const AREA_META: Record = { odontologia: { nome: 'Odontologia', cor: '#2563eb' }, biomedicina: { nome: 'Biomedicina', cor: '#db2777' }, protese: { nome: 'Prótese', cor: '#7c3aed' }, }; const STATUS_STYLE: Record = { 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 = { 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 = { 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 (

{sala ? 'EDITAR SALA' : 'CADASTRAR SALA'}

set('nome', e.target.value)} placeholder="EX: SALA 2 — EQUIPADA P/ HOF" />