feat: 15-minute rental slots + compact calendar cell display
- Rental bookings now use minutes since midnight (start_hour/end_hour store e.g. 495 for 8:15, 570 for 9:30) — DB migration 017 - Buffer applied in exact minutes (no hour ceiling): 15min buffer after 9:00 booking → next slot available at 9:15 - Calendar cell: removed working-hours line (8:00–22:00), now shows colored dot + compact time per booking (8–10, 8:15–9:15) - Time selects show HH:MM format for 15-min intervals Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -44,10 +44,21 @@ interface BookingModalProps {
|
||||
}
|
||||
|
||||
// ── Rental time helpers ────────────────────────────────────────────────────────
|
||||
function rentalHourOptions(from: number, to: number) {
|
||||
return Array.from({ length: Math.max(0, to - from + 1) }, (_, i) => from + i)
|
||||
function rentalTimeOptions(openHour: number, closeHour: number): number[] {
|
||||
const result: number[] = []
|
||||
for (let m = openHour * 60; m < closeHour * 60; m += 15) result.push(m)
|
||||
return result
|
||||
}
|
||||
function rentalEndOptions(fromMin: number, toMin: number): number[] {
|
||||
const result: number[] = []
|
||||
for (let m = fromMin + 15; m <= toMin; m += 15) result.push(m)
|
||||
return result
|
||||
}
|
||||
function fmtTime(min: number) {
|
||||
const h = Math.floor(min / 60)
|
||||
const m = min % 60
|
||||
return m === 0 ? `${h}:00` : `${h}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
function fmtH(h: number) { return `${h}:00` }
|
||||
|
||||
function isConflict(bookings: Booking[], roomId: string, checkIn: string, checkOut: string, excludeId?: string) {
|
||||
if (!checkIn || !checkOut || checkIn >= checkOut) return false
|
||||
@@ -86,8 +97,8 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
const [rentalObjId, setRentalObjId] = useState(rentalObjects?.[0]?.id ?? '')
|
||||
const [rentalDate, setRentalDate] = useState(draft.checkIn)
|
||||
const [rentalIsFullDay, setRentalIsFullDay] = useState(false)
|
||||
const [rentalStartH, setRentalStartH] = useState(0)
|
||||
const [rentalEndH, setRentalEndH] = useState(2)
|
||||
const [rentalStartM, setRentalStartM] = useState(0) // minutes since midnight
|
||||
const [rentalEndM, setRentalEndM] = useState(60)
|
||||
const [rentalGuest, setRentalGuest] = useState('')
|
||||
const [rentalPhone, setRentalPhone] = useState('')
|
||||
const [rentalNotes, setRentalNotes] = useState('')
|
||||
@@ -99,24 +110,24 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
(b.date as string).slice(0, 10) === rentalDate &&
|
||||
b.status !== 'cancelled',
|
||||
)
|
||||
const rentalBreakH = Math.ceil((rentalObj?.bufferMinutes ?? 0) / 60)
|
||||
const rentalBufMin = rentalObj?.bufferMinutes ?? 0
|
||||
const rentalAvailStarts = rentalObj
|
||||
? rentalHourOptions(rentalObj.openHour, rentalObj.closeHour - 1).filter(h =>
|
||||
!rentalDayBookings.some(b => !b.isFullDay && h >= b.startHour && h < b.endHour + rentalBreakH),
|
||||
? rentalTimeOptions(rentalObj.openHour, rentalObj.closeHour).filter(m =>
|
||||
!rentalDayBookings.some(b => !b.isFullDay && m >= b.startHour && m < b.endHour + rentalBufMin),
|
||||
)
|
||||
: []
|
||||
const rentalGetMaxEnd = (start: number) => {
|
||||
const next = rentalDayBookings.filter(b => !b.isFullDay && b.startHour >= start + 1)
|
||||
const next = rentalDayBookings.filter(b => !b.isFullDay && b.startHour >= start + 15)
|
||||
.sort((a, b) => a.startHour - b.startHour)[0]
|
||||
return next ? next.startHour - rentalBreakH : (rentalObj?.closeHour ?? 22)
|
||||
return next ? next.startHour - rentalBufMin : (rentalObj?.closeHour ?? 22) * 60
|
||||
}
|
||||
const rentalEffStart = rentalAvailStarts.includes(rentalStartH) ? rentalStartH : (rentalAvailStarts[0] ?? rentalObj?.openHour ?? 8)
|
||||
const rentalAvailEnds = rentalHourOptions(rentalEffStart + 1, rentalGetMaxEnd(rentalEffStart))
|
||||
const rentalEffStart = rentalAvailStarts.includes(rentalStartM) ? rentalStartM : (rentalAvailStarts[0] ?? (rentalObj?.openHour ?? 8) * 60)
|
||||
const rentalAvailEnds = rentalEndOptions(rentalEffStart, rentalGetMaxEnd(rentalEffStart))
|
||||
|
||||
// Sync rentalEndH when rentalEffStart shifts past it
|
||||
// Sync rentalEndM when rentalEffStart shifts past it
|
||||
useLayoutEffect(() => {
|
||||
if (!rentalIsFullDay && rentalEndH <= rentalEffStart) {
|
||||
setRentalEndH(rentalEffStart + 1)
|
||||
if (!rentalIsFullDay && rentalEndM <= rentalEffStart) {
|
||||
setRentalEndM(rentalEffStart + 60)
|
||||
}
|
||||
}, [rentalEffStart])
|
||||
const rentalHasFullDay = rentalDayBookings.some(b => b.isFullDay)
|
||||
@@ -125,7 +136,7 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
|
||||
const rentalHours = rentalIsFullDay
|
||||
? (rentalObj ? rentalObj.closeHour - rentalObj.openHour : 0)
|
||||
: Math.max(0, rentalEndH - rentalEffStart)
|
||||
: Math.max(0, (rentalEndM - rentalEffStart) / 60)
|
||||
const rentalTotal = rentalObj
|
||||
? (rentalIsFullDay ? rentalObj.pricePerDay : rentalHours * rentalObj.pricePerHour)
|
||||
: 0
|
||||
@@ -146,8 +157,8 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
objectId: rentalObj.id,
|
||||
date: rentalDate,
|
||||
isFullDay: rentalIsFullDay,
|
||||
startHour: rentalIsFullDay ? rentalObj.openHour : rentalEffStart,
|
||||
endHour: rentalIsFullDay ? rentalObj.closeHour : rentalEndH,
|
||||
startHour: rentalIsFullDay ? rentalObj.openHour * 60 : rentalEffStart,
|
||||
endHour: rentalIsFullDay ? rentalObj.closeHour * 60 : rentalEndM,
|
||||
guestName: rentalGuest.trim(),
|
||||
guestPhone: rentalPhone.trim(),
|
||||
notes: rentalNotes.trim() || undefined,
|
||||
@@ -407,7 +418,7 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
{rentalObjects.map(o => (
|
||||
<button
|
||||
key={o.id}
|
||||
onClick={() => { setRentalObjId(o.id); setRentalStartH(o.openHour); setRentalEndH(Math.min(o.openHour + 2, o.closeHour)) }}
|
||||
onClick={() => { setRentalObjId(o.id); setRentalStartM(o.openHour * 60); setRentalEndM(o.openHour * 60 + 60) }}
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-2.5 rounded-xl border text-sm font-medium transition-colors text-left',
|
||||
rentalObjId === o.id
|
||||
@@ -475,8 +486,8 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
Время ({rentalObj.openHour}:00 – {rentalObj.closeHour}:00)
|
||||
{rentalBreakH > 0 && <span className="ml-1 font-normal normal-case">(перерыв {rentalObj.bufferMinutes} мин)</span>}
|
||||
Время
|
||||
{rentalBufMin > 0 && <span className="ml-1 font-normal normal-case">(перерыв {rentalBufMin} мин)</span>}
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
@@ -484,20 +495,20 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
<select className="input text-sm" value={rentalEffStart}
|
||||
onChange={e => {
|
||||
const v = parseInt(e.target.value)
|
||||
setRentalStartH(v)
|
||||
setRentalStartM(v)
|
||||
const maxE = rentalGetMaxEnd(v)
|
||||
if (rentalEndH <= v || rentalEndH > maxE) setRentalEndH(Math.min(v + 1, maxE))
|
||||
if (rentalEndM <= v || rentalEndM > maxE) setRentalEndM(Math.min(v + 60, maxE))
|
||||
}}>
|
||||
{rentalAvailStarts.map(h => <option key={h} value={h}>{fmtH(h)}</option>)}
|
||||
{rentalAvailStarts.map(m => <option key={m} value={m}>{fmtTime(m)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<Clock size={14} className="text-slate-400 mt-4 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-slate-500 mb-1">Конец</label>
|
||||
<select className="input text-sm"
|
||||
value={rentalAvailEnds.includes(rentalEndH) ? rentalEndH : (rentalAvailEnds[0] ?? rentalEndH)}
|
||||
onChange={e => setRentalEndH(parseInt(e.target.value))}>
|
||||
{rentalAvailEnds.map(h => <option key={h} value={h}>{fmtH(h)}</option>)}
|
||||
value={rentalAvailEnds.includes(rentalEndM) ? rentalEndM : (rentalAvailEnds[0] ?? rentalEndM)}
|
||||
onChange={e => setRentalEndM(parseInt(e.target.value))}>
|
||||
{rentalAvailEnds.map(m => <option key={m} value={m}>{fmtTime(m)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user