Add floor map, rental module wiring, VIP tags and payment tracking in BookingModal
- New: Поэтажный план (/floor-map) — room status visualization per floor with color coding (occupied/arriving/departing/available/dirty/maintenance), click free room to create booking - New: FloorMapModal — embeddable in BookingModal via "Поэтажный план" button near room selector - New: RentalBookingModal — full-day toggle, hour start/end selects, conflict detection, price summary - CalendarPage: rental objects/bookings shown in шахматка when rental module is active - BookingsPage: "Аренда" tab with rental bookings table when rental module is active - BookingModal: guest tags (VIP, Постоянный гость, etc.), payment method (cash/terminal), paidAmount input, debt/задолженность display, floor map quick-access button - Sidebar: "План этажей" nav link added under Управление Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Search, Plus, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react'
|
||||
import { Search, Plus, ArrowUp, ArrowDown, ArrowUpDown, Clock } from 'lucide-react'
|
||||
import { MOCK_BOOKINGS, MOCK_ROOMS } from '../data/mockData'
|
||||
import { RENTAL_OBJECTS, MOCK_RENTAL_BOOKINGS } from '../data/rentalData'
|
||||
import { useModules } from '../contexts/ModulesContext'
|
||||
import type { Booking, BookingStatus } from '../types'
|
||||
import type { RentalBooking } from '../data/rentalData'
|
||||
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'
|
||||
@@ -30,8 +33,15 @@ const COLUMNS: { key: SortKey | null; label: string }[] = [
|
||||
{ key: null, label: '' },
|
||||
]
|
||||
|
||||
type Tab = 'rooms' | 'rental'
|
||||
|
||||
export function BookingsPage() {
|
||||
const { statuses } = useModules()
|
||||
const isRentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial'
|
||||
|
||||
const [tab, setTab] = useState<Tab>('rooms')
|
||||
const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS)
|
||||
const [rentalBookings] = useState<RentalBooking[]>(MOCK_RENTAL_BOOKINGS)
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<BookingStatus | 'all'>('all')
|
||||
const [selected, setSelected] = useState<Booking | null>(null)
|
||||
@@ -68,7 +78,14 @@ export function BookingsPage() {
|
||||
})
|
||||
}, [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) => MOCK_ROOMS.find(r => r.id === id)
|
||||
const rentalObj = (id: string) => RENTAL_OBJECTS.find(o => o.id === id)
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
@@ -76,140 +93,241 @@ export function BookingsPage() {
|
||||
<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">{sorted.length} из {bookings.length}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{tab === 'rooms' ? `${sorted.length} из ${bookings.length}` : `${filteredRental.length} аренд`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary"
|
||||
>
|
||||
<Plus size={15} />
|
||||
<span className="hidden sm:inline">Новое</span>
|
||||
</button>
|
||||
{tab === 'rooms' && (
|
||||
<button onClick={() => setShowCreateModal(true)} className="btn-primary">
|
||||
<Plus size={15} />
|
||||
<span className="hidden sm:inline">Новое</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{/* 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="Поиск по гостю, email, ID..."
|
||||
placeholder={tab === 'rooms' ? 'Поиск по гостю, email, ID...' : 'Поиск по гостю, телефону...'}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
{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>
|
||||
|
||||
{/* Table */}
|
||||
<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="text-left px-4 py-3 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide"
|
||||
>
|
||||
{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">
|
||||
<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))}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">
|
||||
<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">
|
||||
<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)}
|
||||
{/* 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="text-left px-4 py-3 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide"
|
||||
>
|
||||
{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">
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">
|
||||
{r ? `№${r.number}` : '—'}
|
||||
</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>
|
||||
)}
|
||||
<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))}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">
|
||||
<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">
|
||||
<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>
|
||||
</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 => (
|
||||
<th key={h} 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} className="border-b border-slate-100 dark:border-slate-700/50 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
|
||||
<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>
|
||||
: `${b.startHour}:00 – ${b.endHour}:00`
|
||||
}
|
||||
</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>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredRental.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
|
||||
Записи об аренде не найдены
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create modal */}
|
||||
{showCreateModal && (
|
||||
|
||||
Reference in New Issue
Block a user