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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user