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:
@@ -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,325 +224,265 @@ 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">
|
||||||
{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">
|
{/* ── LEFT: статус дня + переключатель + время ── */}
|
||||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Уже забронировано</p>
|
<div className="flex-1 space-y-4 min-w-0">
|
||||||
{existingBookings.filter(b => b.status !== 'cancelled').map(b => (
|
|
||||||
<div key={b.id} className="flex items-center gap-2 text-sm">
|
{/* Existing bookings */}
|
||||||
<div className={cn('w-2 h-2 rounded-full shrink-0', obj.color)} />
|
{existingBookings.filter(b => b.status !== 'cancelled').length > 0 && (
|
||||||
<span className="text-slate-700 dark:text-slate-300 font-medium">
|
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/50 p-3 space-y-1.5">
|
||||||
{b.isFullDay ? 'Весь день' : `${fmtTime(b.startHour)} – ${fmtTime(b.endHour)}`}
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Уже забронировано</p>
|
||||||
</span>
|
{existingBookings.filter(b => b.status !== 'cancelled').map(b => (
|
||||||
<span className="text-slate-500 dark:text-slate-400 truncate">{b.guestName}</span>
|
<div key={b.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<div className={cn('w-2 h-2 rounded-full shrink-0', obj.color)} />
|
||||||
|
<span className="text-slate-700 dark:text-slate-300 font-medium">
|
||||||
|
{b.isFullDay ? 'Весь день' : `${fmtTime(b.startHour)} – ${fmtTime(b.endHour)}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500 dark:text-slate-400 truncate">{b.guestName}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasFullDayConflict && (
|
|
||||||
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
|
|
||||||
<AlertCircle size={15} className="shrink-0" />
|
|
||||||
<span>Объект уже забронирован на весь день</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Full day toggle */}
|
|
||||||
<div className={cn(
|
|
||||||
'flex items-center gap-3 p-3 rounded-xl border',
|
|
||||||
(hasTimedConflict || hasFullDayConflict)
|
|
||||||
? '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" />
|
|
||||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 flex-1">Весь день</span>
|
|
||||||
{hasTimedConflict && (
|
|
||||||
<span className="text-xs text-slate-400">Есть частичные брони</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => (!hasTimedConflict && !hasFullDayConflict) && setIsFullDay(v => !v)}
|
|
||||||
className={cn(
|
|
||||||
'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 }}
|
|
||||||
>
|
|
||||||
<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',
|
|
||||||
)} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Time selection */}
|
{hasFullDayConflict && (
|
||||||
{!isFullDay && !hasFullDayConflict && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
|
||||||
Время аренды
|
|
||||||
{bufMin > 0 && <span className="ml-1 font-normal normal-case">(перерыв {bufMin} мин)</span>}
|
|
||||||
</label>
|
|
||||||
{noSlots ? (
|
|
||||||
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
|
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
|
||||||
<AlertCircle size={15} className="shrink-0" />
|
<AlertCircle size={15} className="shrink-0" />
|
||||||
<span>Все слоты на этот день заняты</span>
|
<span>Объект уже забронирован на весь день</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-3">
|
{/* Full day toggle */}
|
||||||
<div className="flex-1">
|
<div className={cn(
|
||||||
<label className="block text-xs text-slate-500 mb-1">Начало</label>
|
'flex items-center gap-3 p-3 rounded-xl border border-slate-200 dark:border-slate-600',
|
||||||
<select
|
(hasTimedConflict || hasFullDayConflict) && 'opacity-50 pointer-events-none',
|
||||||
className="input text-sm"
|
)}>
|
||||||
value={effectiveStart}
|
<CalendarDays size={16} className="text-slate-400 shrink-0" />
|
||||||
onChange={e => {
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 flex-1">Весь день</span>
|
||||||
const v = parseInt(e.target.value)
|
{hasTimedConflict && <span className="text-xs text-slate-400">Есть частичные брони</span>}
|
||||||
setStartMin(v)
|
<button
|
||||||
const maxEnd = getMaxEnd(v)
|
type="button"
|
||||||
if (endMin <= v || endMin > maxEnd) setEndMin(Math.min(v + 60, maxEnd))
|
onClick={() => (!hasTimedConflict && !hasFullDayConflict) && setIsFullDay(v => !v)}
|
||||||
}}
|
className={cn('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 }}
|
||||||
{availableStarts.map(m => (
|
>
|
||||||
<option key={m} value={m}>{fmtTime(m)}</option>
|
<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')} />
|
||||||
))}
|
</button>
|
||||||
</select>
|
</div>
|
||||||
|
|
||||||
|
{/* Time selection */}
|
||||||
|
{!isFullDay && !hasFullDayConflict && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||||
|
Время аренды
|
||||||
|
{bufMin > 0 && <span className="ml-1 font-normal normal-case">(перерыв {bufMin} мин)</span>}
|
||||||
|
</label>
|
||||||
|
{noSlots ? (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
|
||||||
|
<AlertCircle size={15} className="shrink-0" />
|
||||||
|
<span>Все слоты на этот день заняты</span>
|
||||||
</div>
|
</div>
|
||||||
<Clock size={14} className="text-slate-400 mt-4 shrink-0" />
|
) : (
|
||||||
<div className="flex-1">
|
<>
|
||||||
<label className="block text-xs text-slate-500 mb-1">Конец</label>
|
<div className="flex items-center gap-3">
|
||||||
<select
|
<div className="flex-1">
|
||||||
className="input text-sm"
|
<label className="block text-xs text-slate-500 mb-1">Начало</label>
|
||||||
value={availableEnds.includes(endMin) ? endMin : (availableEnds[0] ?? endMin)}
|
<select className="input text-sm" value={effectiveStart}
|
||||||
onChange={e => setEndMin(parseInt(e.target.value))}
|
onChange={e => {
|
||||||
>
|
const v = parseInt(e.target.value)
|
||||||
{availableEnds.map(m => (
|
setStartMin(v)
|
||||||
<option key={m} value={m}>{fmtTime(m)}</option>
|
const maxEnd = getMaxEnd(v)
|
||||||
))}
|
if (endMin <= v || endMin > maxEnd) setEndMin(Math.min(v + 60, maxEnd))
|
||||||
</select>
|
}}
|
||||||
</div>
|
>
|
||||||
</div>
|
{availableStarts.map(m => <option key={m} value={m}>{fmtTime(m)}</option>)}
|
||||||
{obj.maxHoursPerSlot && !maxHoursOk && (
|
</select>
|
||||||
<p className="mt-1.5 text-xs text-red-500">Максимум: {obj.maxHoursPerSlot} ч</p>
|
</div>
|
||||||
|
<Clock size={14} className="text-slate-400 mt-4 shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-xs text-slate-500 mb-1">Конец</label>
|
||||||
|
<select className="input text-sm"
|
||||||
|
value={availableEnds.includes(endMin) ? endMin : (availableEnds[0] ?? endMin)}
|
||||||
|
onChange={e => setEndMin(parseInt(e.target.value))}
|
||||||
|
>
|
||||||
|
{availableEnds.map(m => <option key={m} value={m}>{fmtTime(m)}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{obj.maxHoursPerSlot && !maxHoursOk && (
|
||||||
|
<p className="mt-1.5 text-xs text-red-500">Максимум: {obj.maxHoursPerSlot} ч</p>
|
||||||
|
)}
|
||||||
|
{isTimeConflict && (
|
||||||
|
<p className="mt-1.5 text-xs text-red-500">Время пересекается с существующей бронью</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{isTimeConflict && (
|
</div>
|
||||||
<p className="mt-1.5 text-xs text-red-500">Время пересекается с существующей бронью</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Guest search */}
|
{/* ── RIGHT: гость + оплата ── */}
|
||||||
<div className="space-y-3">
|
<div className="w-full sm:w-72 space-y-4 shrink-0">
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
{/* Guest */}
|
||||||
Гость *
|
<div>
|
||||||
</label>
|
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">Гость *</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="Имя гостя"
|
onChange={e => {
|
||||||
value={guestName}
|
const val = e.target.value
|
||||||
onChange={e => {
|
setGuestName(val)
|
||||||
const val = e.target.value
|
setGuestIsKnown(false)
|
||||||
setGuestName(val)
|
setShowSuggestions(true)
|
||||||
setGuestIsKnown(false) // manual typing = new guest
|
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
|
||||||
setShowSuggestions(true)
|
if (val.trim().length >= 2 && slug) {
|
||||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
|
searchTimerRef.current = setTimeout(async () => {
|
||||||
if (val.trim().length >= 2 && slug) {
|
try { setDbResults((await api.guests.list(slug, val.trim())).slice(0, 8)) }
|
||||||
searchTimerRef.current = setTimeout(async () => {
|
catch { setDbResults([]) }
|
||||||
try {
|
}, 300)
|
||||||
const results = await api.guests.list(slug, val.trim())
|
} else { setDbResults([]) }
|
||||||
setDbResults(results.slice(0, 8))
|
}}
|
||||||
} catch { setDbResults([]) }
|
/>
|
||||||
}, 300)
|
{showSuggestions && (filtered.length > 0 || dbResults.length > 0) && (
|
||||||
} else {
|
<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">
|
||||||
setDbResults([])
|
{filtered.slice(0, 5).map(b => {
|
||||||
}
|
const rLabel = roomLabel(b)
|
||||||
}}
|
const isCI = b.status === 'checked_in'
|
||||||
onFocus={() => setShowSuggestions(true)}
|
return (
|
||||||
autoComplete="off"
|
<button key={b.id} type="button" 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">
|
||||||
{/* Suggestions dropdown */}
|
<div className={cn('w-2 h-2 rounded-full shrink-0', isCI ? 'bg-emerald-500' : 'bg-sky-400')} />
|
||||||
{showSuggestions && (filtered.length > 0 || dbResults.length > 0) && (
|
<div className="min-w-0 flex-1">
|
||||||
<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">
|
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{b.guestName}</p>
|
||||||
{/* Current/upcoming guests from bookings */}
|
<p className="text-[11px] text-slate-400 truncate">{b.guestPhone ?? (isCI ? '🏠 Проживает' : '📅 Бронь')}{rLabel ? ` · ${rLabel}` : ''}</p>
|
||||||
{filtered.slice(0, 5).map(b => {
|
</div>
|
||||||
const rLabel = roomLabel(b)
|
</button>
|
||||||
const isCheckedIn = b.status === 'checked_in'
|
)
|
||||||
return (
|
})}
|
||||||
<button
|
{dbResults.filter(g => !filtered.some(b => b.guestName.startsWith(g.lastName))).map(g => {
|
||||||
key={b.id}
|
|
||||||
type="button"
|
|
||||||
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">
|
|
||||||
<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">
|
|
||||||
{b.guestPhone
|
|
||||||
? b.guestPhone
|
|
||||||
: isCheckedIn ? '🏠 Проживает' : '📅 Бронь'
|
|
||||||
}{rLabel ? ` · ${rLabel}` : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{/* DB guest search results */}
|
|
||||||
{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={() => {
|
<div className="w-2 h-2 rounded-full shrink-0 bg-slate-400" />
|
||||||
setGuestName(fullName)
|
<div className="min-w-0 flex-1">
|
||||||
setGuestPhone(g.phone ?? '')
|
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{fullName}</p>
|
||||||
setShowSuggestions(false)
|
<p className="text-[11px] text-slate-400 truncate">{g.phone ?? 'нет телефона'}</p>
|
||||||
setDbResults([])
|
</div>
|
||||||
setGuestIsKnown(true)
|
</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"
|
})}
|
||||||
>
|
</div>
|
||||||
<div className="w-2 h-2 rounded-full shrink-0 bg-slate-400" />
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
</div>
|
||||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{fullName}</p>
|
</div>
|
||||||
<p className="text-[11px] text-slate-400 truncate">
|
|
||||||
{g.phone ?? 'нет телефона'}
|
{/* Phone */}
|
||||||
</p>
|
<div>
|
||||||
</div>
|
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
||||||
</button>
|
Телефон {phoneRequired && <span className="text-red-500">*</span>}
|
||||||
)})
|
</label>
|
||||||
}
|
<div className="relative">
|
||||||
</div>
|
<Phone size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input type="tel"
|
||||||
|
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'}
|
||||||
|
value={guestPhone} onChange={e => setGuestPhone(maskPhone(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
{phoneRequired && !guestPhone.trim() && (
|
||||||
|
<p className="mt-1 text-[11px] text-red-500">Для нового гостя необходим телефон — он будет добавлен в базу</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
{/* Link to room */}
|
||||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
<div>
|
||||||
Телефон {phoneRequired && <span className="text-red-500">*</span>}
|
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5 flex items-center gap-1">
|
||||||
</label>
|
<Link2 size={11} /> Привязать к номеру
|
||||||
<div className="relative">
|
</label>
|
||||||
<Phone size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
<select className="input text-sm" value={linkedBookingId}
|
||||||
<input
|
onChange={e => {
|
||||||
type="tel"
|
const id = e.target.value
|
||||||
className={cn('input pl-8 text-sm', phoneRequired && !guestPhone.trim() && 'border-red-400 focus:ring-red-400')}
|
setLinkedBookingId(id)
|
||||||
placeholder={phoneRequired ? '+7 999 000 11 22 (обязательно)' : '+7 999 000 11 22'}
|
if (id) {
|
||||||
value={guestPhone}
|
const b = bookings.find(b => b.id === id)
|
||||||
onChange={e => setGuestPhone(maskPhone(e.target.value))}
|
if (b && !guestName) { setGuestName(b.guestName); setGuestPhone(b.guestPhone ?? '') }
|
||||||
/>
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">— Без привязки —</option>
|
||||||
|
{linkableBookings.map(b => {
|
||||||
|
const r = rooms.find(r => r.id === b.roomId)
|
||||||
|
const isCI = b.status === 'checked_in'
|
||||||
|
return (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{isCI ? '🏠' : '📅'} №{r?.number ?? '?'} — {b.guestName}{isCI ? ' (проживает)' : ''}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{phoneRequired && !guestPhone.trim() && (
|
|
||||||
<p className="mt-1 text-[11px] text-red-500">Для нового гостя необходим телефон — он будет добавлен в базу</p>
|
{/* Notes */}
|
||||||
|
<div>
|
||||||
|
<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="Дополнительно..."
|
||||||
|
value={notes} onChange={e => setNotes(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price + Payment */}
|
||||||
|
{hours > 0 && (
|
||||||
|
<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">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{isFullDay ? `Весь день (${obj.openHour}:00–${obj.closeHour}:00)` : `${hours} ч × ${obj.pricePerHour.toLocaleString('ru-RU')} ₽`}
|
||||||
|
</span>
|
||||||
|
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">{totalAmount.toLocaleString('ru-RU')} ₽</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Внесение оплаты</p>
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{([
|
||||||
|
{ id: 'cash' as const, label: 'Наличные', icon: Banknote },
|
||||||
|
{ id: 'card' as const, label: 'Карта', icon: CreditCard },
|
||||||
|
{ id: 'transfer' as const, label: 'Перевод', icon: Building2 },
|
||||||
|
]).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>
|
||||||
|
{payMethod && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-slate-500 dark:text-slate-400 shrink-0">Оплачено (₽)</label>
|
||||||
|
<input type="number" className="input flex-1 text-sm" min="0" max={totalAmount}
|
||||||
|
placeholder={`макс. ${totalAmount.toLocaleString('ru-RU')}`}
|
||||||
|
value={paidAmount} onChange={e => setPaidAmount(e.target.value)} />
|
||||||
|
<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 && (
|
||||||
|
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
Долг: {(totalAmount - parseFloat(paidAmount)).toLocaleString('ru-RU')} ₽
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Link to room */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5 flex items-center gap-1">
|
|
||||||
<Link2 size={11} /> Привязать к номеру
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="input text-sm"
|
|
||||||
value={linkedBookingId}
|
|
||||||
onChange={e => {
|
|
||||||
const id = e.target.value
|
|
||||||
setLinkedBookingId(id)
|
|
||||||
if (id) {
|
|
||||||
const b = bookings.find(b => b.id === id)
|
|
||||||
if (b && !guestName) {
|
|
||||||
setGuestName(b.guestName)
|
|
||||||
setGuestPhone(b.guestPhone ?? '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">— Без привязки —</option>
|
|
||||||
{linkableBookings.map(b => {
|
|
||||||
const r = rooms.find(r => r.id === b.roomId)
|
|
||||||
const isCI = b.status === 'checked_in'
|
|
||||||
return (
|
|
||||||
<option key={b.id} value={b.id}>
|
|
||||||
{isCI ? '🏠' : '📅'} №{r?.number ?? '?'} — {b.guestName}
|
|
||||||
{isCI ? ' (проживает)' : ''}
|
|
||||||
</option>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<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="Дополнительно..."
|
|
||||||
value={notes}
|
|
||||||
onChange={e => setNotes(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Price summary */}
|
|
||||||
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/50 px-4 py-3 flex items-center justify-between">
|
|
||||||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
|
||||||
{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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Payment */}
|
|
||||||
<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">
|
|
||||||
{([
|
|
||||||
{ id: 'cash' as const, label: 'Наличные', icon: Banknote },
|
|
||||||
{ id: 'card' as const, label: 'Карта', icon: CreditCard },
|
|
||||||
{ id: 'transfer' as const, label: 'Перевод', icon: Building2 },
|
|
||||||
]).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>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input text-sm"
|
|
||||||
placeholder={`Сумма (макс. ${totalAmount.toLocaleString('ru-RU')} ₽)`}
|
|
||||||
value={paidAmount}
|
|
||||||
onChange={e => setPaidAmount(e.target.value)}
|
|
||||||
min="0"
|
|
||||||
max={totalAmount}
|
|
||||||
/>
|
|
||||||
{paidAmount && parseFloat(paidAmount) > 0 && parseFloat(paidAmount) < totalAmount && (
|
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
Остаток: {(totalAmount - parseFloat(paidAmount)).toLocaleString('ru-RU')} ₽
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user