From c78c745f3fae5ba79d8d81f31813430c5c89d833 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Tue, 7 Jul 2026 04:14:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(salas):=20reserva=20de=20m=C3=BAltiplos=20?= =?UTF-8?q?dias/semanas/meses=20+=20controles=20vis=C3=ADveis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calendário (passo 2): - qtd vira "quantidade" genérica por modelo: Hora=horas (1–12), Dia=dias (1–30), Semana=semanas (1–8), Mês=meses (1–12). computeRange estende o fim conforme a quantidade; total = valor × quantidade (Turno segue unitário). - O intervalo escolhido (diária/semanal/mensal) é realçado no calendário e some ao trocar de modelo (qtd volta a 1). - Controles (Início/Duração/Período/Quantidade) que ficavam escondidos abaixo do calendário agora vivem num card destacado, com cabeçalho do dia/intervalo selecionado e auto-scroll (scrollIntoView) ao escolher o dia. - Resumo ganha linha "Quantidade" e a nota do total é genérica (N un × valor). Co-Authored-By: Claude Opus 4.8 --- frontend/views/plugins/SalasPlugin.tsx | 144 ++++++++++++++++--------- 1 file changed, 94 insertions(+), 50 deletions(-) diff --git a/frontend/views/plugins/SalasPlugin.tsx b/frontend/views/plugins/SalasPlugin.tsx index 8fcdce2..5b6cf06 100644 --- a/frontend/views/plugins/SalasPlugin.tsx +++ b/frontend/views/plugins/SalasPlugin.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +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 @@ -137,21 +137,30 @@ const monthGrid = (ref: Date): (Date | null)[] => { 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() + Math.max(1, qtd) * 3600_000); + 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() + 6); fim.setHours(18, 0, 0, 0); + 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.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); + 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 }, +}; + 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. @@ -335,7 +344,7 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () = 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; + const rangeFim = diaSel ? computeRange(diaSel, f.modelo, hora, qtd, turno).fim : null; // ── Wizard (4 passos, formato slide) ── const [step, setStep] = useState(0); @@ -345,7 +354,8 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () = 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; - return Number.isFinite(v) ? (f.modelo === 'hora' ? v * Math.max(1, qtd) : v) : 0; + if (!Number.isFinite(v)) return 0; + return f.modelo === 'turno' ? v : v * Math.max(1, qtd); })(); // Modelo padrão = 1º disponível. @@ -356,6 +366,11 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () = 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 (evita herdar 3h→3 dias etc.). + useEffect(() => { setQtd(1); }, [f.modelo]); + // Depois de escolher o dia, revela os controles (que ficam abaixo do calendário). + const ctrlRef = useRef(null); + useEffect(() => { if (diaSel) ctrlRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, [diaSel, f.modelo]); useEffect(() => { apiFetch(`/api/salas/${sala.id}/reservas`) @@ -484,7 +499,7 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () = 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 emRange = !!(diaSel && rangeFim && (f.modelo === 'diaria' || f.modelo === 'semanal' || f.modelo === 'mensal') && dia > diaSel && dia <= rangeFim); const ocupado = diasOcupados.has(dia.toDateString()); return (
- {diaSel && f.modelo === 'hora' && ( -
-
- - -
-
- -
- - {qtd}h - -
-
-
- )} - {diaSel && f.modelo === 'turno' && ( -
- -
- {Object.entries(TURNOS).map(([k, t]) => ( - - ))} -
-
- )} - {diaSel && ocupadasDoDia.length > 0 && ( -
-

Já ocupado neste dia

-
- {ocupadasDoDia.map(o => ( -

- {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}) + {diaSel && ( +

+
+
+

Selecionado

+

+ {diaSel.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })} + {(f.modelo === 'diaria' || f.modelo === 'semanal' || f.modelo === 'mensal') && rangeFim && ` → ${rangeFim.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })}`}

- ))} +
+ {modeloLabel}
+ + {f.modelo === 'hora' && ( +
+
+ + +
+
+ +
+ + {qtd}h + +
+
+
+ )} + + {f.modelo === 'turno' && ( +
+ +
+ {Object.entries(TURNOS).map(([k, t]) => ( + + ))} +
+
+ )} + + {(f.modelo === 'diaria' || f.modelo === 'semanal' || f.modelo === 'mensal') && ( +
+ +
+ + {qtd} {qtd === 1 ? UNIDADE[f.modelo]?.sing : UNIDADE[f.modelo]?.plur} + +
+
+ )} + + {ocupadasDoDia.length > 0 && ( +
+

Já ocupado neste dia

+
+ {ocupadasDoDia.map(o => ( +

+ {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}) +

+ ))} +
+
+ )}
)}
@@ -550,6 +593,7 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () = ['Sala', sala.nome], ['Local', [sala.cidade, sala.estado].filter(Boolean).join(' · ') || '—'], ['Modelo', modeloLabel], + ...(UNIDADE[f.modelo] ? [['Quantidade', `${qtd} ${qtd === 1 ? UNIDADE[f.modelo].sing : UNIDADE[f.modelo].plur}`]] as Array<[string, string]> : []), ['Início', f.inicio ? fmtData(new Date(f.inicio).toISOString()) : '—'], ['Fim', f.fim ? fmtData(new Date(f.fim).toISOString()) : '—'], ['Reservar como', comoUnidade ? (ws?.nome || 'Unidade') : 'Pessoal'], @@ -586,7 +630,7 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =

Total{modeloLabel ? ` · ${modeloLabel}` : ''}

{total > 0 ? fmtValor(String(total)) : 'Sob consulta'}

- {f.modelo === 'hora' && total > 0 &&

{qtd}h × {fmtValor(String(Number(sala.valor_hora)))}

} + {total > 0 && UNIDADE[f.modelo] && qtd > 1 &&

{qtd} {UNIDADE[f.modelo].plur} × {fmtValor(String(Number(sala[MODELOS.find(m => m.id === f.modelo)!.valor])))}

}