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:
2026-03-11 19:00:15 +03:00
parent 57c22be60d
commit fbf4b269e3
2 changed files with 385 additions and 170 deletions

View File

@@ -1,183 +1,401 @@
import { useState } from 'react' import { useState, useRef, useEffect, useCallback } from 'react'
import { format } from 'date-fns' 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 { cn } from '../../lib/utils'
import type { Room, Booking } from '../../types' import type { Room, Booking } from '../../types'
const TODAY = format(new Date(), 'yyyy-MM-dd') 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' type FloorStatus = 'occupied' | 'arriving' | 'departing' | 'available' | 'dirty' | 'maintenance' | 'blocked'
interface RoomWithStatus extends Room { interface RoomPos { x: number; y: number }
floorStatus: FloorStatus type FloorPositions = Record<string, RoomPos>
activeBooking?: Booking type AllPositions = Record<number, FloorPositions>
interface FloorLayout {
positions: FloorPositions
bgImage?: string
} }
const STATUS_CONFIG: Record<FloorStatus, { // ── LocalStorage helpers ──────────────────────────────────────────────────────
label: string
bg: string function loadLayout(floor: number): FloorLayout {
border: string try {
text: string const raw = localStorage.getItem(`hotelsync-floor-${floor}`)
dot: string if (raw) return JSON.parse(raw)
}> = { } catch {}
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' }, return { positions: {} }
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' }, function saveLayout(floor: number, layout: FloorLayout) {
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' }, 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 arriving = roomBookings.find(b => b.checkIn === TODAY && b.status === 'confirmed')
if (checkedIn) {
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' }, 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' }, 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'] const LEGEND: FloorStatus[] = ['available', 'occupied', 'arriving', 'departing', 'dirty', 'maintenance']
function getRoomFloorStatus(room: Room, bookings: Booking[]): { floorStatus: FloorStatus; activeBooking?: Booking } { // ── Default auto-grid position ────────────────────────────────────────────────
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') function defaultPos(index: number, total: number, canvasW: number): RoomPos {
const checkedIn = roomBookings.find(b => b.status === 'checked_in') const cols = Math.max(2, Math.ceil(Math.sqrt(total * 1.5)))
const arrivingToday = roomBookings.find(b => b.checkIn === TODAY && b.status === 'confirmed') const col = index % cols
const row = Math.floor(index / cols)
if (checkedIn) { const padH = 24
const isDeparting = checkedIn.checkOut === TODAY const cellW = (canvasW - padH * 2) / cols
return { floorStatus: isDeparting ? 'departing' : 'occupied', activeBooking: checkedIn } const x = padH + col * cellW + (cellW - ROOM_W) / 2
} const y = 24 + row * (ROOM_H + 16)
if (arrivingToday) return { floorStatus: 'arriving', activeBooking: arrivingToday } return { x: Math.round(x), y: Math.round(y) }
if (room.housekeepingStatus === 'dirty' || room.housekeepingStatus === 'cleaning') {
return { floorStatus: 'dirty' }
}
return { floorStatus: 'available' }
} }
// ── Props ─────────────────────────────────────────────────────────────────────
interface FloorMapProps { interface FloorMapProps {
rooms: Room[] rooms: Room[]
bookings: Booking[] bookings: Booking[]
editMode?: boolean
selectedRoomId?: string selectedRoomId?: string
onSelectRoom?: (id: string) => void onSelectRoom?: (id: string) => void
} }
export function FloorMap({ rooms, bookings, selectedRoomId, onSelectRoom }: FloorMapProps) { export function FloorMap({ rooms, bookings, editMode = false, 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 floors = [...new Set(rooms.map(r => r.floor))].sort((a, b) => a - b)
const selectedFloor = activeFloor ?? floors[0] const [activeFloor, setActiveFloor] = useState<number>(floors[0] ?? 1)
const floorRooms = roomsWithStatus.filter(r => r.floor === selectedFloor) const floorRooms = rooms.filter(r => r.floor === activeFloor)
const floorStats = (floor: number) => { // Layout state keyed by floor
const fr = roomsWithStatus.filter(r => r.floor === floor) const [allPositions, setAllPositions] = useState<AllPositions>(() => {
const occupied = fr.filter(r => r.floorStatus === 'occupied' || r.floorStatus === 'departing').length const init: AllPositions = {}
const available = fr.filter(r => r.floorStatus === 'available').length floors.forEach(f => {
return { total: fr.length, occupied, available } 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 ( return (
<div className="p-5 space-y-5"> <div className="select-none">
{/* Floor tabs */} {/* Floor tabs */}
<div className="flex gap-2 flex-wrap"> <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 => { {floors.map(floor => {
const stats = floorStats(floor) const { total, occupied } = floorStats(floor)
return ( return (
<button <button
key={floor} key={floor}
onClick={() => setActiveFloor(floor)} onClick={() => setActiveFloor(floor)}
className={cn( className={cn(
'flex flex-col items-center px-4 py-2.5 rounded-xl border-2 text-sm font-medium transition-all', 'flex flex-col items-center px-4 py-2 rounded-xl border-2 text-sm font-medium transition-all',
selectedFloor === floor activeFloor === floor
? 'border-brand-600 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300' ? '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', : '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',
)} )}
> >
<span className="font-bold">{floor} этаж</span> <span className="font-bold">{floor} этаж</span>
<span className="text-[10px] text-slate-400 font-normal mt-0.5"> <span className="text-[10px] text-slate-400 font-normal">{occupied}/{total} занято</span>
{stats.occupied}/{stats.total} занято
</span>
</button> </button>
) )
})} })}
</div> </div>
{/* Floor plan */} {/* Edit mode toolbar */}
<div className="relative"> {editMode && (
{/* Corridor bar */} <div className="flex items-center gap-2 shrink-0">
<div className="w-full h-7 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center mb-3"> <input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleImageUpload} />
<span className="text-xs text-slate-400 font-medium uppercase tracking-wide">Коридор</span> <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> </div>
{/* Room grid */} {/* Edit mode hint */}
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))' }}> {editMode && (
{floorRooms.map(room => { <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">
const cfg = STATUS_CONFIG[room.floorStatus] <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 isSelected = selectedRoomId === room.id
const isHovered = hoveredRoom === room.id const isDraggingThis = draggingId === room.id
const isClickable = !!onSelectRoom && (room.floorStatus === 'available') const isClickable = !editMode && !!onSelectRoom && floorStatus === 'available'
return ( return (
<div <div
key={room.id} key={room.id}
onMouseEnter={() => setHoveredRoom(room.id)}
onMouseLeave={() => setHoveredRoom(null)}
onClick={() => isClickable && onSelectRoom(room.id)}
className={cn( className={cn(
'relative rounded-xl border-2 p-3 transition-all select-none', 'absolute rounded-xl border-2 p-2.5 transition-shadow',
cfg.bg, cfg.border, 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', isSelected && 'ring-2 ring-brand-500 ring-offset-2',
isClickable ? 'cursor-pointer hover:scale-105' : onSelectRoom ? 'cursor-not-allowed opacity-70' : 'cursor-default', isClickable && 'cursor-pointer hover:scale-105 hover:shadow-md',
!editMode && !isClickable && onSelectRoom && 'opacity-70',
)} )}
style={{ minHeight: 100 }} 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}
> >
{/* Status dot */} {/* Drag handle (edit mode) */}
<span className={cn('absolute top-2.5 right-2.5 w-2 h-2 rounded-full', cfg.dot)} /> {editMode && (
<GripVertical size={11} className="absolute top-1.5 right-1.5 text-slate-300 dark:text-slate-600" />
)}
{/* Room icon */} {/* Status dot */}
<div className="mb-2"> <span className={cn('absolute top-2 left-2 w-2 h-2 rounded-full', cfg.dot)} />
{room.floorStatus === 'maintenance' ? (
<Wrench size={18} className="text-slate-400" /> {/* Room number */}
) : room.floorStatus === 'blocked' ? ( <div className="pt-1">
<Lock size={18} className="text-slate-400" /> <p className={cn('text-sm font-bold leading-tight', cfg.text)}>
) : room.floorStatus === 'occupied' || room.floorStatus === 'arriving' || room.floorStatus === 'departing' ? ( {room.name ?? `${room.number}`}
<BedDouble size={18} className={cfg.text} /> </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={18} className="text-emerald-500" /> <BedDouble size={14} className={cn(cfg.text, 'opacity-70')} />
)} )}
</div> </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 */} {/* Status label */}
<p className={cn('text-[10px] font-semibold mt-1.5', cfg.text)}> <p className={cn('text-[9px] font-semibold uppercase tracking-wide mt-0.5', cfg.text)}>
{cfg.label} {cfg.label}
</p> </p>
{/* Guest name if occupied */} {/* Guest name */}
{room.activeBooking && (isHovered || isSelected) && ( {activeBooking && (
<div className="mt-1.5 pt-1.5 border-t border-current/20"> <p className="text-[9px] text-slate-500 dark:text-slate-400 truncate mt-0.5">
<p className="text-[10px] text-slate-600 dark:text-slate-300 font-medium truncate"> {activeBooking.guestName}
{room.activeBooking.guestName}
</p> </p>
{room.floorStatus === 'departing' && (
<p className="text-[10px] text-amber-600 dark:text-amber-400">Выезд сегодня</p>
)}
</div>
)} )}
{/* Selected checkmark */} {/* Selected indicator */}
{isSelected && ( {isSelected && (
<div className="absolute inset-0 rounded-xl bg-brand-600/10 flex items-center justify-center"> <div className="absolute inset-0 rounded-xl bg-brand-600/10 flex items-end justify-end p-1.5">
<Sparkles size={14} className="text-brand-600" /> <Sparkles size={12} className="text-brand-600" />
</div> </div>
)} )}
</div> </div>
@@ -185,24 +403,13 @@ export function FloorMap({ rooms, bookings, selectedRoomId, onSelectRoom }: Floo
})} })}
</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 */} {/* 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 => { {LEGEND.map(status => {
const cfg = STATUS_CONFIG[status] const cfg = STATUS_CFG[status]
return ( return (
<div key={status} className="flex items-center gap-1.5"> <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> <span className="text-xs text-slate-600 dark:text-slate-400">{cfg.label}</span>
</div> </div>
) )

View File

@@ -1,19 +1,16 @@
import { useState } from 'react' import { useState } from 'react'
import { format, addDays } from 'date-fns' 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 { MOCK_ROOMS, MOCK_BOOKINGS } from '../data/mockData'
import { FloorMap } from '../components/floormap/FloorMap' import { FloorMap } from '../components/floormap/FloorMap'
import { BookingModal } from '../components/bookings/BookingModal' import { BookingModal } from '../components/bookings/BookingModal'
import { cn } from '../lib/utils'
import type { Booking } from '../types' import type { Booking } from '../types'
export function FloorMapPage() { export function FloorMapPage() {
const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS) const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS)
const [createForRoom, setCreateForRoom] = useState<string | null>(null) const [createForRoom, setCreateForRoom] = useState<string | null>(null)
const [lastUpdate] = useState(new Date()) const [editMode, setEditMode] = useState(false)
const handleSelectRoom = (id: string) => {
setCreateForRoom(id)
}
const stats = { const stats = {
total: MOCK_ROOMS.length, total: MOCK_ROOMS.length,
@@ -29,12 +26,22 @@ export function FloorMapPage() {
<div> <div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Поэтажный план</h1> <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"> <p className="text-sm text-slate-500 dark:text-slate-400">
Обновлено: {format(lastUpdate, 'HH:mm')} · Нажмите на свободный номер чтобы создать бронирование {editMode
? 'Режим редактирования — перетащите номера, загрузите план этажа'
: 'Нажмите на свободный номер чтобы создать бронирование'}
</p> </p>
</div> </div>
<button className="btn-secondary flex items-center gap-1.5"> <button
<RefreshCw size={14} /> onClick={() => setEditMode(v => !v)}
Обновить className={cn(
'flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium border-2 transition-all',
editMode
? 'bg-brand-600 border-brand-600 text-white shadow-md'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:border-brand-400',
)}
>
{editMode ? <Check size={15} /> : <Pencil size={15} />}
{editMode ? 'Готово' : 'Редактировать'}
</button> </button>
</div> </div>
@@ -54,11 +61,12 @@ export function FloorMapPage() {
</div> </div>
{/* Floor map */} {/* Floor map */}
<div className="card"> <div className="card overflow-hidden p-0">
<FloorMap <FloorMap
rooms={MOCK_ROOMS} rooms={MOCK_ROOMS}
bookings={bookings} bookings={bookings}
onSelectRoom={handleSelectRoom} editMode={editMode}
onSelectRoom={editMode ? undefined : (id) => setCreateForRoom(id)}
/> />
</div> </div>