Ранее в форме удержания депозита показывалась цена на момент уборки (price_per_unit). Теперь используется текущая цена позиции (mi.price) — чтобы изменения цен сразу отражались в счёте. Если цена менялась — показываем индикатор ↑ с tooltip. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2008 lines
99 KiB
TypeScript
2008 lines
99 KiB
TypeScript
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} ` : ''}•••• {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},
|
||
{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>
|
||
)}
|
||
</>
|
||
)
|
||
}
|