Rental modal: require phone for new guests, auto-create in guests DB
- Track guestIsKnown: true when selected from dropdown, false when typed manually - If guest is new (typed manually): phone field is required (red border + hint) - On save: if new guest + slug available, call api.guests.create before saving rental - Button shows 'Сохранение...' while saving, disabled during async flow Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,8 @@ export function RentalBookingModal({
|
||||
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)
|
||||
@@ -46,6 +48,7 @@ export function RentalBookingModal({
|
||||
// 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')
|
||||
@@ -80,6 +83,7 @@ export function RentalBookingModal({
|
||||
setGuestPhone(b.guestPhone ?? '')
|
||||
setLinkedBookingId(b.id)
|
||||
setShowSuggestions(false)
|
||||
setGuestIsKnown(true)
|
||||
}
|
||||
|
||||
const hours = isFullDay
|
||||
@@ -96,26 +100,45 @@ export function RentalBookingModal({
|
||||
)
|
||||
const hasFullDayConflict = otherBookings.some(b => b.isFullDay && b.status !== 'cancelled')
|
||||
|
||||
const canSave = guestName.trim() !== '' && hours > 0 && maxHoursOk && !isTimeConflict && !hasFullDayConflict
|
||||
// For new (unknown) guests, phone is required
|
||||
const phoneRequired = !guestIsKnown
|
||||
const canSave = guestName.trim() !== '' &&
|
||||
(!phoneRequired || guestPhone.trim() !== '') &&
|
||||
hours > 0 && maxHoursOk && !isTimeConflict && !hasFullDayConflict
|
||||
|
||||
const handleSave = () => {
|
||||
if (!canSave) return
|
||||
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',
|
||||
})
|
||||
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', {
|
||||
@@ -243,6 +266,7 @@ export function RentalBookingModal({
|
||||
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) {
|
||||
@@ -298,6 +322,7 @@ export function RentalBookingModal({
|
||||
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"
|
||||
>
|
||||
@@ -318,18 +343,21 @@ export function RentalBookingModal({
|
||||
|
||||
<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="input pl-8 text-sm"
|
||||
placeholder="+7 999 000 11 22"
|
||||
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 */}
|
||||
@@ -437,11 +465,11 @@ export function RentalBookingModal({
|
||||
<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={handleSave}
|
||||
disabled={!canSave}
|
||||
onClick={() => { void handleSave() }}
|
||||
disabled={!canSave || saving}
|
||||
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isEdit ? 'Сохранить' : 'Забронировать'}
|
||||
{saving ? 'Сохранение...' : isEdit ? 'Сохранить' : 'Забронировать'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user