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:
2026-03-23 14:30:14 +03:00
parent 8c6cdfb1c7
commit fd50e02be0
6 changed files with 252 additions and 33 deletions

View 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,
)
}