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 [paidAmount, setPaidAmount] = useState<string>(editBooking?.paidAmount ? String(editBooking.paidAmount) : '')
|
||||||
const [payMethod, setPayMethod] = useState<'cash' | 'card' | 'transfer'>('cash')
|
const [payMethod, setPayMethod] = useState<'cash' | 'card' | 'transfer'>('cash')
|
||||||
const [linkedBookingId, setLinkedBookingId] = useState<string>(editBooking?.linkedRoomId ?? '')
|
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
|
// Guest search
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||||
@@ -46,6 +48,7 @@ export function RentalBookingModal({
|
|||||||
// DB guest search
|
// DB guest search
|
||||||
const [dbResults, setDbResults] = useState<{ id: string; firstName: string; lastName: string; phone: string | null }[]>([])
|
const [dbResults, setDbResults] = useState<{ id: string; firstName: string; lastName: string; phone: string | null }[]>([])
|
||||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
// Bookings with guest info — checked_in first, then confirmed
|
// Bookings with guest info — checked_in first, then confirmed
|
||||||
const checkedIn = bookings.filter(b => b.status === 'checked_in')
|
const checkedIn = bookings.filter(b => b.status === 'checked_in')
|
||||||
@@ -80,6 +83,7 @@ export function RentalBookingModal({
|
|||||||
setGuestPhone(b.guestPhone ?? '')
|
setGuestPhone(b.guestPhone ?? '')
|
||||||
setLinkedBookingId(b.id)
|
setLinkedBookingId(b.id)
|
||||||
setShowSuggestions(false)
|
setShowSuggestions(false)
|
||||||
|
setGuestIsKnown(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hours = isFullDay
|
const hours = isFullDay
|
||||||
@@ -96,10 +100,26 @@ export function RentalBookingModal({
|
|||||||
)
|
)
|
||||||
const hasFullDayConflict = otherBookings.some(b => b.isFullDay && b.status !== 'cancelled')
|
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 = () => {
|
const handleSave = async () => {
|
||||||
if (!canSave) return
|
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)
|
const linked = bookings.find(b => b.id === linkedBookingId)
|
||||||
onSave({
|
onSave({
|
||||||
id: editBooking?.id ?? `rb-${Date.now()}`,
|
id: editBooking?.id ?? `rb-${Date.now()}`,
|
||||||
@@ -116,6 +136,9 @@ export function RentalBookingModal({
|
|||||||
paidAmount: parseFloat(paidAmount) || 0,
|
paidAmount: parseFloat(paidAmount) || 0,
|
||||||
status: 'confirmed',
|
status: 'confirmed',
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayDate = new Intl.DateTimeFormat('ru-RU', {
|
const displayDate = new Intl.DateTimeFormat('ru-RU', {
|
||||||
@@ -243,6 +266,7 @@ export function RentalBookingModal({
|
|||||||
onChange={e => {
|
onChange={e => {
|
||||||
const val = e.target.value
|
const val = e.target.value
|
||||||
setGuestName(val)
|
setGuestName(val)
|
||||||
|
setGuestIsKnown(false) // manual typing = new guest
|
||||||
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) {
|
||||||
@@ -298,6 +322,7 @@ export function RentalBookingModal({
|
|||||||
setGuestPhone(g.phone ?? '')
|
setGuestPhone(g.phone ?? '')
|
||||||
setShowSuggestions(false)
|
setShowSuggestions(false)
|
||||||
setDbResults([])
|
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"
|
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>
|
<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>}
|
||||||
</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="input pl-8 text-sm"
|
className={cn('input pl-8 text-sm', phoneRequired && !guestPhone.trim() && 'border-red-400 focus:ring-red-400')}
|
||||||
placeholder="+7 999 000 11 22"
|
placeholder={phoneRequired ? '+7 999 000 11 22 (обязательно)' : '+7 999 000 11 22'}
|
||||||
value={guestPhone}
|
value={guestPhone}
|
||||||
onChange={e => setGuestPhone(e.target.value)}
|
onChange={e => setGuestPhone(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{phoneRequired && !guestPhone.trim() && (
|
||||||
|
<p className="mt-1 text-[11px] text-red-500">Для нового гостя необходим телефон — он будет добавлен в базу</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Link to room */}
|
{/* 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">
|
<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={onClose} className="btn-secondary">Отмена</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={() => { void handleSave() }}
|
||||||
disabled={!canSave}
|
disabled={!canSave || saving}
|
||||||
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isEdit ? 'Сохранить' : 'Забронировать'}
|
{saving ? 'Сохранение...' : isEdit ? 'Сохранить' : 'Забронировать'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user