feat: right-click context menu for room status + bed type capacity
- Add RoomContextMenu component (portal, Escape/click-outside to close) - Change room status: available / maintenance / blocked - Change housekeeping status: clean / dirty / cleaning / inspect - Open room edit (optional) - Calendar: right-click on room label → context menu; onRoomUpdate prop wired to CalendarPage (calls api.rooms.update); show 🔧/🔒 badge on label - Rooms page: right-click on any room card → same context menu; show orange "ремонт" badge on maintenance rooms - BedTypesContext: add capacity field (max guests per bed type) - BedTypes reference UI: add capacity input to add/edit form; show "N чел." Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,11 +4,12 @@ import { addDays, format, startOfDay, differenceInDays, parseISO, isToday } from
|
||||
import { ru } from 'date-fns/locale'
|
||||
import { ChevronLeft, ChevronRight, Plus, CalendarDays, ChevronDown, AlignJustify, Clock } from 'lucide-react'
|
||||
import { cn, BOOKING_STATUS_COLORS, BOOKING_STATUS_LABELS, SOURCE_LABELS } from '../../lib/utils'
|
||||
import type { Room, Booking, DraftBooking } from '../../types'
|
||||
import type { Room, Booking, DraftBooking, RoomStatus, HousekeepingStatus } from '../../types'
|
||||
import type { RentalObject, RentalBooking } from '../../data/rentalData'
|
||||
import { BookingModal } from '../bookings/BookingModal'
|
||||
import { BookingDetailPanel } from '../bookings/BookingDetailPanel'
|
||||
import { RentalBookingModal } from '../rental/RentalBookingModal'
|
||||
import { RoomContextMenu } from '../rooms/RoomContextMenu'
|
||||
|
||||
const CELL_WIDTH = 52
|
||||
const ROW_HEIGHT = 56
|
||||
@@ -25,6 +26,7 @@ interface BookingCalendarProps {
|
||||
onBookingCreate: (b: Partial<Booking>) => void
|
||||
onBookingUpdate: (id: string, b: Partial<Booking>) => void
|
||||
onBookingBulkUpdate?: (updates: Array<{ id: string; data: Partial<Booking> }>) => void
|
||||
onRoomUpdate?: (roomId: string, patch: Partial<Room>) => void
|
||||
fadingBookingIds?: Set<string>
|
||||
rentalObjects?: RentalObject[]
|
||||
rentalBookings?: RentalBooking[]
|
||||
@@ -56,10 +58,26 @@ function getRoomTypeColor(type: string): string {
|
||||
return map[type] ?? 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300'
|
||||
}
|
||||
|
||||
export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBookingUpdate, onBookingBulkUpdate, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate, locks = new Map(), onDraftStart, onDraftCancel, wsConnected }: BookingCalendarProps) {
|
||||
export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBookingUpdate, onBookingBulkUpdate, onRoomUpdate, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate, locks = new Map(), onDraftStart, onDraftCancel, wsConnected }: BookingCalendarProps) {
|
||||
const [startDate, setStartDate] = useState(() => startOfDay(new Date()))
|
||||
const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE)
|
||||
|
||||
// Room context menu
|
||||
const [ctxMenu, setCtxMenu] = useState<{ room: Room; x: number; y: number } | null>(null)
|
||||
|
||||
const handleRoomContextMenu = (e: React.MouseEvent, room: Room) => {
|
||||
e.preventDefault()
|
||||
setCtxMenu({ room, x: e.clientX, y: e.clientY })
|
||||
}
|
||||
|
||||
const handleCtxStatusChange = (roomId: string, status: RoomStatus) => {
|
||||
onRoomUpdate?.(roomId, { status })
|
||||
}
|
||||
|
||||
const handleCtxHkChange = (roomId: string, status: HousekeepingStatus) => {
|
||||
onRoomUpdate?.(roomId, { housekeepingStatus: status })
|
||||
}
|
||||
|
||||
// Date picker state
|
||||
const [showNavPicker, setShowNavPicker] = useState(false)
|
||||
const [pickerDateInput, setPickerDateInput] = useState(format(new Date(), 'yyyy-MM-dd'))
|
||||
@@ -429,14 +447,21 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
|
||||
>
|
||||
{/* Room label */}
|
||||
<div
|
||||
className={cn('shrink-0 sticky left-0 z-10 bg-white dark:bg-slate-800 border-r border-slate-200 dark:border-slate-700 flex items-center gap-2 group-hover:bg-slate-50 dark:group-hover:bg-slate-800', isMobile ? 'px-2' : 'px-3')}
|
||||
className={cn('shrink-0 sticky left-0 z-10 bg-white dark:bg-slate-800 border-r border-slate-200 dark:border-slate-700 flex items-center gap-2 group-hover:bg-slate-50 dark:group-hover:bg-slate-800 cursor-context-menu select-none', isMobile ? 'px-2' : 'px-3')}
|
||||
style={{ width: labelWidth }}
|
||||
onContextMenu={e => handleRoomContextMenu(e, room)}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-bold text-slate-900 dark:text-slate-100">{room.number}</span>
|
||||
{room.name && !isMobile && <span className="text-xs text-slate-500 dark:text-slate-400">{room.name}</span>}
|
||||
</div>
|
||||
{room.status === 'maintenance' && (
|
||||
<span className="text-[10px] text-orange-500 font-medium leading-none">🔧 ремонт</span>
|
||||
)}
|
||||
{room.status === 'blocked' && (
|
||||
<span className="text-[10px] text-slate-400 font-medium leading-none">🔒 закрыт</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -804,6 +829,17 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{ctxMenu && (
|
||||
<RoomContextMenu
|
||||
room={ctxMenu.room}
|
||||
x={ctxMenu.x}
|
||||
y={ctxMenu.y}
|
||||
onClose={() => setCtxMenu(null)}
|
||||
onStatusChange={handleCtxStatusChange}
|
||||
onHkStatusChange={handleCtxHkChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
118
src/components/rooms/RoomContextMenu.tsx
Normal file
118
src/components/rooms/RoomContextMenu.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Wrench, Ban, CheckCircle, Sparkles, ClipboardList, Pencil } from 'lucide-react'
|
||||
import type { Room, RoomStatus, HousekeepingStatus } from '../../types'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
interface RoomContextMenuProps {
|
||||
room: Room
|
||||
x: number
|
||||
y: number
|
||||
onClose: () => void
|
||||
onStatusChange: (roomId: string, status: RoomStatus) => void
|
||||
onHkStatusChange: (roomId: string, status: HousekeepingStatus) => void
|
||||
onEdit?: (room: Room) => void
|
||||
}
|
||||
|
||||
const STATUS_ITEMS: { status: RoomStatus; label: string; icon: React.ReactNode; color: string }[] = [
|
||||
{ status: 'available', label: 'Свободен', icon: <CheckCircle size={14} />, color: 'text-emerald-600 dark:text-emerald-400' },
|
||||
{ status: 'maintenance', label: 'На ремонт', icon: <Wrench size={14} />, color: 'text-orange-600 dark:text-orange-400' },
|
||||
{ status: 'blocked', label: 'Закрыт', icon: <Ban size={14} />, color: 'text-slate-500 dark:text-slate-400' },
|
||||
]
|
||||
|
||||
const HK_ITEMS: { status: HousekeepingStatus; label: string; icon: React.ReactNode; color: string }[] = [
|
||||
{ status: 'clean', label: 'Чисто', icon: <Sparkles size={14} />, color: 'text-emerald-600 dark:text-emerald-400' },
|
||||
{ status: 'dirty', label: 'Убрать', icon: <ClipboardList size={14} />, color: 'text-red-500 dark:text-red-400' },
|
||||
{ status: 'cleaning', label: 'Убирается', icon: <ClipboardList size={14} />, color: 'text-blue-500 dark:text-blue-400' },
|
||||
{ status: 'inspect', label: 'Проверить', icon: <ClipboardList size={14} />, color: 'text-amber-600 dark:text-amber-400' },
|
||||
]
|
||||
|
||||
export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatusChange, onEdit }: RoomContextMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Adjust position so menu doesn't go off screen
|
||||
const menuX = Math.min(x, window.innerWidth - 220)
|
||||
const menuY = Math.min(y, window.innerHeight - 280)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose()
|
||||
}
|
||||
const keyHandler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('mousedown', handler)
|
||||
document.addEventListener('keydown', keyHandler)
|
||||
return () => { document.removeEventListener('mousedown', handler); document.removeEventListener('keydown', keyHandler) }
|
||||
}, [onClose])
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed z-[9999] w-52 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-xl shadow-xl overflow-hidden select-none"
|
||||
style={{ left: menuX, top: menuY }}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-slate-100 dark:border-slate-700">
|
||||
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Номер {room.number}</p>
|
||||
</div>
|
||||
|
||||
{/* Room status */}
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="px-2 py-1 text-[10px] font-semibold text-slate-400 uppercase tracking-wide">Статус номера</p>
|
||||
{STATUS_ITEMS.map(item => (
|
||||
<button
|
||||
key={item.status}
|
||||
onClick={() => { onStatusChange(room.id, item.status); onClose() }}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700',
|
||||
item.color,
|
||||
room.status === item.status && 'bg-slate-100 dark:bg-slate-700 font-medium',
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
{room.status === item.status && <span className="ml-auto text-[10px] text-slate-400">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-100 dark:border-slate-700 mx-2" />
|
||||
|
||||
{/* Housekeeping status */}
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="px-2 py-1 text-[10px] font-semibold text-slate-400 uppercase tracking-wide">Уборка</p>
|
||||
{HK_ITEMS.map(item => (
|
||||
<button
|
||||
key={item.status}
|
||||
onClick={() => { onHkStatusChange(room.id, item.status); onClose() }}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700',
|
||||
item.color,
|
||||
room.housekeepingStatus === item.status && 'bg-slate-100 dark:bg-slate-700 font-medium',
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
{room.housekeepingStatus === item.status && <span className="ml-auto text-[10px] text-slate-400">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{onEdit && (
|
||||
<>
|
||||
<div className="border-t border-slate-100 dark:border-slate-700 mx-2" />
|
||||
<div className="px-2 py-1.5">
|
||||
<button
|
||||
onClick={() => { onEdit(room); onClose() }}
|
||||
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
Редактировать номер
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user