Calendar: add drag ghost element + grabbing cursor; BookingDetail: add Relocate room for checked_in guests

This commit is contained in:
2026-03-21 00:37:26 +03:00
parent 75d7d6aa08
commit 3e4a2a3cd2
3 changed files with 93 additions and 2 deletions

View File

@@ -250,6 +250,26 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
const [conflictResolveRoomId, setConflictResolveRoomId] = useState('') const [conflictResolveRoomId, setConflictResolveRoomId] = useState('')
const canEditDates = booking.status === 'confirmed' || booking.status === 'inquiry' const canEditDates = booking.status === 'confirmed' || booking.status === 'inquiry'
const canRelocate = booking.status === 'checked_in'
const [relocating, setRelocating] = useState(false)
const [relocateRoomId, setRelocateRoomId] = useState(booking.roomId)
const freeRoomsForRelocate = (rooms ?? []).filter(r =>
r.id !== booking.roomId &&
!(allBookings ?? []).some(b =>
b.id !== booking.id &&
b.roomId === r.id &&
b.status !== 'cancelled' && b.status !== 'no_show' && b.status !== 'checked_out' &&
b.checkIn < booking.checkOut && b.checkOut > booking.checkIn,
),
)
const handleRelocate = () => {
if (!relocateRoomId || relocateRoomId === booking.roomId) return
onUpdate(booking.id, { roomId: relocateRoomId })
setRelocating(false)
}
const findConflict = (ci: string, co: string, rid: string): Booking | null => const findConflict = (ci: string, co: string, rid: string): Booking | null =>
(allBookings ?? []).find(b => (allBookings ?? []).find(b =>
@@ -405,13 +425,66 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
<div className="space-y-4"> <div className="space-y-4">
{room && ( {room && (
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/40 p-3"> <div className="rounded-xl bg-slate-50 dark:bg-slate-700/40 p-3">
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium mb-1">Номер</p> <div className="flex items-start justify-between mb-1">
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium">Номер</p>
{canRelocate && !relocating && (
<button
onClick={() => { setRelocating(true); setRelocateRoomId('') }}
className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300 font-medium"
>
<Pencil size={11} /> Переселить
</button>
)}
</div>
<p className="font-semibold text-slate-900 dark:text-slate-100"> <p className="font-semibold text-slate-900 dark:text-slate-100">
{room.number} {room.type} {room.number} {room.type}
</p> </p>
<p className="text-sm text-slate-500 dark:text-slate-400"> <p className="text-sm text-slate-500 dark:text-slate-400">
Этаж {room.floor} · {room.bedType} bed Этаж {room.floor} · {room.bedType} bed
</p> </p>
{/* Форма переселения */}
{relocating && (
<div className="mt-3 space-y-2 pt-3 border-t border-slate-200 dark:border-slate-600">
<p className="text-xs font-medium text-amber-700 dark:text-amber-400">Переселить гостя в другой номер</p>
{freeRoomsForRelocate.length > 0 ? (
<>
<select
className="input text-sm"
value={relocateRoomId}
onChange={e => setRelocateRoomId(e.target.value)}
>
<option value=""> выберите номер </option>
{freeRoomsForRelocate.map(r => (
<option key={r.id} value={r.id}>
{r.number} {r.type} ({r.baseRate.toLocaleString('ru-RU')} /ночь)
</option>
))}
</select>
<div className="flex gap-2">
<button
onClick={handleRelocate}
disabled={!relocateRoomId}
className="flex-1 py-1.5 rounded-lg bg-amber-500 hover:bg-amber-600 disabled:opacity-50 text-white text-xs font-semibold transition-colors"
>
Переселить
</button>
<button
onClick={() => setRelocating(false)}
className="px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-600 text-xs text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
Отмена
</button>
</div>
</>
) : (
<div className="text-xs text-slate-500 dark:text-slate-400">
Нет свободных номеров на эти даты
<button onClick={() => setRelocating(false)} className="ml-2 text-brand-600 dark:text-brand-400 underline">Закрыть</button>
</div>
)}
</div>
)}
</div> </div>
)} )}

View File

@@ -84,6 +84,7 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
// Drag-to-move booking state // Drag-to-move booking state
const [movingBooking, setMovingBooking] = useState<Booking | null>(null) const [movingBooking, setMovingBooking] = useState<Booking | null>(null)
const [moveTargetRoomId, setMoveTargetRoomId] = useState<string | null>(null) const [moveTargetRoomId, setMoveTargetRoomId] = useState<string | null>(null)
const [ghostPos, setGhostPos] = useState<{ x: number; y: number } | null>(null)
const movingBookingRef = useRef<Booking | null>(null) const movingBookingRef = useRef<Booking | null>(null)
const didDragRef = useRef(false) const didDragRef = useRef(false)
movingBookingRef.current = movingBooking movingBookingRef.current = movingBooking
@@ -486,9 +487,11 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
dragging = true dragging = true
setMovingBooking(booking) setMovingBooking(booking)
setMoveTargetRoomId(booking.roomId) setMoveTargetRoomId(booking.roomId)
document.body.style.cursor = 'grabbing'
} }
return return
} }
setGhostPos({ x: ev.clientX, y: ev.clientY })
const el = document.elementFromPoint(ev.clientX, ev.clientY) const el = document.elementFromPoint(ev.clientX, ev.clientY)
const row = el?.closest('[data-room-id]') as HTMLElement | null const row = el?.closest('[data-room-id]') as HTMLElement | null
if (row?.dataset.roomId) setMoveTargetRoomId(row.dataset.roomId) if (row?.dataset.roomId) setMoveTargetRoomId(row.dataset.roomId)
@@ -496,6 +499,7 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
const onUp = (ev: MouseEvent) => { const onUp = (ev: MouseEvent) => {
window.removeEventListener('mousemove', onMove) window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp) window.removeEventListener('mouseup', onUp)
document.body.style.cursor = ''
if (dragging) { if (dragging) {
const el = document.elementFromPoint(ev.clientX, ev.clientY) const el = document.elementFromPoint(ev.clientX, ev.clientY)
const row = el?.closest('[data-room-id]') as HTMLElement | null const row = el?.closest('[data-room-id]') as HTMLElement | null
@@ -506,6 +510,7 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
} }
setMovingBooking(null) setMovingBooking(null)
setMoveTargetRoomId(null) setMoveTargetRoomId(null)
setGhostPos(null)
} }
} }
window.addEventListener('mousemove', onMove) window.addEventListener('mousemove', onMove)
@@ -741,6 +746,19 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
/> />
)} )}
{/* Drag ghost element */}
{movingBooking && ghostPos && (
<div
className={cn(
'fixed z-[9999] pointer-events-none px-3 py-1.5 rounded-lg text-white text-xs font-semibold shadow-xl border-2 border-white/40',
BOOKING_STATUS_COLORS[movingBooking.status],
)}
style={{ left: ghostPos.x + 14, top: ghostPos.y - 16 }}
>
{movingBooking.guestName}
</div>
)}
{/* Rental booking modal */} {/* Rental booking modal */}
{rentalModal && ( {rentalModal && (
<RentalBookingModal <RentalBookingModal

View File

@@ -38,7 +38,7 @@
@layer components { @layer components {
.booking-block { .booking-block {
@apply absolute inset-y-1 rounded-md text-white text-xs font-medium @apply absolute inset-y-1 rounded-md text-white text-xs font-medium
flex items-center px-2 overflow-hidden cursor-pointer flex items-center px-2 overflow-hidden cursor-grab
select-none transition-all duration-100 hover:brightness-110; select-none transition-all duration-100 hover:brightness-110;
} }