624 lines
28 KiB
TypeScript
624 lines
28 KiB
TypeScript
import { useState, useMemo, useEffect } from 'react'
|
||
import { Search, Plus, ArrowUp, ArrowDown, ArrowUpDown, Clock, Pencil, Loader2 } from 'lucide-react'
|
||
import { useModules } from '../contexts/ModulesContext'
|
||
import { useAuth } from '../contexts/AuthContext'
|
||
import { api } from '../lib/api'
|
||
import type { RentalObjectApi, RentalBookingApi } from '../lib/api'
|
||
import type { Booking, BookingStatus, Room } from '../types'
|
||
import type { RentalObject, RentalBooking } from '../data/rentalData'
|
||
import { RentalBookingModal } from '../components/rental/RentalBookingModal'
|
||
import { cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS, SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount } from '../lib/utils'
|
||
import { Badge } from '../components/ui/Badge'
|
||
import { BookingModal } from '../components/bookings/BookingModal'
|
||
import { BookingDetailPanel } from '../components/bookings/BookingDetailPanel'
|
||
import { format, addDays } from 'date-fns'
|
||
|
||
const STATUS_FILTERS: { label: string; value: BookingStatus | 'all' }[] = [
|
||
{ label: 'Все', value: 'all' },
|
||
{ label: 'Подтверждённые', value: 'confirmed' },
|
||
{ label: 'Заселены', value: 'checked_in' },
|
||
{ label: 'Запросы', value: 'inquiry' },
|
||
{ label: 'Выехали', value: 'checked_out' },
|
||
{ label: 'Отменены', value: 'cancelled' },
|
||
]
|
||
|
||
type SortKey = 'guestName' | 'checkIn' | 'checkOut' | 'status' | 'source' | 'totalAmount'
|
||
|
||
const COLUMNS: { key: SortKey | null; label: string }[] = [
|
||
{ key: 'guestName', label: 'Гость' },
|
||
{ key: null, label: 'Номер' },
|
||
{ key: 'checkIn', label: 'Заезд' },
|
||
{ key: 'checkOut', label: 'Выезд' },
|
||
{ key: 'status', label: 'Статус' },
|
||
{ key: 'source', label: 'Источник' },
|
||
{ key: 'totalAmount', label: 'Сумма' },
|
||
{ key: null, label: '' },
|
||
]
|
||
|
||
type Tab = 'rooms' | 'rental'
|
||
|
||
function fromObjApi(o: RentalObjectApi): RentalObject {
|
||
return {
|
||
id: o.id, name: o.name, icon: o.icon, color: o.color,
|
||
textColor: o.textColor, pricePerHour: o.pricePerHour, pricePerDay: o.pricePerDay,
|
||
openHour: o.openHour, closeHour: o.closeHour,
|
||
maxHoursPerSlot: o.maxHoursPerSlot ?? undefined,
|
||
bufferMinutes: o.bufferMinutes ?? undefined,
|
||
}
|
||
}
|
||
|
||
function fromBookingApi(b: RentalBookingApi): RentalBooking {
|
||
return {
|
||
id: b.id, objectId: b.objectId, date: (b.date as string).slice(0, 10),
|
||
isFullDay: b.isFullDay, startHour: b.startHour, endHour: b.endHour,
|
||
guestName: b.guestName, guestPhone: b.guestPhone ?? '',
|
||
linkedRoomId: b.linkedRoomId ?? undefined,
|
||
totalAmount: b.totalAmount, paidAmount: b.paidAmount,
|
||
status: b.status, notes: b.notes ?? undefined,
|
||
}
|
||
}
|
||
|
||
function fmtMin(m: number) {
|
||
return `${Math.floor(m / 60).toString().padStart(2, '0')}:${(m % 60).toString().padStart(2, '0')}`
|
||
}
|
||
|
||
export function BookingsPage() {
|
||
const { user } = useAuth()
|
||
const slug = user?.hotelSlug ?? ''
|
||
const { statuses } = useModules()
|
||
const isRentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial'
|
||
|
||
const [tab, setTab] = useState<Tab>('rooms')
|
||
const [rooms, setRooms] = useState<Room[]>([])
|
||
const [bookings, setBookings] = useState<Booking[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [rentalObjects, setRentalObjects] = useState<RentalObject[]>([])
|
||
const [rentalBookings, setRentalBookings] = useState<RentalBooking[]>([])
|
||
const [newRentalStep, setNewRentalStep] = useState<'idle' | 'pick'>('idle')
|
||
const [rentalPickObj, setRentalPickObj] = useState('')
|
||
const [rentalPickDate, setRentalPickDate] = useState(format(new Date(), 'yyyy-MM-dd'))
|
||
const [showRentalModal, setShowRentalModal] = useState<{ obj: RentalObject; date: string; editBooking?: RentalBooking } | null>(null)
|
||
const [search, setSearch] = useState('')
|
||
const [statusFilter, setStatusFilter] = useState<BookingStatus | 'all'>('all')
|
||
const [selected, setSelected] = useState<Booking | null>(null)
|
||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||
const [sortKey, setSortKey] = useState<SortKey | null>(null)
|
||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||
|
||
useEffect(() => {
|
||
if (!slug) return
|
||
Promise.all([
|
||
api.rooms.list(slug),
|
||
api.bookings.list(slug),
|
||
api.rental.listObjects(slug),
|
||
api.rental.listBookings(slug),
|
||
])
|
||
.then(([r, b, objs, rbks]) => {
|
||
setRooms(r)
|
||
setBookings(b)
|
||
const convertedObjs = objs.map(fromObjApi)
|
||
setRentalObjects(convertedObjs)
|
||
setRentalBookings(rbks.map(fromBookingApi))
|
||
setRentalPickObj(convertedObjs[0]?.id ?? '')
|
||
})
|
||
.catch(console.error)
|
||
.finally(() => setLoading(false))
|
||
}, [slug])
|
||
|
||
const handleSort = (key: SortKey) => {
|
||
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||
else { setSortKey(key); setSortDir('asc') }
|
||
}
|
||
|
||
const filtered = bookings.filter(b => {
|
||
const matchSearch = search === '' ||
|
||
b.guestName.toLowerCase().includes(search.toLowerCase()) ||
|
||
b.guestEmail.toLowerCase().includes(search.toLowerCase()) ||
|
||
b.id.includes(search)
|
||
const matchStatus = statusFilter === 'all' || b.status === statusFilter
|
||
return matchSearch && matchStatus
|
||
})
|
||
|
||
const sorted = useMemo(() => {
|
||
if (!sortKey) return filtered
|
||
return [...filtered].sort((a, b) => {
|
||
const av = a[sortKey]
|
||
const bv = b[sortKey]
|
||
let cmp = 0
|
||
if (typeof av === 'string' && typeof bv === 'string') {
|
||
cmp = av.localeCompare(bv, 'ru')
|
||
} else {
|
||
cmp = (av as number) - (bv as number)
|
||
}
|
||
return sortDir === 'asc' ? cmp : -cmp
|
||
})
|
||
}, [filtered, sortKey, sortDir])
|
||
|
||
const filteredRental = rentalBookings.filter(b => {
|
||
if (search === '') return true
|
||
return b.guestName.toLowerCase().includes(search.toLowerCase()) ||
|
||
b.guestPhone.includes(search)
|
||
})
|
||
|
||
const room = (id: string) => rooms.find(r => r.id === id)
|
||
const rentalObj = (id: string) => rentalObjects.find(o => o.id === id)
|
||
|
||
const handleCreateBooking = async (data: Partial<Booking>) => {
|
||
try {
|
||
const created = await api.bookings.create(slug, {
|
||
roomId: data.roomId, guestName: data.guestName, guestEmail: data.guestEmail, guestPhone: data.guestPhone,
|
||
checkIn: data.checkIn, checkOut: data.checkOut,
|
||
adults: data.adults, children: data.children,
|
||
status: data.status, source: data.source,
|
||
totalAmount: data.totalAmount, notes: data.notes,
|
||
})
|
||
setBookings(prev => [...prev, created])
|
||
setShowCreateModal(false)
|
||
} catch (err) {
|
||
console.error('Failed to create booking:', err)
|
||
}
|
||
}
|
||
|
||
const handleUpdateBooking = async (id: string, data: Partial<Booking>) => {
|
||
try {
|
||
const updated = await api.bookings.update(slug, id, {
|
||
guestName: data.guestName, guestEmail: data.guestEmail, guestPhone: data.guestPhone,
|
||
checkIn: data.checkIn, checkOut: data.checkOut,
|
||
adults: data.adults, children: data.children,
|
||
status: data.status, source: data.source,
|
||
totalAmount: data.totalAmount, paidAmount: data.paidAmount, notes: data.notes,
|
||
})
|
||
setBookings(prev => prev.map(b => b.id === id ? updated : b))
|
||
setSelected(null)
|
||
} catch (err) {
|
||
console.error('Failed to update booking:', err)
|
||
}
|
||
}
|
||
|
||
const handleBulkUpdate = async (updates: Array<{ id: string; data: Partial<Booking> }>) => {
|
||
try {
|
||
const results = await Promise.all(
|
||
updates.map(u => api.bookings.update(slug, u.id, { status: u.data.status })),
|
||
)
|
||
setBookings(prev => {
|
||
let next = [...prev]
|
||
results.forEach(r => { next = next.map(b => b.id === r.id ? r : b) })
|
||
return next
|
||
})
|
||
setSelected(null)
|
||
} catch (err) {
|
||
console.error('Failed to bulk update bookings:', err)
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center h-64">
|
||
<Loader2 size={24} className="animate-spin text-brand-600" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Step-1 modal: occupied slots for selected object + date
|
||
const pickObj = rentalObjects.find(o => o.id === rentalPickObj)
|
||
const pickBookings = rentalBookings.filter(
|
||
b => b.objectId === rentalPickObj && b.date === rentalPickDate && b.status === 'confirmed',
|
||
)
|
||
const pickOpenMin = (pickObj?.openHour ?? 8) * 60
|
||
const pickCloseMin = (pickObj?.closeHour ?? 22) * 60
|
||
const pickTotalMin = pickCloseMin - pickOpenMin
|
||
const pickHasFullDay = pickBookings.some(b => b.isFullDay)
|
||
const fmtPickMin = (m: number) => `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}`
|
||
|
||
return (
|
||
<div className="p-4 md:p-6 space-y-4">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Бронирования</h1>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||
{tab === 'rooms' ? `${sorted.length} из ${bookings.length}` : `${filteredRental.length} аренд`}
|
||
</p>
|
||
</div>
|
||
{tab === 'rooms' && (
|
||
<button onClick={() => setShowCreateModal(true)} className="btn-primary">
|
||
<Plus size={15} />
|
||
<span className="hidden sm:inline">Новое</span>
|
||
</button>
|
||
)}
|
||
{tab === 'rental' && (
|
||
<button onClick={() => setNewRentalStep('pick')} className="btn-primary">
|
||
<Plus size={15} />
|
||
<span className="hidden sm:inline">Новая аренда</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
{isRentalActive && (
|
||
<div className="flex gap-1 p-1 bg-slate-100 dark:bg-slate-700/50 rounded-xl w-fit">
|
||
<button
|
||
onClick={() => setTab('rooms')}
|
||
className={cn(
|
||
'px-4 py-1.5 rounded-lg text-sm font-medium transition-colors',
|
||
tab === 'rooms'
|
||
? 'bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 shadow-sm'
|
||
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200',
|
||
)}
|
||
>
|
||
Номера
|
||
</button>
|
||
<button
|
||
onClick={() => setTab('rental')}
|
||
className={cn(
|
||
'px-4 py-1.5 rounded-lg text-sm font-medium transition-colors flex items-center gap-1.5',
|
||
tab === 'rental'
|
||
? 'bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 shadow-sm'
|
||
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200',
|
||
)}
|
||
>
|
||
<Clock size={13} />
|
||
Аренда
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Search */}
|
||
<div className="flex flex-col sm:flex-row gap-3">
|
||
<div className="relative flex-1">
|
||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||
<input
|
||
type="text"
|
||
className="input pl-9"
|
||
placeholder={tab === 'rooms' ? 'Поиск по гостю, email, ID...' : 'Поиск по гостю, телефону...'}
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
{tab === 'rooms' && (
|
||
<div className="flex gap-1.5 flex-wrap">
|
||
{STATUS_FILTERS.map(f => (
|
||
<button
|
||
key={f.value}
|
||
onClick={() => setStatusFilter(f.value)}
|
||
className={cn(
|
||
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||
statusFilter === f.value
|
||
? 'bg-brand-600 text-white border-brand-600'
|
||
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
|
||
)}
|
||
>
|
||
{f.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Room bookings table */}
|
||
{tab === 'rooms' && (
|
||
<div className="card overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-700/40">
|
||
{COLUMNS.map(col => (
|
||
<th
|
||
key={col.label}
|
||
className={cn(
|
||
'text-left px-4 py-3 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide',
|
||
(col.label === 'Выезд' || col.label === 'Источник') && 'hidden sm:table-cell',
|
||
col.label === 'Номер' && 'hidden md:table-cell',
|
||
)}
|
||
>
|
||
{col.key ? (
|
||
<button
|
||
onClick={() => handleSort(col.key!)}
|
||
className="flex items-center gap-1 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
|
||
>
|
||
{col.label}
|
||
{sortKey === col.key
|
||
? sortDir === 'asc'
|
||
? <ArrowUp size={12} className="text-brand-600" />
|
||
: <ArrowDown size={12} className="text-brand-600" />
|
||
: <ArrowUpDown size={12} className="opacity-30" />
|
||
}
|
||
</button>
|
||
) : col.label}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{sorted.map(b => {
|
||
const r = room(b.roomId)
|
||
const nights = nightsCount(b.checkIn, b.checkOut)
|
||
return (
|
||
<tr
|
||
key={b.id}
|
||
className="border-b border-slate-100 dark:border-slate-700/50 hover:bg-slate-50 dark:hover:bg-slate-700/30 cursor-pointer transition-colors"
|
||
onClick={() => setSelected(b)}
|
||
>
|
||
<td className="px-4 py-3">
|
||
<p className="font-medium text-slate-900 dark:text-slate-100">{b.guestName}</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">{b.guestEmail}</p>
|
||
</td>
|
||
<td className="px-4 py-3 hidden md:table-cell">
|
||
<p className="font-medium text-slate-900 dark:text-slate-100">
|
||
{r ? `№${r.number}` : '—'}
|
||
</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">{r?.type}</p>
|
||
</td>
|
||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">
|
||
{new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(new Date(b.checkIn))}
|
||
<span className="ml-1 text-xs text-slate-400 sm:hidden">· {nights}н</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 hidden sm:table-cell">
|
||
<span>{new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(new Date(b.checkOut))}</span>
|
||
<span className="ml-1.5 text-xs text-slate-400">{nights}н</span>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<Badge className={BOOKING_STATUS_BADGE[b.status]}>
|
||
{BOOKING_STATUS_LABELS[b.status]}
|
||
</Badge>
|
||
</td>
|
||
<td className="px-4 py-3 hidden sm:table-cell">
|
||
<Badge className={SOURCE_COLORS[b.source]}>
|
||
{SOURCE_LABELS[b.source]}
|
||
</Badge>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<p className="font-semibold text-slate-900 dark:text-slate-100">
|
||
{formatCurrency(b.totalAmount)}
|
||
</p>
|
||
{b.paidAmount < b.totalAmount && (
|
||
<p className="text-xs text-red-500">
|
||
-{formatCurrency(b.totalAmount - b.paidAmount)}
|
||
</p>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<span className="text-brand-600 dark:text-brand-400 text-xs font-medium">
|
||
Открыть →
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
{sorted.length === 0 && (
|
||
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
|
||
Бронирования не найдены
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Rental bookings table */}
|
||
{tab === 'rental' && (
|
||
<div className="card overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-700/40">
|
||
{['Гость', 'Объект', 'Дата', 'Время', 'Сумма', 'Статус', ''].map((h, i) => (
|
||
<th key={i} className="text-left px-4 py-3 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
|
||
{h}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredRental.map(b => {
|
||
const obj = rentalObj(b.objectId)
|
||
return (
|
||
<tr key={b.id} onClick={() => obj && setShowRentalModal({ obj, date: b.date, editBooking: b })} className="border-b border-slate-100 dark:border-slate-700/50 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors cursor-pointer">
|
||
<td className="px-4 py-3">
|
||
<p className="font-medium text-slate-900 dark:text-slate-100">{b.guestName}</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">{b.guestPhone}</p>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
{obj && (
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-base">{obj.icon}</span>
|
||
<span className="text-slate-800 dark:text-slate-200 font-medium">{obj.name}</span>
|
||
</div>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">
|
||
{new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short', weekday: 'short' }).format(new Date(b.date))}
|
||
</td>
|
||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">
|
||
{b.isFullDay
|
||
? <span className="text-xs font-medium px-2 py-0.5 bg-slate-100 dark:bg-slate-700 rounded-full">Весь день</span>
|
||
: `${fmtMin(b.startHour)} – ${fmtMin(b.endHour)}`
|
||
}
|
||
</td>
|
||
<td className="px-4 py-3 font-semibold text-slate-900 dark:text-slate-100">
|
||
{formatCurrency(b.totalAmount)}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<Badge className={b.status === 'confirmed'
|
||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
|
||
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400'
|
||
}>
|
||
{b.status === 'confirmed' ? 'Подтверждено' : 'Отменено'}
|
||
</Badge>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
{obj && (
|
||
<button
|
||
onClick={() => setShowRentalModal({ obj, date: b.date, editBooking: b })}
|
||
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 transition-colors"
|
||
>
|
||
<Pencil size={13} />
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
{filteredRental.length === 0 && (
|
||
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
|
||
Записи об аренде не найдены
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Create booking modal */}
|
||
{showCreateModal && rooms.length > 0 && (
|
||
<BookingModal
|
||
open
|
||
draft={{
|
||
roomId: rooms[0].id,
|
||
checkIn: format(new Date(), 'yyyy-MM-dd'),
|
||
checkOut: format(addDays(new Date(), 1), 'yyyy-MM-dd'),
|
||
}}
|
||
rooms={rooms}
|
||
onClose={() => setShowCreateModal(false)}
|
||
onSave={handleCreateBooking}
|
||
/>
|
||
)}
|
||
|
||
{/* Detail panel */}
|
||
{selected && (
|
||
<BookingDetailPanel
|
||
booking={selected}
|
||
room={room(selected.roomId)}
|
||
rooms={rooms}
|
||
allBookings={bookings}
|
||
slug={slug}
|
||
onClose={() => setSelected(null)}
|
||
onUpdate={handleUpdateBooking}
|
||
onBulkUpdate={handleBulkUpdate}
|
||
/>
|
||
)}
|
||
|
||
{/* New rental — step 1 */}
|
||
{newRentalStep === 'pick' && (
|
||
<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-sm p-6 space-y-4">
|
||
<h2 className="font-semibold text-slate-900 dark:text-slate-100 text-lg">Новая аренда</h2>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Объект</label>
|
||
<select className="input" value={rentalPickObj} onChange={e => setRentalPickObj(e.target.value)}>
|
||
{rentalObjects.map(o => (
|
||
<option key={o.id} value={o.id}>{o.icon} {o.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Дата</label>
|
||
<input type="date" className="input" value={rentalPickDate} onChange={e => setRentalPickDate(e.target.value)} />
|
||
</div>
|
||
|
||
{/* Occupied slots preview */}
|
||
{pickObj && (
|
||
<div className="space-y-2">
|
||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide">
|
||
Занятость · {new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(new Date(rentalPickDate))}
|
||
</p>
|
||
{pickHasFullDay ? (
|
||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700">
|
||
<span className="w-2 h-2 rounded-full bg-red-500 shrink-0" />
|
||
<span className="text-sm text-red-700 dark:text-red-300 font-medium">Весь день занят</span>
|
||
</div>
|
||
) : pickBookings.length === 0 ? (
|
||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-700">
|
||
<span className="w-2 h-2 rounded-full bg-emerald-500 shrink-0" />
|
||
<span className="text-sm text-emerald-700 dark:text-emerald-300 font-medium">Свободно весь день</span>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{/* Timeline bar */}
|
||
<div className="relative h-5 rounded-md overflow-hidden bg-emerald-100 dark:bg-emerald-900/20">
|
||
{pickBookings.filter(b => !b.isFullDay).map(b => {
|
||
const left = ((b.startHour - pickOpenMin) / pickTotalMin) * 100
|
||
const width = ((b.endHour - b.startHour) / pickTotalMin) * 100
|
||
return (
|
||
<div
|
||
key={b.id}
|
||
className="absolute top-0 h-full bg-red-400 dark:bg-red-600 opacity-80"
|
||
style={{ left: `${Math.max(0, left)}%`, width: `${Math.min(100 - Math.max(0, left), width)}%` }}
|
||
title={`${b.guestName} ${fmtPickMin(b.startHour)}–${fmtPickMin(b.endHour)}`}
|
||
/>
|
||
)
|
||
})}
|
||
</div>
|
||
{/* Hour labels */}
|
||
<div className="flex justify-between text-[10px] text-slate-400 dark:text-slate-500">
|
||
<span>{fmtPickMin(pickOpenMin)}</span>
|
||
<span>{fmtPickMin(Math.round((pickOpenMin + pickCloseMin) / 2))}</span>
|
||
<span>{fmtPickMin(pickCloseMin)}</span>
|
||
</div>
|
||
{/* Booking list */}
|
||
<div className="space-y-1 max-h-24 overflow-y-auto">
|
||
{pickBookings.filter(b => !b.isFullDay).map(b => (
|
||
<div key={b.id} className="flex items-center gap-2 text-xs">
|
||
<span className="w-1.5 h-1.5 rounded-full bg-red-400 shrink-0" />
|
||
<span className="font-medium text-slate-700 dark:text-slate-300 tabular-nums">
|
||
{fmtPickMin(b.startHour)}–{fmtPickMin(b.endHour)}
|
||
</span>
|
||
<span className="text-slate-400 dark:text-slate-500 truncate">{b.guestName}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-2 justify-end">
|
||
<button onClick={() => setNewRentalStep('idle')} className="btn-secondary">Отмена</button>
|
||
<button
|
||
className="btn-primary"
|
||
onClick={() => {
|
||
const obj = rentalObjects.find(o => o.id === rentalPickObj)
|
||
if (obj) { setShowRentalModal({ obj, date: rentalPickDate }); setNewRentalStep('idle') }
|
||
}}
|
||
>
|
||
Далее →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* New rental — step 2 */}
|
||
{showRentalModal && (
|
||
<RentalBookingModal
|
||
obj={showRentalModal.obj}
|
||
date={showRentalModal.date}
|
||
existingBookings={rentalBookings.filter(b => b.objectId === showRentalModal.obj.id && b.date === showRentalModal.date)}
|
||
editBooking={showRentalModal.editBooking}
|
||
onClose={() => setShowRentalModal(null)}
|
||
onSave={async (b) => {
|
||
if (!slug) return
|
||
const payload = {
|
||
object_id: b.objectId, date: b.date, is_full_day: b.isFullDay,
|
||
start_hour: b.startHour, end_hour: b.endHour,
|
||
guest_name: b.guestName, guest_phone: b.guestPhone || undefined,
|
||
linked_room_id: b.linkedRoomId || undefined,
|
||
total_amount: b.totalAmount, paid_amount: b.paidAmount ?? 0,
|
||
status: b.status, notes: b.notes || undefined,
|
||
}
|
||
const isNew = !rentalBookings.find(x => x.id === b.id)
|
||
const saved = isNew
|
||
? await api.rental.createBooking(slug, payload)
|
||
: await api.rental.updateBooking(slug, b.id, payload)
|
||
const converted = fromBookingApi(saved)
|
||
setRentalBookings(prev => isNew ? [...prev, converted] : prev.map(x => x.id === b.id ? converted : x))
|
||
setShowRentalModal(null)
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|