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

@@ -0,0 +1,213 @@
import { useState } from 'react'
import { format } from 'date-fns'
import { BedDouble, Wrench, Lock, Sparkles } from 'lucide-react'
import { cn } from '../../lib/utils'
import type { Room, Booking } from '../../types'
const TODAY = format(new Date(), 'yyyy-MM-dd')
type FloorStatus = 'occupied' | 'arriving' | 'departing' | 'available' | 'dirty' | 'maintenance' | 'blocked'
interface RoomWithStatus extends Room {
floorStatus: FloorStatus
activeBooking?: Booking
}
const STATUS_CONFIG: Record<FloorStatus, {
label: string
bg: string
border: string
text: string
dot: string
}> = {
occupied: { label: 'Занят', bg: 'bg-red-50 dark:bg-red-900/20', border: 'border-red-300 dark:border-red-700', text: 'text-red-700 dark:text-red-300', dot: 'bg-red-500' },
arriving: { label: 'Заезд сегодня', bg: 'bg-blue-50 dark:bg-blue-900/20', border: 'border-blue-300 dark:border-blue-700', text: 'text-blue-700 dark:text-blue-300', dot: 'bg-blue-500' },
departing: { label: 'Выезд сегодня', bg: 'bg-amber-50 dark:bg-amber-900/20', border: 'border-amber-300 dark:border-amber-700', text: 'text-amber-700 dark:text-amber-300', dot: 'bg-amber-500' },
available: { label: 'Свободен', bg: 'bg-emerald-50 dark:bg-emerald-900/20', border: 'border-emerald-300 dark:border-emerald-700', text: 'text-emerald-700 dark:text-emerald-300', dot: 'bg-emerald-500' },
dirty: { label: 'Уборка', bg: 'bg-orange-50 dark:bg-orange-900/20', border: 'border-orange-300 dark:border-orange-700', text: 'text-orange-700 dark:text-orange-300', dot: 'bg-orange-500' },
maintenance: { label: 'Ремонт', bg: 'bg-slate-100 dark:bg-slate-700', border: 'border-slate-300 dark:border-slate-600', text: 'text-slate-500 dark:text-slate-400', dot: 'bg-slate-400' },
blocked: { label: 'Закрыт', bg: 'bg-slate-100 dark:bg-slate-700', border: 'border-slate-300 dark:border-slate-600', text: 'text-slate-500 dark:text-slate-400', dot: 'bg-slate-400' },
}
const LEGEND: FloorStatus[] = ['available', 'occupied', 'arriving', 'departing', 'dirty', 'maintenance']
function getRoomFloorStatus(room: Room, bookings: Booking[]): { floorStatus: FloorStatus; activeBooking?: Booking } {
if (room.status === 'maintenance') return { floorStatus: 'maintenance' }
if (room.status === 'blocked') return { floorStatus: 'blocked' }
const roomBookings = bookings.filter(b => b.roomId === room.id && b.status !== 'cancelled')
const checkedIn = roomBookings.find(b => b.status === 'checked_in')
const arrivingToday = roomBookings.find(b => b.checkIn === TODAY && b.status === 'confirmed')
if (checkedIn) {
const isDeparting = checkedIn.checkOut === TODAY
return { floorStatus: isDeparting ? 'departing' : 'occupied', activeBooking: checkedIn }
}
if (arrivingToday) return { floorStatus: 'arriving', activeBooking: arrivingToday }
if (room.housekeepingStatus === 'dirty' || room.housekeepingStatus === 'cleaning') {
return { floorStatus: 'dirty' }
}
return { floorStatus: 'available' }
}
interface FloorMapProps {
rooms: Room[]
bookings: Booking[]
selectedRoomId?: string
onSelectRoom?: (id: string) => void
}
export function FloorMap({ rooms, bookings, selectedRoomId, onSelectRoom }: FloorMapProps) {
const [activeFloor, setActiveFloor] = useState<number | null>(null)
const [hoveredRoom, setHoveredRoom] = useState<string | null>(null)
const roomsWithStatus: RoomWithStatus[] = rooms.map(r => ({
...r,
...getRoomFloorStatus(r, bookings),
}))
const floors = [...new Set(rooms.map(r => r.floor))].sort((a, b) => a - b)
const selectedFloor = activeFloor ?? floors[0]
const floorRooms = roomsWithStatus.filter(r => r.floor === selectedFloor)
const floorStats = (floor: number) => {
const fr = roomsWithStatus.filter(r => r.floor === floor)
const occupied = fr.filter(r => r.floorStatus === 'occupied' || r.floorStatus === 'departing').length
const available = fr.filter(r => r.floorStatus === 'available').length
return { total: fr.length, occupied, available }
}
const hoveredRoomData = hoveredRoom ? roomsWithStatus.find(r => r.id === hoveredRoom) : null
return (
<div className="p-5 space-y-5">
{/* Floor tabs */}
<div className="flex gap-2 flex-wrap">
{floors.map(floor => {
const stats = floorStats(floor)
return (
<button
key={floor}
onClick={() => setActiveFloor(floor)}
className={cn(
'flex flex-col items-center px-4 py-2.5 rounded-xl border-2 text-sm font-medium transition-all',
selectedFloor === floor
? 'border-brand-600 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
: 'border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:border-slate-300',
)}
>
<span className="font-bold">{floor} этаж</span>
<span className="text-[10px] text-slate-400 font-normal mt-0.5">
{stats.occupied}/{stats.total} занято
</span>
</button>
)
})}
</div>
{/* Floor plan */}
<div className="relative">
{/* Corridor bar */}
<div className="w-full h-7 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center mb-3">
<span className="text-xs text-slate-400 font-medium uppercase tracking-wide">Коридор</span>
</div>
{/* Room grid */}
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))' }}>
{floorRooms.map(room => {
const cfg = STATUS_CONFIG[room.floorStatus]
const isSelected = selectedRoomId === room.id
const isHovered = hoveredRoom === room.id
const isClickable = !!onSelectRoom && (room.floorStatus === 'available')
return (
<div
key={room.id}
onMouseEnter={() => setHoveredRoom(room.id)}
onMouseLeave={() => setHoveredRoom(null)}
onClick={() => isClickable && onSelectRoom(room.id)}
className={cn(
'relative rounded-xl border-2 p-3 transition-all select-none',
cfg.bg, cfg.border,
isSelected && 'ring-2 ring-brand-500 ring-offset-2',
isClickable ? 'cursor-pointer hover:scale-105' : onSelectRoom ? 'cursor-not-allowed opacity-70' : 'cursor-default',
)}
style={{ minHeight: 100 }}
>
{/* Status dot */}
<span className={cn('absolute top-2.5 right-2.5 w-2 h-2 rounded-full', cfg.dot)} />
{/* Room icon */}
<div className="mb-2">
{room.floorStatus === 'maintenance' ? (
<Wrench size={18} className="text-slate-400" />
) : room.floorStatus === 'blocked' ? (
<Lock size={18} className="text-slate-400" />
) : room.floorStatus === 'occupied' || room.floorStatus === 'arriving' || room.floorStatus === 'departing' ? (
<BedDouble size={18} className={cfg.text} />
) : (
<BedDouble size={18} className="text-emerald-500" />
)}
</div>
{/* Room number */}
<p className={cn('text-base font-bold', cfg.text)}>
{room.name ?? `${room.number}`}
</p>
<p className="text-[10px] text-slate-400 mt-0.5 truncate">{room.type}</p>
{/* Status label */}
<p className={cn('text-[10px] font-semibold mt-1.5', cfg.text)}>
{cfg.label}
</p>
{/* Guest name if occupied */}
{room.activeBooking && (isHovered || isSelected) && (
<div className="mt-1.5 pt-1.5 border-t border-current/20">
<p className="text-[10px] text-slate-600 dark:text-slate-300 font-medium truncate">
{room.activeBooking.guestName}
</p>
{room.floorStatus === 'departing' && (
<p className="text-[10px] text-amber-600 dark:text-amber-400">Выезд сегодня</p>
)}
</div>
)}
{/* Selected checkmark */}
{isSelected && (
<div className="absolute inset-0 rounded-xl bg-brand-600/10 flex items-center justify-center">
<Sparkles size={14} className="text-brand-600" />
</div>
)}
</div>
)
})}
</div>
{/* Stairwell / elevator hint */}
<div className="mt-3 flex gap-2 justify-end">
<div className="w-8 h-14 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<span className="text-[9px] text-slate-400 rotate-90 tracking-wider">Лифт</span>
</div>
<div className="w-8 h-14 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<span className="text-[9px] text-slate-400 rotate-90 tracking-wider">Лестн.</span>
</div>
</div>
</div>
{/* Legend */}
<div className="flex flex-wrap gap-3 pt-2 border-t border-slate-200 dark:border-slate-700">
{LEGEND.map(status => {
const cfg = STATUS_CONFIG[status]
return (
<div key={status} className="flex items-center gap-1.5">
<span className={cn('w-2.5 h-2.5 rounded-full', cfg.dot)} />
<span className="text-xs text-slate-600 dark:text-slate-400">{cfg.label}</span>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,39 @@
import { X } from 'lucide-react'
import { FloorMap } from './FloorMap'
import type { Room } from '../../types'
import { MOCK_BOOKINGS } from '../../data/mockData'
interface FloorMapModalProps {
rooms: Room[]
selectedRoomId?: string
onSelectRoom?: (id: string) => void
onClose: () => void
}
export function FloorMapModal({ rooms, selectedRoomId, onSelectRoom, onClose }: FloorMapModalProps) {
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
<div>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Поэтажный план</h2>
{onSelectRoom && (
<p className="text-xs text-slate-500 dark:text-slate-400">Выберите номер для бронирования</p>
)}
</div>
<button onClick={onClose} className="btn-ghost p-1.5">
<X size={16} />
</button>
</div>
<div className="overflow-y-auto flex-1">
<FloorMap
rooms={rooms}
bookings={MOCK_BOOKINGS}
selectedRoomId={selectedRoomId}
onSelectRoom={onSelectRoom}
/>
</div>
</div>
</div>
)
}