Calendar: add drag ghost element + grabbing cursor; BookingDetail: add Relocate room for checked_in guests
This commit is contained in:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user