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:
@@ -17,8 +17,25 @@ interface RentalBookingModalProps {
|
||||
onSave: (b: RentalBooking) => void
|
||||
}
|
||||
|
||||
function hourOptions(from: number, to: number): number[] {
|
||||
return Array.from({ length: to - from + 1 }, (_, i) => from + i)
|
||||
// 15-minute slot options (values in minutes since midnight)
|
||||
function timeOptions(openHour: number, closeHour: number): number[] {
|
||||
const result: number[] = []
|
||||
for (let m = openHour * 60; m < closeHour * 60; m += 15) result.push(m)
|
||||
return result
|
||||
}
|
||||
|
||||
// End-time options: from start+15 to maxEnd (inclusive), step 15
|
||||
function endOptions(fromMin: number, toMin: number): number[] {
|
||||
const result: number[] = []
|
||||
for (let m = fromMin + 15; m <= toMin; m += 15) result.push(m)
|
||||
return result
|
||||
}
|
||||
|
||||
// Format minutes since midnight as "H:MM" or "H:00"
|
||||
function fmtTime(min: number): string {
|
||||
const h = Math.floor(min / 60)
|
||||
const m = min % 60
|
||||
return m === 0 ? `${h}:00` : `${h}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function maskPhone(raw: string): string {
|
||||
@@ -39,8 +56,9 @@ export function RentalBookingModal({
|
||||
}: RentalBookingModalProps) {
|
||||
const isEdit = !!editBooking
|
||||
const [isFullDay, setIsFullDay] = useState(editBooking?.isFullDay ?? false)
|
||||
const [startHour, setStartHour] = useState(editBooking?.startHour ?? obj.openHour)
|
||||
const [endHour, setEndHour] = useState(editBooking?.endHour ?? Math.min(obj.openHour + 2, obj.closeHour))
|
||||
// startMin / endMin store minutes since midnight (e.g. 8:15 = 495)
|
||||
const [startMin, setStartMin] = useState(editBooking?.startHour ?? obj.openHour * 60)
|
||||
const [endMin, setEndMin] = useState(editBooking?.endHour ?? obj.openHour * 60 + 60)
|
||||
const [guestName, setGuestName] = useState(editBooking?.guestName ?? '')
|
||||
const [guestPhone, setGuestPhone] = useState(editBooking?.guestPhone ?? '')
|
||||
const [notes, setNotes] = useState(editBooking?.notes ?? '')
|
||||
@@ -101,48 +119,48 @@ export function RentalBookingModal({
|
||||
// If any timed slot is booked, "весь день" is unavailable
|
||||
const hasTimedConflict = timedBookings.length > 0
|
||||
|
||||
// Break between sessions (ceil to whole hours)
|
||||
const breakHours = Math.ceil((obj.bufferMinutes ?? 0) / 60)
|
||||
// Buffer in minutes (used directly — no hour ceiling)
|
||||
const bufMin = obj.bufferMinutes ?? 0
|
||||
|
||||
// Available start hours: exclude hours inside any booked slot + its buffer
|
||||
const availableStartHours = hourOptions(obj.openHour, obj.closeHour - 1).filter(h =>
|
||||
!timedBookings.some(b => h >= b.startHour && h < b.endHour + breakHours),
|
||||
// Available 15-min start slots: exclude minutes inside any booked slot + its buffer
|
||||
const availableStarts = timeOptions(obj.openHour, obj.closeHour).filter(m =>
|
||||
!timedBookings.some(b => m >= b.startHour && m < b.endHour + bufMin),
|
||||
)
|
||||
|
||||
// Max end hour for a given start: limited by next booking minus buffer
|
||||
const getMaxEndHour = (start: number) => {
|
||||
// Max end minute for a given start: limited by next booking start minus buffer
|
||||
const getMaxEnd = (start: number) => {
|
||||
const next = timedBookings
|
||||
.filter(b => b.startHour >= start + 1)
|
||||
.filter(b => b.startHour >= start + 15)
|
||||
.sort((a, b) => a.startHour - b.startHour)[0]
|
||||
return next ? next.startHour - breakHours : obj.closeHour
|
||||
return next ? next.startHour - bufMin : obj.closeHour * 60
|
||||
}
|
||||
|
||||
// If current startHour is blocked, snap to first available
|
||||
const effectiveStartHour = availableStartHours.includes(startHour)
|
||||
? startHour
|
||||
: (availableStartHours[0] ?? obj.openHour)
|
||||
// If current startMin is blocked, snap to first available
|
||||
const effectiveStart = availableStarts.includes(startMin)
|
||||
? startMin
|
||||
: (availableStarts[0] ?? obj.openHour * 60)
|
||||
|
||||
const availableEndHours = hourOptions(effectiveStartHour + 1, getMaxEndHour(effectiveStartHour))
|
||||
const availableEnds = endOptions(effectiveStart, getMaxEnd(effectiveStart))
|
||||
|
||||
// Sync endHour state when effectiveStartHour shifts past it (e.g. first available start is beyond initial endHour)
|
||||
// Sync endMin when effectiveStart shifts past it
|
||||
useLayoutEffect(() => {
|
||||
if (!isFullDay && endHour <= effectiveStartHour) {
|
||||
setEndHour(effectiveStartHour + 1)
|
||||
if (!isFullDay && endMin <= effectiveStart) {
|
||||
setEndMin(effectiveStart + 60) // default 1 hour
|
||||
}
|
||||
}, [effectiveStartHour])
|
||||
}, [effectiveStart])
|
||||
|
||||
const hours = isFullDay
|
||||
? obj.closeHour - obj.openHour
|
||||
: Math.max(0, endHour - effectiveStartHour)
|
||||
: Math.max(0, (endMin - effectiveStart) / 60)
|
||||
|
||||
const totalAmount = isFullDay ? obj.pricePerDay : hours * obj.pricePerHour
|
||||
const maxHoursOk = !obj.maxHoursPerSlot || hours <= obj.maxHoursPerSlot
|
||||
|
||||
const isTimeConflict = !isFullDay && timedBookings.some(b =>
|
||||
b.startHour < endHour && b.endHour > effectiveStartHour,
|
||||
b.startHour < endMin && b.endHour > effectiveStart,
|
||||
)
|
||||
|
||||
const noSlots = !hasFullDayConflict && availableStartHours.length === 0
|
||||
const noSlots = !hasFullDayConflict && availableStarts.length === 0
|
||||
|
||||
// For new (unknown) guests, phone is required
|
||||
const phoneRequired = !guestIsKnown
|
||||
@@ -171,8 +189,8 @@ export function RentalBookingModal({
|
||||
objectId: obj.id,
|
||||
date,
|
||||
isFullDay,
|
||||
startHour: isFullDay ? obj.openHour : startHour,
|
||||
endHour: isFullDay ? obj.closeHour : endHour,
|
||||
startHour: isFullDay ? obj.openHour * 60 : effectiveStart,
|
||||
endHour: isFullDay ? obj.closeHour * 60 : endMin,
|
||||
guestName: guestName.trim(),
|
||||
guestPhone: guestPhone.trim(),
|
||||
linkedRoomId: (linked?.roomId ?? linkedBookingId) || undefined,
|
||||
@@ -215,7 +233,7 @@ export function RentalBookingModal({
|
||||
<div key={b.id} className="flex items-center gap-2 text-sm">
|
||||
<div className={cn('w-2 h-2 rounded-full shrink-0', obj.color)} />
|
||||
<span className="text-slate-700 dark:text-slate-300 font-medium">
|
||||
{b.isFullDay ? 'Весь день' : `${b.startHour}:00 – ${b.endHour}:00`}
|
||||
{b.isFullDay ? 'Весь день' : `${fmtTime(b.startHour)} – ${fmtTime(b.endHour)}`}
|
||||
</span>
|
||||
<span className="text-slate-500 dark:text-slate-400 truncate">{b.guestName}</span>
|
||||
</div>
|
||||
@@ -261,8 +279,8 @@ export function RentalBookingModal({
|
||||
{!isFullDay && !hasFullDayConflict && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
Время аренды ({obj.openHour}:00 – {obj.closeHour}:00)
|
||||
{breakHours > 0 && <span className="ml-1 font-normal normal-case">(перерыв {obj.bufferMinutes} мин)</span>}
|
||||
Время аренды
|
||||
{bufMin > 0 && <span className="ml-1 font-normal normal-case">(перерыв {bufMin} мин)</span>}
|
||||
</label>
|
||||
{noSlots ? (
|
||||
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
|
||||
@@ -276,16 +294,16 @@ export function RentalBookingModal({
|
||||
<label className="block text-xs text-slate-500 mb-1">Начало</label>
|
||||
<select
|
||||
className="input text-sm"
|
||||
value={effectiveStartHour}
|
||||
value={effectiveStart}
|
||||
onChange={e => {
|
||||
const v = parseInt(e.target.value)
|
||||
setStartHour(v)
|
||||
const maxEnd = getMaxEndHour(v)
|
||||
if (endHour <= v || endHour > maxEnd) setEndHour(Math.min(v + 1, maxEnd))
|
||||
setStartMin(v)
|
||||
const maxEnd = getMaxEnd(v)
|
||||
if (endMin <= v || endMin > maxEnd) setEndMin(Math.min(v + 60, maxEnd))
|
||||
}}
|
||||
>
|
||||
{availableStartHours.map(h => (
|
||||
<option key={h} value={h}>{formatHour(h)}</option>
|
||||
{availableStarts.map(m => (
|
||||
<option key={m} value={m}>{fmtTime(m)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -294,11 +312,11 @@ export function RentalBookingModal({
|
||||
<label className="block text-xs text-slate-500 mb-1">Конец</label>
|
||||
<select
|
||||
className="input text-sm"
|
||||
value={availableEndHours.includes(endHour) ? endHour : (availableEndHours[0] ?? endHour)}
|
||||
onChange={e => setEndHour(parseInt(e.target.value))}
|
||||
value={availableEnds.includes(endMin) ? endMin : (availableEnds[0] ?? endMin)}
|
||||
onChange={e => setEndMin(parseInt(e.target.value))}
|
||||
>
|
||||
{availableEndHours.map(h => (
|
||||
<option key={h} value={h}>{formatHour(h)}</option>
|
||||
{availableEnds.map(m => (
|
||||
<option key={m} value={m}>{fmtTime(m)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user