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 { ru } from 'date-fns/locale'
|
||||||
import { ChevronLeft, ChevronRight, Plus, CalendarDays, ChevronDown, AlignJustify, Clock } from 'lucide-react'
|
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 { 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 type { RentalObject, RentalBooking } from '../../data/rentalData'
|
||||||
import { BookingModal } from '../bookings/BookingModal'
|
import { BookingModal } from '../bookings/BookingModal'
|
||||||
import { BookingDetailPanel } from '../bookings/BookingDetailPanel'
|
import { BookingDetailPanel } from '../bookings/BookingDetailPanel'
|
||||||
import { RentalBookingModal } from '../rental/RentalBookingModal'
|
import { RentalBookingModal } from '../rental/RentalBookingModal'
|
||||||
|
import { RoomContextMenu } from '../rooms/RoomContextMenu'
|
||||||
|
|
||||||
const CELL_WIDTH = 52
|
const CELL_WIDTH = 52
|
||||||
const ROW_HEIGHT = 56
|
const ROW_HEIGHT = 56
|
||||||
@@ -25,6 +26,7 @@ interface BookingCalendarProps {
|
|||||||
onBookingCreate: (b: Partial<Booking>) => void
|
onBookingCreate: (b: Partial<Booking>) => void
|
||||||
onBookingUpdate: (id: string, b: Partial<Booking>) => void
|
onBookingUpdate: (id: string, b: Partial<Booking>) => void
|
||||||
onBookingBulkUpdate?: (updates: Array<{ id: string; data: Partial<Booking> }>) => void
|
onBookingBulkUpdate?: (updates: Array<{ id: string; data: Partial<Booking> }>) => void
|
||||||
|
onRoomUpdate?: (roomId: string, patch: Partial<Room>) => void
|
||||||
fadingBookingIds?: Set<string>
|
fadingBookingIds?: Set<string>
|
||||||
rentalObjects?: RentalObject[]
|
rentalObjects?: RentalObject[]
|
||||||
rentalBookings?: RentalBooking[]
|
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'
|
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 [startDate, setStartDate] = useState(() => startOfDay(new Date()))
|
||||||
const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE)
|
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
|
// Date picker state
|
||||||
const [showNavPicker, setShowNavPicker] = useState(false)
|
const [showNavPicker, setShowNavPicker] = useState(false)
|
||||||
const [pickerDateInput, setPickerDateInput] = useState(format(new Date(), 'yyyy-MM-dd'))
|
const [pickerDateInput, setPickerDateInput] = useState(format(new Date(), 'yyyy-MM-dd'))
|
||||||
@@ -429,14 +447,21 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
|
|||||||
>
|
>
|
||||||
{/* Room label */}
|
{/* Room label */}
|
||||||
<div
|
<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 }}
|
style={{ width: labelWidth }}
|
||||||
|
onContextMenu={e => handleRoomContextMenu(e, room)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm font-bold text-slate-900 dark:text-slate-100">{room.number}</span>
|
<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>}
|
{room.name && !isMobile && <span className="text-xs text-slate-500 dark:text-slate-400">{room.name}</span>}
|
||||||
</div>
|
</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>
|
||||||
</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>
|
</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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,17 +5,18 @@ export interface BedTypeItem {
|
|||||||
value: string // unique key stored in DB (e.g. 'double', 'sofa')
|
value: string // unique key stored in DB (e.g. 'double', 'sofa')
|
||||||
label: string // display name
|
label: string // display name
|
||||||
icon: string // emoji
|
icon: string // emoji
|
||||||
|
capacity: number // max guests on this bed type
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_BED_TYPES: BedTypeItem[] = [
|
const DEFAULT_BED_TYPES: BedTypeItem[] = [
|
||||||
{ value: 'single', label: 'Кровать 1-сп.', icon: '🛏' },
|
{ value: 'single', label: 'Кровать 1-сп.', icon: '🛏', capacity: 1 },
|
||||||
{ value: 'double', label: 'Кровать 2-сп.', icon: '🛏' },
|
{ value: 'double', label: 'Кровать 2-сп.', icon: '🛏', capacity: 2 },
|
||||||
{ value: 'queen', label: 'Queen-кровать', icon: '🛏' },
|
{ value: 'queen', label: 'Queen-кровать', icon: '🛏', capacity: 2 },
|
||||||
{ value: 'king', label: 'King-кровать', icon: '🛏' },
|
{ value: 'king', label: 'King-кровать', icon: '🛏', capacity: 2 },
|
||||||
{ value: 'twin', label: 'Две кровати', icon: '🛏' },
|
{ value: 'twin', label: 'Две кровати', icon: '🛏', capacity: 2 },
|
||||||
{ value: 'sofa', label: 'Диван', icon: '🛋' },
|
{ value: 'sofa', label: 'Диван', icon: '🛋', capacity: 1 },
|
||||||
{ value: 'bunk', label: 'Двухъярусная', icon: '🛏' },
|
{ value: 'bunk', label: 'Двухъярусная', icon: '🛏', capacity: 2 },
|
||||||
{ value: 'cot', label: 'Раскладушка', icon: '🪑' },
|
{ value: 'cot', label: 'Раскладушка', icon: '🪑', capacity: 1 },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface BedTypesContextValue {
|
interface BedTypesContextValue {
|
||||||
|
|||||||
@@ -140,6 +140,15 @@ export function CalendarPage() {
|
|||||||
send({ type: 'unlock', roomId })
|
send({ type: 'unlock', roomId })
|
||||||
}, [send])
|
}, [send])
|
||||||
|
|
||||||
|
const handleRoomUpdate = useCallback(async (roomId: string, patch: Partial<Room>) => {
|
||||||
|
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) => {
|
const handleRentalBookingCreate = useCallback(async (b: RentalBooking) => {
|
||||||
try {
|
try {
|
||||||
const created = await api.rental.createBooking(slug, {
|
const created = await api.rental.createBooking(slug, {
|
||||||
@@ -174,6 +183,7 @@ export function CalendarPage() {
|
|||||||
onBookingCreate={handleCreate}
|
onBookingCreate={handleCreate}
|
||||||
onBookingUpdate={handleUpdate}
|
onBookingUpdate={handleUpdate}
|
||||||
onBookingBulkUpdate={handleBulkUpdate}
|
onBookingBulkUpdate={handleBulkUpdate}
|
||||||
|
onRoomUpdate={handleRoomUpdate}
|
||||||
fadingBookingIds={fadingBookings}
|
fadingBookingIds={fadingBookings}
|
||||||
rentalObjects={isRentalActive ? rentalObjects as unknown as import('../data/rentalData').RentalObject[] : undefined}
|
rentalObjects={isRentalActive ? rentalObjects as unknown as import('../data/rentalData').RentalObject[] : undefined}
|
||||||
rentalBookings={isRentalActive ? rentalBookings as unknown as RentalBooking[] : undefined}
|
rentalBookings={isRentalActive ? rentalBookings as unknown as RentalBooking[] : undefined}
|
||||||
|
|||||||
@@ -368,9 +368,11 @@ export function RoomCategoriesPage() {
|
|||||||
const [showBedTypes, setShowBedTypes] = useState(false)
|
const [showBedTypes, setShowBedTypes] = useState(false)
|
||||||
const [newBedLabel, setNewBedLabel] = useState('')
|
const [newBedLabel, setNewBedLabel] = useState('')
|
||||||
const [newBedIcon, setNewBedIcon] = useState('🛏')
|
const [newBedIcon, setNewBedIcon] = useState('🛏')
|
||||||
|
const [newBedCapacity, setNewBedCapacity] = useState(2)
|
||||||
const [editingBed, setEditingBed] = useState<string | null>(null)
|
const [editingBed, setEditingBed] = useState<string | null>(null)
|
||||||
const [editBedLabel, setEditBedLabel] = useState('')
|
const [editBedLabel, setEditBedLabel] = useState('')
|
||||||
const [editBedIcon, setEditBedIcon] = useState('')
|
const [editBedIcon, setEditBedIcon] = useState('')
|
||||||
|
const [editBedCapacity, setEditBedCapacity] = useState(2)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug) return
|
if (!slug) return
|
||||||
@@ -631,7 +633,7 @@ export function RoomCategoriesPage() {
|
|||||||
{/* Add new */}
|
{/* Add new */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select
|
<select
|
||||||
className="input w-16 text-lg px-2 text-center"
|
className="input w-14 text-lg px-2 text-center"
|
||||||
value={newBedIcon}
|
value={newBedIcon}
|
||||||
onChange={e => setNewBedIcon(e.target.value)}
|
onChange={e => setNewBedIcon(e.target.value)}
|
||||||
>
|
>
|
||||||
@@ -642,7 +644,7 @@ export function RoomCategoriesPage() {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="input flex-1 text-sm"
|
className="input flex-1 text-sm"
|
||||||
placeholder="Название (напр. Водяная кровать, Гамак...)"
|
placeholder="Название (напр. Водяная кровать...)"
|
||||||
value={newBedLabel}
|
value={newBedLabel}
|
||||||
onChange={e => setNewBedLabel(e.target.value)}
|
onChange={e => setNewBedLabel(e.target.value)}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
@@ -650,23 +652,28 @@ export function RoomCategoriesPage() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const label = newBedLabel.trim()
|
const label = newBedLabel.trim()
|
||||||
if (!label) return
|
if (!label) return
|
||||||
const value = label.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_а-яё]/gi, '')
|
const value = label.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_а-яё]/gi, '') + '_' + Date.now().toString(36)
|
||||||
+ '_' + Date.now().toString(36)
|
addBedType({ value, label, icon: newBedIcon, capacity: newBedCapacity })
|
||||||
addBedType({ value, label, icon: newBedIcon })
|
setNewBedLabel(''); setNewBedIcon('🛏'); setNewBedCapacity(2)
|
||||||
setNewBedLabel('')
|
|
||||||
setNewBedIcon('🛏')
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400 whitespace-nowrap">мест:</span>
|
||||||
|
<input
|
||||||
|
type="number" min={1} max={10}
|
||||||
|
className="input w-14 text-sm text-center"
|
||||||
|
value={newBedCapacity}
|
||||||
|
onChange={e => setNewBedCapacity(parseInt(e.target.value) || 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const label = newBedLabel.trim()
|
const label = newBedLabel.trim()
|
||||||
if (!label) return
|
if (!label) return
|
||||||
const value = label.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_а-яё]/gi, '')
|
const value = label.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_а-яё]/gi, '') + '_' + Date.now().toString(36)
|
||||||
+ '_' + Date.now().toString(36)
|
addBedType({ value, label, icon: newBedIcon, capacity: newBedCapacity })
|
||||||
addBedType({ value, label, icon: newBedIcon })
|
setNewBedLabel(''); setNewBedIcon('🛏'); setNewBedCapacity(2)
|
||||||
setNewBedLabel('')
|
|
||||||
setNewBedIcon('🛏')
|
|
||||||
}}
|
}}
|
||||||
disabled={!newBedLabel.trim()}
|
disabled={!newBedLabel.trim()}
|
||||||
className="btn-primary px-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="btn-primary px-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
@@ -699,14 +706,21 @@ export function RoomCategoriesPage() {
|
|||||||
onChange={e => setEditBedLabel(e.target.value)}
|
onChange={e => setEditBedLabel(e.target.value)}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
updateBedType(b.value, { label: editBedLabel, icon: editBedIcon })
|
updateBedType(b.value, { label: editBedLabel, icon: editBedIcon, capacity: editBedCapacity })
|
||||||
setEditingBed(null)
|
setEditingBed(null)
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') setEditingBed(null)
|
if (e.key === 'Escape') setEditingBed(null)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
type="number" min={1} max={10}
|
||||||
|
className="input text-xs w-10 py-0.5 h-6 text-center"
|
||||||
|
value={editBedCapacity}
|
||||||
|
onChange={e => setEditBedCapacity(parseInt(e.target.value) || 1)}
|
||||||
|
title="Вместимость"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => { updateBedType(b.value, { label: editBedLabel, icon: editBedIcon }); setEditingBed(null) }}
|
onClick={() => { updateBedType(b.value, { label: editBedLabel, icon: editBedIcon, capacity: editBedCapacity }); setEditingBed(null) }}
|
||||||
className="text-emerald-500 hover:text-emerald-600 shrink-0"
|
className="text-emerald-500 hover:text-emerald-600 shrink-0"
|
||||||
>
|
>
|
||||||
<Check size={13} />
|
<Check size={13} />
|
||||||
@@ -719,9 +733,10 @@ export function RoomCategoriesPage() {
|
|||||||
<>
|
<>
|
||||||
<span className="text-base shrink-0">{b.icon}</span>
|
<span className="text-base shrink-0">{b.icon}</span>
|
||||||
<span className="text-xs text-slate-700 dark:text-slate-300 flex-1 truncate">{b.label}</span>
|
<span className="text-xs text-slate-700 dark:text-slate-300 flex-1 truncate">{b.label}</span>
|
||||||
|
<span className="text-[10px] text-slate-400 shrink-0">{b.capacity} чел.</span>
|
||||||
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditingBed(b.value); setEditBedLabel(b.label); setEditBedIcon(b.icon) }}
|
onClick={() => { setEditingBed(b.value); setEditBedLabel(b.label); setEditBedIcon(b.icon); setEditBedCapacity(b.capacity) }}
|
||||||
className="p-0.5 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-700"
|
className="p-0.5 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-700"
|
||||||
>
|
>
|
||||||
<Pencil size={11} />
|
<Pencil size={11} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { BedDouble, Users, Wifi, Plus, Search, Loader2, Pencil, Layers } from 'lucide-react'
|
import { BedDouble, Users, Wifi, Plus, Search, Loader2, Pencil, Layers, Wrench } from 'lucide-react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import type { Room, RoomCategory } from '../types'
|
import type { Room, RoomCategory } from '../types'
|
||||||
@@ -7,6 +7,8 @@ import { cn, ROOM_STATUS_COLORS, ROOM_STATUS_LABELS, HK_STATUS_COLORS, HK_STATUS
|
|||||||
import { Badge } from '../components/ui/Badge'
|
import { Badge } from '../components/ui/Badge'
|
||||||
import { RoomModal } from '../components/rooms/RoomModal'
|
import { RoomModal } from '../components/rooms/RoomModal'
|
||||||
import { BulkRoomModal, type BulkRoomData } from '../components/rooms/BulkRoomModal'
|
import { BulkRoomModal, type BulkRoomData } from '../components/rooms/BulkRoomModal'
|
||||||
|
import { RoomContextMenu } from '../components/rooms/RoomContextMenu'
|
||||||
|
import type { RoomStatus, HousekeepingStatus } from '../types'
|
||||||
|
|
||||||
export function RoomsPage() {
|
export function RoomsPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
@@ -69,6 +71,16 @@ export function RoomsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
const [ctxMenu, setCtxMenu] = useState<{ room: Room; x: number; y: number } | null>(null)
|
||||||
|
|
||||||
|
const handleRoomQuickUpdate = async (roomId: string, patch: Partial<Room>) => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleBulkSave = async (bulkRooms: BulkRoomData[]) => {
|
const handleBulkSave = async (bulkRooms: BulkRoomData[]) => {
|
||||||
const created: Room[] = []
|
const created: Room[] = []
|
||||||
@@ -213,7 +225,12 @@ export function RoomsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{filtered.map(room => (
|
{filtered.map(room => (
|
||||||
<RoomCard key={room.id} room={room} onEdit={() => openEdit(room)} />
|
<RoomCard
|
||||||
|
key={room.id}
|
||||||
|
room={room}
|
||||||
|
onEdit={() => openEdit(room)}
|
||||||
|
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ room, x: e.clientX, y: e.clientY }) }}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -239,19 +256,41 @@ export function RoomsPage() {
|
|||||||
onClose={() => setBulkOpen(false)}
|
onClose={() => setBulkOpen(false)}
|
||||||
onSave={handleBulkSave}
|
onSave={handleBulkSave}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{ctxMenu && (
|
||||||
|
<RoomContextMenu
|
||||||
|
room={ctxMenu.room}
|
||||||
|
x={ctxMenu.x}
|
||||||
|
y={ctxMenu.y}
|
||||||
|
onClose={() => setCtxMenu(null)}
|
||||||
|
onStatusChange={(id, status: RoomStatus) => handleRoomQuickUpdate(id, { status })}
|
||||||
|
onHkStatusChange={(id, status: HousekeepingStatus) => handleRoomQuickUpdate(id, { housekeepingStatus: status })}
|
||||||
|
onEdit={(room) => openEdit(room)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoomCard({ room, onEdit }: { room: Room; onEdit: () => void }) {
|
function RoomCard({ room, onEdit, onContextMenu }: { room: Room; onEdit: () => void; onContextMenu: (e: React.MouseEvent) => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="card p-4 hover:shadow-card-hover transition-shadow cursor-pointer group relative">
|
<div
|
||||||
|
className="card p-4 hover:shadow-card-hover transition-shadow cursor-pointer group relative"
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); onEdit() }}
|
onClick={e => { e.stopPropagation(); onEdit() }}
|
||||||
className="absolute top-3 right-3 p-1.5 rounded-lg bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-500 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:text-brand-600"
|
className="absolute top-3 right-3 p-1.5 rounded-lg bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-500 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:text-brand-600"
|
||||||
>
|
>
|
||||||
<Pencil size={13} />
|
<Pencil size={13} />
|
||||||
</button>
|
</button>
|
||||||
|
{room.status === 'maintenance' && (
|
||||||
|
<div className="absolute top-3 left-3">
|
||||||
|
<span className="flex items-center gap-1 text-[10px] font-medium text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20 px-1.5 py-0.5 rounded-md border border-orange-200 dark:border-orange-800">
|
||||||
|
<Wrench size={10} />ремонт
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user