feat(salas): modelo "Dia" com seleção de dias avulsos (não-consecutivos)

Antes o modelo Dia só permitia um intervalo consecutivo (início + N dias).
Agora permite escolher dias avulsos (ex.: 1 e 5, 3 e 20):

- Clique no calendário alterna (marca/desmarca) cada dia — estado diasMulti[].
- Card mostra chips dos dias selecionados (removíveis; dia ocupado fica âmbar) +
  dica "cada dia vira uma reserva (08h–18h)".
- Total = valor_diaria × nº de dias; resumo lista os dias.
- handleReserve envia uma reserva por dia; reporta quantas foram criadas e
  quantas falharam (ex.: já ocupado).
- Travas de navegação/botão passam a usar temSelecao (Dia não usa f.inicio).
- Semana/Mês seguem com stepper de quantidade; Hora/Período inalterados.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-07 04:25:49 +02:00
parent c78c745f3f
commit d6aa1cdeac
+83 -31
View File
@@ -337,6 +337,7 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
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');
@@ -345,6 +346,8 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
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);
@@ -355,7 +358,9 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
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;
return f.modelo === 'turno' ? v : v * Math.max(1, qtd);
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.
@@ -366,11 +371,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).
// 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) ctrlRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, [diaSel, f.modelo]);
useEffect(() => { if (diaSel || diasMulti.length) ctrlRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, [diaSel, diasMulti.length, f.modelo]);
useEffect(() => {
apiFetch(`/api/salas/${sala.id}/reservas`)
@@ -389,21 +394,31 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
}, [comoUnidade]);
const handleReserve = async () => {
if (!f.inicio || !f.fim) { toast.error('INFORME INÍCIO E FIM.'); return; }
// 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; }
setSaving(true);
try {
const res = await apiFetch(`/api/salas/${sala.id}/reservas`, {
method: 'POST',
body: JSON.stringify({
inicio: new Date(f.inicio).toISOString(), fim: new Date(f.fim).toISOString(), modelo: f.modelo,
observacoes: [f.observacoes, `Forma de pagamento: ${FORMAS[formaPagamento] || formaPagamento}`].filter(Boolean).join(' · ') || null,
clinicaId: comoUnidade && ws?.id ? ws.id : null,
profissionalUsuarioId: comoUnidade && profissionalId ? profissionalId : null,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Erro ao reservar.');
toast.success('RESERVA SOLICITADA! AGUARDE A CONFIRMAÇÃO DO LOCADOR.');
const obs = [f.observacoes, `Forma de pagamento: ${FORMAS[formaPagamento] || formaPagamento}`].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()); }
@@ -498,11 +513,20 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
{monthGrid(mesRef).map((dia, i) => {
if (!dia) return <div key={i} />;
const passado = dia < hoje0;
const sel = !!(diaSel && mesmoDia(dia, diaSel));
const emRange = !!(diaSel && rangeFim && (f.modelo === 'diaria' || f.modelo === 'semanal' || f.modelo === 'mensal') && dia > diaSel && dia <= rangeFim);
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={() => setDiaSel(dia)}
<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()}
@@ -512,14 +536,15 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
})}
</div>
</div>
{diaSel && (
{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">
{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' })}`}
{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>
@@ -559,7 +584,7 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
</div>
)}
{(f.modelo === 'diaria' || f.modelo === 'semanal' || f.modelo === 'mensal') && (
{(f.modelo === 'semanal' || f.modelo === 'mensal') && (
<div>
<label className={labelCls}>Quantidade de {UNIDADE[f.modelo]?.plur}</label>
<div className="flex items-center gap-2">
@@ -570,6 +595,25 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
</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 (08h18h)</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} /> ocupado neste dia</p>
@@ -593,9 +637,16 @@ 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()) : '—'],
...(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">
@@ -630,7 +681,8 @@ 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>
{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>}
{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>
@@ -656,12 +708,12 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
{step === 0 ? 'Cancelar' : 'Voltar'}
</button>
{step < 3 ? (
<button onClick={() => setStep(s => s + 1)} disabled={step === 1 && !f.inicio}
<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 || !f.inicio}
<button onClick={handleReserve} disabled={saving || !temSelecao}
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>