feat: rental tab 2-column layout + payment section
Left col: object picker, date, full-day toggle, time selectors Right col: guest autocomplete, phone, notes, total + payment block Payment: Наличные/Терминал toggle buttons, оплачено input, 'Всю сумму' shortcut, debt indicator; paidAmount saved to booking Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -112,6 +112,8 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
const [rentalPhone, setRentalPhone] = useState('')
|
||||
const [rentalNotes, setRentalNotes] = useState('')
|
||||
const [rentalSaving, setRentalSaving] = useState(false)
|
||||
const [rentalPayMethod, setRentalPayMethod] = useState<'cash' | 'terminal' | null>(null)
|
||||
const [rentalPaidAmount, setRentalPaidAmount] = useState(0)
|
||||
// Rental guest autocomplete
|
||||
const [rentalShowSugg, setRentalShowSugg] = useState(false)
|
||||
const [rentalDbResults, setRentalDbResults] = useState<{ id: string; firstName: string; lastName: string; middleName: string | null; phone: string | null }[]>([])
|
||||
@@ -218,7 +220,7 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
guestPhone: rentalPhone.trim(),
|
||||
notes: rentalNotes.trim() || undefined,
|
||||
totalAmount: rentalTotal,
|
||||
paidAmount: 0,
|
||||
paidAmount: rentalPaidAmount,
|
||||
status: 'confirmed',
|
||||
})
|
||||
onClose()
|
||||
@@ -464,124 +466,127 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
|
||||
{/* ── RENTAL FORM ── */}
|
||||
{bookingType === 'rental' && rentalObj && (
|
||||
<div className="space-y-4">
|
||||
{/* Object picker */}
|
||||
{rentalObjects && rentalObjects.length > 1 && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Объект аренды</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{rentalObjects.map(o => (
|
||||
<button
|
||||
key={o.id}
|
||||
onClick={() => { setRentalObjId(o.id); setRentalStartM(o.openHour * 60); setRentalEndM(o.openHour * 60 + 60) }}
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-2.5 rounded-xl border text-sm font-medium transition-colors text-left',
|
||||
rentalObjId === o.id
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
|
||||
: 'border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:border-brand-300 dark:hover:border-brand-600',
|
||||
)}
|
||||
>
|
||||
<span className="text-lg">{o.icon}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate">{o.name}</p>
|
||||
<p className="text-xs text-slate-400">{o.pricePerHour} ₽/ч</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{rentalObjects?.length === 1 && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-slate-50 dark:bg-slate-700/40">
|
||||
<span className="text-2xl">{rentalObj.icon}</span>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">{rentalObj.name}</p>
|
||||
<p className="text-xs text-slate-500">{rentalObj.pricePerHour} ₽/ч · {rentalObj.pricePerDay} ₽/день</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
|
||||
|
||||
{/* Date */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-1.5">Дата</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={rentalDate}
|
||||
onChange={e => setRentalDate(e.target.value)}
|
||||
/>
|
||||
{/* ── LEFT: объект + дата + время ── */}
|
||||
<div className="flex-1 space-y-4 min-w-0">
|
||||
|
||||
{/* Object picker */}
|
||||
{rentalObjects && rentalObjects.length > 1 && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Объект аренды</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{rentalObjects.map(o => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => { setRentalObjId(o.id); setRentalStartM(o.openHour * 60); setRentalEndM(o.openHour * 60 + 60) }}
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-2.5 rounded-xl border text-sm font-medium transition-colors text-left',
|
||||
rentalObjId === o.id
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
|
||||
: 'border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:border-brand-300 dark:hover:border-brand-600',
|
||||
)}
|
||||
>
|
||||
<span className="text-lg">{o.icon}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate">{o.name}</p>
|
||||
<p className="text-xs text-slate-400">{o.pricePerHour} ₽/ч</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{rentalObjects?.length === 1 && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-slate-50 dark:bg-slate-700/40">
|
||||
<span className="text-2xl">{rentalObj.icon}</span>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">{rentalObj.name}</p>
|
||||
<p className="text-xs text-slate-500">{rentalObj.pricePerHour} ₽/ч · {rentalObj.pricePerDay} ₽/день</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-1.5">Дата</label>
|
||||
<input type="date" className="input" value={rentalDate} onChange={e => setRentalDate(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* Full day / time */}
|
||||
{rentalHasFullDay ? (
|
||||
<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 className={cn('flex items-center gap-3 p-3 rounded-xl border border-slate-200 dark:border-slate-600', rentalHasTimed && 'opacity-50 pointer-events-none')}>
|
||||
<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>
|
||||
{rentalHasTimed && <span className="text-xs text-slate-400">Есть частичные брони</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !rentalHasTimed && setRentalIsFullDay(v => !v)}
|
||||
className={cn('relative rounded-full overflow-hidden transition-colors shrink-0 p-0', rentalIsFullDay ? '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', rentalIsFullDay ? 'translate-x-[18px]' : 'translate-x-0')} />
|
||||
</button>
|
||||
</div>
|
||||
{!rentalIsFullDay && (
|
||||
rentalNoSlots ? (
|
||||
<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>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
Время
|
||||
{rentalBufMin > 0 && <span className="ml-1 font-normal normal-case">(перерыв {rentalBufMin} мин)</span>}
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-slate-500 mb-1">Начало</label>
|
||||
<select className="input text-sm" value={rentalEffStart}
|
||||
onChange={e => {
|
||||
const v = parseInt(e.target.value)
|
||||
setRentalStartM(v)
|
||||
const maxE = rentalGetMaxEnd(v)
|
||||
if (rentalEndM <= v || rentalEndM > maxE) setRentalEndM(Math.min(v + 60, maxE))
|
||||
}}>
|
||||
{rentalAvailStarts.map(m => <option key={m} value={m}>{fmtTime(m)}</option>)}
|
||||
</select>
|
||||
</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={rentalAvailEnds.includes(rentalEndM) ? rentalEndM : (rentalAvailEnds[0] ?? rentalEndM)}
|
||||
onChange={e => setRentalEndM(parseInt(e.target.value))}>
|
||||
{rentalAvailEnds.map(m => <option key={m} value={m}>{fmtTime(m)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Full day / time */}
|
||||
{rentalHasFullDay ? (
|
||||
<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 className={cn('flex items-center gap-3 p-3 rounded-xl border border-slate-200 dark:border-slate-600', rentalHasTimed && 'opacity-50 pointer-events-none')}>
|
||||
<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>
|
||||
{rentalHasTimed && <span className="text-xs text-slate-400">Есть частичные брони</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !rentalHasTimed && setRentalIsFullDay(v => !v)}
|
||||
className={cn('relative rounded-full overflow-hidden transition-colors shrink-0 p-0', rentalIsFullDay ? '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', rentalIsFullDay ? 'translate-x-[18px]' : 'translate-x-0')} />
|
||||
</button>
|
||||
</div>
|
||||
{!rentalIsFullDay && (
|
||||
rentalNoSlots ? (
|
||||
<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>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
Время
|
||||
{rentalBufMin > 0 && <span className="ml-1 font-normal normal-case">(перерыв {rentalBufMin} мин)</span>}
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-slate-500 mb-1">Начало</label>
|
||||
<select className="input text-sm" value={rentalEffStart}
|
||||
onChange={e => {
|
||||
const v = parseInt(e.target.value)
|
||||
setRentalStartM(v)
|
||||
const maxE = rentalGetMaxEnd(v)
|
||||
if (rentalEndM <= v || rentalEndM > maxE) setRentalEndM(Math.min(v + 60, maxE))
|
||||
}}>
|
||||
{rentalAvailStarts.map(m => <option key={m} value={m}>{fmtTime(m)}</option>)}
|
||||
</select>
|
||||
</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={rentalAvailEnds.includes(rentalEndM) ? rentalEndM : (rentalAvailEnds[0] ?? rentalEndM)}
|
||||
onChange={e => setRentalEndM(parseInt(e.target.value))}>
|
||||
{rentalAvailEnds.map(m => <option key={m} value={m}>{fmtTime(m)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* ── RIGHT: гость + оплата ── */}
|
||||
<div className="w-full sm:w-72 space-y-4 shrink-0">
|
||||
|
||||
{/* Guest */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Guest */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
<User size={13} className="inline mr-1" />Гость *
|
||||
</label>
|
||||
<div className="relative" ref={rentalNameRef}>
|
||||
<input type="text" className="input" placeholder="Имя Фамилия" autoComplete="off"
|
||||
<input type="text" className="input" placeholder="Фамилия Имя Отчество" autoComplete="off"
|
||||
value={rentalGuest}
|
||||
onFocus={() => setRentalShowSugg(true)}
|
||||
onChange={e => {
|
||||
@@ -616,7 +621,7 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
</button>
|
||||
))}
|
||||
{rentalDbResults
|
||||
.filter(g => !rentalFiltered.some(b => b.guestName === `${g.lastName} ${g.firstName}`))
|
||||
.filter(g => !rentalFiltered.some(b => b.guestName.startsWith(g.lastName)))
|
||||
.map(g => (
|
||||
<button key={g.id} type="button"
|
||||
onMouseDown={() => { setRentalGuest([g.lastName, g.firstName, g.middleName].filter(Boolean).join(' ')); setRentalPhone(g.phone ?? ''); setRentalShowSugg(false); setRentalDbResults([]) }}
|
||||
@@ -624,7 +629,7 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full shrink-0 bg-slate-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{g.lastName} {g.firstName}</p>
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{[g.lastName, g.firstName, g.middleName].filter(Boolean).join(' ')}</p>
|
||||
<p className="text-[11px] text-slate-400 truncate">{g.phone ?? 'нет телефона'}</p>
|
||||
</div>
|
||||
</button>
|
||||
@@ -633,6 +638,8 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
<Phone size={13} className="inline mr-1" />Телефон
|
||||
@@ -640,24 +647,84 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
<input type="tel" className="input" placeholder="+7 (999) 000-00-00"
|
||||
value={rentalPhone} onChange={e => setRentalPhone(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-1.5">Примечания</label>
|
||||
<textarea className="input resize-none h-16 text-sm" placeholder="Доп. пожелания..."
|
||||
value={rentalNotes} onChange={e => setRentalNotes(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{rentalHours > 0 && (
|
||||
<div className="rounded-xl bg-brand-50 dark:bg-brand-900/20 border border-brand-200 dark:border-brand-700 p-3">
|
||||
<p className="text-sm font-semibold text-brand-700 dark:text-brand-300">
|
||||
Итого: {rentalTotal.toLocaleString('ru-RU')} ₽
|
||||
<span className="ml-2 text-xs font-normal text-brand-500">
|
||||
({rentalIsFullDay ? 'весь день' : `${rentalHours} ч × ${rentalObj.pricePerHour} ₽`})
|
||||
</span>
|
||||
</p>
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-1.5">Примечания</label>
|
||||
<textarea className="input resize-none h-16 text-sm" placeholder="Доп. пожелания..."
|
||||
value={rentalNotes} onChange={e => setRentalNotes(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment + Total */}
|
||||
{rentalHours > 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">
|
||||
{rentalIsFullDay ? 'Весь день' : `${rentalHours} ч × ${rentalObj.pricePerHour.toLocaleString('ru-RU')} ₽`}
|
||||
</span>
|
||||
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{rentalTotal.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</div>
|
||||
{/* Способ оплаты */}
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Внесение оплаты</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRentalPayMethod(p => p === 'cash' ? null : 'cash')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors flex-1 justify-center',
|
||||
rentalPayMethod === 'cash'
|
||||
? 'bg-emerald-600 text-white border-emerald-600'
|
||||
: 'bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-emerald-400',
|
||||
)}
|
||||
>
|
||||
<Banknote size={14} /> Наличные
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRentalPayMethod(p => p === 'terminal' ? null : 'terminal')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors flex-1 justify-center',
|
||||
rentalPayMethod === 'terminal'
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-blue-400',
|
||||
)}
|
||||
>
|
||||
<CreditCard size={14} /> Терминал
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Оплачено */}
|
||||
{rentalPayMethod && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-slate-500 dark:text-slate-400 shrink-0">Оплачено (₽)</label>
|
||||
<input
|
||||
type="number" min={0} max={rentalTotal}
|
||||
className="input flex-1 text-sm"
|
||||
value={rentalPaidAmount}
|
||||
onChange={e => setRentalPaidAmount(Math.min(rentalTotal, Math.max(0, parseInt(e.target.value) || 0)))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRentalPaidAmount(rentalTotal)}
|
||||
className="text-xs text-brand-600 dark:text-brand-400 hover:underline shrink-0"
|
||||
>
|
||||
Всю сумму
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{rentalPayMethod && rentalPaidAmount > 0 && rentalPaidAmount < rentalTotal && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
Долг: {(rentalTotal - rentalPaidAmount).toLocaleString('ru-RU')} ₽
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user