feat(salas): reserva de múltiplos dias/semanas/meses + controles visíveis
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<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 },
|
||||
};
|
||||
|
||||
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<HTMLDivElement>(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 <div key={i} />;
|
||||
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 (
|
||||
<button key={i} type="button" disabled={passado} onClick={() => setDiaSel(dia)}
|
||||
@@ -497,48 +512,76 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{diaSel && 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 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 text-gray-500 font-black text-lg leading-none">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{diaSel && 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-gray-50 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>
|
||||
)}
|
||||
{diaSel && 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})
|
||||
{diaSel && (
|
||||
<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">
|
||||
{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' })}`}
|
||||
</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 === 'diaria' || 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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
@@ -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: () =
|
||||
<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>
|
||||
{f.modelo === 'hora' && total > 0 && <p className="text-[10px] text-teal-500 font-bold mt-1">{qtd}h × {fmtValor(String(Number(sala.valor_hora)))}</p>}
|
||||
{total > 0 && 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>
|
||||
|
||||
Reference in New Issue
Block a user