Floor map: drag & drop room editor with background image support
- Edit mode toggle on FloorMapPage (кнопка Редактировать / Готово) - Rooms are draggable within the canvas in edit mode; positions persist to localStorage per floor - Upload a floor plan image as background (per floor), stored as base64 in localStorage - Reset button to clear custom layout back to auto-grid - Grid-dot pattern background when no image is uploaded - Hint bar in edit mode; drag cursor, hover ring on draggable cards - View mode: click free room → create booking (unchanged) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,208 +1,415 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { BedDouble, Wrench, Lock, Sparkles } from 'lucide-react'
|
||||
import { BedDouble, Wrench, Lock, Sparkles, ImagePlus, Trash2, GripVertical, RotateCcw } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import type { Room, Booking } from '../../types'
|
||||
|
||||
const TODAY = format(new Date(), 'yyyy-MM-dd')
|
||||
|
||||
const ROOM_W = 114
|
||||
const ROOM_H = 88
|
||||
const CANVAS_H = 540
|
||||
|
||||
type FloorStatus = 'occupied' | 'arriving' | 'departing' | 'available' | 'dirty' | 'maintenance' | 'blocked'
|
||||
|
||||
interface RoomWithStatus extends Room {
|
||||
floorStatus: FloorStatus
|
||||
activeBooking?: Booking
|
||||
interface RoomPos { x: number; y: number }
|
||||
type FloorPositions = Record<string, RoomPos>
|
||||
type AllPositions = Record<number, FloorPositions>
|
||||
|
||||
interface FloorLayout {
|
||||
positions: FloorPositions
|
||||
bgImage?: string
|
||||
}
|
||||
|
||||
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' },
|
||||
// ── LocalStorage helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function loadLayout(floor: number): FloorLayout {
|
||||
try {
|
||||
const raw = localStorage.getItem(`hotelsync-floor-${floor}`)
|
||||
if (raw) return JSON.parse(raw)
|
||||
} catch {}
|
||||
return { positions: {} }
|
||||
}
|
||||
|
||||
const LEGEND: FloorStatus[] = ['available', 'occupied', 'arriving', 'departing', 'dirty', 'maintenance']
|
||||
function saveLayout(floor: number, layout: FloorLayout) {
|
||||
localStorage.setItem(`hotelsync-floor-${floor}`, JSON.stringify(layout))
|
||||
}
|
||||
|
||||
// ── Status helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
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')
|
||||
|
||||
const arriving = 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: checkedIn.checkOut === TODAY ? 'departing' : 'occupied', activeBooking: checkedIn }
|
||||
}
|
||||
if (arriving) return { floorStatus: 'arriving', activeBooking: arriving }
|
||||
if (room.housekeepingStatus === 'dirty' || room.housekeepingStatus === 'cleaning') return { floorStatus: 'dirty' }
|
||||
return { floorStatus: 'available' }
|
||||
}
|
||||
|
||||
const STATUS_CFG: Record<FloorStatus, { label: string; bg: string; border: string; text: string; dot: string }> = {
|
||||
occupied: { label: 'Занят', bg: 'bg-red-50 dark:bg-red-900/30', 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/30', 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/30', 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/30', 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/30', 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']
|
||||
|
||||
// ── Default auto-grid position ────────────────────────────────────────────────
|
||||
|
||||
function defaultPos(index: number, total: number, canvasW: number): RoomPos {
|
||||
const cols = Math.max(2, Math.ceil(Math.sqrt(total * 1.5)))
|
||||
const col = index % cols
|
||||
const row = Math.floor(index / cols)
|
||||
const padH = 24
|
||||
const cellW = (canvasW - padH * 2) / cols
|
||||
const x = padH + col * cellW + (cellW - ROOM_W) / 2
|
||||
const y = 24 + row * (ROOM_H + 16)
|
||||
return { x: Math.round(x), y: Math.round(y) }
|
||||
}
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface FloorMapProps {
|
||||
rooms: Room[]
|
||||
bookings: Booking[]
|
||||
editMode?: boolean
|
||||
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),
|
||||
}))
|
||||
|
||||
export function FloorMap({ rooms, bookings, editMode = false, selectedRoomId, onSelectRoom }: FloorMapProps) {
|
||||
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 [activeFloor, setActiveFloor] = useState<number>(floors[0] ?? 1)
|
||||
const floorRooms = rooms.filter(r => r.floor === activeFloor)
|
||||
|
||||
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 }
|
||||
// Layout state keyed by floor
|
||||
const [allPositions, setAllPositions] = useState<AllPositions>(() => {
|
||||
const init: AllPositions = {}
|
||||
floors.forEach(f => {
|
||||
init[f] = loadLayout(f).positions
|
||||
})
|
||||
return init
|
||||
})
|
||||
const [bgImages, setBgImages] = useState<Record<number, string>>(() => {
|
||||
const init: Record<number, string> = {}
|
||||
floors.forEach(f => {
|
||||
const l = loadLayout(f)
|
||||
if (l.bgImage) init[f] = l.bgImage
|
||||
})
|
||||
return init
|
||||
})
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Drag state
|
||||
const draggingRef = useRef<{ roomId: string; offsetX: number; offsetY: number } | null>(null)
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null)
|
||||
|
||||
// Save to localStorage whenever layout changes
|
||||
const persistLayout = useCallback((floor: number, positions: FloorPositions, bgImage?: string) => {
|
||||
saveLayout(floor, { positions, bgImage: bgImage ?? bgImages[floor] })
|
||||
}, [bgImages])
|
||||
|
||||
// Get position for a room on the current floor
|
||||
const getPos = (room: Room, index: number): RoomPos => {
|
||||
const saved = allPositions[activeFloor]?.[room.id]
|
||||
if (saved) return saved
|
||||
const canvasW = canvasRef.current?.offsetWidth ?? 800
|
||||
return defaultPos(index, floorRooms.length, canvasW)
|
||||
}
|
||||
|
||||
const hoveredRoomData = hoveredRoom ? roomsWithStatus.find(r => r.id === hoveredRoom) : null
|
||||
// ── Drag handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
const handleRoomMouseDown = useCallback((e: React.MouseEvent, room: Room, index: number) => {
|
||||
if (!editMode) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const canvasRect = canvas.getBoundingClientRect()
|
||||
const pos = getPos(room, index)
|
||||
const offsetX = e.clientX - canvasRect.left - pos.x
|
||||
const offsetY = e.clientY - canvasRect.top - pos.y
|
||||
draggingRef.current = { roomId: room.id, offsetX, offsetY }
|
||||
setDraggingId(room.id)
|
||||
}, [editMode, activeFloor, allPositions, floorRooms.length])
|
||||
|
||||
const handleCanvasMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!draggingRef.current || !canvasRef.current) return
|
||||
const { roomId, offsetX, offsetY } = draggingRef.current
|
||||
const canvasRect = canvasRef.current.getBoundingClientRect()
|
||||
const newX = Math.max(0, Math.min(canvasRect.width - ROOM_W, e.clientX - canvasRect.left - offsetX))
|
||||
const newY = Math.max(0, Math.min(CANVAS_H - ROOM_H, e.clientY - canvasRect.top - offsetY))
|
||||
setAllPositions(prev => {
|
||||
const floorPos = { ...(prev[activeFloor] ?? {}), [roomId]: { x: Math.round(newX), y: Math.round(newY) } }
|
||||
return { ...prev, [activeFloor]: floorPos }
|
||||
})
|
||||
}, [activeFloor])
|
||||
|
||||
const handleCanvasMouseUp = useCallback(() => {
|
||||
if (!draggingRef.current) return
|
||||
draggingRef.current = null
|
||||
setDraggingId(null)
|
||||
setAllPositions(prev => {
|
||||
persistLayout(activeFloor, prev[activeFloor] ?? {})
|
||||
return prev
|
||||
})
|
||||
}, [activeFloor, persistLayout])
|
||||
|
||||
// ── Background image ───────────────────────────────────────────────────────
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const dataUrl = ev.target?.result as string
|
||||
setBgImages(prev => {
|
||||
const next = { ...prev, [activeFloor]: dataUrl }
|
||||
persistLayout(activeFloor, allPositions[activeFloor] ?? {}, dataUrl)
|
||||
return next
|
||||
})
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const removeBgImage = () => {
|
||||
setBgImages(prev => {
|
||||
const next = { ...prev }
|
||||
delete next[activeFloor]
|
||||
persistLayout(activeFloor, allPositions[activeFloor] ?? {}, undefined)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const resetLayout = () => {
|
||||
setAllPositions(prev => {
|
||||
const next = { ...prev, [activeFloor]: {} }
|
||||
persistLayout(activeFloor, {})
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Global mouseup safety
|
||||
useEffect(() => {
|
||||
const up = () => { draggingRef.current = null; setDraggingId(null) }
|
||||
window.addEventListener('mouseup', up)
|
||||
return () => window.removeEventListener('mouseup', up)
|
||||
}, [])
|
||||
|
||||
// Floor stats
|
||||
const floorStats = (floor: number) => {
|
||||
const fr = rooms.filter(r => r.floor === floor)
|
||||
const occupied = fr.filter(r => ['occupied', 'departing'].includes(getRoomFloorStatus(r, bookings).floorStatus)).length
|
||||
return { total: fr.length, occupied }
|
||||
}
|
||||
|
||||
const bgImage = bgImages[activeFloor]
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-5">
|
||||
<div className="select-none">
|
||||
{/* 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')
|
||||
|
||||
<div className="flex items-center gap-2 px-5 pt-4 pb-3 flex-wrap border-b border-slate-200 dark:border-slate-700">
|
||||
<div className="flex gap-2 flex-wrap flex-1">
|
||||
{floors.map(floor => {
|
||||
const { total, occupied } = floorStats(floor)
|
||||
return (
|
||||
<div
|
||||
key={room.id}
|
||||
onMouseEnter={() => setHoveredRoom(room.id)}
|
||||
onMouseLeave={() => setHoveredRoom(null)}
|
||||
onClick={() => isClickable && onSelectRoom(room.id)}
|
||||
<button
|
||||
key={floor}
|
||||
onClick={() => setActiveFloor(floor)}
|
||||
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',
|
||||
'flex flex-col items-center px-4 py-2 rounded-xl border-2 text-sm font-medium transition-all',
|
||||
activeFloor === 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 dark:hover:border-slate-500',
|
||||
)}
|
||||
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>
|
||||
<span className="font-bold">{floor} этаж</span>
|
||||
<span className="text-[10px] text-slate-400 font-normal">{occupied}/{total} занято</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
{/* Edit mode toolbar */}
|
||||
{editMode && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleImageUpload} />
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="btn-secondary flex items-center gap-1.5 text-xs"
|
||||
title="Загрузить план этажа"
|
||||
>
|
||||
<ImagePlus size={14} />
|
||||
{bgImage ? 'Сменить фон' : 'Фон этажа'}
|
||||
</button>
|
||||
{bgImage && (
|
||||
<button onClick={removeBgImage} className="btn-secondary text-xs flex items-center gap-1 text-red-600" title="Удалить фоновое изображение">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={resetLayout} className="btn-secondary text-xs flex items-center gap-1.5" title="Сбросить расположение">
|
||||
<RotateCcw size={13} />
|
||||
Сброс
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit mode hint */}
|
||||
{editMode && (
|
||||
<div className="mx-5 mt-3 px-3 py-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 text-xs text-amber-700 dark:text-amber-300 flex items-center gap-2">
|
||||
<GripVertical size={13} className="shrink-0" />
|
||||
Перетащите номера на нужные позиции. Расположение сохраняется автоматически. Загрузите план этажа как фоновое изображение.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canvas */}
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className={cn(
|
||||
'relative mx-5 my-4 rounded-2xl overflow-hidden border-2',
|
||||
editMode
|
||||
? 'border-dashed border-brand-400 dark:border-brand-600 cursor-default'
|
||||
: 'border-slate-200 dark:border-slate-700',
|
||||
)}
|
||||
style={{ height: CANVAS_H }}
|
||||
onMouseMove={editMode ? handleCanvasMouseMove : undefined}
|
||||
onMouseUp={editMode ? handleCanvasMouseUp : undefined}
|
||||
onMouseLeave={editMode ? handleCanvasMouseUp : undefined}
|
||||
>
|
||||
{/* Background */}
|
||||
{bgImage ? (
|
||||
<img
|
||||
src={bgImage}
|
||||
alt="floor plan"
|
||||
className="absolute inset-0 w-full h-full object-contain pointer-events-none"
|
||||
style={{ opacity: editMode ? 0.7 : 0.85 }}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
/* Default grid pattern bg */
|
||||
<div className="absolute inset-0 bg-slate-50 dark:bg-slate-900/40"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, rgba(148,163,184,0.12) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(148,163,184,0.12) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '40px 40px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Upload prompt when no bg and in edit mode */}
|
||||
{!bgImage && editMode && (
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col items-center justify-center gap-2 opacity-20 pointer-events-none"
|
||||
>
|
||||
<ImagePlus size={48} className="text-slate-400" />
|
||||
<p className="text-sm text-slate-400 font-medium">Загрузите план этажа</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Room cards */}
|
||||
{floorRooms.map((room, index) => {
|
||||
const { floorStatus, activeBooking } = getRoomFloorStatus(room, bookings)
|
||||
const cfg = STATUS_CFG[floorStatus]
|
||||
const pos = getPos(room, index)
|
||||
const isSelected = selectedRoomId === room.id
|
||||
const isDraggingThis = draggingId === room.id
|
||||
const isClickable = !editMode && !!onSelectRoom && floorStatus === 'available'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={room.id}
|
||||
className={cn(
|
||||
'absolute rounded-xl border-2 p-2.5 transition-shadow',
|
||||
cfg.bg, cfg.border,
|
||||
editMode && 'cursor-grab active:cursor-grabbing hover:shadow-lg hover:ring-2 hover:ring-brand-400/50',
|
||||
isDraggingThis && 'shadow-2xl ring-2 ring-brand-500 z-50 cursor-grabbing scale-105',
|
||||
isSelected && 'ring-2 ring-brand-500 ring-offset-2',
|
||||
isClickable && 'cursor-pointer hover:scale-105 hover:shadow-md',
|
||||
!editMode && !isClickable && onSelectRoom && 'opacity-70',
|
||||
)}
|
||||
style={{
|
||||
left: pos.x,
|
||||
top: pos.y,
|
||||
width: ROOM_W,
|
||||
height: ROOM_H,
|
||||
zIndex: isDraggingThis ? 100 : isSelected ? 10 : 1,
|
||||
transition: isDraggingThis ? 'none' : 'box-shadow 150ms, transform 150ms',
|
||||
}}
|
||||
onMouseDown={editMode ? (e) => handleRoomMouseDown(e, room, index) : undefined}
|
||||
onClick={isClickable ? () => onSelectRoom!(room.id) : undefined}
|
||||
>
|
||||
{/* Drag handle (edit mode) */}
|
||||
{editMode && (
|
||||
<GripVertical size={11} className="absolute top-1.5 right-1.5 text-slate-300 dark:text-slate-600" />
|
||||
)}
|
||||
|
||||
{/* Status dot */}
|
||||
<span className={cn('absolute top-2 left-2 w-2 h-2 rounded-full', cfg.dot)} />
|
||||
|
||||
{/* Room number */}
|
||||
<div className="pt-1">
|
||||
<p className={cn('text-sm font-bold leading-tight', cfg.text)}>
|
||||
{room.name ?? `№${room.number}`}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400 dark:text-slate-500 truncate mt-0.5">{room.type}</p>
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div className="mt-1.5">
|
||||
{floorStatus === 'maintenance' || room.status === 'maintenance' ? (
|
||||
<Wrench size={14} className="text-slate-400" />
|
||||
) : room.status === 'blocked' ? (
|
||||
<Lock size={14} className="text-slate-400" />
|
||||
) : (
|
||||
<BedDouble size={14} className={cn(cfg.text, 'opacity-70')} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status label */}
|
||||
<p className={cn('text-[9px] font-semibold uppercase tracking-wide mt-0.5', cfg.text)}>
|
||||
{cfg.label}
|
||||
</p>
|
||||
|
||||
{/* Guest name */}
|
||||
{activeBooking && (
|
||||
<p className="text-[9px] text-slate-500 dark:text-slate-400 truncate mt-0.5">
|
||||
{activeBooking.guestName}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Selected indicator */}
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 rounded-xl bg-brand-600/10 flex items-end justify-end p-1.5">
|
||||
<Sparkles size={12} className="text-brand-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-3 pt-2 border-t border-slate-200 dark:border-slate-700">
|
||||
<div className="flex flex-wrap gap-3 px-5 pb-4 border-t border-slate-200 dark:border-slate-700 pt-3">
|
||||
{LEGEND.map(status => {
|
||||
const cfg = STATUS_CONFIG[status]
|
||||
const cfg = STATUS_CFG[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={cn('w-2.5 h-2.5 rounded-full shrink-0', cfg.dot)} />
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">{cfg.label}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user