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:
2026-03-21 18:49:23 +03:00
parent b831106ae2
commit 6dbff276c8

View File

@@ -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">