BookingModal: - Add 'Номер' / 'Аренда объекта' tabs when rental objects exist - Rental tab: object picker, date, full-day toggle, conflict-aware time dropdowns, guest/phone fields, total amount RentalBookingModal: - Filter start times: exclude hours inside booked slots + bufferMinutes gap - Filter end times: cap at next booking's start hour - Disable 'Весь день' if any timed bookings exist for the day - Show 'Все слоты заняты' when no start hours are available - Show buffer duration in time section label Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
527 lines
24 KiB
TypeScript
527 lines
24 KiB
TypeScript
import { useState, useRef, useEffect } from 'react'
|
||
import { X, Clock, CalendarDays, User, Phone, AlertCircle, Banknote, CreditCard, Building2, Link2 } from 'lucide-react'
|
||
import { cn } from '../../lib/utils'
|
||
import { api } from '../../lib/api'
|
||
import type { RentalObject, RentalBooking } from '../../data/rentalData'
|
||
import type { Booking, Room } from '../../types'
|
||
|
||
interface RentalBookingModalProps {
|
||
obj: RentalObject
|
||
date: string
|
||
existingBookings: RentalBooking[]
|
||
editBooking?: RentalBooking
|
||
bookings?: Booking[] // all room bookings (for guest search + link)
|
||
rooms?: Room[] // room list (for displaying room numbers)
|
||
slug?: string
|
||
onClose: () => void
|
||
onSave: (b: RentalBooking) => void
|
||
}
|
||
|
||
function hourOptions(from: number, to: number): number[] {
|
||
return Array.from({ length: to - from + 1 }, (_, i) => from + i)
|
||
}
|
||
|
||
function formatHour(h: number): string {
|
||
return `${h}:00`
|
||
}
|
||
|
||
export function RentalBookingModal({
|
||
obj, date, existingBookings, editBooking, bookings = [], rooms = [], slug, onClose, onSave,
|
||
}: RentalBookingModalProps) {
|
||
const isEdit = !!editBooking
|
||
const [isFullDay, setIsFullDay] = useState(editBooking?.isFullDay ?? false)
|
||
const [startHour, setStartHour] = useState(editBooking?.startHour ?? obj.openHour)
|
||
const [endHour, setEndHour] = useState(editBooking?.endHour ?? Math.min(obj.openHour + 2, obj.closeHour))
|
||
const [guestName, setGuestName] = useState(editBooking?.guestName ?? '')
|
||
const [guestPhone, setGuestPhone] = useState(editBooking?.guestPhone ?? '')
|
||
const [notes, setNotes] = useState(editBooking?.notes ?? '')
|
||
const [paidAmount, setPaidAmount] = useState<string>(editBooking?.paidAmount ? String(editBooking.paidAmount) : '')
|
||
const [payMethod, setPayMethod] = useState<'cash' | 'card' | 'transfer'>('cash')
|
||
const [linkedBookingId, setLinkedBookingId] = useState<string>(editBooking?.linkedRoomId ?? '')
|
||
// true = selected from dropdown (existing guest), false = typed manually (new guest)
|
||
const [guestIsKnown, setGuestIsKnown] = useState(isEdit)
|
||
|
||
// Guest search
|
||
const [showSuggestions, setShowSuggestions] = useState(false)
|
||
const nameRef = useRef<HTMLDivElement>(null)
|
||
|
||
// DB guest search
|
||
const [dbResults, setDbResults] = useState<{ id: string; firstName: string; lastName: string; phone: string | null }[]>([])
|
||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
const [saving, setSaving] = useState(false)
|
||
|
||
// Bookings with guest info — checked_in first, then confirmed
|
||
const checkedIn = bookings.filter(b => b.status === 'checked_in')
|
||
const confirmed = bookings.filter(b => b.status === 'confirmed')
|
||
const guestSuggestions = [...checkedIn, ...confirmed]
|
||
|
||
const filtered = guestName.trim().length > 0
|
||
? guestSuggestions.filter(b =>
|
||
b.guestName.toLowerCase().includes(guestName.toLowerCase()),
|
||
)
|
||
: guestSuggestions
|
||
|
||
// Room label helper
|
||
const roomLabel = (b: Booking) => {
|
||
const r = rooms.find(r => r.id === b.roomId)
|
||
return r ? `№${r.number}` : ''
|
||
}
|
||
|
||
// Close suggestions on outside click
|
||
useEffect(() => {
|
||
const handler = (e: MouseEvent) => {
|
||
if (nameRef.current && !nameRef.current.contains(e.target as Node)) {
|
||
setShowSuggestions(false)
|
||
}
|
||
}
|
||
document.addEventListener('mousedown', handler)
|
||
return () => document.removeEventListener('mousedown', handler)
|
||
}, [])
|
||
|
||
const selectGuest = (b: Booking) => {
|
||
setGuestName(b.guestName)
|
||
setGuestPhone(b.guestPhone ?? '')
|
||
setLinkedBookingId(b.id)
|
||
setShowSuggestions(false)
|
||
setGuestIsKnown(true)
|
||
}
|
||
|
||
const otherBookings = existingBookings.filter(b => b.id !== editBooking?.id && b.status !== 'cancelled')
|
||
const timedBookings = otherBookings.filter(b => !b.isFullDay)
|
||
const hasFullDayConflict = otherBookings.some(b => b.isFullDay)
|
||
// If any timed slot is booked, "весь день" is unavailable
|
||
const hasTimedConflict = timedBookings.length > 0
|
||
|
||
// Break between sessions (ceil to whole hours)
|
||
const breakHours = Math.ceil((obj.bufferMinutes ?? 0) / 60)
|
||
|
||
// Available start hours: exclude hours inside any booked slot + its buffer
|
||
const availableStartHours = hourOptions(obj.openHour, obj.closeHour - 1).filter(h =>
|
||
!timedBookings.some(b => h >= b.startHour && h < b.endHour + breakHours),
|
||
)
|
||
|
||
// Max end hour for a given start: limited by the next booking that starts after `start`
|
||
const getMaxEndHour = (start: number) => {
|
||
const next = timedBookings
|
||
.filter(b => b.startHour >= start + 1)
|
||
.sort((a, b) => a.startHour - b.startHour)[0]
|
||
return next ? next.startHour : obj.closeHour
|
||
}
|
||
|
||
const availableEndHours = hourOptions(startHour + 1, getMaxEndHour(startHour))
|
||
|
||
// If current startHour is blocked, snap to first available
|
||
const effectiveStartHour = availableStartHours.includes(startHour)
|
||
? startHour
|
||
: (availableStartHours[0] ?? obj.openHour)
|
||
|
||
const hours = isFullDay
|
||
? obj.closeHour - obj.openHour
|
||
: Math.max(0, endHour - effectiveStartHour)
|
||
|
||
const totalAmount = isFullDay ? obj.pricePerDay : hours * obj.pricePerHour
|
||
const maxHoursOk = !obj.maxHoursPerSlot || hours <= obj.maxHoursPerSlot
|
||
|
||
const isTimeConflict = !isFullDay && timedBookings.some(b =>
|
||
b.startHour < endHour && b.endHour > effectiveStartHour,
|
||
)
|
||
|
||
const noSlots = !hasFullDayConflict && availableStartHours.length === 0
|
||
|
||
// For new (unknown) guests, phone is required
|
||
const phoneRequired = !guestIsKnown
|
||
const canSave = guestName.trim() !== '' &&
|
||
(!phoneRequired || guestPhone.trim() !== '') &&
|
||
hours > 0 && maxHoursOk && !isTimeConflict && !hasFullDayConflict && !noSlots &&
|
||
(!isFullDay || (!hasFullDayConflict && !hasTimedConflict))
|
||
|
||
const handleSave = async () => {
|
||
if (!canSave || saving) return
|
||
setSaving(true)
|
||
try {
|
||
// If guest is new (typed manually), create them in the guests DB
|
||
if (!guestIsKnown && slug) {
|
||
const nameParts = guestName.trim().split(' ')
|
||
await api.guests.create(slug, {
|
||
last_name: nameParts[0] ?? '',
|
||
first_name: nameParts[1] ?? '',
|
||
middle_name: nameParts.slice(2).join(' ') || undefined,
|
||
phone: guestPhone.trim() || undefined,
|
||
})
|
||
}
|
||
const linked = bookings.find(b => b.id === linkedBookingId)
|
||
onSave({
|
||
id: editBooking?.id ?? `rb-${Date.now()}`,
|
||
objectId: obj.id,
|
||
date,
|
||
isFullDay,
|
||
startHour: isFullDay ? obj.openHour : startHour,
|
||
endHour: isFullDay ? obj.closeHour : endHour,
|
||
guestName: guestName.trim(),
|
||
guestPhone: guestPhone.trim(),
|
||
linkedRoomId: (linked?.roomId ?? linkedBookingId) || undefined,
|
||
notes: notes.trim() || undefined,
|
||
totalAmount,
|
||
paidAmount: parseFloat(paidAmount) || 0,
|
||
status: 'confirmed',
|
||
})
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const displayDate = new Intl.DateTimeFormat('ru-RU', {
|
||
day: 'numeric', month: 'long', weekday: 'short',
|
||
}).format(new Date(date))
|
||
|
||
// For link dropdown — show checked-in first, label includes room number
|
||
const linkableBookings = [...checkedIn, ...confirmed.filter(b => !checkedIn.includes(b))]
|
||
|
||
return (
|
||
<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]">
|
||
{/* Header */}
|
||
<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>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="font-semibold text-slate-900 dark:text-slate-100">{obj.name}</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 capitalize">{displayDate}</p>
|
||
</div>
|
||
<button onClick={onClose} className="btn-ghost p-1.5"><X size={16} /></button>
|
||
</div>
|
||
|
||
<div className="overflow-y-auto flex-1 p-5 space-y-4">
|
||
{/* Existing bookings for the day */}
|
||
{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">
|
||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Уже забронировано</p>
|
||
{existingBookings.filter(b => b.status !== 'cancelled').map(b => (
|
||
<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 ? 'Весь день' : `${b.startHour}:00 – ${b.endHour}:00`}
|
||
</span>
|
||
<span className="text-slate-500 dark:text-slate-400 truncate">{b.guestName}</span>
|
||
</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
|
||
onClick={() => (!hasTimedConflict && !hasFullDayConflict) && setIsFullDay(v => !v)}
|
||
className={cn(
|
||
'relative rounded-full transition-colors shrink-0',
|
||
isFullDay ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600',
|
||
)}
|
||
style={{ height: 22, width: 40 }}
|
||
>
|
||
<span className={cn(
|
||
'absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform',
|
||
isFullDay ? 'translate-x-5' : 'translate-x-0.5',
|
||
)} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Time selection */}
|
||
{!isFullDay && !hasFullDayConflict && (
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||
Время аренды ({obj.openHour}:00 – {obj.closeHour}:00)
|
||
{breakHours > 0 && <span className="ml-1 font-normal normal-case">(перерыв {obj.bufferMinutes} мин)</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 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={effectiveStartHour}
|
||
onChange={e => {
|
||
const v = parseInt(e.target.value)
|
||
setStartHour(v)
|
||
const maxEnd = getMaxEndHour(v)
|
||
if (endHour <= v || endHour > maxEnd) setEndHour(Math.min(v + 1, maxEnd))
|
||
}}
|
||
>
|
||
{availableStartHours.map(h => (
|
||
<option key={h} value={h}>{formatHour(h)}</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={availableEndHours.includes(endHour) ? endHour : (availableEndHours[0] ?? endHour)}
|
||
onChange={e => setEndHour(parseInt(e.target.value))}
|
||
>
|
||
{availableEndHours.map(h => (
|
||
<option key={h} value={h}>{formatHour(h)}</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>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Guest search */}
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
||
Гость *
|
||
</label>
|
||
<div className="relative" ref={nameRef}>
|
||
<User size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 z-10" />
|
||
<input
|
||
type="text"
|
||
className="input pl-8 text-sm"
|
||
placeholder="Имя гостя"
|
||
value={guestName}
|
||
onChange={e => {
|
||
const val = e.target.value
|
||
setGuestName(val)
|
||
setGuestIsKnown(false) // manual typing = new guest
|
||
setShowSuggestions(true)
|
||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
|
||
if (val.trim().length >= 2 && slug) {
|
||
searchTimerRef.current = setTimeout(async () => {
|
||
try {
|
||
const results = await api.guests.list(slug, val.trim())
|
||
setDbResults(results.slice(0, 8))
|
||
} catch { setDbResults([]) }
|
||
}, 300)
|
||
} else {
|
||
setDbResults([])
|
||
}
|
||
}}
|
||
onFocus={() => setShowSuggestions(true)}
|
||
autoComplete="off"
|
||
/>
|
||
{/* Suggestions dropdown */}
|
||
{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">
|
||
{/* Current/upcoming guests from bookings */}
|
||
{filtered.slice(0, 5).map(b => {
|
||
const rLabel = roomLabel(b)
|
||
const isCheckedIn = b.status === 'checked_in'
|
||
return (
|
||
<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"
|
||
>
|
||
<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 === `${g.lastName} ${g.firstName}`))
|
||
.map(g => (
|
||
<button
|
||
key={g.id}
|
||
type="button"
|
||
onMouseDown={() => {
|
||
setGuestName(`${g.lastName} ${g.firstName}`)
|
||
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="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-[11px] text-slate-400 truncate">
|
||
{g.phone ?? 'нет телефона'}
|
||
</p>
|
||
</div>
|
||
</button>
|
||
))
|
||
}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
||
Телефон {phoneRequired && <span className="text-red-500">*</span>}
|
||
</label>
|
||
<div className="relative">
|
||
<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(e.target.value)}
|
||
/>
|
||
</div>
|
||
{phoneRequired && !guestPhone.trim() && (
|
||
<p className="mt-1 text-[11px] text-red-500">Для нового гостя необходим телефон — он будет добавлен в базу</p>
|
||
)}
|
||
</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>
|
||
|
||
{/* Footer */}
|
||
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-slate-200 dark:border-slate-700 shrink-0">
|
||
<button onClick={onClose} className="btn-secondary">Отмена</button>
|
||
<button
|
||
onClick={() => { void handleSave() }}
|
||
disabled={!canSave || saving}
|
||
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{saving ? 'Сохранение...' : isEdit ? 'Сохранить' : 'Забронировать'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|