Calendar: hide cancelled bookings, drag-to-move rooms, allow booking through checked-out

- Filter cancelled bookings from calendar grid (they no longer appear as red blocks)
- Remove cancelled from legend
- Drag-to-move: left-click drag a booking block to a different room row; click without dragging still opens the detail panel
- checked_out bookings are now semi-transparent and pointer-events-none so clicking through them creates a new booking
- Conflict detection in BookingDetailPanel now excludes checked_out bookings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 13:09:13 +03:00
parent ed397dbcd9
commit 3602ea7318
2 changed files with 64 additions and 6 deletions

View File

@@ -81,6 +81,7 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, onClose,
b.roomId === rid && b.roomId === rid &&
b.status !== 'cancelled' && b.status !== 'cancelled' &&
b.status !== 'no_show' && b.status !== 'no_show' &&
b.status !== 'checked_out' &&
b.checkIn < co && b.checkOut > ci, b.checkIn < co && b.checkOut > ci,
) ?? null ) ?? null

View File

@@ -80,6 +80,16 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
const [dragStart, setDragStart] = useState<{ roomId: string; dayIdx: number } | null>(null) const [dragStart, setDragStart] = useState<{ roomId: string; dayIdx: number } | null>(null)
const [dragEnd, setDragEnd] = useState<number | null>(null) const [dragEnd, setDragEnd] = useState<number | null>(null)
// Drag-to-move booking state
const [movingBooking, setMovingBooking] = useState<Booking | null>(null)
const [moveTargetRoomId, setMoveTargetRoomId] = useState<string | null>(null)
const pendingMoveBooking = useRef<Booking | null>(null)
const moveStartPos = useRef<{ x: number; y: number } | null>(null)
const movingBookingRef = useRef<Booking | null>(null)
const moveTargetRoomIdRef = useRef<string | null>(null)
movingBookingRef.current = movingBooking
moveTargetRoomIdRef.current = moveTargetRoomId
// Compact mode (default from Settings → Appearance) // Compact mode (default from Settings → Appearance)
const [compact, setCompact] = useState( const [compact, setCompact] = useState(
() => localStorage.getItem('calendarCompact') === 'true', () => localStorage.getItem('calendarCompact') === 'true',
@@ -130,6 +140,7 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
const handleCellMouseDown = useCallback((roomId: string, dayIdx: number, e: React.MouseEvent) => { const handleCellMouseDown = useCallback((roomId: string, dayIdx: number, e: React.MouseEvent) => {
if (e.button !== 0) return if (e.button !== 0) return
if (pendingMoveBooking.current || movingBookingRef.current) return
e.preventDefault() e.preventDefault()
setDragStart({ roomId, dayIdx }) setDragStart({ roomId, dayIdx })
setDragEnd(dayIdx) setDragEnd(dayIdx)
@@ -140,7 +151,40 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
setDragEnd(dayIdx) setDragEnd(dayIdx)
}, [dragStart]) }, [dragStart])
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!pendingMoveBooking.current || movingBookingRef.current) return
const dx = Math.abs(e.clientX - (moveStartPos.current?.x ?? 0))
const dy = Math.abs(e.clientY - (moveStartPos.current?.y ?? 0))
if (dx > 5 || dy > 5) {
setMovingBooking(pendingMoveBooking.current)
setMoveTargetRoomId(pendingMoveBooking.current.roomId)
}
}, [])
const handleMouseUp = useCallback(() => { const handleMouseUp = useCallback(() => {
const mb = movingBookingRef.current
const targetRoom = moveTargetRoomIdRef.current
if (mb) {
// Was dragging a booking to another room
if (targetRoom && targetRoom !== mb.roomId) {
onBookingUpdate(mb.id, { roomId: targetRoom })
}
setMovingBooking(null)
setMoveTargetRoomId(null)
pendingMoveBooking.current = null
moveStartPos.current = null
return
}
if (pendingMoveBooking.current) {
// Mouse went up without moving — treat as click
setSelectedBooking(pendingMoveBooking.current)
pendingMoveBooking.current = null
moveStartPos.current = null
return
}
if (!dragStart || dragEnd === null) return if (!dragStart || dragEnd === null) return
const minDay = Math.min(dragStart.dayIdx, dragEnd) const minDay = Math.min(dragStart.dayIdx, dragEnd)
const maxDay = Math.max(dragStart.dayIdx, dragEnd) const maxDay = Math.max(dragStart.dayIdx, dragEnd)
@@ -151,7 +195,7 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
setDragStart(null) setDragStart(null)
setDragEnd(null) setDragEnd(null)
setDraft(null) setDraft(null)
}, [dragStart, dragEnd, startDate, onDraftStart]) }, [dragStart, dragEnd, startDate, onDraftStart, onBookingUpdate])
const getLockStyle = (roomId: string) => { const getLockStyle = (roomId: string) => {
const lock = locks.get(roomId) const lock = locks.get(roomId)
@@ -170,7 +214,7 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
} }
return ( return (
<div className="flex flex-col h-full" onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}> <div className="flex flex-col h-full" onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} onMouseMove={handleMouseMove}>
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shrink-0 flex-wrap gap-y-2"> <div className="flex items-center gap-3 px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shrink-0 flex-wrap gap-y-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -259,7 +303,7 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
{/* Legend */} {/* Legend */}
<div className="hidden lg:flex items-center gap-3 text-xs text-slate-500"> <div className="hidden lg:flex items-center gap-3 text-xs text-slate-500">
{(['confirmed', 'checked_in', 'checked_out', 'inquiry', 'cancelled'] as const).map(s => ( {(['confirmed', 'checked_in', 'checked_out', 'inquiry'] as const).map(s => (
<div key={s} className="flex items-center gap-1.5"> <div key={s} className="flex items-center gap-1.5">
<div className={cn('w-3 h-3 rounded-sm', BOOKING_STATUS_COLORS[s].split(' ')[0])} /> <div className={cn('w-3 h-3 rounded-sm', BOOKING_STATUS_COLORS[s].split(' ')[0])} />
<span>{BOOKING_STATUS_LABELS[s]}</span> <span>{BOOKING_STATUS_LABELS[s]}</span>
@@ -380,13 +424,17 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
) )
} }
// Room row // Room row
const roomBookings = bookings.filter(b => b.roomId === room.id) const roomBookings = bookings.filter(b => b.roomId === room.id && b.status !== 'cancelled')
const draftStyle = getDraftStyle(room.id) const draftStyle = getDraftStyle(room.id)
rows.push( rows.push(
<div <div
key={room.id} key={room.id}
className="flex border-b border-slate-200 dark:border-slate-700 group hover:bg-slate-50/50 dark:hover:bg-slate-800/30" className={cn(
'flex border-b border-slate-200 dark:border-slate-700 group hover:bg-slate-50/50 dark:hover:bg-slate-800/30',
movingBooking && moveTargetRoomId === room.id && 'bg-brand-50/60 dark:bg-brand-900/20',
)}
style={{ height: rowHeight }} style={{ height: rowHeight }}
onMouseEnter={() => { if (movingBookingRef.current) setMoveTargetRoomId(room.id) }}
> >
{/* Room label */} {/* Room label */}
<div <div
@@ -449,6 +497,9 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
'booking-block border-l-4 transition-all duration-700', 'booking-block border-l-4 transition-all duration-700',
BOOKING_STATUS_COLORS[booking.status], BOOKING_STATUS_COLORS[booking.status],
isFading && 'opacity-0 scale-y-0', isFading && 'opacity-0 scale-y-0',
booking.status === 'checked_out' && 'opacity-50 pointer-events-none',
movingBooking?.id === booking.id && 'opacity-40 pointer-events-none',
booking.status !== 'checked_out' && !movingBooking && 'cursor-grab',
)} )}
style={{ style={{
left: style.left + 2, left: style.left + 2,
@@ -457,7 +508,13 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
bottom: compact ? 2 : 4, bottom: compact ? 2 : 4,
position: 'absolute', position: 'absolute',
}} }}
onClick={(e) => { e.stopPropagation(); setSelectedBooking(booking) }} onMouseDown={(e) => {
if (e.button !== 0) return
e.stopPropagation()
e.preventDefault()
pendingMoveBooking.current = booking
moveStartPos.current = { x: e.clientX, y: e.clientY }
}}
title={`${booking.guestName}${booking.checkIn}${hourlyMatch ? ` ${hourlyMatch[1]}:00${hourlyMatch[2]}:00` : ` ${booking.checkOut}`}${isUnpaid ? ' • Не оплачено' : ''}`} title={`${booking.guestName}${booking.checkIn}${hourlyMatch ? ` ${hourlyMatch[1]}:00${hourlyMatch[2]}:00` : ` ${booking.checkOut}`}${isUnpaid ? ' • Не оплачено' : ''}`}
> >
{isUnpaid && ( {isUnpaid && (