Files
hotelsync/src/components/bookings/BookingDetailPanel.tsx
HotelSync 83d797c55f fix: minibar deposit — использовать актуальную цену из справочника, а не историческую
Ранее в форме удержания депозита показывалась цена на момент уборки (price_per_unit).
Теперь используется текущая цена позиции (mi.price) — чтобы изменения цен сразу
отражались в счёте. Если цена менялась — показываем индикатор ↑ с tooltip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 15:51:34 +03:00

2008 lines
99 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, LogIn, KeyRound,
ShieldCheck, QrCode, Copy, RefreshCw, Ban,
} from 'lucide-react'
import {
cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS,
SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount,
} from '../../lib/utils'
import type { Booking, Room } from '../../types'
import { api, type BookingGuest, type BookingGuestPayload, type GuestApiType, type BookingDeposit, type DepositSettings, type BookingPayment, type DepositPreset } from '../../lib/api'
import { getIdentity, type AgentIdentity } from '../../lib/agent'
const fmtDate = (iso: string) =>
new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(new Date(iso))
import { Badge } from '../ui/Badge'
import { MOCK_DISCOUNTS } from '../../pages/DiscountsPage'
// ─── Local types ──────────────────────────────────────────────────────────────
interface PassportData {
lastName: string; firstName: string; middleName: string
dob: string; series: string; number: string
issuedBy: string; issueDate: string; regAddress: string
}
interface Payment {
id: string; date: string; amount: number
method: 'cash' | 'card' | 'transfer'; note: string
}
type Tab = 'booking' | 'guest' | 'payment' | 'docs'
const TABS: { id: Tab; label: string }[] = [
{ id: 'booking', label: 'Бронь' },
{ id: 'guest', label: 'Гости' },
{ id: 'payment', label: 'Оплата' },
{ id: 'docs', label: 'Документы' },
]
const METHODS: { id: 'cash' | 'card' | 'transfer'; label: string; icon: React.ElementType }[] = [
{ id: 'cash', label: 'Наличные', icon: Banknote },
{ id: 'card', label: 'Карта', icon: CreditCard },
{ id: 'transfer', label: 'Перевод', icon: Building2 },
]
const DOCUMENTS = [
{ id: 'reg', label: 'Регистрационная карта', desc: 'Персональные данные гостя', icon: IdCard, always: true },
{ id: 'contract', label: 'Договор на проживание', desc: 'Договор между гостем и отелем', icon: FileText, always: true },
{ id: 'invoice', label: 'Счёт на оплату', desc: 'Счёт для оплаты проживания', icon: Receipt, always: true },
{ id: 'act', label: 'Акт об оказании услуг', desc: 'Закрывающий документ при выезде', icon: FileCheck, always: false },
]
// ─── DepositWidget ────────────────────────────────────────────────────────────
type ReleaseItem = { id: string; name: string; amount: string }
function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string }) {
const [depositSettings, setDepositSettings] = useState<DepositSettings | null>(null)
const [deposit, setDeposit] = useState<BookingDeposit | null>(null)
const [depositLoading, setDepositLoading] = useState(true)
const [depositCreating, setDepositCreating] = useState<'cash' | 'yookassa' | null>(null)
const [depositCancelling, setDepositCancelling] = useState(false)
const [depositReleasing, setDepositReleasing] = useState(false)
const [showReleaseForm, setShowReleaseForm] = useState(false)
const [releaseItems, setReleaseItems] = useState<ReleaseItem[]>([])
const [releaseComment, setReleaseComment] = useState('')
const [yookassaMsg, setYookassaMsg] = useState<string | null>(null)
const [releaseError, setReleaseError] = useState<string | null>(null)
const [presets, setPresets] = useState<DepositPreset[]>([])
const [minibarItems, setMinibarItems] = useState<Array<{ itemName: string; quantity: number; recordedPrice: number; currentPrice: number; lineTotal: number }>>([])
const [minibarTotal, setMinibarTotal] = useState(0)
useEffect(() => {
Promise.all([
api.deposits.getSettings(slug),
api.deposits.getBookingDeposit(slug, bookingId).catch(() => null),
api.deposits.getPresets(slug).catch(() => []),
api.deposits.getMinibarForBooking(slug, bookingId).catch(() => ({ items: [], total: 0 })),
]).then(([settings, dep, presetList, minibar]) => {
setDepositSettings(settings)
setDeposit(dep)
setPresets(presetList)
setMinibarItems(minibar.items)
setMinibarTotal(minibar.total)
}).catch(() => {
// silently ignore — deposit module may not be available
}).finally(() => setDepositLoading(false))
}, [slug, bookingId])
const handleCreateCash = async () => {
setDepositCreating('cash')
try {
const dep = await api.deposits.payByCash(slug, bookingId)
setDeposit(dep)
} catch {
// ignore
} finally {
setDepositCreating(null)
}
}
const handleCreateYookassa = async () => {
setDepositCreating('yookassa')
try {
const dep = await api.deposits.createYookassaHold(slug, bookingId)
setDeposit(dep)
setYookassaMsg('QR-код активирован. Гость может сканировать QR на стойке ресепшена.')
} catch {
// ignore
} finally {
setDepositCreating(null)
}
}
const handleRefresh = async () => {
try {
const dep = await api.deposits.getBookingDeposit(slug, bookingId)
setDeposit(dep)
} catch {
// ignore
}
}
const releaseTotalAmount = releaseItems.reduce((s, i) => s + (parseFloat(i.amount) || 0), 0)
const handleRelease = async () => {
setDepositReleasing(true)
setReleaseError(null)
try {
const items = releaseItems
.filter(i => parseFloat(i.amount) > 0)
.map(i => ({ name: i.name, amount: parseFloat(i.amount) }))
const dep = await api.deposits.release(slug, bookingId, releaseTotalAmount, releaseComment || undefined, items)
setDeposit(dep)
setShowReleaseForm(false)
} catch {
setReleaseError('Не удалось выполнить операцию')
} finally {
setDepositReleasing(false)
}
}
const addReleaseItem = (name: string, amount: number) => {
setReleaseItems(prev => [...prev, { id: `item-${Date.now()}`, name, amount: String(amount) }])
}
const removeReleaseItem = (id: string) => {
setReleaseItems(prev => prev.filter(i => i.id !== id))
}
const handleCancel = async () => {
if (!window.confirm('Отменить депозит?')) return
setDepositCancelling(true)
try {
await api.deposits.cancel(slug, bookingId)
setDeposit(null)
setYookassaMsg(null)
} catch {
// ignore
} finally {
setDepositCancelling(false)
}
}
const copyPayLink = () => {
navigator.clipboard.writeText(`https://app.hotelsync.ru/${slug}/pay`).catch(() => {})
}
if (depositLoading) {
return <Loader2 size={14} className="animate-spin text-slate-400 mt-3" />
}
if (!depositSettings?.isEnabled) return null
const badgeBase = 'inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium'
return (
<div>
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mt-4 mb-2 flex items-center gap-1">
<ShieldCheck size={14} /> Депозит
</p>
{yookassaMsg && (
<div className="mb-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 p-2 text-xs text-emerald-700 dark:text-emerald-300">
{yookassaMsg}
</div>
)}
{/* No deposit yet */}
{deposit === null && (
<div className="space-y-2">
<p className="text-xs text-slate-500 dark:text-slate-400">
Сумма: {formatCurrency(depositSettings.amount)}
</p>
<div className="flex gap-2">
<button
onClick={handleCreateCash}
disabled={depositCreating !== null}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-xs font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors disabled:opacity-50"
>
{depositCreating === 'cash' ? <Loader2 size={12} className="animate-spin" /> : <Banknote size={12} />}
Наличными
</button>
{depositSettings.yookassaShopId && (
<button
onClick={handleCreateYookassa}
disabled={depositCreating !== null}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-xs font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors disabled:opacity-50"
>
{depositCreating === 'yookassa' ? <Loader2 size={12} className="animate-spin" /> : <QrCode size={12} />}
ЮКасса (QR)
</button>
)}
</div>
</div>
)}
{/* hold_created — awaiting card payment */}
{deposit?.status === 'hold_created' && (
<div className="space-y-2">
<span className={cn(badgeBase, 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400')}>
Ожидает оплаты по карте
</span>
<div className="flex items-center gap-1.5 mt-1">
<span className="text-xs text-slate-500 font-mono truncate flex-1">
https://app.hotelsync.ru/{slug}/pay
</span>
<button
onClick={copyPayLink}
className="shrink-0 p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-600 transition-colors"
title="Скопировать ссылку"
>
<Copy size={12} />
</button>
</div>
<p className="text-xs text-slate-400">QR-код на ресепшене активен</p>
<div className="flex items-center gap-3">
<button
onClick={handleRefresh}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors"
>
<RefreshCw size={11} /> Обновить статус
</button>
<button
onClick={handleCancel}
disabled={depositCancelling}
className="flex items-center gap-1 text-xs text-red-500 hover:text-red-700 transition-colors disabled:opacity-50"
>
{depositCancelling ? <Loader2 size={11} className="animate-spin" /> : <Ban size={11} />}
Отменить
</button>
</div>
</div>
)}
{/* hold_confirmed — held on card */}
{deposit?.status === 'hold_confirmed' && (
<div className="space-y-2">
<span className={cn(badgeBase, 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400')}>
Холд подтверждён
</span>
<p className="text-xs text-slate-600 dark:text-slate-300">{formatCurrency(deposit.amount)}</p>
{!showReleaseForm && (
<button
onClick={() => setShowReleaseForm(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-xs font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors"
>
Вернуть / Списать депозит
</button>
)}
</div>
)}
{/* hold_confirmed — show card info */}
{deposit?.status === 'hold_confirmed' && deposit.cardLast4 && (
<div className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 mb-1">
<CreditCard size={12} />
{deposit.cardBrand ? `${deposit.cardBrand} ` : ''}&bull;&bull;&bull;&bull; {deposit.cardLast4}
</div>
)}
{/* paid_cash */}
{deposit?.status === 'paid_cash' && (
<div className="space-y-2">
<span className={cn(badgeBase, 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400')}>
Наличными
</span>
<p className="text-xs text-slate-600 dark:text-slate-300">{formatCurrency(deposit.amount)}</p>
{!showReleaseForm && (
<div className="flex gap-2">
<button
onClick={() => setShowReleaseForm(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-xs font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors"
>
Вернуть / Списать
</button>
<button
onClick={handleCancel}
disabled={depositCancelling}
className="flex items-center gap-1 text-xs text-red-500 hover:text-red-700 transition-colors disabled:opacity-50"
>
{depositCancelling ? <Loader2 size={11} className="animate-spin" /> : <Ban size={11} />}
Отменить запись
</button>
</div>
)}
</div>
)}
{/* Release form */}
{showReleaseForm && deposit && (deposit.status === 'hold_confirmed' || deposit.status === 'paid_cash') && (
<div className="mt-2 rounded-xl border border-slate-200 dark:border-slate-600 p-3 space-y-3 bg-slate-50 dark:bg-slate-800/40">
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300">Возврат / Удержание депозита</p>
{/* Minibar items breakdown */}
{minibarItems.length > 0 && (
<div className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/10 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-amber-200 dark:border-amber-800">
<span className="text-xs font-semibold text-amber-800 dark:text-amber-300">Минибар {formatCurrency(minibarTotal)}</span>
<button
onClick={() => minibarItems.forEach(it => addReleaseItem(`${it.itemName} × ${it.quantity}`, it.lineTotal))}
className="text-xs text-amber-700 dark:text-amber-400 hover:underline font-medium"
>
+ Добавить всё
</button>
</div>
<div className="divide-y divide-amber-100 dark:divide-amber-900/40">
{minibarItems.map((it, i) => {
const priceChanged = Math.abs(it.currentPrice - it.recordedPrice) > 0.001
return (
<div key={i} className="flex items-center justify-between px-3 py-1.5 gap-2">
<span className="text-xs text-amber-800 dark:text-amber-300 flex-1 min-w-0">
{it.itemName} × {it.quantity}
{' '}
<span className={priceChanged ? 'text-orange-500 dark:text-orange-400' : 'text-amber-500'}>
({formatCurrency(it.currentPrice)} / шт.)
</span>
{priceChanged && (
<span className="ml-1 text-orange-500 dark:text-orange-400" title={`Цена изменена с ${formatCurrency(it.recordedPrice)}`}>
</span>
)}
</span>
<span className="text-xs font-medium text-amber-800 dark:text-amber-300 shrink-0">{formatCurrency(it.lineTotal)}</span>
<button
onClick={() => addReleaseItem(`${it.itemName} × ${it.quantity}`, it.lineTotal)}
className="text-xs text-amber-600 dark:text-amber-400 hover:underline font-medium shrink-0"
>
+ Добавить
</button>
</div>
)
})}
</div>
</div>
)}
{/* Quick presets from settings */}
{(presets.length > 0 || minibarTotal === 0) && (
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Добавить позицию:</p>
<div className="flex gap-1.5 flex-wrap">
{presets.map(p => (
<button
key={p.id}
onClick={() => addReleaseItem(p.name, p.amount)}
className="px-2.5 py-1 rounded-lg text-xs font-medium border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
+ {p.name} {formatCurrency(p.amount)}
</button>
))}
<button
onClick={() => addReleaseItem('Прочее', 0)}
className="px-2.5 py-1 rounded-lg text-xs font-medium border border-dashed border-slate-300 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
+ Прочее
</button>
</div>
</div>
)}
{presets.length === 0 && minibarTotal > 0 && (
<button
onClick={() => addReleaseItem('Прочее', 0)}
className="text-xs text-brand-600 hover:underline"
>
+ Добавить позицию вручную
</button>
)}
{/* Added items */}
{releaseItems.length > 0 && (
<div className="space-y-1.5">
{releaseItems.map(item => (
<div key={item.id} className="flex items-center gap-2">
<span className="text-xs text-slate-600 dark:text-slate-300 flex-1 truncate">{item.name}</span>
<input
type="number"
min="0"
step="100"
value={item.amount}
onFocus={e => e.target.select()}
onChange={e => setReleaseItems(prev => prev.map(i => i.id === item.id ? { ...i, amount: e.target.value } : i))}
className="input text-xs w-24 text-right"
placeholder="0"
/>
<span className="text-xs text-slate-400"></span>
<button onClick={() => removeReleaseItem(item.id)} className="text-slate-400 hover:text-red-500 transition-colors">
<X size={12} />
</button>
</div>
))}
<div className="flex justify-between items-center pt-1 border-t border-slate-200 dark:border-slate-600">
<span className="text-xs font-semibold text-slate-700 dark:text-slate-300">Итого удержание:</span>
<span className="text-xs font-bold text-slate-900 dark:text-slate-100">{formatCurrency(releaseTotalAmount)}</span>
</div>
</div>
)}
{/* No items — full return */}
{releaseItems.length === 0 && (
<p className="text-xs text-emerald-600 dark:text-emerald-400 font-medium"> Полный возврат депозита</p>
)}
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-0.5">Комментарий (отправим гостю на email)</label>
<textarea
value={releaseComment}
onChange={e => setReleaseComment(e.target.value)}
rows={2}
className="input text-sm w-full resize-none"
placeholder="Например: нарушение правил отеля…"
/>
</div>
{releaseError && (
<p className="text-xs text-red-600 dark:text-red-400">{releaseError}</p>
)}
<div className="flex gap-2">
<button
onClick={() => { setShowReleaseForm(false); setReleaseError(null); setReleaseItems([]) }}
className="btn-secondary flex-1 justify-center text-xs py-1.5"
>
Отмена
</button>
<button
onClick={handleRelease}
disabled={depositReleasing}
className="btn-primary flex-1 justify-center text-xs py-1.5 flex items-center gap-1"
>
{depositReleasing && <Loader2 size={11} className="animate-spin" />}
{releaseTotalAmount > 0 ? `Списать ${formatCurrency(releaseTotalAmount)}` : 'Вернуть депозит'}
</button>
</div>
</div>
)}
{/* Final statuses */}
{deposit?.status === 'captured' && (
<span className={cn(badgeBase, 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300')}>
Списано {formatCurrency(deposit.capturedAmount ?? 0)}
</span>
)}
{deposit?.status === 'refunded' && (
<span className={cn(badgeBase, 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400')}>
Возвращён
</span>
)}
{deposit?.status === 'cancelled' && (
<span className={cn(badgeBase, 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300')}>
Отменён
</span>
)}
</div>
)
}
// ─── Component ────────────────────────────────────────────────────────────────
interface BookingDetailPanelProps {
booking: Booking
room?: Room
rooms?: Room[]
allBookings?: Booking[]
slug?: string
onClose: () => void
onUpdate: (id: string, data: Partial<Booking>) => void
onBulkUpdate?: (updates: Array<{ id: string; data: Partial<Booking> }>) => void
}
export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, onClose, onUpdate, onBulkUpdate }: BookingDetailPanelProps) {
const [tab, setTab] = useState<Tab>('booking')
// ── Multi-guest state ────────────────────────────────────────────────────
const [bgGuests, setBgGuests] = useState<BookingGuest[]>([])
const [bgLoading, setBgLoading] = useState(false)
const [bgLoaded, setBgLoaded] = useState(false)
const [requireDocs, setRequireDocs] = useState(false)
const [showGuestForm, setShowGuestForm] = useState(false)
const [editingBg, setEditingBg] = useState<BookingGuest | null>(null)
const [bgForm, setBgForm] = useState<BookingGuestPayload>({})
const [bgSaving, setBgSaving] = useState(false)
// Autocomplete
const [acQuery, setAcQuery] = useState('')
const [acResults, setAcResults] = useState<GuestApiType[]>([])
const [acLoading, setAcLoading] = useState(false)
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
setBgLoading(true)
Promise.all([
api.bookingGuests.list(slug, booking.id),
api.hotelSettings.get(slug).catch(() => ({})),
])
.then(([guests, settings]) => {
setBgGuests(guests)
setRequireDocs(Boolean((settings as { require_guest_docs?: boolean }).require_guest_docs))
setBgLoaded(true)
// Auto-open add form pre-filled with booking FIO when no guests yet
if (guests.length === 0 && booking.status !== 'cancelled') {
const parts = (booking.guestName ?? '').split(' ')
setBgForm({
last_name: parts[0] ?? '',
first_name: parts[1] ?? '',
middle_name: parts.slice(2).join(' ') || undefined,
is_main: true,
})
setShowGuestForm(true)
}
})
.catch(console.error)
.finally(() => setBgLoading(false))
}, [tab, slug, booking.id, bgLoaded])
// Reset when booking changes
useEffect(() => {
setBgLoaded(false)
setBgGuests([])
setShowGuestForm(false)
setEditingBg(null)
setBgForm({})
setEarlyOutOpen(false)
setEarlyOutDate(booking.checkOut.slice(0, 10))
setEarlyOpen(false)
}, [booking.id])
// Проверяем локальный агент (localhost:9000) при открытии панели
useEffect(() => {
setAgentIdentity('loading')
getIdentity().then(identity => setAgentIdentity(identity))
}, [booking.id])
// Parse ФИО string into form fields
const parseFio = (val: string) => {
const parts = val.trim().split(/\s+/)
return { last_name: parts[0] ?? '', first_name: parts[1] ?? '', middle_name: parts.slice(2).join(' ') || undefined }
}
// Debounced guest autocomplete
const handleAcInput = (val: string) => {
setAcQuery(val)
setBgForm(f => ({ ...f, ...parseFio(val) }))
if (acTimerRef.current) clearTimeout(acTimerRef.current)
if (val.length < 2 || !slug) { setAcResults([]); return }
acTimerRef.current = setTimeout(() => {
setAcLoading(true)
api.guests.list(slug, val)
.then(setAcResults)
.catch(() => setAcResults([]))
.finally(() => setAcLoading(false))
}, 300)
}
const selectAcGuest = (g: GuestApiType) => {
const fullName = [g.lastName, g.firstName, g.middleName].filter(Boolean).join(' ')
setBgForm(f => ({
...f,
first_name: g.firstName,
last_name: g.lastName,
middle_name: g.middleName ?? undefined,
birth_date: g.birthDate ?? undefined,
passport_series: g.passportSeries ?? undefined,
passport_number: g.passportNumber ?? undefined,
passport_issued_by: g.passportIssuedBy ?? undefined,
passport_issue_date: g.passportIssueDate ?? undefined,
nationality: g.nationality ?? undefined,
}))
setAcQuery(fullName)
setAcResults([])
}
const passportTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handlePassportInput = (field: 'passport_series' | 'passport_number', val: string) => {
setBgForm(f => ({ ...f, [field]: val }))
if (passportTimerRef.current) clearTimeout(passportTimerRef.current)
const series = field === 'passport_series' ? val : (bgForm.passport_series ?? '')
const number = field === 'passport_number' ? val : (bgForm.passport_number ?? '')
const query = (series + number).trim()
if (query.length < 4 || !slug) return
passportTimerRef.current = setTimeout(() => {
api.guests.list(slug, undefined, query)
.then(results => {
if (results.length === 1) selectAcGuest(results[0])
})
.catch(() => {/* ignore */})
}, 400)
}
const openAddForm = () => {
setEditingBg(null)
setBgForm({ is_main: bgGuests.length === 0 })
setAcQuery('')
setAcResults([])
setShowGuestForm(true)
}
const openEditForm = (bg: BookingGuest) => {
setEditingBg(bg)
setBgForm({
first_name: bg.firstName,
last_name: bg.lastName,
middle_name: bg.middleName ?? undefined,
birth_date: bg.birthDate?.slice(0, 10) ?? undefined,
is_child: bg.isChild,
is_main: bg.isMain,
passport_series: bg.passportSeries ?? undefined,
passport_number: bg.passportNumber ?? undefined,
passport_issued_by: bg.passportIssuedBy ?? undefined,
passport_issue_date: bg.passportIssueDate?.slice(0, 10) ?? undefined,
nationality: bg.nationality ?? undefined,
})
setAcQuery([bg.lastName, bg.firstName, bg.middleName].filter(Boolean).join(' '))
setAcResults([])
setShowGuestForm(true)
}
const cancelGuestForm = () => {
setShowGuestForm(false)
setEditingBg(null)
setBgForm({})
setAcResults([])
setAcQuery('')
}
const saveGuest = async () => {
if (!slug || !bgForm.first_name?.trim() || !bgForm.last_name?.trim()) {
showToast('Введите имя и фамилию')
return
}
setBgSaving(true)
try {
if (editingBg) {
const updated = await api.bookingGuests.update(slug, booking.id, editingBg.id, bgForm)
setBgGuests(prev => prev.map(g => g.id === editingBg.id ? updated : g))
} else {
const added = await api.bookingGuests.add(slug, booking.id, bgForm)
setBgGuests(prev => [...prev, added])
}
cancelGuestForm()
} catch {
showToast('Ошибка при сохранении гостя')
} finally {
setBgSaving(false)
}
}
const removeGuest = async (id: string) => {
if (!slug) return
try {
await api.bookingGuests.remove(slug, booking.id, id)
setBgGuests(prev => prev.filter(g => g.id !== id))
} catch {
showToast('Ошибка при удалении гостя')
}
}
const adultsWithoutDocs = bgGuests.filter(g => !g.isChild && (!g.passportSeries || !g.passportNumber))
// ── Date editing ─────────────────────────────────────────────────────────
const [editDates, setEditDates] = useState(false)
const [newCheckIn, setNewCheckIn] = useState(booking.checkIn)
const [newCheckOut, setNewCheckOut] = useState(booking.checkOut)
const [newRoomId, setNewRoomId] = useState(booking.roomId)
const [conflictBooking, setConflictBooking] = useState<Booking | null>(null)
const [conflictResolveRoomId, setConflictResolveRoomId] = useState('')
const canEditDates = booking.status === 'confirmed' || booking.status === 'inquiry'
const canRelocate = booking.status === 'checked_in'
const today = format(new Date(), 'yyyy-MM-dd')
const checkInDate = booking.checkIn.slice(0, 10)
const checkOutDate = booking.checkOut.slice(0, 10)
const canEarlyCheckIn = booking.status === 'confirmed' && checkInDate > today
const [relocating, setRelocating] = useState(false)
const [relocateRoomId, setRelocateRoomId] = useState(booking.roomId)
const [earlyOpen, setEarlyOpen] = useState(false)
const [earlyNewCheckOut, setEarlyNewCheckOut] = useState(checkOutDate)
const [earlyRoomId, setEarlyRoomId] = useState(booking.roomId)
const [earlySaving, setEarlySaving] = useState(false)
const [earlyOutOpen, setEarlyOutOpen] = useState(false)
const [earlyOutDate, setEarlyOutDate] = useState(checkOutDate)
const [earlyOutMethod, setEarlyOutMethod] = useState<'cash' | 'card' | 'transfer'>('cash')
const [earlyOutSaving, setEarlyOutSaving] = useState(false)
const [cardIssuing, setCardIssuing] = useState(false)
const [cardEncoders, setCardEncoders] = useState<Array<{ id: string; name: string }>>([])
const [cardEncoderPickerOpen, setCardEncoderPickerOpen] = useState(false)
const [selectedEncoderId, setSelectedEncoderId] = useState('')
const [agentIdentity, setAgentIdentity] = useState<AgentIdentity | null | 'loading'>('loading')
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 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 handleEarlyCheckOut = async () => {
if (earlyOutDate > booking.checkOut) { showToast('Дата не может быть позже плановой'); return }
setEarlyOutSaving(true)
try {
const origNights = differenceInDays(new Date(booking.checkOut), new Date(booking.checkIn))
const actualNights = differenceInDays(new Date(earlyOutDate), new Date(booking.checkIn))
const refundNights = origNights - actualNights
if (refundNights > 0) {
const refundAmt = Math.round(refundNights * booking.totalAmount / origNights)
setPayments(prev => [...prev, {
id: `refund-${Date.now()}`, hotelId: '', bookingId: booking.id,
amount: -refundAmt, method: earlyOutMethod,
note: `Возврат за ${refundNights} неиспользованных ${refundNights === 1 ? 'ночь' : refundNights < 5 ? 'ночи' : 'ночей'}`,
createdAt: new Date().toISOString(), createdByName: null,
}])
}
await onUpdate(booking.id, { checkOut: earlyOutDate, status: 'checked_out' })
setEarlyOutOpen(false)
showToast('Гость выселен')
} finally {
setEarlyOutSaving(false)
}
}
const findConflict = (ci: string, co: string, rid: string): Booking | null =>
(allBookings ?? []).find(b =>
b.id !== booking.id &&
b.roomId === rid &&
b.status !== 'cancelled' &&
b.status !== 'no_show' &&
b.status !== 'checked_out' &&
b.checkIn < co && b.checkOut > ci,
) ?? null
const handleSaveDates = () => {
if (newCheckIn >= newCheckOut) { showToast('Дата выезда должна быть позже заезда'); return }
const conflict = findConflict(newCheckIn, newCheckOut, newRoomId)
if (conflict) { setConflictBooking(conflict); setConflictResolveRoomId(''); return }
onUpdate(booking.id, { checkIn: newCheckIn, checkOut: newCheckOut, roomId: newRoomId })
}
const handleForceUpdate = () =>
onUpdate(booking.id, { checkIn: newCheckIn, checkOut: newCheckOut, roomId: newRoomId })
const handleResolveWithRoomSwap = () => {
if (!conflictBooking || !onBulkUpdate || !conflictResolveRoomId) return
if (findConflict(conflictBooking.checkIn, conflictBooking.checkOut, conflictResolveRoomId)) {
showToast('Выбранный номер тоже занят на эти даты')
return
}
onBulkUpdate([
{ id: booking.id, data: { checkIn: newCheckIn, checkOut: newCheckOut, roomId: newRoomId } },
{ id: conflictBooking.id, data: { roomId: conflictResolveRoomId } },
])
}
const handleIssueCard = async () => {
if (!slug || cardIssuing) return
// Если локальный агент запущен — используем его рабочее место напрямую
if (agentIdentity && agentIdentity !== 'loading' && agentIdentity.workstation_id) {
await doIssueCard(agentIdentity.workstation_id)
return
}
// Агент не запущен — ищем онлайн-рабочие места с энкодером через сервер
let encoders: Array<{ id: string; name: string }> = []
try {
const wsList = await api.workstations.list(slug)
encoders = wsList
.filter(w => w.isOnline && w.ttlockComPort)
.map(w => ({ id: w.id, name: w.name }))
} catch { /* ignore */ }
if (encoders.length === 0) {
showToast('✗ Нет онлайн-рабочих мест с настроенным энкодером')
return
}
if (encoders.length === 1) {
await doIssueCard(encoders[0].id)
return
}
// Несколько энкодеров — показываем выбор
setCardEncoders(encoders)
setSelectedEncoderId(encoders[0].id)
setCardEncoderPickerOpen(true)
}
const doIssueCard = async (workstationId: string) => {
setCardIssuing(true)
setCardEncoderPickerOpen(false)
try {
const r = await api.ttlock.issueCard(slug!, booking.id, workstationId)
showToast(`✓ Ключ выдан (${r.roomName})`)
} catch (e) {
const msg = e instanceof Error ? e.message : 'Ошибка выдачи ключа'
showToast(`${msg}`)
} finally {
setCardIssuing(false)
}
}
// Passport data
const nameParts = booking.guestName.split(' ')
const [passport, setPassport] = useState<PassportData>({
lastName: nameParts[0] ?? '', firstName: nameParts[1] ?? '', middleName: nameParts[2] ?? '',
dob: '', series: '', number: '', issuedBy: '', issueDate: '', regAddress: '',
})
const setP = <K extends keyof PassportData>(k: K, v: PassportData[K]) =>
setPassport(prev => ({ ...prev, [k]: v }))
// Payments — loaded from API
const [payments, setPayments] = useState<BookingPayment[]>([])
const [paymentsLoaded, setPaymentsLoaded] = useState(false)
const [showPayForm, setShowPayForm] = useState(false)
const [payAmount, setPayAmount] = useState('')
const [payMethod, setPayMethod] = useState<'cash' | 'card' | 'transfer'>('cash')
const [payNote, setPayNote] = useState('')
const [payAdding, setPayAdding] = useState(false)
// Discount
const [discountId, setDiscountId] = useState('')
// Load payments from API when payment tab is opened
useEffect(() => {
if (!slug || paymentsLoaded) return
api.payments.list(slug, booking.id)
.then(setPayments)
.catch(() => {})
.finally(() => setPaymentsLoaded(true))
}, [slug, booking.id, paymentsLoaded])
// Toast
const [toast, setToast] = useState('')
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) }
// Calculations
const nights = nightsCount(booking.checkIn, booking.checkOut)
const baseTotal = booking.totalAmount
const activeDiscs = MOCK_DISCOUNTS.filter(d => d.isActive)
const selDiscount = activeDiscs.find(d => d.id === discountId)
const discountAmt = selDiscount
? selDiscount.valueType === 'percent'
? Math.round(baseTotal * Math.min(100, selDiscount.value) / 100)
: Math.min(baseTotal, selDiscount.value)
: 0
const finalTotal = baseTotal - discountAmt
const totalPaid = payments.reduce((s, p) => s + Number(p.amount), 0)
const balance = finalTotal - totalPaid
const setStatus = (status: typeof booking.status) => onUpdate(booking.id, { status })
const addPayment = async () => {
if (!slug) return
const amt = parseFloat(payAmount)
if (!amt || amt <= 0) return
setPayAdding(true)
try {
const payment = await api.payments.add(slug, booking.id, amt, payMethod, payNote || undefined)
setPayments(prev => [...prev, payment])
onUpdate(booking.id, { paidAmount: payments.reduce((s, p) => s + Number(p.amount), 0) + amt })
setPayAmount(''); setPayNote('')
setShowPayForm(false)
showToast(`✓ Оплата ${formatCurrency(amt)} принята`)
} catch {
showToast('Ошибка при сохранении оплаты')
} finally {
setPayAdding(false)
}
}
const printDoc = (label: string) => showToast(`🖨 «${label}» отправлен на печать`)
const fld = 'input text-sm'
const lbl = 'block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1'
return (
<>
<div className="fixed inset-0 z-40" onClick={onClose} />
<div className="fixed right-0 top-0 bottom-0 z-50 w-full sm:w-[400px] bg-white dark:bg-slate-800 border-l border-slate-200 dark:border-slate-700 shadow-2xl flex flex-col">
{/* Toast notification */}
{toast && (
<div className="absolute top-3 left-4 right-4 z-20 bg-slate-900 dark:bg-slate-700 text-white text-sm rounded-xl px-4 py-2.5 text-center shadow-lg pointer-events-none">
{toast}
</div>
)}
{/* Header */}
<div className="px-5 pt-4 pb-3 border-b border-slate-200 dark:border-slate-700 shrink-0">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">
{booking.guestName}
</h3>
<div className="flex items-center gap-2 mt-1">
<Badge className={BOOKING_STATUS_BADGE[booking.status]}>
{BOOKING_STATUS_LABELS[booking.status]}
</Badge>
<Badge className={SOURCE_COLORS[booking.source]}>
{SOURCE_LABELS[booking.source]}
</Badge>
</div>
</div>
<button onClick={onClose} className="btn-ghost p-1.5 mt-0.5">
<X size={18} />
</button>
</div>
{/* Tab bar */}
<div className="flex gap-0.5 mt-3">
{TABS.map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cn(
'flex-1 py-1.5 text-xs font-medium rounded-lg transition-colors',
tab === t.id
? 'bg-brand-600 text-white'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700',
)}
>
{t.label}
</button>
))}
</div>
{/* Статус агента — показываем только если агент не запущен */}
{agentIdentity === null && (
<div className="flex items-center gap-1.5 mt-2 px-1">
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 dark:bg-slate-500 shrink-0" />
<span className="text-[11px] text-slate-400 dark:text-slate-500">
Агент не запущен · ключи недоступны
</span>
</div>
)}
</div>
{/* Tab content */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{/* ── Бронь ────────────────────────────────────────────────────────── */}
{tab === 'booking' && (
<div className="space-y-4">
{room && (
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/40 p-3">
<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">
{room.number} {room.type}
</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
Этаж {room.floor} · {room.bedType} bed
</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>
)}
{/* 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(checkOutDate) }}
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}
max={booking.checkOut}
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">
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium flex items-center gap-1">
<Calendar size={12} /> Даты проживания
</p>
{!editDates && canEditDates && (
<button
onClick={() => { setEditDates(true); setConflictBooking(null) }}
className="flex items-center gap-1 text-xs text-brand-600 dark:text-brand-400 hover:text-brand-700 dark:hover:text-brand-300 font-medium"
>
<Pencil size={11} /> Изменить
</button>
)}
</div>
{editDates ? (
<div className="space-y-3 p-3 rounded-xl border border-brand-200 dark:border-brand-700 bg-brand-50/30 dark:bg-brand-900/10">
<div className="grid grid-cols-2 gap-3">
<div>
<label className={lbl}>Заезд</label>
<input type="date" className="input text-sm" value={newCheckIn}
onChange={e => { setNewCheckIn(e.target.value); setConflictBooking(null) }} />
</div>
<div>
<label className={lbl}>Выезд</label>
<input type="date" className="input text-sm" value={newCheckOut} min={newCheckIn}
onChange={e => { setNewCheckOut(e.target.value); setConflictBooking(null) }} />
</div>
</div>
{rooms && rooms.length > 1 && (
<div>
<label className={lbl}>Номер</label>
<select className="input text-sm" value={newRoomId}
onChange={e => { setNewRoomId(e.target.value); setConflictBooking(null) }}>
{rooms.map(r => (
<option key={r.id} value={r.id}>{r.number} {r.type}</option>
))}
</select>
</div>
)}
{/* Conflict warning */}
{conflictBooking && (
<div className="rounded-xl border border-red-200 dark:border-red-700 bg-red-50 dark:bg-red-900/20 p-3 space-y-2">
<div className="flex items-start gap-2 text-red-700 dark:text-red-400 text-xs font-medium">
<AlertTriangle size={13} className="shrink-0 mt-0.5" />
<span>
Номер занят: {conflictBooking.guestName},&nbsp;
{fmtDate(conflictBooking.checkIn)} {fmtDate(conflictBooking.checkOut)}
</span>
</div>
{conflictBooking.status !== 'checked_in' && onBulkUpdate && rooms && (
<div className="space-y-1.5">
<p className="text-xs text-red-600 dark:text-red-400">
Переместить {conflictBooking.guestName} в другой номер:
</p>
<select className="input text-sm" value={conflictResolveRoomId}
onChange={e => setConflictResolveRoomId(e.target.value)}>
<option value=""> выберите номер </option>
{rooms.filter(r => r.id !== newRoomId).map(r => (
<option key={r.id} value={r.id}>{r.number} {r.type}</option>
))}
</select>
<button
disabled={!conflictResolveRoomId}
onClick={handleResolveWithRoomSwap}
className="w-full btn-primary text-xs py-1.5 justify-center disabled:opacity-50 disabled:cursor-not-allowed"
>
Переместить и сохранить
</button>
</div>
)}
<button onClick={handleForceUpdate}
className="w-full btn-secondary text-xs py-1.5 justify-center text-red-600 dark:text-red-400 border-red-200 dark:border-red-700">
Сохранить принудительно
</button>
</div>
)}
<div className="flex gap-2">
<button
onClick={() => { setEditDates(false); setConflictBooking(null) }}
className="btn-secondary flex-1 justify-center text-sm py-1.5"
>
Отмена
</button>
{!conflictBooking && (
<button onClick={handleSaveDates}
className="btn-primary flex-1 justify-center text-sm py-1.5">
Сохранить
</button>
)}
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium mb-1">Заезд</p>
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{fmtDate(booking.checkIn)}
</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium mb-1">Выезд</p>
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{fmtDate(booking.checkOut)}
</p>
</div>
</div>
)}
{!editDates && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'}
</p>
)}
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium flex items-center gap-1 mb-1">
<Users size={12} /> Гости
</p>
<p className="text-sm text-slate-900 dark:text-slate-100">
{booking.adults} взр.{booking.children > 0 ? ` · ${booking.children} дет.` : ''}
</p>
</div>
{booking.guestEmail && (
<div className="space-y-1.5">
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium">Контакты</p>
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<Mail size={13} className="text-slate-400" />
{booking.guestEmail}
</div>
</div>
)}
{booking.notes && (
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium flex items-center gap-1 mb-1">
<Tag size={12} /> Примечания
</p>
<p className="text-sm text-slate-700 dark:text-slate-300 bg-slate-50 dark:bg-slate-700/40 rounded-lg p-2.5">
{booking.notes}
</p>
</div>
)}
{booking.channelBookingId && (
<div>
<p className="text-xs text-slate-500 mb-0.5">ID канала</p>
<code className="text-xs text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/20 px-2 py-1 rounded">
{booking.channelBookingId}
</code>
</div>
)}
</div>
)}
{/* ── Гости ────────────────────────────────────────────────────────── */}
{tab === 'guest' && slug && (
<div className="space-y-3">
{/* Require-docs warning */}
{requireDocs && adultsWithoutDocs.length > 0 && (
<div className="flex items-start gap-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 p-2.5 text-xs text-amber-700 dark:text-amber-400">
<AlertTriangle size={13} className="shrink-0 mt-0.5" />
<span>У {adultsWithoutDocs.length} взрослого(-ых) не заполнены паспортные данные</span>
</div>
)}
{/* Loading */}
{bgLoading && (
<div className="flex items-center justify-center py-6">
<Loader2 size={20} className="animate-spin text-slate-400" />
</div>
)}
{/* Guest list */}
{!bgLoading && bgGuests.map(bg => (
<div key={bg.id} className="rounded-xl border border-slate-200 dark:border-slate-600 p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
{bg.lastName} {bg.firstName}{bg.middleName ? ` ${bg.middleName}` : ''}
</span>
{bg.isMain && (
<span className="flex items-center gap-0.5 px-1.5 py-0.5 rounded-md text-xs bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400">
<UserCheck size={10} /> Главный
</span>
)}
{bg.isChild && (
<span className="flex items-center gap-0.5 px-1.5 py-0.5 rounded-md text-xs bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
<Baby size={10} /> Ребёнок
</span>
)}
</div>
{bg.passportSeries && bg.passportNumber ? (
<p className="text-xs text-slate-400 font-mono mt-0.5">
{bg.passportSeries} {bg.passportNumber}
</p>
) : !bg.isChild && requireDocs ? (
<p className="text-xs text-amber-500 mt-0.5">Паспорт не заполнен</p>
) : null}
{bg.birthDate && (
<p className="text-xs text-slate-400 mt-0.5">
{new Date(bg.birthDate).toLocaleDateString('ru-RU')}
</p>
)}
</div>
<div className="flex gap-1 shrink-0">
<button
onClick={() => openEditForm(bg)}
className="p-1.5 rounded-lg text-slate-400 hover:text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
>
<Pencil size={13} />
</button>
<button
onClick={() => removeGuest(bg.id)}
className="p-1.5 rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<Trash2 size={13} />
</button>
</div>
</div>
</div>
))}
{/* Empty state */}
{!bgLoading && bgGuests.length === 0 && !showGuestForm && (
<p className="text-xs text-center text-slate-400 py-4">Гости не добавлены</p>
)}
{/* Add guest form */}
{showGuestForm && (
<div className="rounded-xl border border-brand-200 dark:border-brand-700 bg-brand-50/30 dark:bg-brand-900/10 p-3 space-y-3">
<p className="text-xs font-semibold text-brand-700 dark:text-brand-400">
{editingBg ? 'Редактировать гостя' : 'Добавить гостя'}
</p>
{/* ФИО with autocomplete */}
<div ref={acWrapRef} className="relative">
<label className={lbl}>ФИО *</label>
<div className="relative">
<input
className={fld}
value={acQuery}
onChange={e => handleAcInput(e.target.value)}
placeholder="Иванов Иван Иванович"
autoComplete="off"
/>
{acLoading && (
<Loader2 size={12} className="absolute right-2.5 top-1/2 -translate-y-1/2 animate-spin text-slate-400" />
)}
</div>
{acResults.length > 0 && (
<div className="absolute z-20 left-0 right-0 mt-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-xl shadow-lg overflow-hidden max-h-48 overflow-y-auto">
{acResults.map(g => (
<button
key={g.id}
type="button"
onClick={() => selectAcGuest(g)}
className="w-full text-left px-3 py-2 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
>
<div className="flex items-center gap-2">
<Search size={11} className="text-slate-400 shrink-0" />
<div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{[g.lastName, g.firstName, g.middleName].filter(Boolean).join(' ')}
</p>
{(g.passportSeries || g.passportNumber) && (
<p className="text-xs text-slate-400 font-mono">
{[g.passportSeries, g.passportNumber].filter(Boolean).join(' ')}
</p>
)}
</div>
</div>
</button>
))}
</div>
)}
</div>
{/* Birth date + flags */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className={lbl}>Дата рождения</label>
<input type="date" className={fld} value={bgForm.birth_date ?? ''} onChange={e => setBgForm(f => ({ ...f, birth_date: e.target.value }))} />
</div>
<div className="flex flex-col gap-2 pt-4">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={bgForm.is_child ?? false} onChange={e => setBgForm(f => ({ ...f, is_child: e.target.checked }))} className="rounded" />
<span className="text-xs text-slate-600 dark:text-slate-400">Ребёнок (до 16 лет)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={bgForm.is_main ?? false} onChange={e => setBgForm(f => ({ ...f, is_main: e.target.checked }))} className="rounded" />
<span className="text-xs text-slate-600 dark:text-slate-400">Главный гость</span>
</label>
</div>
</div>
{/* Passport (adults only) */}
{!bgForm.is_child && (
<div className="space-y-2 pt-2 border-t border-brand-100 dark:border-brand-800">
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400">Паспорт</p>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={lbl}>Серия</label>
<input className={`${fld} font-mono uppercase`} value={bgForm.passport_series ?? ''} onChange={e => handlePassportInput('passport_series', e.target.value.toUpperCase())} placeholder="4510" maxLength={10} />
</div>
<div>
<label className={lbl}>Номер</label>
<input className={`${fld} font-mono`} value={bgForm.passport_number ?? ''} onChange={e => handlePassportInput('passport_number', e.target.value)} placeholder="123456" maxLength={10} />
</div>
</div>
<div>
<label className={lbl}>Кем выдан</label>
<input className={fld} value={bgForm.passport_issued_by ?? ''} onChange={e => setBgForm(f => ({ ...f, passport_issued_by: e.target.value }))} placeholder="УФМС России по г. Москве" />
</div>
<div>
<label className={lbl}>Дата выдачи</label>
<input type="date" className={fld} value={bgForm.passport_issue_date ?? ''} onChange={e => setBgForm(f => ({ ...f, passport_issue_date: e.target.value }))} />
</div>
</div>
)}
{/* Scan + save/cancel */}
<button
onClick={() => showToast('Для сканирования подключите RFID/OCR сканер')}
className="w-full flex items-center justify-center gap-2 py-2 rounded-xl border border-dashed border-brand-300 dark:border-brand-700 text-brand-600 dark:text-brand-400 text-xs font-medium hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
>
<ScanLine size={13} /> Сканировать паспорт
</button>
<div className="flex gap-2">
<button onClick={cancelGuestForm} className="btn-secondary flex-1 justify-center text-sm py-1.5">Отмена</button>
<button onClick={saveGuest} disabled={bgSaving} className="btn-primary flex-1 justify-center text-sm py-1.5">
{bgSaving ? <Loader2 size={13} className="animate-spin" /> : null}
{editingBg ? 'Сохранить' : 'Добавить'}
</button>
</div>
</div>
)}
{/* Add button */}
{!showGuestForm && (
<button
onClick={openAddForm}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 text-sm font-medium hover:border-brand-300 dark:hover:border-brand-700 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
>
<Plus size={15} /> Добавить гостя
</button>
)}
</div>
)}
{/* Fallback Гость tab (no slug — old static form) */}
{tab === 'guest' && !slug && (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3">
<div>
<label className={lbl}>Фамилия</label>
<input className={fld} value={passport.lastName} onChange={e => setP('lastName', e.target.value)} placeholder="Иванов" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={lbl}>Имя</label>
<input className={fld} value={passport.firstName} onChange={e => setP('firstName', e.target.value)} placeholder="Иван" />
</div>
<div>
<label className={lbl}>Отчество</label>
<input className={fld} value={passport.middleName} onChange={e => setP('middleName', e.target.value)} placeholder="Иванович" />
</div>
</div>
<div>
<label className={lbl}>Дата рождения</label>
<input type="date" className={fld} value={passport.dob} onChange={e => setP('dob', e.target.value)} />
</div>
</div>
<div className="pt-2 border-t border-slate-100 dark:border-slate-700">
<p className="text-xs font-semibold text-slate-600 dark:text-slate-400 mb-3">Паспорт</p>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className={lbl}>Серия</label>
<input className={fld} value={passport.series} onChange={e => setP('series', e.target.value)} placeholder="4510" maxLength={4} />
</div>
<div>
<label className={lbl}>Номер</label>
<input className={fld} value={passport.number} onChange={e => setP('number', e.target.value)} placeholder="123456" maxLength={6} />
</div>
</div>
<div>
<label className={lbl}>Кем выдан</label>
<input className={fld} value={passport.issuedBy} onChange={e => setP('issuedBy', e.target.value)} placeholder="УФМС России по г. Москве" />
</div>
<div>
<label className={lbl}>Дата выдачи</label>
<input type="date" className={fld} value={passport.issueDate} onChange={e => setP('issueDate', e.target.value)} />
</div>
<div>
<label className={lbl}>Адрес регистрации</label>
<input className={fld} value={passport.regAddress} onChange={e => setP('regAddress', e.target.value)} placeholder="г. Москва, ул. Примерная, д. 1" />
</div>
</div>
</div>
<button
onClick={() => showToast('Для сканирования подключите RFID/OCR сканер')}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl border-2 border-dashed border-brand-300 dark:border-brand-700 text-brand-600 dark:text-brand-400 text-sm font-medium hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
>
<ScanLine size={16} /> Сканировать паспорт
</button>
<button
onClick={() => showToast('✓ Данные гостя сохранены')}
className="w-full btn-primary justify-center"
>
Сохранить данные
</button>
</div>
)}
{/* ── Оплата ───────────────────────────────────────────────────────── */}
{tab === 'payment' && (
<div className="space-y-4">
{/* Discount selector */}
<div>
<label className={lbl}>Скидка</label>
<select className="input text-sm" value={discountId} onChange={e => setDiscountId(e.target.value)}>
<option value=""> Без скидки </option>
{activeDiscs.map(d => (
<option key={d.id} value={d.id}>
{d.name} ({d.valueType === 'percent' ? `${d.value}%` : formatCurrency(d.value)})
</option>
))}
</select>
</div>
{/* Breakdown */}
<div className="rounded-xl border border-slate-200 dark:border-slate-600 divide-y divide-slate-200 dark:divide-slate-600 text-sm">
<div className="flex justify-between px-3 py-2">
<span className="text-slate-500 dark:text-slate-400">Стоимость</span>
<span className="font-medium text-slate-900 dark:text-slate-100">{formatCurrency(baseTotal)}</span>
</div>
{discountAmt > 0 && (
<div className="flex justify-between px-3 py-2">
<span className="text-slate-500 dark:text-slate-400">Скидка «{selDiscount?.name}»</span>
<span className="font-medium text-emerald-600 dark:text-emerald-400">{formatCurrency(discountAmt)}</span>
</div>
)}
<div className="flex justify-between px-3 py-2 font-semibold">
<span className="text-slate-700 dark:text-slate-300">Итого</span>
<span className="text-slate-900 dark:text-slate-100">{formatCurrency(finalTotal)}</span>
</div>
<div className="flex justify-between px-3 py-2">
<span className="text-slate-500 dark:text-slate-400">Оплачено</span>
<span className="font-medium text-emerald-600 dark:text-emerald-400">{formatCurrency(totalPaid)}</span>
</div>
<div className={cn(
'flex justify-between px-3 py-2 font-bold',
balance > 0 ? 'text-red-600 dark:text-red-400' : 'text-emerald-600 dark:text-emerald-400',
)}>
<span>{balance > 0 ? 'Остаток' : 'Переплата'}</span>
<span>{balance > 0 ? formatCurrency(balance) : `+${formatCurrency(-balance)}`}</span>
</div>
</div>
{/* Accept payment */}
{!showPayForm ? (
<button
onClick={() => { setPayAmount(balance > 0 ? String(balance) : ''); setShowPayForm(true) }}
className="w-full btn-primary justify-center"
disabled={balance <= 0}
>
<Plus size={15} /> Принять оплату
</button>
) : (
<div className="rounded-xl border border-brand-200 dark:border-brand-700 p-3 space-y-3 bg-brand-50/40 dark:bg-brand-900/10">
<p className="text-xs font-semibold text-brand-700 dark:text-brand-400">Новый платёж</p>
<div>
<label className={lbl}>Сумма, </label>
<input type="number" className="input text-sm" value={payAmount}
onChange={e => setPayAmount(e.target.value)}
onFocus={e => e.target.select()}
placeholder="0" min="0" />
</div>
<div>
<label className={lbl}>Способ оплаты</label>
<div className="grid grid-cols-3 gap-1.5">
{METHODS.map(m => (
<button
key={m.id} type="button" onClick={() => setPayMethod(m.id)}
className={cn(
'flex flex-col items-center gap-1 py-2 rounded-lg border text-xs font-medium transition-colors',
payMethod === m.id
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
)}
>
<m.icon size={14} /> {m.label}
</button>
))}
</div>
</div>
<div>
<label className={lbl}>Комментарий</label>
<input className="input text-sm" value={payNote}
onChange={e => setPayNote(e.target.value)} placeholder="Необязательно" />
</div>
<div className="flex gap-2">
<button onClick={() => setShowPayForm(false)} className="btn-secondary flex-1 justify-center text-sm py-2">Отмена</button>
<button onClick={addPayment} disabled={!payAmount || parseFloat(payAmount) <= 0 || payAdding}
className="btn-primary flex-1 justify-center text-sm py-2 flex items-center gap-1">
{payAdding && <Loader2 size={13} className="animate-spin" />}
Принять
</button>
</div>
</div>
)}
{/* Payment history */}
{payments.length > 0 && (
<div>
<p className={lbl + ' mt-1'}>История платежей</p>
<div className="space-y-1.5">
{payments.map(p => {
const M = METHODS.find(m => m.id === p.method) ?? METHODS[0]
return (
<div key={p.id} className="flex items-center gap-2 text-sm px-2.5 py-2 rounded-lg bg-slate-50 dark:bg-slate-700/40">
<M.icon size={13} className="text-slate-400 shrink-0" />
<span className="text-slate-400 text-xs shrink-0">{format(new Date(p.createdAt), 'dd.MM.yyyy')}</span>
<span className="flex-1 text-slate-600 dark:text-slate-300 text-xs truncate">{p.note || M.label}</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400 shrink-0">{formatCurrency(Number(p.amount))}</span>
</div>
)
})}
</div>
</div>
)}
{/* Deposit widget */}
{slug && (
<DepositWidget slug={slug} bookingId={booking.id} />
)}
</div>
)}
{/* ── Документы ────────────────────────────────────────────────────── */}
{tab === 'docs' && (
<div className="space-y-2">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-3">
Распечатайте документы для гостя
</p>
{DOCUMENTS
.filter(d => d.always || booking.status === 'checked_in' || booking.status === 'checked_out')
.map(doc => (
<div key={doc.id} className="flex items-center gap-3 p-3 rounded-xl border border-slate-200 dark:border-slate-600 hover:border-brand-300 dark:hover:border-brand-600 transition-colors">
<div className="w-9 h-9 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center shrink-0">
<doc.icon size={17} className="text-slate-500 dark:text-slate-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{doc.label}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{doc.desc}</p>
</div>
<button
onClick={() => printDoc(doc.label)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-xs font-medium hover:bg-brand-700 transition-colors shrink-0"
>
<Printer size={12} /> Печать
</button>
</div>
))
}
<div className="pt-3 mt-1 border-t border-slate-100 dark:border-slate-700">
<button
onClick={() => showToast('🖨 Комплект документов отправлен на печать')}
className="w-full btn-primary justify-center"
>
<Printer size={15} /> Напечатать комплект
</button>
<p className="text-xs text-center text-slate-400 dark:text-slate-500 mt-1.5">
Состав комплекта настраивается в разделе «Документы»
</p>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="shrink-0 px-5 py-4 border-t border-slate-200 dark:border-slate-700 space-y-2">
{balance > 0 && booking.status === 'confirmed' && (
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700">
<AlertTriangle size={14} className="text-amber-500 shrink-0" />
<p className="text-xs text-amber-700 dark:text-amber-400">
Долг {formatCurrency(balance)} получите оплату перед заселением
</p>
</div>
)}
{booking.status === 'confirmed' && checkInDate <= 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()}`, hotelId: '', bookingId: booking.id,
amount: room!.earlyCheckinFee!, method: 'cash' as const,
note: `Ранний заезд (заезд до ${hotelCheckInTime})`,
createdAt: new Date().toISOString(), createdByName: null,
}])
}
setStatus('checked_in')
}}
className="w-full btn-primary justify-center"
>
<CheckCircle size={15} /> {isEarlyArrival ? `Заселить (+ ${formatCurrency(room!.earlyCheckinFee!)})` : 'Заселить'}
</button>
{slug && (
<button
onClick={handleIssueCard}
disabled={cardIssuing}
className="w-full btn-secondary justify-center flex items-center gap-2"
title="Записать карту-ключ для гостя"
>
{cardIssuing ? <Loader2 size={15} className="animate-spin" /> : <KeyRound size={15} />}
Выдать ключ (TTLock)
</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()}`, hotelId: '', bookingId: booking.id,
amount: room!.lateCheckoutFee!, method: 'cash' as const,
note: `Поздний выезд (выезд после ${hotelCheckOutTime})`,
createdAt: new Date().toISOString(), createdByName: null,
}])
}
setEarlyOutOpen(true)
setEarlyOutDate(checkOutDate)
}}
className="w-full btn-primary justify-center bg-emerald-600 hover:bg-emerald-700"
>
<CheckCircle size={15} /> {isLateCheckout ? `Выселить (+ ${formatCurrency(room!.lateCheckoutFee!)})` : 'Выселить'}
</button>
{slug && (
<button
onClick={handleIssueCard}
disabled={cardIssuing}
className="w-full btn-secondary justify-center flex items-center gap-2"
title="Выдать дубликат карты-ключа"
>
{cardIssuing ? <Loader2 size={15} className="animate-spin" /> : <KeyRound size={15} />}
Выдать ключ (TTLock)
</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)))
const refundNights = origNights - actualNights
const refundAmt = refundNights > 0 ? Math.round(refundNights * booking.totalAmount / origNights) : 0
return (
<div className="rounded-xl border border-emerald-200 dark:border-emerald-700 bg-emerald-50 dark:bg-emerald-900/20 p-3 space-y-3">
<p className="text-xs font-semibold text-emerald-800 dark:text-emerald-300 flex items-center gap-1.5">
<CheckCircle size={13} /> Выселение гостя
</p>
<div>
<label className={lbl}>Дата выезда</label>
<input
type="date"
className="input text-sm"
value={earlyOutDate}
min={booking.checkIn}
max={booking.checkOut}
onChange={e => setEarlyOutDate(e.target.value)}
/>
</div>
{refundNights > 0 && (
<div className="rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 p-2.5 space-y-2">
<p className="text-xs font-medium text-amber-800 dark:text-amber-300">
Возврат за {refundNights} {refundNights === 1 ? 'ночь' : refundNights < 5 ? 'ночи' : 'ночей'}: {formatCurrency(refundAmt)}
</p>
<div className="flex gap-1.5">
{METHODS.map(m => (
<button
key={m.id}
onClick={() => setEarlyOutMethod(m.id)}
className={cn(
'flex-1 py-1 rounded-lg text-xs font-medium border transition-colors',
earlyOutMethod === m.id
? 'bg-amber-500 border-amber-500 text-white'
: 'border-amber-200 dark:border-amber-700 text-amber-700 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-800/30',
)}
>
{m.label}
</button>
))}
</div>
<p className="text-[11px] text-amber-600 dark:text-amber-400">
Выдайте гостю {formatCurrency(refundAmt)} {earlyOutMethod === 'cash' ? 'наличными' : earlyOutMethod === 'card' ? 'на карту' : 'переводом'}
</p>
</div>
)}
{refundNights <= 0 && (
<p className="text-xs text-slate-500 dark:text-slate-400">Возврат не требуется</p>
)}
<div className="flex gap-2">
<button
onClick={() => setEarlyOutOpen(false)}
className="btn-secondary flex-1 justify-center text-xs py-1.5"
>
Отмена
</button>
<button
onClick={handleEarlyCheckOut}
disabled={earlyOutSaving}
className="flex-1 py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 text-white text-xs font-semibold transition-colors flex items-center justify-center gap-1"
>
{earlyOutSaving ? <Loader2 size={12} className="animate-spin" /> : <CheckCircle size={12} />}
Подтвердить выезд
</button>
</div>
</div>
)
})()}
{(booking.status === 'confirmed' || booking.status === 'inquiry') && (
<button
onClick={() => setStatus('cancelled')}
className="w-full btn-secondary justify-center text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<XCircle size={15} /> Отменить
</button>
)}
<p className="text-xs text-center text-slate-400 dark:text-slate-500">
ID: {booking.id} · Создано: {booking.createdAt}
</p>
</div>
</div>
{cardEncoderPickerOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 w-80 shadow-2xl space-y-4">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Выбрать энкодер</h3>
<select
className="input w-full"
value={selectedEncoderId}
onChange={e => setSelectedEncoderId(e.target.value)}
>
{cardEncoders.map(e => (
<option key={e.id} value={e.id}>{e.name}</option>
))}
</select>
<div className="flex gap-2 justify-end">
<button onClick={() => setCardEncoderPickerOpen(false)} className="btn-secondary">Отмена</button>
<button onClick={() => doIssueCard(selectedEncoderId)} className="btn-primary">Выдать ключ</button>
</div>
</div>
</div>
)}
</>
)
}