feat: early check-in for guests who arrive before booking date
Show amber banner on confirmed bookings where check-in is in the future. Opens an inline form to check in today: checks if current room is free, otherwise shows available rooms with price difference. Allows overriding checkout date. On confirm, sets status=checked_in and check-in=today. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { format, differenceInDays } from 'date-fns'
|
||||
import {
|
||||
X, Mail, Calendar, Users, CreditCard, Tag, CheckCircle, XCircle,
|
||||
Printer, ScanLine, Banknote, Building2, Plus, Pencil,
|
||||
FileText, FileCheck, Receipt, IdCard, AlertTriangle,
|
||||
Trash2, Loader2, UserCheck, Baby, Search,
|
||||
Trash2, Loader2, UserCheck, Baby, Search, LogIn,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS,
|
||||
@@ -252,9 +253,17 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
||||
const canEditDates = booking.status === 'confirmed' || booking.status === 'inquiry'
|
||||
const canRelocate = booking.status === 'checked_in'
|
||||
|
||||
const today = format(new Date(), 'yyyy-MM-dd')
|
||||
const canEarlyCheckIn = booking.status === 'confirmed' && booking.checkIn > today
|
||||
|
||||
const [relocating, setRelocating] = useState(false)
|
||||
const [relocateRoomId, setRelocateRoomId] = useState(booking.roomId)
|
||||
|
||||
const [earlyOpen, setEarlyOpen] = useState(false)
|
||||
const [earlyNewCheckOut, setEarlyNewCheckOut] = useState(booking.checkOut)
|
||||
const [earlyRoomId, setEarlyRoomId] = useState(booking.roomId)
|
||||
const [earlySaving, setEarlySaving] = useState(false)
|
||||
|
||||
const freeRoomsForRelocate = (rooms ?? []).filter(r =>
|
||||
r.id !== booking.roomId &&
|
||||
!(allBookings ?? []).some(b =>
|
||||
@@ -271,6 +280,23 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
||||
setRelocating(false)
|
||||
}
|
||||
|
||||
const handleEarlyCheckIn = async () => {
|
||||
if (earlyNewCheckOut <= today) { showToast('Дата выезда должна быть позже сегодня'); return }
|
||||
setEarlySaving(true)
|
||||
try {
|
||||
await onUpdate(booking.id, {
|
||||
checkIn: today,
|
||||
checkOut: earlyNewCheckOut,
|
||||
roomId: earlyRoomId,
|
||||
status: 'checked_in',
|
||||
})
|
||||
setEarlyOpen(false)
|
||||
showToast('Гость заселён досрочно')
|
||||
} finally {
|
||||
setEarlySaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const findConflict = (ci: string, co: string, rid: string): Booking | null =>
|
||||
(allBookings ?? []).find(b =>
|
||||
b.id !== booking.id &&
|
||||
@@ -488,6 +514,132 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Early check-in banner */}
|
||||
{canEarlyCheckIn && !earlyOpen && (
|
||||
<div className="rounded-xl border border-amber-200 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<LogIn size={15} className="text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-amber-800 dark:text-amber-300">Гость приехал раньше</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
|
||||
Бронь начинается {fmtDate(booking.checkIn)}, но можно заселить сейчас
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setEarlyOpen(true); setEarlyRoomId(booking.roomId); setEarlyNewCheckOut(booking.checkOut) }}
|
||||
className="shrink-0 text-xs font-semibold text-amber-700 dark:text-amber-300 bg-amber-100 dark:bg-amber-800/40 hover:bg-amber-200 dark:hover:bg-amber-700/50 px-2.5 py-1 rounded-lg transition-colors"
|
||||
>
|
||||
Заселить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Early check-in form */}
|
||||
{canEarlyCheckIn && earlyOpen && (() => {
|
||||
const currentRoomFree = !((allBookings ?? []).some(b =>
|
||||
b.id !== booking.id && b.roomId === booking.roomId &&
|
||||
b.status !== 'cancelled' && b.status !== 'no_show' && b.status !== 'checked_out' &&
|
||||
b.checkIn < earlyNewCheckOut && b.checkOut > today,
|
||||
))
|
||||
const availableRooms = (rooms ?? []).filter(r =>
|
||||
!((allBookings ?? []).some(b =>
|
||||
b.id !== booking.id && b.roomId === r.id &&
|
||||
b.status !== 'cancelled' && b.status !== 'no_show' && b.status !== 'checked_out' &&
|
||||
b.checkIn < earlyNewCheckOut && b.checkOut > today,
|
||||
)),
|
||||
)
|
||||
const extraNights = differenceInDays(new Date(booking.checkIn), new Date(today))
|
||||
const selectedRoom = (rooms ?? []).find(r => r.id === earlyRoomId)
|
||||
const origRoom = (rooms ?? []).find(r => r.id === booking.roomId)
|
||||
const priceDiff = selectedRoom && origRoom ? selectedRoom.baseRate - origRoom.baseRate : 0
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 p-3 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<LogIn size={14} className="text-amber-600 dark:text-amber-400" />
|
||||
<p className="text-xs font-semibold text-amber-800 dark:text-amber-300">Ранний заезд</p>
|
||||
</div>
|
||||
|
||||
{/* Room selector */}
|
||||
{currentRoomFree ? (
|
||||
<div className="text-xs text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-lg px-2.5 py-2">
|
||||
Текущий номер свободен — можно заселить без переноса
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400">
|
||||
Текущий номер занят. Выберите свободный:
|
||||
</p>
|
||||
{availableRooms.length > 0 ? (
|
||||
<select
|
||||
className="input text-sm"
|
||||
value={earlyRoomId}
|
||||
onChange={e => setEarlyRoomId(e.target.value)}
|
||||
>
|
||||
<option value="">— выберите номер —</option>
|
||||
{availableRooms.map(r => {
|
||||
const diff = r.baseRate - (origRoom?.baseRate ?? 0)
|
||||
const diffStr = diff > 0 ? ` (+${diff.toLocaleString('ru-RU')} ₽/н)` : diff < 0 ? ` (${diff.toLocaleString('ru-RU')} ₽/н)` : ''
|
||||
return (
|
||||
<option key={r.id} value={r.id}>
|
||||
№{r.number} — {r.type}{diffStr}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">Нет свободных номеров</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Checkout date */}
|
||||
<div>
|
||||
<label className={lbl}>Дата выезда</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input text-sm"
|
||||
value={earlyNewCheckOut}
|
||||
min={today}
|
||||
onChange={e => setEarlyNewCheckOut(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Extra nights info */}
|
||||
{earlyNewCheckOut > today && (
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 space-y-0.5">
|
||||
<p>Заезд сегодня → доп. {extraNights} {extraNights === 1 ? 'ночь' : extraNights < 5 ? 'ночи' : 'ночей'}</p>
|
||||
{priceDiff !== 0 && selectedRoom && (
|
||||
<p className={priceDiff > 0 ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}>
|
||||
Разница в стоимости: {priceDiff > 0 ? '+' : ''}{priceDiff.toLocaleString('ru-RU')} ₽/ночь
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEarlyOpen(false)}
|
||||
className="btn-secondary flex-1 justify-center text-xs py-1.5"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEarlyCheckIn}
|
||||
disabled={earlySaving || (!currentRoomFree && !earlyRoomId) || availableRooms.length === 0}
|
||||
className="flex-1 py-1.5 rounded-lg bg-amber-500 hover:bg-amber-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-semibold transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
{earlySaving ? <Loader2 size={12} className="animate-spin" /> : <LogIn size={12} />}
|
||||
Заселить сейчас
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Dates — read or edit mode */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
|
||||
Reference in New Issue
Block a user