feat: early check-in / late checkout fees with room-level configuration

- DB migration 016: add early_checkin_fee, late_checkout_fee to rooms table
- Backend rooms: support new fee columns in POST/PATCH
- Backend hotel-settings GET: also returns check_in_time, check_out_time
- Room type + RoomPayload: add earlyCheckinFee, lateCheckoutFee fields
- RoomModal: add fee amount inputs in pricing section
- SettingsPage: add toggles for early_checkin_enabled / late_checkout_enabled
- BookingDetailPanel: on check-in shows fee warning + adds payment record if
  current time is before checkInTime and fee is set on the room; same logic
  for check-out after checkOutTime

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 19:24:36 +03:00
parent 9f1dd0c8c9
commit edd493e24e
8 changed files with 195 additions and 25 deletions

View File

@@ -85,6 +85,27 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
const acTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const acWrapRef = useRef<HTMLDivElement | null>(null)
// Hotel timing settings (for early/late fee warnings)
const [hotelCheckInTime, setHotelCheckInTime] = useState('14:00')
const [hotelCheckOutTime, setHotelCheckOutTime] = useState('12:00')
const [earlyCheckinEnabled, setEarlyCheckinEnabled] = useState(false)
const [lateCheckoutEnabled, setLateCheckoutEnabled] = useState(false)
const [hotelTimingsLoaded, setHotelTimingsLoaded] = useState(false)
useEffect(() => {
if (!slug || hotelTimingsLoaded) return
api.hotelSettings.get(slug)
.then(s => {
const st = s as Record<string, unknown>
if (st.check_in_time) setHotelCheckInTime(String(st.check_in_time).slice(0, 5))
if (st.check_out_time) setHotelCheckOutTime(String(st.check_out_time).slice(0, 5))
setEarlyCheckinEnabled(Boolean(st.early_checkin_enabled))
setLateCheckoutEnabled(Boolean(st.late_checkout_enabled))
setHotelTimingsLoaded(true)
})
.catch(console.error)
}, [slug, hotelTimingsLoaded])
// Load booking guests + settings when tab is activated
useEffect(() => {
if (tab !== 'guest' || !slug || bgLoaded) return
@@ -1272,19 +1293,73 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
</div>
)}
{booking.status === 'confirmed' && booking.checkIn <= today && (
<button onClick={() => setStatus('checked_in')} className="w-full btn-primary justify-center">
<CheckCircle size={15} /> Заселить
</button>
)}
{booking.status === 'checked_in' && !earlyOutOpen && (
<button
onClick={() => { setEarlyOutOpen(true); setEarlyOutDate(today) }}
className="w-full btn-primary justify-center bg-emerald-600 hover:bg-emerald-700"
>
<CheckCircle size={15} /> Выселить
</button>
)}
{booking.status === 'confirmed' && booking.checkIn <= today && (() => {
const nowTime = format(new Date(), 'HH:mm')
const isEarlyArrival = earlyCheckinEnabled && nowTime < hotelCheckInTime && (room?.earlyCheckinFee ?? 0) > 0
return (
<>
{isEarlyArrival && (
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700">
<AlertTriangle size={14} className="text-blue-500 shrink-0" />
<p className="text-xs text-blue-700 dark:text-blue-400">
Ранний заезд (до {hotelCheckInTime}) доп. оплата {formatCurrency(room!.earlyCheckinFee!)}
</p>
</div>
)}
<button
onClick={() => {
if (isEarlyArrival) {
setPayments(prev => [...prev, {
id: `early-${Date.now()}`,
date: new Date().toLocaleDateString('ru-RU'),
amount: room!.earlyCheckinFee!,
method: 'cash' as const,
note: `Ранний заезд (заезд до ${hotelCheckInTime})`,
}])
}
setStatus('checked_in')
}}
className="w-full btn-primary justify-center"
>
<CheckCircle size={15} /> {isEarlyArrival ? `Заселить (+ ${formatCurrency(room!.earlyCheckinFee!)})` : 'Заселить'}
</button>
</>
)
})()}
{booking.status === 'checked_in' && !earlyOutOpen && (() => {
const nowTime = format(new Date(), 'HH:mm')
const isLateCheckout = lateCheckoutEnabled && nowTime > hotelCheckOutTime && (room?.lateCheckoutFee ?? 0) > 0
return (
<>
{isLateCheckout && (
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-700">
<AlertTriangle size={14} className="text-orange-500 shrink-0" />
<p className="text-xs text-orange-700 dark:text-orange-400">
Поздний выезд (после {hotelCheckOutTime}) доп. оплата {formatCurrency(room!.lateCheckoutFee!)}
</p>
</div>
)}
<button
onClick={() => {
if (isLateCheckout) {
setPayments(prev => [...prev, {
id: `late-${Date.now()}`,
date: new Date().toLocaleDateString('ru-RU'),
amount: room!.lateCheckoutFee!,
method: 'cash' as const,
note: `Поздний выезд (выезд после ${hotelCheckOutTime})`,
}])
}
setEarlyOutOpen(true)
setEarlyOutDate(today)
}}
className="w-full btn-primary justify-center bg-emerald-600 hover:bg-emerald-700"
>
<CheckCircle size={15} /> {isLateCheckout ? `Выселить (+ ${formatCurrency(room!.lateCheckoutFee!)})` : 'Выселить'}
</button>
</>
)
})()}
{booking.status === 'checked_in' && earlyOutOpen && (() => {
const origNights = differenceInDays(new Date(booking.checkOut), new Date(booking.checkIn))
const actualNights = Math.max(1, differenceInDays(new Date(earlyOutDate), new Date(booking.checkIn)))

View File

@@ -72,6 +72,8 @@ export function RoomModal({ open, room, categories = [], onClose, onSave, onDele
hourlyRate: room?.hourlyRate ?? 1000,
categoryId: room?.categoryId ?? '',
description: room?.description ?? '',
earlyCheckinFee: room?.earlyCheckinFee ?? '',
lateCheckoutFee: room?.lateCheckoutFee ?? '',
})
const { amenities: allAmenities } = useAmenities()
@@ -148,6 +150,8 @@ export function RoomModal({ open, room, categories = [], onClose, onSave, onDele
beds: beds.length > 0 ? beds : undefined,
extraPlace: extraPlace.enabled ? extraPlace : undefined,
childPolicy: childPolicy.enabled ? childPolicy : undefined,
earlyCheckinFee: form.earlyCheckinFee !== '' ? Number(form.earlyCheckinFee) : undefined,
lateCheckoutFee: form.lateCheckoutFee !== '' ? Number(form.lateCheckoutFee) : undefined,
})
}
@@ -303,6 +307,34 @@ export function RoomModal({ open, room, categories = [], onClose, onSave, onDele
)}
</div>
{/* Early check-in / late checkout fees */}
<div className="rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-700/40 border-b border-slate-200 dark:border-slate-700">
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Тарифы за ранний/поздний заезд</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Применяются если включено в настройках отеля</p>
</div>
<div className="p-4 grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Ранний заезд </label>
<input
type="number" min={0} className="input"
placeholder="0 = бесплатно"
value={form.earlyCheckinFee}
onChange={e => set('earlyCheckinFee', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Поздний выезд </label>
<input
type="number" min={0} className="input"
placeholder="0 = бесплатно"
value={form.lateCheckoutFee}
onChange={e => set('lateCheckoutFee', e.target.value)}
/>
</div>
</div>
</div>
{/* Amenities */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Удобства</label>