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:
2026-03-11 17:08:10 +03:00
parent 74e0223c3c
commit 57c22be60d
11 changed files with 1432 additions and 297 deletions

View File

@@ -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 && (