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

View File

@@ -1,11 +1,18 @@
import { useState } from 'react'
import { BookingCalendar } from '../components/calendar/BookingCalendar'
import { MOCK_ROOMS, MOCK_BOOKINGS } from '../data/mockData'
import { RENTAL_OBJECTS, MOCK_RENTAL_BOOKINGS } from '../data/rentalData'
import { useModules } from '../contexts/ModulesContext'
import type { Booking } from '../types'
import type { RentalBooking } from '../data/rentalData'
export function CalendarPage() {
const { statuses } = useModules()
const isRentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial'
const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS)
const [fadingBookings, setFadingBookings] = useState<Set<string>>(new Set())
const [rentalBookings, setRentalBookings] = useState<RentalBooking[]>(MOCK_RENTAL_BOOKINGS)
const handleCreate = (data: Partial<Booking>) => {
setBookings(prev => [...prev, data as Booking])
@@ -26,6 +33,10 @@ export function CalendarPage() {
}
}
const handleRentalCreate = (b: RentalBooking) => {
setRentalBookings(prev => [...prev, b])
}
return (
<div className="flex flex-col h-full">
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shrink-0">
@@ -43,6 +54,9 @@ export function CalendarPage() {
onBookingCreate={handleCreate}
onBookingUpdate={handleUpdate}
fadingBookingIds={fadingBookings}
rentalObjects={isRentalActive ? RENTAL_OBJECTS : undefined}
rentalBookings={isRentalActive ? rentalBookings : undefined}
onRentalBookingCreate={isRentalActive ? handleRentalCreate : undefined}
/>
</div>
</div>

View File

@@ -0,0 +1,84 @@
import { useState } from 'react'
import { format, addDays } from 'date-fns'
import { RefreshCw } from 'lucide-react'
import { MOCK_ROOMS, MOCK_BOOKINGS } from '../data/mockData'
import { FloorMap } from '../components/floormap/FloorMap'
import { BookingModal } from '../components/bookings/BookingModal'
import type { Booking } from '../types'
export function FloorMapPage() {
const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS)
const [createForRoom, setCreateForRoom] = useState<string | null>(null)
const [lastUpdate] = useState(new Date())
const handleSelectRoom = (id: string) => {
setCreateForRoom(id)
}
const stats = {
total: MOCK_ROOMS.length,
occupied: MOCK_ROOMS.filter(r => r.status === 'occupied').length,
available: MOCK_ROOMS.filter(r => r.status === 'available').length,
maintenance: MOCK_ROOMS.filter(r => r.status === 'maintenance' || r.status === 'blocked').length,
}
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">
Обновлено: {format(lastUpdate, 'HH:mm')} · Нажмите на свободный номер чтобы создать бронирование
</p>
</div>
<button className="btn-secondary flex items-center gap-1.5">
<RefreshCw size={14} />
Обновить
</button>
</div>
{/* Quick stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: 'Всего номеров', value: stats.total, color: 'text-slate-900 dark:text-slate-100' },
{ label: 'Занято', value: stats.occupied, color: 'text-red-600 dark:text-red-400' },
{ label: 'Свободно', value: stats.available, color: 'text-emerald-600 dark:text-emerald-400' },
{ label: 'Ремонт/закрыт', value: stats.maintenance, color: 'text-slate-500 dark:text-slate-400' },
].map(s => (
<div key={s.label} className="card px-4 py-3">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-0.5">{s.label}</p>
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
</div>
))}
</div>
{/* Floor map */}
<div className="card">
<FloorMap
rooms={MOCK_ROOMS}
bookings={bookings}
onSelectRoom={handleSelectRoom}
/>
</div>
{/* Create booking modal */}
{createForRoom && (
<BookingModal
open
draft={{
roomId: createForRoom,
checkIn: format(new Date(), 'yyyy-MM-dd'),
checkOut: format(addDays(new Date(), 1), 'yyyy-MM-dd'),
}}
rooms={MOCK_ROOMS}
onClose={() => setCreateForRoom(null)}
onSave={(data) => {
setBookings(prev => [...prev, data as Booking])
setCreateForRoom(null)
}}
/>
)}
</div>
)
}