From fbf4b269e3fba380578cfaaa277da136f2b370ee Mon Sep 17 00:00:00 2001 From: HotelSync Date: Wed, 11 Mar 2026 19:00:15 +0300 Subject: [PATCH] Floor map: drag & drop room editor with background image support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/floormap/FloorMap.tsx | 511 +++++++++++++++++++-------- src/pages/FloorMapPage.tsx | 44 ++- 2 files changed, 385 insertions(+), 170 deletions(-) diff --git a/src/components/floormap/FloorMap.tsx b/src/components/floormap/FloorMap.tsx index 96906a2..1c68c68 100644 --- a/src/components/floormap/FloorMap.tsx +++ b/src/components/floormap/FloorMap.tsx @@ -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 +type AllPositions = Record + +interface FloorLayout { + positions: FloorPositions + bgImage?: string } -const STATUS_CONFIG: Record = { - 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 = { + 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(null) - const [hoveredRoom, setHoveredRoom] = useState(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(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(() => { + const init: AllPositions = {} + floors.forEach(f => { + init[f] = loadLayout(f).positions + }) + return init + }) + const [bgImages, setBgImages] = useState>(() => { + const init: Record = {} + floors.forEach(f => { + const l = loadLayout(f) + if (l.bgImage) init[f] = l.bgImage + }) + return init + }) + + const canvasRef = useRef(null) + const fileRef = useRef(null) + + // Drag state + const draggingRef = useRef<{ roomId: string; offsetX: number; offsetY: number } | null>(null) + const [draggingId, setDraggingId] = useState(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) => { + 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 ( -
+
{/* Floor tabs */} -
- {floors.map(floor => { - const stats = floorStats(floor) - return ( - - ) - })} -
- - {/* Floor plan */} -
- {/* Corridor bar */} -
- Коридор -
- - {/* Room grid */} -
- {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') - +
+
+ {floors.map(floor => { + const { total, occupied } = floorStats(floor) return ( -
setHoveredRoom(room.id)} - onMouseLeave={() => setHoveredRoom(null)} - onClick={() => isClickable && onSelectRoom(room.id)} +
+ {floor} этаж + {occupied}/{total} занято + ) })}
- {/* Stairwell / elevator hint */} -
-
- Лифт -
-
- Лестн. + {/* Edit mode toolbar */} + {editMode && ( +
+ + + {bgImage && ( + + )} +
+ )} +
+ + {/* Edit mode hint */} + {editMode && ( +
+ + Перетащите номера на нужные позиции. Расположение сохраняется автоматически. Загрузите план этажа как фоновое изображение.
+ )} + + {/* Canvas */} +
+ {/* Background */} + {bgImage ? ( + floor plan + ) : ( + /* Default grid pattern bg */ +
+ )} + + {/* Upload prompt when no bg and in edit mode */} + {!bgImage && editMode && ( +
+ +

Загрузите план этажа

+
+ )} + + {/* 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 ( +
handleRoomMouseDown(e, room, index) : undefined} + onClick={isClickable ? () => onSelectRoom!(room.id) : undefined} + > + {/* Drag handle (edit mode) */} + {editMode && ( + + )} + + {/* Status dot */} + + + {/* Room number */} +
+

+ {room.name ?? `№${room.number}`} +

+

{room.type}

+
+ + {/* Icon */} +
+ {floorStatus === 'maintenance' || room.status === 'maintenance' ? ( + + ) : room.status === 'blocked' ? ( + + ) : ( + + )} +
+ + {/* Status label */} +

+ {cfg.label} +

+ + {/* Guest name */} + {activeBooking && ( +

+ {activeBooking.guestName} +

+ )} + + {/* Selected indicator */} + {isSelected && ( +
+ +
+ )} +
+ ) + })}
{/* Legend */} -
+
{LEGEND.map(status => { - const cfg = STATUS_CONFIG[status] + const cfg = STATUS_CFG[status] return (
- + {cfg.label}
) diff --git a/src/pages/FloorMapPage.tsx b/src/pages/FloorMapPage.tsx index cd9b842..e4a1fbe 100644 --- a/src/pages/FloorMapPage.tsx +++ b/src/pages/FloorMapPage.tsx @@ -1,24 +1,21 @@ import { useState } from 'react' import { format, addDays } from 'date-fns' -import { RefreshCw } from 'lucide-react' +import { Pencil, Check } from 'lucide-react' import { MOCK_ROOMS, MOCK_BOOKINGS } from '../data/mockData' import { FloorMap } from '../components/floormap/FloorMap' import { BookingModal } from '../components/bookings/BookingModal' +import { cn } from '../lib/utils' import type { Booking } from '../types' export function FloorMapPage() { const [bookings, setBookings] = useState(MOCK_BOOKINGS) const [createForRoom, setCreateForRoom] = useState(null) - const [lastUpdate] = useState(new Date()) - - const handleSelectRoom = (id: string) => { - setCreateForRoom(id) - } + const [editMode, setEditMode] = useState(false) const stats = { - total: MOCK_ROOMS.length, - occupied: MOCK_ROOMS.filter(r => r.status === 'occupied').length, - available: MOCK_ROOMS.filter(r => r.status === 'available').length, + 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, } @@ -29,19 +26,29 @@ export function FloorMapPage() {

Поэтажный план

- Обновлено: {format(lastUpdate, 'HH:mm')} · Нажмите на свободный номер чтобы создать бронирование + {editMode + ? 'Режим редактирования — перетащите номера, загрузите план этажа' + : 'Нажмите на свободный номер чтобы создать бронирование'}

-
{/* Quick stats */}
{[ - { label: 'Всего номеров', value: stats.total, color: 'text-slate-900 dark:text-slate-100' }, + { 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' }, @@ -54,11 +61,12 @@ export function FloorMapPage() {
{/* Floor map */} -
+
setCreateForRoom(id)} />
@@ -67,8 +75,8 @@ export function FloorMapPage() {