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

@@ -80,6 +80,16 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
const [dragStart, setDragStart] = useState<{ roomId: string; dayIdx: 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)
const [compact, setCompact] = useState(
() => 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) => {
if (e.button !== 0) return
if (pendingMoveBooking.current || movingBookingRef.current) return
e.preventDefault()
setDragStart({ roomId, dayIdx })
setDragEnd(dayIdx)
@@ -140,7 +151,40 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
setDragEnd(dayIdx)
}, [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 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
const minDay = Math.min(dragStart.dayIdx, dragEnd)
const maxDay = Math.max(dragStart.dayIdx, dragEnd)
@@ -151,7 +195,7 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
setDragStart(null)
setDragEnd(null)
setDraft(null)
}, [dragStart, dragEnd, startDate, onDraftStart])
}, [dragStart, dragEnd, startDate, onDraftStart, onBookingUpdate])
const getLockStyle = (roomId: string) => {
const lock = locks.get(roomId)
@@ -170,7 +214,7 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
}
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 */}
<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">
@@ -259,7 +303,7 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
{/* Legend */}
<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 className={cn('w-3 h-3 rounded-sm', BOOKING_STATUS_COLORS[s].split(' ')[0])} />
<span>{BOOKING_STATUS_LABELS[s]}</span>
@@ -380,13 +424,17 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
)
}
// 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)
rows.push(
<div
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 }}
onMouseEnter={() => { if (movingBookingRef.current) setMoveTargetRoomId(room.id) }}
>
{/* Room label */}
<div
@@ -449,6 +497,9 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
'booking-block border-l-4 transition-all duration-700',
BOOKING_STATUS_COLORS[booking.status],
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={{
left: style.left + 2,
@@ -457,7 +508,13 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
bottom: compact ? 2 : 4,
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 ? ' • Не оплачено' : ''}`}
>
{isUnpaid && (