feat: maintenance/blocked date ranges with booking overlap warning

- DB migration 020: add maintenance_from, maintenance_to to rooms
- Room type + RoomPayload: maintenanceFrom, maintenanceTo fields
- Context menu: clicking "На ремонт"/"Закрыт" expands inline date picker
  (С / По + "Без срока" checkbox), confirms with "Применить" button
- Calendar: stripe overlay covers only the maintenance date range
  (full row if no dates set, partial if date range specified)
- Booking creation: if drag-selected dates overlap with maintenance period,
  shows warning dialog with "Отмена" / "Всё равно забронировать"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 14:49:19 +03:00
parent 4a39d5f731
commit 63da3d21cc
7 changed files with 274 additions and 55 deletions

View File

@@ -65,13 +65,18 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
// Room context menu
const [ctxMenu, setCtxMenu] = useState<{ room: Room; x: number; y: number } | null>(null)
// Maintenance warning before booking
const [maintenanceWarningDraft, setMaintenanceWarningDraft] = useState<{
draft: DraftBooking; roomStatus: RoomStatus; period: string
} | null>(null)
const handleRoomContextMenu = (e: React.MouseEvent, room: Room) => {
e.preventDefault()
setCtxMenu({ room, x: e.clientX, y: e.clientY })
}
const handleCtxStatusChange = (roomId: string, status: RoomStatus) => {
onRoomUpdate?.(roomId, { status })
const handleCtxStatusChange = (roomId: string, status: RoomStatus, from?: string | null, to?: string | null) => {
onRoomUpdate?.(roomId, { status, maintenanceFrom: from, maintenanceTo: to })
}
const handleCtxHkChange = (roomId: string, status: HousekeepingStatus) => {
@@ -184,14 +189,34 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
if (!dragStart || dragEnd === null) return
const minDay = Math.min(dragStart.dayIdx, dragEnd)
const maxDay = Math.max(dragStart.dayIdx, dragEnd)
const checkIn = format(addDays(startDate, minDay), 'yyyy-MM-dd')
const checkIn = format(addDays(startDate, minDay), 'yyyy-MM-dd')
const checkOut = format(addDays(startDate, maxDay + 1), 'yyyy-MM-dd')
setBookingModalDraft({ roomId: dragStart.roomId, checkIn, checkOut })
const draft = { roomId: dragStart.roomId, checkIn, checkOut }
// Check maintenance/blocked overlap
const draftRoom = rooms.find(r => r.id === dragStart.roomId)
if (draftRoom && (draftRoom.status === 'maintenance' || draftRoom.status === 'blocked')) {
const bookIn = new Date(checkIn + 'T00:00:00')
const bookOut = new Date(checkOut + 'T00:00:00')
const mFrom = draftRoom.maintenanceFrom ? new Date(draftRoom.maintenanceFrom + 'T00:00:00') : null
const mTo = draftRoom.maintenanceTo ? new Date(draftRoom.maintenanceTo + 'T00:00:00') : null
const overlaps = (!mFrom || bookOut > mFrom) && (!mTo || bookIn < mTo)
if (overlaps) {
const period = mFrom
? ` (${draftRoom.maintenanceFrom}${mTo ? `${draftRoom.maintenanceTo}` : ' — без срока'})`
: ' (без срока)'
setMaintenanceWarningDraft({ draft, roomStatus: draftRoom.status, period })
setDragStart(null); setDragEnd(null); setDraft(null)
return
}
}
setBookingModalDraft(draft)
onDraftStart?.(dragStart.roomId, checkIn, checkOut)
setDragStart(null)
setDragEnd(null)
setDraft(null)
}, [dragStart, dragEnd, startDate, onDraftStart, onBookingUpdate])
}, [dragStart, dragEnd, startDate, onDraftStart, onBookingUpdate, rooms])
const getLockStyle = (roomId: string) => {
const lock = locks.get(roomId)
@@ -478,20 +503,34 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
{/* Day cells + booking blocks */}
<div className="relative flex-1">
{/* Maintenance / blocked overlay */}
{(room.status === 'maintenance' || room.status === 'blocked') && (
<div
className="absolute inset-0 z-[1] pointer-events-none"
style={{
background: room.status === 'maintenance'
? 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(251,146,60,0.12) 6px, rgba(251,146,60,0.12) 12px)'
: 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(148,163,184,0.15) 6px, rgba(148,163,184,0.15) 12px)',
backgroundColor: room.status === 'maintenance'
? 'rgba(251,146,60,0.05)'
: 'rgba(148,163,184,0.08)',
}}
/>
)}
{/* Maintenance / blocked overlay — with optional date range */}
{(room.status === 'maintenance' || room.status === 'blocked') && (() => {
const from = room.maintenanceFrom ? new Date(room.maintenanceFrom + 'T00:00:00') : null
const to = room.maintenanceTo ? new Date(room.maintenanceTo + 'T00:00:00') : null
// Calculate left offset and width if dates are set
let left = 0, width = '100%'
if (from || to) {
const visibleFrom = from ? Math.max(0, differenceInDays(from, startDate)) : 0
const visibleTo = to ? Math.min(dates.length - 1, differenceInDays(to, startDate)) : dates.length - 1
if (visibleTo < 0 || visibleFrom >= dates.length) return null
left = visibleFrom * CELL_WIDTH
width = `${(visibleTo - visibleFrom + 1) * CELL_WIDTH}px`
}
return (
<div
className="absolute top-0 bottom-0 z-[1] pointer-events-none"
style={{
left, width,
background: room.status === 'maintenance'
? 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(251,146,60,0.12) 6px, rgba(251,146,60,0.12) 12px)'
: 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(148,163,184,0.15) 6px, rgba(148,163,184,0.15) 12px)',
backgroundColor: room.status === 'maintenance'
? 'rgba(251,146,60,0.05)'
: 'rgba(148,163,184,0.08)',
}}
/>
)
})()}
<div className="flex h-full">
{dates.map((date, i) => {
const isWe = date.getDay() === 0 || date.getDay() === 6
@@ -781,6 +820,38 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
</div>
</div>
{/* Maintenance warning before booking */}
{maintenanceWarningDraft && (
<div className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/40" onClick={() => setMaintenanceWarningDraft(null)}>
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl p-6 w-80 max-w-[90vw]" onClick={e => e.stopPropagation()}>
<div className="flex items-start gap-3 mb-4">
<span className="text-2xl">{maintenanceWarningDraft.roomStatus === 'maintenance' ? '🔧' : '🔒'}</span>
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">
Номер {maintenanceWarningDraft.roomStatus === 'maintenance' ? 'на ремонте' : 'закрыт'}
</p>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
Выбранные даты пересекаются с периодом
{maintenanceWarningDraft.roomStatus === 'maintenance' ? ' ремонта' : ' закрытия'}
{maintenanceWarningDraft.period}.
</p>
</div>
</div>
<div className="flex gap-2">
<button className="flex-1 btn-secondary text-sm" onClick={() => setMaintenanceWarningDraft(null)}>
Отмена
</button>
<button
className="flex-1 py-2 px-3 rounded-xl text-sm font-medium bg-orange-500 hover:bg-orange-600 text-white transition-colors"
onClick={() => { setBookingModalDraft(maintenanceWarningDraft.draft); setMaintenanceWarningDraft(null) }}
>
Всё равно забронировать
</button>
</div>
</div>
</div>
)}
{/* Booking create modal */}
{bookingModalDraft && (
<BookingModal