diff --git a/frontend/views/plugins/SalasPlugin.tsx b/frontend/views/plugins/SalasPlugin.tsx index 5140d23..86177f0 100644 --- a/frontend/views/plugins/SalasPlugin.tsx +++ b/frontend/views/plugins/SalasPlugin.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { DoorOpen, MapPin, Search, RefreshCw, Plus, Pencil, Trash2, X, Save, - CalendarClock, CheckCircle2, XCircle, Clock, Building2, Briefcase, Home, Inbox, Send, Eraser, ChevronUp + CalendarClock, CheckCircle2, XCircle, Clock, Building2, Briefcase, Home, Inbox, Send, Eraser, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react'; import { PageHeader } from '../../components/PageHeader.tsx'; import { useToast } from '../../contexts/ToastContext.tsx'; @@ -106,6 +106,52 @@ const precos = (s: Sala): Array<[string, string]> => { .map(([l, v]) => [l, fmtValor(v as string | null) || 'Sob consulta'] as [string, string]); }; +// ── Calendário de reserva ──────────────────────────────────────────────────── +const MODELOS: Array<{ id: string; label: string; valor: keyof Sala; consulta: keyof Sala }> = [ + { id: 'hora', label: 'Hora', valor: 'valor_hora', consulta: 'hora_consulta' }, + { id: 'turno', label: 'Turno', valor: 'valor_turno', consulta: 'turno_consulta' }, + { id: 'diaria', label: 'Diária', valor: 'valor_diaria', consulta: 'diaria_consulta' }, + { id: 'semanal', label: 'Semanal', valor: 'valor_semanal', consulta: 'semanal_consulta' }, + { id: 'mensal', label: 'Mensal', valor: 'valor_mensal', consulta: 'mensal_consulta' }, +]; +const TURNOS: Record = { + manha: { label: 'Manhã', ini: 8, fim: 12 }, + tarde: { label: 'Tarde', ini: 13, fim: 18 }, + noite: { label: 'Noite', ini: 18, fim: 22 }, +}; +const WEEKDAYS = ['D', 'S', 'T', 'Q', 'Q', 'S', 'S']; +const MESES = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro']; +const pad2 = (n: number) => String(n).padStart(2, '0'); +const toLocalInput = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`; +const mesmoDia = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +// Células do mês (com padding p/ alinhar ao dia da semana). +const monthGrid = (ref: Date): (Date | null)[] => { + const first = new Date(ref.getFullYear(), ref.getMonth(), 1); + const dias = new Date(ref.getFullYear(), ref.getMonth() + 1, 0).getDate(); + const cells: (Date | null)[] = []; + for (let i = 0; i < first.getDay(); i++) cells.push(null); + for (let d = 1; d <= dias; d++) cells.push(new Date(ref.getFullYear(), ref.getMonth(), d)); + return cells; +}; +// Início/fim conforme o modelo escolhido. +const computeRange = (dia: Date, modelo: string, hora: string, qtd: number, turno: string): { ini: Date; fim: Date } => { + const b = new Date(dia); b.setHours(0, 0, 0, 0); + const ini = new Date(b), fim = new Date(b); + if (modelo === 'hora') { + const [h, m] = hora.split(':').map(Number); ini.setHours(h || 8, m || 0, 0, 0); + fim.setTime(ini.getTime() + Math.max(1, qtd) * 3600_000); + } else if (modelo === 'turno') { + const t = TURNOS[turno] || TURNOS.manha; ini.setHours(t.ini, 0, 0, 0); fim.setHours(t.fim, 0, 0, 0); + } else if (modelo === 'semanal') { + ini.setHours(8, 0, 0, 0); fim.setDate(fim.getDate() + 6); fim.setHours(18, 0, 0, 0); + } else if (modelo === 'mensal') { + ini.setHours(8, 0, 0, 0); fim.setDate(fim.getDate() + 29); fim.setHours(18, 0, 0, 0); + } else { // diária + ini.setHours(8, 0, 0, 0); fim.setHours(18, 0, 0, 0); + } + return { ini, fim }; +}; + const inputCls = 'w-full px-3 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-xs font-bold text-gray-700 outline-none focus:border-teal-400'; const labelCls = 'text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1'; // Estilo padrão de filtros — fonte única em components/filterStyles. @@ -278,6 +324,28 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () = const [profissionais, setProfissionais] = useState>([]); 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(null); + const [hora, setHora] = useState('08:00'); + const [qtd, setQtd] = useState(1); + const [turno, setTurno] = useState('manha'); + const HORAS = Array.from({ length: 29 }, (_, i) => `${pad2(7 + Math.floor(i / 2))}:${i % 2 ? '30' : '00'}`); // 07: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 ? (() => { const d = new Date(diaSel); d.setDate(d.getDate() + (f.modelo === 'mensal' ? 29 : 6)); return d; })() : null; + + // Modelo padrão = 1º disponível. + useEffect(() => { if (modelosDisp.length && !modelosDisp.some(m => m.id === f.modelo)) setF(p => ({ ...p, modelo: modelosDisp[0].id })); /* eslint-disable-next-line */ }, []); + // Recalcula início/fim conforme dia + modelo + horário. + useEffect(() => { + if (!diaSel) return; + const { ini, fim } = computeRange(diaSel, f.modelo, hora, qtd, turno); + setF(p => ({ ...p, inicio: toLocalInput(ini), fim: toLocalInput(fim) })); + }, [diaSel, f.modelo, hora, qtd, turno]); + useEffect(() => { apiFetch(`/api/salas/${sala.id}/reservas`) .then(r => (r.ok ? r.json() : [])) @@ -317,7 +385,7 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () = return (
-
+

RESERVAR {sala.nome}

@@ -350,36 +418,109 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =

O anti-conflito verifica a agenda do profissional selecionado

)} -
-
- - setF(p => ({ ...p, inicio: e.target.value }))} /> -
-
- - setF(p => ({ ...p, fim: e.target.value }))} /> -
-
+ {/* Modelo de locação — cartões com preço */}
- +
+ + {/* Calendário mensal */} +
+
+ + {MESES[mesRef.getMonth()]} {mesRef.getFullYear()} + +
+
+ {WEEKDAYS.map((w, i) =>
{w}
)} +
+
+ {monthGrid(mesRef).map((dia, i) => { + if (!dia) return
; + const passado = dia < hoje0; + const sel = !!(diaSel && mesmoDia(dia, diaSel)); + const emRange = !!(diaSel && rangeFim && (f.modelo === 'semanal' || f.modelo === 'mensal') && dia > diaSel && dia <= rangeFim); + const ocupado = diasOcupados.has(dia.toDateString()); + return ( + + ); + })} +
+
+ + {/* Controles de horário (conforme o modelo) */} + {diaSel && f.modelo === 'hora' && ( +
+
+ + +
+
+ +
+ + {qtd}h + +
+
+
+ )} + {diaSel && f.modelo === 'turno' && ( +
+ +
+ {Object.entries(TURNOS).map(([k, t]) => ( + + ))} +
+
+ )} + + {/* Resumo do período */} + {f.inicio && f.fim && ( +
+ +
+ {fmtData(new Date(f.inicio).toISOString())} {fmtData(new Date(f.fim).toISOString())} +
+
+ )}