From d6aa1cdeac4a25c8c5c688bf3f8928b5dd28d8b3 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Tue, 7 Jul 2026 04:25:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(salas):=20modelo=20"Dia"=20com=20sele?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20dias=20avulsos=20(n=C3=A3o-consecutivos)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/views/plugins/SalasPlugin.tsx | 114 ++++++++++++++++++------- 1 file changed, 83 insertions(+), 31 deletions(-) diff --git a/frontend/views/plugins/SalasPlugin.tsx b/frontend/views/plugins/SalasPlugin.tsx index 5b6cf06..75beeac 100644 --- a/frontend/views/plugins/SalasPlugin.tsx +++ b/frontend/views/plugins/SalasPlugin.tsx @@ -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(null); + const [diasMulti, setDiasMulti] = useState([]); // 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(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
; 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 ( -
- {diaSel && ( + {temSelecao && (

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

{modeloLabel} @@ -559,7 +584,7 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
)} - {(f.modelo === 'diaria' || f.modelo === 'semanal' || f.modelo === 'mensal') && ( + {(f.modelo === 'semanal' || f.modelo === 'mensal') && (
@@ -570,6 +595,25 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
)} + {f.modelo === 'diaria' && ( +
+ +
+ {diasMultiSorted.map((d, i) => { + const ocup = diasOcupados.has(d.toDateString()); + return ( + + ); + })} +
+

Toque no calendário para adicionar/remover dias · cada dia vira uma reserva (08h–18h)

+
+ )} + {ocupadasDoDia.length > 0 && (

Já ocupado neste dia

@@ -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]) => (
@@ -630,7 +681,8 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =

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

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

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

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

} + {total > 0 && f.modelo === 'diaria' && diasMulti.length > 1 &&

{diasMulti.length} dias × {fmtValor(String(Number(sala.valor_diaria)))}

} + {total > 0 && f.modelo !== 'diaria' && UNIDADE[f.modelo] && qtd > 1 &&

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

}
@@ -656,12 +708,12 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () = {step === 0 ? 'Cancelar' : 'Voltar'} {step < 3 ? ( - ) : ( -