From fd50e02be0362d219d328ec6812cab8cff46af12 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Mon, 23 Mar 2026 14:30:14 +0300 Subject: [PATCH] feat: right-click context menu for room status + bed type capacity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/calendar/BookingCalendar.tsx | 42 ++++++- src/components/rooms/RoomContextMenu.tsx | 118 ++++++++++++++++++++ src/contexts/BedTypesContext.tsx | 23 ++-- src/pages/CalendarPage.tsx | 10 ++ src/pages/RoomCategoriesPage.tsx | 45 +++++--- src/pages/RoomsPage.tsx | 47 +++++++- 6 files changed, 252 insertions(+), 33 deletions(-) create mode 100644 src/components/rooms/RoomContextMenu.tsx diff --git a/src/components/calendar/BookingCalendar.tsx b/src/components/calendar/BookingCalendar.tsx index 8250371..83c3977 100644 --- a/src/components/calendar/BookingCalendar.tsx +++ b/src/components/calendar/BookingCalendar.tsx @@ -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) => void onBookingUpdate: (id: string, b: Partial) => void onBookingBulkUpdate?: (updates: Array<{ id: string; data: Partial }>) => void + onRoomUpdate?: (roomId: string, patch: Partial) => void fadingBookingIds?: Set 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 */}
handleRoomContextMenu(e, room)} >
{room.number} {room.name && !isMobile && {room.name}}
+ {room.status === 'maintenance' && ( + 🔧 ремонт + )} + {room.status === 'blocked' && ( + 🔒 закрыт + )}
@@ -804,6 +829,17 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook }} /> )} + + {ctxMenu && ( + setCtxMenu(null)} + onStatusChange={handleCtxStatusChange} + onHkStatusChange={handleCtxHkChange} + /> + )} ) } diff --git a/src/components/rooms/RoomContextMenu.tsx b/src/components/rooms/RoomContextMenu.tsx new file mode 100644 index 0000000..fb8335c --- /dev/null +++ b/src/components/rooms/RoomContextMenu.tsx @@ -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: , color: 'text-emerald-600 dark:text-emerald-400' }, + { status: 'maintenance', label: 'На ремонт', icon: , color: 'text-orange-600 dark:text-orange-400' }, + { status: 'blocked', label: 'Закрыт', icon: , color: 'text-slate-500 dark:text-slate-400' }, +] + +const HK_ITEMS: { status: HousekeepingStatus; label: string; icon: React.ReactNode; color: string }[] = [ + { status: 'clean', label: 'Чисто', icon: , color: 'text-emerald-600 dark:text-emerald-400' }, + { status: 'dirty', label: 'Убрать', icon: , color: 'text-red-500 dark:text-red-400' }, + { status: 'cleaning', label: 'Убирается', icon: , color: 'text-blue-500 dark:text-blue-400' }, + { status: 'inspect', label: 'Проверить', icon: , color: 'text-amber-600 dark:text-amber-400' }, +] + +export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatusChange, onEdit }: RoomContextMenuProps) { + const ref = useRef(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( +
e.preventDefault()} + > + {/* Header */} +
+

Номер {room.number}

+
+ + {/* Room status */} +
+

Статус номера

+ {STATUS_ITEMS.map(item => ( + + ))} +
+ +
+ + {/* Housekeeping status */} +
+

Уборка

+ {HK_ITEMS.map(item => ( + + ))} +
+ + {onEdit && ( + <> +
+
+ +
+ + )} +
, + document.body, + ) +} diff --git a/src/contexts/BedTypesContext.tsx b/src/contexts/BedTypesContext.tsx index 566de3f..5c1a3d9 100644 --- a/src/contexts/BedTypesContext.tsx +++ b/src/contexts/BedTypesContext.tsx @@ -2,20 +2,21 @@ import { createContext, useContext, useState } from 'react' import type { ReactNode } from 'react' export interface BedTypeItem { - value: string // unique key stored in DB (e.g. 'double', 'sofa') - label: string // display name - icon: string // emoji + value: string // unique key stored in DB (e.g. 'double', 'sofa') + label: string // display name + icon: string // emoji + capacity: number // max guests on this bed type } const DEFAULT_BED_TYPES: BedTypeItem[] = [ - { value: 'single', label: 'Кровать 1-сп.', icon: '🛏' }, - { value: 'double', label: 'Кровать 2-сп.', icon: '🛏' }, - { value: 'queen', label: 'Queen-кровать', icon: '🛏' }, - { value: 'king', label: 'King-кровать', icon: '🛏' }, - { value: 'twin', label: 'Две кровати', icon: '🛏' }, - { value: 'sofa', label: 'Диван', icon: '🛋' }, - { value: 'bunk', label: 'Двухъярусная', icon: '🛏' }, - { value: 'cot', label: 'Раскладушка', icon: '🪑' }, + { value: 'single', label: 'Кровать 1-сп.', icon: '🛏', capacity: 1 }, + { value: 'double', label: 'Кровать 2-сп.', icon: '🛏', capacity: 2 }, + { value: 'queen', label: 'Queen-кровать', icon: '🛏', capacity: 2 }, + { value: 'king', label: 'King-кровать', icon: '🛏', capacity: 2 }, + { value: 'twin', label: 'Две кровати', icon: '🛏', capacity: 2 }, + { value: 'sofa', label: 'Диван', icon: '🛋', capacity: 1 }, + { value: 'bunk', label: 'Двухъярусная', icon: '🛏', capacity: 2 }, + { value: 'cot', label: 'Раскладушка', icon: '🪑', capacity: 1 }, ] interface BedTypesContextValue { diff --git a/src/pages/CalendarPage.tsx b/src/pages/CalendarPage.tsx index 3d4a177..9da8713 100644 --- a/src/pages/CalendarPage.tsx +++ b/src/pages/CalendarPage.tsx @@ -140,6 +140,15 @@ export function CalendarPage() { send({ type: 'unlock', roomId }) }, [send]) + const handleRoomUpdate = useCallback(async (roomId: string, patch: Partial) => { + try { + const updated = await api.rooms.update(slug, roomId, patch) + setRooms(prev => prev.map(r => r.id === updated.id ? updated : r)) + } catch (err) { + console.error('Failed to update room:', err) + } + }, [slug]) + const handleRentalBookingCreate = useCallback(async (b: RentalBooking) => { try { const created = await api.rental.createBooking(slug, { @@ -174,6 +183,7 @@ export function CalendarPage() { onBookingCreate={handleCreate} onBookingUpdate={handleUpdate} onBookingBulkUpdate={handleBulkUpdate} + onRoomUpdate={handleRoomUpdate} fadingBookingIds={fadingBookings} rentalObjects={isRentalActive ? rentalObjects as unknown as import('../data/rentalData').RentalObject[] : undefined} rentalBookings={isRentalActive ? rentalBookings as unknown as RentalBooking[] : undefined} diff --git a/src/pages/RoomCategoriesPage.tsx b/src/pages/RoomCategoriesPage.tsx index 5dc16e0..2a81fae 100644 --- a/src/pages/RoomCategoriesPage.tsx +++ b/src/pages/RoomCategoriesPage.tsx @@ -368,9 +368,11 @@ export function RoomCategoriesPage() { const [showBedTypes, setShowBedTypes] = useState(false) const [newBedLabel, setNewBedLabel] = useState('') const [newBedIcon, setNewBedIcon] = useState('🛏') + const [newBedCapacity, setNewBedCapacity] = useState(2) const [editingBed, setEditingBed] = useState(null) const [editBedLabel, setEditBedLabel] = useState('') const [editBedIcon, setEditBedIcon] = useState('') + const [editBedCapacity, setEditBedCapacity] = useState(2) useEffect(() => { if (!slug) return @@ -631,7 +633,7 @@ export function RoomCategoriesPage() { {/* Add new */}
setNewBedLabel(e.target.value)} onKeyDown={e => { @@ -650,23 +652,28 @@ export function RoomCategoriesPage() { e.preventDefault() const label = newBedLabel.trim() if (!label) return - const value = label.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_а-яё]/gi, '') - + '_' + Date.now().toString(36) - addBedType({ value, label, icon: newBedIcon }) - setNewBedLabel('') - setNewBedIcon('🛏') + const value = label.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_а-яё]/gi, '') + '_' + Date.now().toString(36) + addBedType({ value, label, icon: newBedIcon, capacity: newBedCapacity }) + setNewBedLabel(''); setNewBedIcon('🛏'); setNewBedCapacity(2) } }} /> +
+ мест: + setNewBedCapacity(parseInt(e.target.value) || 1)} + /> +
) } -function RoomCard({ room, onEdit }: { room: Room; onEdit: () => void }) { +function RoomCard({ room, onEdit, onContextMenu }: { room: Room; onEdit: () => void; onContextMenu: (e: React.MouseEvent) => void }) { return ( -
+
+ {room.status === 'maintenance' && ( +
+ + ремонт + +
+ )}