feat: RentalBookingModal 2-column layout

Left col: existing bookings list, full-day toggle, time selectors
Right col: guest autocomplete, phone, link-to-room, notes, price+payment block
Modal widened to max-w-2xl; payment section unified with BookingModal style

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 01:38:25 +03:00
parent 7c65bebc22
commit 2475c76d45

View File

@@ -213,7 +213,7 @@ export function RentalBookingModal({
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl w-full max-w-md flex flex-col max-h-[90vh]"> <div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl w-full max-w-2xl flex flex-col max-h-[90vh]">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3 px-5 py-4 border-b border-slate-200 dark:border-slate-700 shrink-0"> <div className="flex items-center gap-3 px-5 py-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
<span className="text-2xl">{obj.icon}</span> <span className="text-2xl">{obj.icon}</span>
@@ -224,8 +224,13 @@ export function RentalBookingModal({
<button onClick={onClose} className="btn-ghost p-1.5"><X size={16} /></button> <button onClick={onClose} className="btn-ghost p-1.5"><X size={16} /></button>
</div> </div>
<div className="overflow-y-auto flex-1 p-5 space-y-4"> <div className="overflow-y-auto flex-1 p-5">
{/* Existing bookings for the day */} <div className="flex flex-col sm:flex-row gap-5 sm:gap-6">
{/* ── LEFT: статус дня + переключатель + время ── */}
<div className="flex-1 space-y-4 min-w-0">
{/* Existing bookings */}
{existingBookings.filter(b => b.status !== 'cancelled').length > 0 && ( {existingBookings.filter(b => b.status !== 'cancelled').length > 0 && (
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/50 p-3 space-y-1.5"> <div className="rounded-xl bg-slate-50 dark:bg-slate-700/50 p-3 space-y-1.5">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Уже забронировано</p> <p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Уже забронировано</p>
@@ -250,29 +255,19 @@ export function RentalBookingModal({
{/* Full day toggle */} {/* Full day toggle */}
<div className={cn( <div className={cn(
'flex items-center gap-3 p-3 rounded-xl border', 'flex items-center gap-3 p-3 rounded-xl border border-slate-200 dark:border-slate-600',
(hasTimedConflict || hasFullDayConflict) (hasTimedConflict || hasFullDayConflict) && 'opacity-50 pointer-events-none',
? 'border-slate-200 dark:border-slate-600 opacity-50 pointer-events-none'
: 'border-slate-200 dark:border-slate-600',
)}> )}>
<CalendarDays size={16} className="text-slate-400 shrink-0" /> <CalendarDays size={16} className="text-slate-400 shrink-0" />
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 flex-1">Весь день</span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300 flex-1">Весь день</span>
{hasTimedConflict && ( {hasTimedConflict && <span className="text-xs text-slate-400">Есть частичные брони</span>}
<span className="text-xs text-slate-400">Есть частичные брони</span>
)}
<button <button
type="button" type="button"
onClick={() => (!hasTimedConflict && !hasFullDayConflict) && setIsFullDay(v => !v)} onClick={() => (!hasTimedConflict && !hasFullDayConflict) && setIsFullDay(v => !v)}
className={cn( className={cn('relative rounded-full overflow-hidden transition-colors shrink-0 p-0', isFullDay ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
'relative rounded-full overflow-hidden transition-colors shrink-0 p-0',
isFullDay ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600',
)}
style={{ height: 22, width: 40 }} style={{ height: 22, width: 40 }}
> >
<span className={cn( <span className={cn('absolute top-[3px] left-[2px] w-4 h-4 rounded-full bg-white shadow transition-transform duration-200', isFullDay ? 'translate-x-[18px]' : 'translate-x-0')} />
'absolute top-[3px] left-[2px] w-4 h-4 rounded-full bg-white shadow transition-transform duration-200',
isFullDay ? 'translate-x-[18px]' : 'translate-x-0',
)} />
</button> </button>
</div> </div>
@@ -293,9 +288,7 @@ export function RentalBookingModal({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex-1"> <div className="flex-1">
<label className="block text-xs text-slate-500 mb-1">Начало</label> <label className="block text-xs text-slate-500 mb-1">Начало</label>
<select <select className="input text-sm" value={effectiveStart}
className="input text-sm"
value={effectiveStart}
onChange={e => { onChange={e => {
const v = parseInt(e.target.value) const v = parseInt(e.target.value)
setStartMin(v) setStartMin(v)
@@ -303,22 +296,17 @@ export function RentalBookingModal({
if (endMin <= v || endMin > maxEnd) setEndMin(Math.min(v + 60, maxEnd)) if (endMin <= v || endMin > maxEnd) setEndMin(Math.min(v + 60, maxEnd))
}} }}
> >
{availableStarts.map(m => ( {availableStarts.map(m => <option key={m} value={m}>{fmtTime(m)}</option>)}
<option key={m} value={m}>{fmtTime(m)}</option>
))}
</select> </select>
</div> </div>
<Clock size={14} className="text-slate-400 mt-4 shrink-0" /> <Clock size={14} className="text-slate-400 mt-4 shrink-0" />
<div className="flex-1"> <div className="flex-1">
<label className="block text-xs text-slate-500 mb-1">Конец</label> <label className="block text-xs text-slate-500 mb-1">Конец</label>
<select <select className="input text-sm"
className="input text-sm"
value={availableEnds.includes(endMin) ? endMin : (availableEnds[0] ?? endMin)} value={availableEnds.includes(endMin) ? endMin : (availableEnds[0] ?? endMin)}
onChange={e => setEndMin(parseInt(e.target.value))} onChange={e => setEndMin(parseInt(e.target.value))}
> >
{availableEnds.map(m => ( {availableEnds.map(m => <option key={m} value={m}>{fmtTime(m)}</option>)}
<option key={m} value={m}>{fmtTime(m)}</option>
))}
</select> </select>
</div> </div>
</div> </div>
@@ -332,113 +320,79 @@ export function RentalBookingModal({
)} )}
</div> </div>
)} )}
</div>
{/* Guest search */} {/* ── RIGHT: гость + оплата ── */}
<div className="space-y-3"> <div className="w-full sm:w-72 space-y-4 shrink-0">
{/* Guest */}
<div> <div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5"> <label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">Гость *</label>
Гость *
</label>
<div className="relative" ref={nameRef}> <div className="relative" ref={nameRef}>
<User size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 z-10" /> <User size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 z-10" />
<input <input
type="text" type="text" className="input pl-8 text-sm" placeholder="Фамилия Имя Отчество"
className="input pl-8 text-sm" value={guestName} onFocus={() => setShowSuggestions(true)} autoComplete="off"
placeholder="Имя гостя"
value={guestName}
onChange={e => { onChange={e => {
const val = e.target.value const val = e.target.value
setGuestName(val) setGuestName(val)
setGuestIsKnown(false) // manual typing = new guest setGuestIsKnown(false)
setShowSuggestions(true) setShowSuggestions(true)
if (searchTimerRef.current) clearTimeout(searchTimerRef.current) if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
if (val.trim().length >= 2 && slug) { if (val.trim().length >= 2 && slug) {
searchTimerRef.current = setTimeout(async () => { searchTimerRef.current = setTimeout(async () => {
try { try { setDbResults((await api.guests.list(slug, val.trim())).slice(0, 8)) }
const results = await api.guests.list(slug, val.trim()) catch { setDbResults([]) }
setDbResults(results.slice(0, 8))
} catch { setDbResults([]) }
}, 300) }, 300)
} else { } else { setDbResults([]) }
setDbResults([])
}
}} }}
onFocus={() => setShowSuggestions(true)}
autoComplete="off"
/> />
{/* Suggestions dropdown */}
{showSuggestions && (filtered.length > 0 || dbResults.length > 0) && ( {showSuggestions && (filtered.length > 0 || dbResults.length > 0) && (
<div className="absolute top-full left-0 right-0 mt-1 z-50 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-600 shadow-xl overflow-hidden max-h-60 overflow-y-auto"> <div className="absolute top-full left-0 right-0 mt-1 z-50 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-600 shadow-xl overflow-hidden max-h-52 overflow-y-auto">
{/* Current/upcoming guests from bookings */}
{filtered.slice(0, 5).map(b => { {filtered.slice(0, 5).map(b => {
const rLabel = roomLabel(b) const rLabel = roomLabel(b)
const isCheckedIn = b.status === 'checked_in' const isCI = b.status === 'checked_in'
return ( return (
<button <button key={b.id} type="button" onMouseDown={() => selectGuest(b)}
key={b.id} className="w-full flex items-center gap-2.5 px-3 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors text-left">
type="button" <div className={cn('w-2 h-2 rounded-full shrink-0', isCI ? 'bg-emerald-500' : 'bg-sky-400')} />
onMouseDown={() => selectGuest(b)}
className="w-full flex items-center gap-2.5 px-3 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors text-left"
>
<div className={cn('w-2 h-2 rounded-full shrink-0', isCheckedIn ? 'bg-emerald-500' : 'bg-sky-400')} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{b.guestName}</p> <p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{b.guestName}</p>
<p className="text-[11px] text-slate-400 truncate"> <p className="text-[11px] text-slate-400 truncate">{b.guestPhone ?? (isCI ? '🏠 Проживает' : '📅 Бронь')}{rLabel ? ` · ${rLabel}` : ''}</p>
{b.guestPhone
? b.guestPhone
: isCheckedIn ? '🏠 Проживает' : '📅 Бронь'
}{rLabel ? ` · ${rLabel}` : ''}
</p>
</div> </div>
</button> </button>
) )
})} })}
{/* DB guest search results */} {dbResults.filter(g => !filtered.some(b => b.guestName.startsWith(g.lastName))).map(g => {
{dbResults
.filter(g => !filtered.some(b => b.guestName.startsWith(g.lastName)))
.map(g => {
const fullName = [g.lastName, g.firstName, g.middleName].filter(Boolean).join(' ') const fullName = [g.lastName, g.firstName, g.middleName].filter(Boolean).join(' ')
return ( return (
<button <button key={g.id} type="button"
key={g.id} onMouseDown={() => { setGuestName(fullName); setGuestPhone(g.phone ?? ''); setShowSuggestions(false); setDbResults([]); setGuestIsKnown(true) }}
type="button" className="w-full flex items-center gap-2.5 px-3 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors text-left">
onMouseDown={() => {
setGuestName(fullName)
setGuestPhone(g.phone ?? '')
setShowSuggestions(false)
setDbResults([])
setGuestIsKnown(true)
}}
className="w-full flex items-center gap-2.5 px-3 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors text-left"
>
<div className="w-2 h-2 rounded-full shrink-0 bg-slate-400" /> <div className="w-2 h-2 rounded-full shrink-0 bg-slate-400" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{fullName}</p> <p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{fullName}</p>
<p className="text-[11px] text-slate-400 truncate"> <p className="text-[11px] text-slate-400 truncate">{g.phone ?? 'нет телефона'}</p>
{g.phone ?? 'нет телефона'}
</p>
</div> </div>
</button> </button>
)}) )
} })}
</div> </div>
)} )}
</div> </div>
</div> </div>
{/* Phone */}
<div> <div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5"> <label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
Телефон {phoneRequired && <span className="text-red-500">*</span>} Телефон {phoneRequired && <span className="text-red-500">*</span>}
</label> </label>
<div className="relative"> <div className="relative">
<Phone size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" /> <Phone size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input <input type="tel"
type="tel"
className={cn('input pl-8 text-sm', phoneRequired && !guestPhone.trim() && 'border-red-400 focus:ring-red-400')} className={cn('input pl-8 text-sm', phoneRequired && !guestPhone.trim() && 'border-red-400 focus:ring-red-400')}
placeholder={phoneRequired ? '+7 999 000 11 22 (обязательно)' : '+7 999 000 11 22'} placeholder={phoneRequired ? '+7 999 000 11 22 (обязательно)' : '+7 999 000 11 22'}
value={guestPhone} value={guestPhone} onChange={e => setGuestPhone(maskPhone(e.target.value))} />
onChange={e => setGuestPhone(maskPhone(e.target.value))}
/>
</div> </div>
{phoneRequired && !guestPhone.trim() && ( {phoneRequired && !guestPhone.trim() && (
<p className="mt-1 text-[11px] text-red-500">Для нового гостя необходим телефон он будет добавлен в базу</p> <p className="mt-1 text-[11px] text-red-500">Для нового гостя необходим телефон он будет добавлен в базу</p>
@@ -450,18 +404,13 @@ export function RentalBookingModal({
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5 flex items-center gap-1"> <label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5 flex items-center gap-1">
<Link2 size={11} /> Привязать к номеру <Link2 size={11} /> Привязать к номеру
</label> </label>
<select <select className="input text-sm" value={linkedBookingId}
className="input text-sm"
value={linkedBookingId}
onChange={e => { onChange={e => {
const id = e.target.value const id = e.target.value
setLinkedBookingId(id) setLinkedBookingId(id)
if (id) { if (id) {
const b = bookings.find(b => b.id === id) const b = bookings.find(b => b.id === id)
if (b && !guestName) { if (b && !guestName) { setGuestName(b.guestName); setGuestPhone(b.guestPhone ?? '') }
setGuestName(b.guestName)
setGuestPhone(b.guestPhone ?? '')
}
} }
}} }}
> >
@@ -471,53 +420,38 @@ export function RentalBookingModal({
const isCI = b.status === 'checked_in' const isCI = b.status === 'checked_in'
return ( return (
<option key={b.id} value={b.id}> <option key={b.id} value={b.id}>
{isCI ? '🏠' : '📅'} {r?.number ?? '?'} {b.guestName} {isCI ? '🏠' : '📅'} {r?.number ?? '?'} {b.guestName}{isCI ? ' (проживает)' : ''}
{isCI ? ' (проживает)' : ''}
</option> </option>
) )
})} })}
</select> </select>
</div> </div>
{/* Notes */}
<div> <div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5"> <label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">Примечание</label>
Примечание <textarea className="input resize-none text-sm" rows={2} placeholder="Дополнительно..."
</label> value={notes} onChange={e => setNotes(e.target.value)} />
<textarea
className="input resize-none text-sm"
rows={2}
placeholder="Дополнительно..."
value={notes}
onChange={e => setNotes(e.target.value)}
/>
</div>
</div> </div>
{/* Price summary */} {/* Price + Payment */}
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/50 px-4 py-3 flex items-center justify-between"> {hours > 0 && (
<span className="text-sm text-slate-600 dark:text-slate-300"> <div className="rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-700/40 p-3 space-y-3">
{isFullDay <div className="flex items-baseline justify-between">
? `Весь день (${obj.openHour}:00 ${obj.closeHour}:00)` <span className="text-xs text-slate-500 dark:text-slate-400">
: `${hours} ч × ${obj.pricePerHour.toLocaleString('ru-RU')}`} {isFullDay ? `Весь день (${obj.openHour}:00${obj.closeHour}:00)` : `${hours} ч × ${obj.pricePerHour.toLocaleString('ru-RU')}`}
</span>
<span className="text-lg font-bold text-slate-900 dark:text-slate-100">
{totalAmount.toLocaleString('ru-RU')}
</span> </span>
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">{totalAmount.toLocaleString('ru-RU')} </span>
</div> </div>
<div>
{/* Payment */} <p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Внесение оплаты</p>
<div className="space-y-2">
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide">
Оплата при бронировании
</label>
<div className="grid grid-cols-3 gap-1.5"> <div className="grid grid-cols-3 gap-1.5">
{([ {([
{ id: 'cash' as const, label: 'Наличные', icon: Banknote }, { id: 'cash' as const, label: 'Наличные', icon: Banknote },
{ id: 'card' as const, label: 'Карта', icon: CreditCard }, { id: 'card' as const, label: 'Карта', icon: CreditCard },
{ id: 'transfer' as const, label: 'Перевод', icon: Building2 }, { id: 'transfer' as const, label: 'Перевод', icon: Building2 },
]).map(m => ( ]).map(m => (
<button <button key={m.id} type="button" onClick={() => setPayMethod(m.id)}
key={m.id} type="button" onClick={() => setPayMethod(m.id)}
className={cn( className={cn(
'flex flex-col items-center gap-1 py-2 rounded-lg border text-xs font-medium transition-colors', 'flex flex-col items-center gap-1 py-2 rounded-lg border text-xs font-medium transition-colors',
payMethod === m.id payMethod === m.id
@@ -529,21 +463,27 @@ export function RentalBookingModal({
</button> </button>
))} ))}
</div> </div>
<input </div>
type="number" {payMethod && (
className="input text-sm" <div className="flex items-center gap-2">
placeholder={`Сумма (макс. ${totalAmount.toLocaleString('ru-RU')} ₽)`} <label className="text-xs text-slate-500 dark:text-slate-400 shrink-0">Оплачено ()</label>
value={paidAmount} <input type="number" className="input flex-1 text-sm" min="0" max={totalAmount}
onChange={e => setPaidAmount(e.target.value)} placeholder={`макс. ${totalAmount.toLocaleString('ru-RU')}`}
min="0" value={paidAmount} onChange={e => setPaidAmount(e.target.value)} />
max={totalAmount} <button type="button" onClick={() => setPaidAmount(String(totalAmount))}
/> className="text-xs text-brand-600 dark:text-brand-400 hover:underline shrink-0">Всю сумму</button>
</div>
)}
{paidAmount && parseFloat(paidAmount) > 0 && parseFloat(paidAmount) < totalAmount && ( {paidAmount && parseFloat(paidAmount) > 0 && parseFloat(paidAmount) < totalAmount && (
<p className="text-xs text-amber-600 dark:text-amber-400"> <p className="text-xs text-amber-600 dark:text-amber-400">
Остаток: {(totalAmount - parseFloat(paidAmount)).toLocaleString('ru-RU')} Долг: {(totalAmount - parseFloat(paidAmount)).toLocaleString('ru-RU')}
</p> </p>
)} )}
</div> </div>
)}
</div>
</div>
</div> </div>
{/* Footer */} {/* Footer */}