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:
6
backend/migrations/017_rental_minutes.sql
Normal file
6
backend/migrations/017_rental_minutes.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Convert rental_bookings start_hour/end_hour from whole hours to minutes since midnight
|
||||||
|
-- Safe: only converts rows where start_hour < 25 (i.e., stored as hours, not minutes yet)
|
||||||
|
UPDATE rental_bookings
|
||||||
|
SET start_hour = start_hour * 60,
|
||||||
|
end_hour = end_hour * 60
|
||||||
|
WHERE start_hour < 25;
|
||||||
@@ -44,10 +44,21 @@ interface BookingModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Rental time helpers ────────────────────────────────────────────────────────
|
// ── Rental time helpers ────────────────────────────────────────────────────────
|
||||||
function rentalHourOptions(from: number, to: number) {
|
function rentalTimeOptions(openHour: number, closeHour: number): number[] {
|
||||||
return Array.from({ length: Math.max(0, to - from + 1) }, (_, i) => from + i)
|
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) {
|
function isConflict(bookings: Booking[], roomId: string, checkIn: string, checkOut: string, excludeId?: string) {
|
||||||
if (!checkIn || !checkOut || checkIn >= checkOut) return false
|
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 [rentalObjId, setRentalObjId] = useState(rentalObjects?.[0]?.id ?? '')
|
||||||
const [rentalDate, setRentalDate] = useState(draft.checkIn)
|
const [rentalDate, setRentalDate] = useState(draft.checkIn)
|
||||||
const [rentalIsFullDay, setRentalIsFullDay] = useState(false)
|
const [rentalIsFullDay, setRentalIsFullDay] = useState(false)
|
||||||
const [rentalStartH, setRentalStartH] = useState(0)
|
const [rentalStartM, setRentalStartM] = useState(0) // minutes since midnight
|
||||||
const [rentalEndH, setRentalEndH] = useState(2)
|
const [rentalEndM, setRentalEndM] = useState(60)
|
||||||
const [rentalGuest, setRentalGuest] = useState('')
|
const [rentalGuest, setRentalGuest] = useState('')
|
||||||
const [rentalPhone, setRentalPhone] = useState('')
|
const [rentalPhone, setRentalPhone] = useState('')
|
||||||
const [rentalNotes, setRentalNotes] = 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.date as string).slice(0, 10) === rentalDate &&
|
||||||
b.status !== 'cancelled',
|
b.status !== 'cancelled',
|
||||||
)
|
)
|
||||||
const rentalBreakH = Math.ceil((rentalObj?.bufferMinutes ?? 0) / 60)
|
const rentalBufMin = rentalObj?.bufferMinutes ?? 0
|
||||||
const rentalAvailStarts = rentalObj
|
const rentalAvailStarts = rentalObj
|
||||||
? rentalHourOptions(rentalObj.openHour, rentalObj.closeHour - 1).filter(h =>
|
? rentalTimeOptions(rentalObj.openHour, rentalObj.closeHour).filter(m =>
|
||||||
!rentalDayBookings.some(b => !b.isFullDay && h >= b.startHour && h < b.endHour + rentalBreakH),
|
!rentalDayBookings.some(b => !b.isFullDay && m >= b.startHour && m < b.endHour + rentalBufMin),
|
||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
const rentalGetMaxEnd = (start: number) => {
|
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]
|
.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 rentalEffStart = rentalAvailStarts.includes(rentalStartM) ? rentalStartM : (rentalAvailStarts[0] ?? (rentalObj?.openHour ?? 8) * 60)
|
||||||
const rentalAvailEnds = rentalHourOptions(rentalEffStart + 1, rentalGetMaxEnd(rentalEffStart))
|
const rentalAvailEnds = rentalEndOptions(rentalEffStart, rentalGetMaxEnd(rentalEffStart))
|
||||||
|
|
||||||
// Sync rentalEndH when rentalEffStart shifts past it
|
// Sync rentalEndM when rentalEffStart shifts past it
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!rentalIsFullDay && rentalEndH <= rentalEffStart) {
|
if (!rentalIsFullDay && rentalEndM <= rentalEffStart) {
|
||||||
setRentalEndH(rentalEffStart + 1)
|
setRentalEndM(rentalEffStart + 60)
|
||||||
}
|
}
|
||||||
}, [rentalEffStart])
|
}, [rentalEffStart])
|
||||||
const rentalHasFullDay = rentalDayBookings.some(b => b.isFullDay)
|
const rentalHasFullDay = rentalDayBookings.some(b => b.isFullDay)
|
||||||
@@ -125,7 +136,7 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
|||||||
|
|
||||||
const rentalHours = rentalIsFullDay
|
const rentalHours = rentalIsFullDay
|
||||||
? (rentalObj ? rentalObj.closeHour - rentalObj.openHour : 0)
|
? (rentalObj ? rentalObj.closeHour - rentalObj.openHour : 0)
|
||||||
: Math.max(0, rentalEndH - rentalEffStart)
|
: Math.max(0, (rentalEndM - rentalEffStart) / 60)
|
||||||
const rentalTotal = rentalObj
|
const rentalTotal = rentalObj
|
||||||
? (rentalIsFullDay ? rentalObj.pricePerDay : rentalHours * rentalObj.pricePerHour)
|
? (rentalIsFullDay ? rentalObj.pricePerDay : rentalHours * rentalObj.pricePerHour)
|
||||||
: 0
|
: 0
|
||||||
@@ -146,8 +157,8 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
|||||||
objectId: rentalObj.id,
|
objectId: rentalObj.id,
|
||||||
date: rentalDate,
|
date: rentalDate,
|
||||||
isFullDay: rentalIsFullDay,
|
isFullDay: rentalIsFullDay,
|
||||||
startHour: rentalIsFullDay ? rentalObj.openHour : rentalEffStart,
|
startHour: rentalIsFullDay ? rentalObj.openHour * 60 : rentalEffStart,
|
||||||
endHour: rentalIsFullDay ? rentalObj.closeHour : rentalEndH,
|
endHour: rentalIsFullDay ? rentalObj.closeHour * 60 : rentalEndM,
|
||||||
guestName: rentalGuest.trim(),
|
guestName: rentalGuest.trim(),
|
||||||
guestPhone: rentalPhone.trim(),
|
guestPhone: rentalPhone.trim(),
|
||||||
notes: rentalNotes.trim() || undefined,
|
notes: rentalNotes.trim() || undefined,
|
||||||
@@ -407,7 +418,7 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
|||||||
{rentalObjects.map(o => (
|
{rentalObjects.map(o => (
|
||||||
<button
|
<button
|
||||||
key={o.id}
|
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(
|
className={cn(
|
||||||
'flex items-center gap-2 p-2.5 rounded-xl border text-sm font-medium transition-colors text-left',
|
'flex items-center gap-2 p-2.5 rounded-xl border text-sm font-medium transition-colors text-left',
|
||||||
rentalObjId === o.id
|
rentalObjId === o.id
|
||||||
@@ -475,8 +486,8 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
|||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
<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>
|
</label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -484,20 +495,20 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
|||||||
<select className="input text-sm" value={rentalEffStart}
|
<select className="input text-sm" value={rentalEffStart}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
const v = parseInt(e.target.value)
|
const v = parseInt(e.target.value)
|
||||||
setRentalStartH(v)
|
setRentalStartM(v)
|
||||||
const maxE = rentalGetMaxEnd(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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<Clock size={14} className="text-slate-400 mt-4 shrink-0" />
|
<Clock size={14} className="text-slate-400 mt-4 shrink-0" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="block text-xs text-slate-500 mb-1">Конец</label>
|
<label className="block text-xs text-slate-500 mb-1">Конец</label>
|
||||||
<select className="input text-sm"
|
<select className="input text-sm"
|
||||||
value={rentalAvailEnds.includes(rentalEndH) ? rentalEndH : (rentalAvailEnds[0] ?? rentalEndH)}
|
value={rentalAvailEnds.includes(rentalEndM) ? rentalEndM : (rentalAvailEnds[0] ?? rentalEndM)}
|
||||||
onChange={e => setRentalEndH(parseInt(e.target.value))}>
|
onChange={e => setRentalEndM(parseInt(e.target.value))}>
|
||||||
{rentalAvailEnds.map(h => <option key={h} value={h}>{fmtH(h)}</option>)}
|
{rentalAvailEnds.map(m => <option key={m} value={m}>{fmtTime(m)}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -696,26 +696,21 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
|
|||||||
) : dayBookings.length > 0 ? (
|
) : dayBookings.length > 0 ? (
|
||||||
/* Hourly bookings — squares + times */
|
/* Hourly bookings — squares + times */
|
||||||
<div className="absolute inset-x-1 top-1.5 flex flex-col gap-0.5">
|
<div className="absolute inset-x-1 top-1.5 flex flex-col gap-0.5">
|
||||||
{/* One colored square per booking */}
|
{/* One colored dot per booking + its time */}
|
||||||
<div className="flex gap-0.5 flex-wrap">
|
{dayBookings.map(b => {
|
||||||
{dayBookings.map(b => (
|
const fmtT = (min: number) => {
|
||||||
<div
|
const h = Math.floor(min / 60), m = min % 60
|
||||||
key={b.id}
|
return m === 0 ? `${h}` : `${h}:${String(m).padStart(2,'0')}`
|
||||||
className={cn('w-3 h-3 rounded-[3px]', obj.color)}
|
}
|
||||||
title={`${b.guestName} · ${b.startHour}:00–${b.endHour}:00`}
|
return (
|
||||||
/>
|
<div key={b.id} className="flex items-center gap-0.5">
|
||||||
))}
|
<div className={cn('w-2.5 h-2.5 rounded-[3px] shrink-0', obj.color)} />
|
||||||
|
<span className="text-[9px] text-slate-700 dark:text-slate-200 font-medium leading-none truncate">
|
||||||
|
{fmtT(b.startHour)}–{fmtT(b.endHour)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Working hours range */}
|
)
|
||||||
<span className="text-[9px] text-slate-400 dark:text-slate-500 leading-none">
|
})}
|
||||||
{obj.openHour}:00–{obj.closeHour}:00
|
|
||||||
</span>
|
|
||||||
{/* Booked time slots */}
|
|
||||||
{dayBookings.map(b => (
|
|
||||||
<span key={b.id} className="text-[9px] text-slate-600 dark:text-slate-300 font-medium leading-none">
|
|
||||||
{b.startHour}–{b.endHour}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Empty — show "+" hint on hover */
|
/* Empty — show "+" hint on hover */
|
||||||
|
|||||||
@@ -17,8 +17,25 @@ interface RentalBookingModalProps {
|
|||||||
onSave: (b: RentalBooking) => void
|
onSave: (b: RentalBooking) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function hourOptions(from: number, to: number): number[] {
|
// 15-minute slot options (values in minutes since midnight)
|
||||||
return Array.from({ length: to - from + 1 }, (_, i) => from + i)
|
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 {
|
function maskPhone(raw: string): string {
|
||||||
@@ -39,8 +56,9 @@ export function RentalBookingModal({
|
|||||||
}: RentalBookingModalProps) {
|
}: RentalBookingModalProps) {
|
||||||
const isEdit = !!editBooking
|
const isEdit = !!editBooking
|
||||||
const [isFullDay, setIsFullDay] = useState(editBooking?.isFullDay ?? false)
|
const [isFullDay, setIsFullDay] = useState(editBooking?.isFullDay ?? false)
|
||||||
const [startHour, setStartHour] = useState(editBooking?.startHour ?? obj.openHour)
|
// startMin / endMin store minutes since midnight (e.g. 8:15 = 495)
|
||||||
const [endHour, setEndHour] = useState(editBooking?.endHour ?? Math.min(obj.openHour + 2, obj.closeHour))
|
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 [guestName, setGuestName] = useState(editBooking?.guestName ?? '')
|
||||||
const [guestPhone, setGuestPhone] = useState(editBooking?.guestPhone ?? '')
|
const [guestPhone, setGuestPhone] = useState(editBooking?.guestPhone ?? '')
|
||||||
const [notes, setNotes] = useState(editBooking?.notes ?? '')
|
const [notes, setNotes] = useState(editBooking?.notes ?? '')
|
||||||
@@ -101,48 +119,48 @@ export function RentalBookingModal({
|
|||||||
// If any timed slot is booked, "весь день" is unavailable
|
// If any timed slot is booked, "весь день" is unavailable
|
||||||
const hasTimedConflict = timedBookings.length > 0
|
const hasTimedConflict = timedBookings.length > 0
|
||||||
|
|
||||||
// Break between sessions (ceil to whole hours)
|
// Buffer in minutes (used directly — no hour ceiling)
|
||||||
const breakHours = Math.ceil((obj.bufferMinutes ?? 0) / 60)
|
const bufMin = obj.bufferMinutes ?? 0
|
||||||
|
|
||||||
// Available start hours: exclude hours inside any booked slot + its buffer
|
// Available 15-min start slots: exclude minutes inside any booked slot + its buffer
|
||||||
const availableStartHours = hourOptions(obj.openHour, obj.closeHour - 1).filter(h =>
|
const availableStarts = timeOptions(obj.openHour, obj.closeHour).filter(m =>
|
||||||
!timedBookings.some(b => h >= b.startHour && h < b.endHour + breakHours),
|
!timedBookings.some(b => m >= b.startHour && m < b.endHour + bufMin),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Max end hour for a given start: limited by next booking minus buffer
|
// Max end minute for a given start: limited by next booking start minus buffer
|
||||||
const getMaxEndHour = (start: number) => {
|
const getMaxEnd = (start: number) => {
|
||||||
const next = timedBookings
|
const next = timedBookings
|
||||||
.filter(b => b.startHour >= start + 1)
|
.filter(b => b.startHour >= start + 15)
|
||||||
.sort((a, b) => a.startHour - b.startHour)[0]
|
.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
|
// If current startMin is blocked, snap to first available
|
||||||
const effectiveStartHour = availableStartHours.includes(startHour)
|
const effectiveStart = availableStarts.includes(startMin)
|
||||||
? startHour
|
? startMin
|
||||||
: (availableStartHours[0] ?? obj.openHour)
|
: (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(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isFullDay && endHour <= effectiveStartHour) {
|
if (!isFullDay && endMin <= effectiveStart) {
|
||||||
setEndHour(effectiveStartHour + 1)
|
setEndMin(effectiveStart + 60) // default 1 hour
|
||||||
}
|
}
|
||||||
}, [effectiveStartHour])
|
}, [effectiveStart])
|
||||||
|
|
||||||
const hours = isFullDay
|
const hours = isFullDay
|
||||||
? obj.closeHour - obj.openHour
|
? obj.closeHour - obj.openHour
|
||||||
: Math.max(0, endHour - effectiveStartHour)
|
: Math.max(0, (endMin - effectiveStart) / 60)
|
||||||
|
|
||||||
const totalAmount = isFullDay ? obj.pricePerDay : hours * obj.pricePerHour
|
const totalAmount = isFullDay ? obj.pricePerDay : hours * obj.pricePerHour
|
||||||
const maxHoursOk = !obj.maxHoursPerSlot || hours <= obj.maxHoursPerSlot
|
const maxHoursOk = !obj.maxHoursPerSlot || hours <= obj.maxHoursPerSlot
|
||||||
|
|
||||||
const isTimeConflict = !isFullDay && timedBookings.some(b =>
|
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
|
// For new (unknown) guests, phone is required
|
||||||
const phoneRequired = !guestIsKnown
|
const phoneRequired = !guestIsKnown
|
||||||
@@ -171,8 +189,8 @@ export function RentalBookingModal({
|
|||||||
objectId: obj.id,
|
objectId: obj.id,
|
||||||
date,
|
date,
|
||||||
isFullDay,
|
isFullDay,
|
||||||
startHour: isFullDay ? obj.openHour : startHour,
|
startHour: isFullDay ? obj.openHour * 60 : effectiveStart,
|
||||||
endHour: isFullDay ? obj.closeHour : endHour,
|
endHour: isFullDay ? obj.closeHour * 60 : endMin,
|
||||||
guestName: guestName.trim(),
|
guestName: guestName.trim(),
|
||||||
guestPhone: guestPhone.trim(),
|
guestPhone: guestPhone.trim(),
|
||||||
linkedRoomId: (linked?.roomId ?? linkedBookingId) || undefined,
|
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 key={b.id} className="flex items-center gap-2 text-sm">
|
||||||
<div className={cn('w-2 h-2 rounded-full shrink-0', obj.color)} />
|
<div className={cn('w-2 h-2 rounded-full shrink-0', obj.color)} />
|
||||||
<span className="text-slate-700 dark:text-slate-300 font-medium">
|
<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>
|
||||||
<span className="text-slate-500 dark:text-slate-400 truncate">{b.guestName}</span>
|
<span className="text-slate-500 dark:text-slate-400 truncate">{b.guestName}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,8 +279,8 @@ export function RentalBookingModal({
|
|||||||
{!isFullDay && !hasFullDayConflict && (
|
{!isFullDay && !hasFullDayConflict && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
<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>
|
</label>
|
||||||
{noSlots ? (
|
{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">
|
<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>
|
<label className="block text-xs text-slate-500 mb-1">Начало</label>
|
||||||
<select
|
<select
|
||||||
className="input text-sm"
|
className="input text-sm"
|
||||||
value={effectiveStartHour}
|
value={effectiveStart}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
const v = parseInt(e.target.value)
|
const v = parseInt(e.target.value)
|
||||||
setStartHour(v)
|
setStartMin(v)
|
||||||
const maxEnd = getMaxEndHour(v)
|
const maxEnd = getMaxEnd(v)
|
||||||
if (endHour <= v || endHour > maxEnd) setEndHour(Math.min(v + 1, maxEnd))
|
if (endMin <= v || endMin > maxEnd) setEndMin(Math.min(v + 60, maxEnd))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{availableStartHours.map(h => (
|
{availableStarts.map(m => (
|
||||||
<option key={h} value={h}>{formatHour(h)}</option>
|
<option key={m} value={m}>{fmtTime(m)}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -294,11 +312,11 @@ export function RentalBookingModal({
|
|||||||
<label className="block text-xs text-slate-500 mb-1">Конец</label>
|
<label className="block text-xs text-slate-500 mb-1">Конец</label>
|
||||||
<select
|
<select
|
||||||
className="input text-sm"
|
className="input text-sm"
|
||||||
value={availableEndHours.includes(endHour) ? endHour : (availableEndHours[0] ?? endHour)}
|
value={availableEnds.includes(endMin) ? endMin : (availableEnds[0] ?? endMin)}
|
||||||
onChange={e => setEndHour(parseInt(e.target.value))}
|
onChange={e => setEndMin(parseInt(e.target.value))}
|
||||||
>
|
>
|
||||||
{availableEndHours.map(h => (
|
{availableEnds.map(m => (
|
||||||
<option key={h} value={h}>{formatHour(h)}</option>
|
<option key={m} value={m}>{fmtTime(m)}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -491,7 +491,10 @@ export function RentalPage() {
|
|||||||
<td className="px-4 py-2.5 text-slate-700 dark:text-slate-300">
|
<td className="px-4 py-2.5 text-slate-700 dark:text-slate-300">
|
||||||
{b.isFullDay ? (
|
{b.isFullDay ? (
|
||||||
<Badge className="bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400">Весь день</Badge>
|
<Badge className="bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400">Весь день</Badge>
|
||||||
) : `${b.startHour}:00 – ${b.endHour}:00`}
|
) : (() => {
|
||||||
|
const fmt = (m: number) => { const h = Math.floor(m/60), mn = m%60; return mn === 0 ? `${h}:00` : `${h}:${String(mn).padStart(2,'0')}` }
|
||||||
|
return `${fmt(b.startHour)} – ${fmt(b.endHour)}`
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 font-medium text-slate-900 dark:text-slate-100">{b.guestName}</td>
|
<td className="px-4 py-2.5 font-medium text-slate-900 dark:text-slate-100">{b.guestName}</td>
|
||||||
<td className="px-4 py-2.5 text-slate-500 dark:text-slate-400">{b.guestPhone || '—'}</td>
|
<td className="px-4 py-2.5 text-slate-500 dark:text-slate-400">{b.guestPhone || '—'}</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user