diff --git a/backend/migrations/020_room_maintenance_dates.sql b/backend/migrations/020_room_maintenance_dates.sql
new file mode 100644
index 0000000..e19eb17
--- /dev/null
+++ b/backend/migrations/020_room_maintenance_dates.sql
@@ -0,0 +1,3 @@
+-- Add maintenance/blocked date range columns to rooms
+ALTER TABLE rooms ADD COLUMN IF NOT EXISTS maintenance_from DATE;
+ALTER TABLE rooms ADD COLUMN IF NOT EXISTS maintenance_to DATE;
diff --git a/backend/src/routes/rooms.ts b/backend/src/routes/rooms.ts
index e72b42e..f134b75 100644
--- a/backend/src/routes/rooms.ts
+++ b/backend/src/routes/rooms.ts
@@ -143,6 +143,7 @@ const rooms: FastifyPluginAsync = async (fastify) => {
'housekeeping_status', 'sort_order', 'allow_hourly', 'hourly_rate',
'extra_place', 'child_policy', 'description', 'photos',
'early_checkin_fee', 'late_checkout_fee',
+ 'maintenance_from', 'maintenance_to',
]
const updates: string[] = []
const values: unknown[] = []
diff --git a/src/components/calendar/BookingCalendar.tsx b/src/components/calendar/BookingCalendar.tsx
index 0d6b4ce..d0c6531 100644
--- a/src/components/calendar/BookingCalendar.tsx
+++ b/src/components/calendar/BookingCalendar.tsx
@@ -65,13 +65,18 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
// Room context menu
const [ctxMenu, setCtxMenu] = useState<{ room: Room; x: number; y: number } | null>(null)
+ // Maintenance warning before booking
+ const [maintenanceWarningDraft, setMaintenanceWarningDraft] = useState<{
+ draft: DraftBooking; roomStatus: RoomStatus; period: string
+ } | 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 handleCtxStatusChange = (roomId: string, status: RoomStatus, from?: string | null, to?: string | null) => {
+ onRoomUpdate?.(roomId, { status, maintenanceFrom: from, maintenanceTo: to })
}
const handleCtxHkChange = (roomId: string, status: HousekeepingStatus) => {
@@ -184,14 +189,34 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
if (!dragStart || dragEnd === null) return
const minDay = Math.min(dragStart.dayIdx, dragEnd)
const maxDay = Math.max(dragStart.dayIdx, dragEnd)
- const checkIn = format(addDays(startDate, minDay), 'yyyy-MM-dd')
+ const checkIn = format(addDays(startDate, minDay), 'yyyy-MM-dd')
const checkOut = format(addDays(startDate, maxDay + 1), 'yyyy-MM-dd')
- setBookingModalDraft({ roomId: dragStart.roomId, checkIn, checkOut })
+ const draft = { roomId: dragStart.roomId, checkIn, checkOut }
+
+ // Check maintenance/blocked overlap
+ const draftRoom = rooms.find(r => r.id === dragStart.roomId)
+ if (draftRoom && (draftRoom.status === 'maintenance' || draftRoom.status === 'blocked')) {
+ const bookIn = new Date(checkIn + 'T00:00:00')
+ const bookOut = new Date(checkOut + 'T00:00:00')
+ const mFrom = draftRoom.maintenanceFrom ? new Date(draftRoom.maintenanceFrom + 'T00:00:00') : null
+ const mTo = draftRoom.maintenanceTo ? new Date(draftRoom.maintenanceTo + 'T00:00:00') : null
+ const overlaps = (!mFrom || bookOut > mFrom) && (!mTo || bookIn < mTo)
+ if (overlaps) {
+ const period = mFrom
+ ? ` (${draftRoom.maintenanceFrom}${mTo ? ` — ${draftRoom.maintenanceTo}` : ' — без срока'})`
+ : ' (без срока)'
+ setMaintenanceWarningDraft({ draft, roomStatus: draftRoom.status, period })
+ setDragStart(null); setDragEnd(null); setDraft(null)
+ return
+ }
+ }
+
+ setBookingModalDraft(draft)
onDraftStart?.(dragStart.roomId, checkIn, checkOut)
setDragStart(null)
setDragEnd(null)
setDraft(null)
- }, [dragStart, dragEnd, startDate, onDraftStart, onBookingUpdate])
+ }, [dragStart, dragEnd, startDate, onDraftStart, onBookingUpdate, rooms])
const getLockStyle = (roomId: string) => {
const lock = locks.get(roomId)
@@ -478,20 +503,34 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
{/* Day cells + booking blocks */}
- {/* Maintenance / blocked overlay */}
- {(room.status === 'maintenance' || room.status === 'blocked') && (
-
- )}
+ {/* Maintenance / blocked overlay — with optional date range */}
+ {(room.status === 'maintenance' || room.status === 'blocked') && (() => {
+ const from = room.maintenanceFrom ? new Date(room.maintenanceFrom + 'T00:00:00') : null
+ const to = room.maintenanceTo ? new Date(room.maintenanceTo + 'T00:00:00') : null
+ // Calculate left offset and width if dates are set
+ let left = 0, width = '100%'
+ if (from || to) {
+ const visibleFrom = from ? Math.max(0, differenceInDays(from, startDate)) : 0
+ const visibleTo = to ? Math.min(dates.length - 1, differenceInDays(to, startDate)) : dates.length - 1
+ if (visibleTo < 0 || visibleFrom >= dates.length) return null
+ left = visibleFrom * CELL_WIDTH
+ width = `${(visibleTo - visibleFrom + 1) * CELL_WIDTH}px`
+ }
+ return (
+
+ )
+ })()}
{dates.map((date, i) => {
const isWe = date.getDay() === 0 || date.getDay() === 6
@@ -781,6 +820,38 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
+ {/* Maintenance warning before booking */}
+ {maintenanceWarningDraft && (
+ setMaintenanceWarningDraft(null)}>
+
e.stopPropagation()}>
+
+
{maintenanceWarningDraft.roomStatus === 'maintenance' ? '🔧' : '🔒'}
+
+
+ Номер {maintenanceWarningDraft.roomStatus === 'maintenance' ? 'на ремонте' : 'закрыт'}
+
+
+ Выбранные даты пересекаются с периодом
+ {maintenanceWarningDraft.roomStatus === 'maintenance' ? ' ремонта' : ' закрытия'}
+ {maintenanceWarningDraft.period}.
+
+
+
+
+
+
+
+
+
+ )}
+
{/* Booking create modal */}
{bookingModalDraft && (
void
- onStatusChange: (roomId: string, status: RoomStatus) => void
+ onStatusChange: (roomId: string, status: RoomStatus, maintenanceFrom?: string | null, maintenanceTo?: string | null) => 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' },
]
-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' },
-]
+type SubForm = 'maintenance' | 'blocked' | null
export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatusChange, onEdit }: RoomContextMenuProps) {
const ref = useRef(null)
+ const [subForm, setSubForm] = useState(null)
+ const [dateFrom, setDateFrom] = useState(room.maintenanceFrom ?? '')
+ const [dateTo, setDateTo] = useState(room.maintenanceTo ?? '')
+ const [indefinite, setIndefinite] = useState(!room.maintenanceTo)
useEffect(() => {
const handler = (e: MouseEvent) => {
@@ -38,7 +38,7 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
document.addEventListener('mousedown', handler)
document.addEventListener('keydown', keyHandler)
- // After mount, reposition if menu overflows viewport
+ // Reposition if overflows viewport
if (ref.current) {
const rect = ref.current.getBoundingClientRect()
const overflowX = rect.right - window.innerWidth + 8
@@ -47,17 +47,34 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
if (overflowY > 0) ref.current.style.top = `${rect.top - overflowY}px`
}
- return () => { document.removeEventListener('mousedown', handler); document.removeEventListener('keydown', keyHandler) }
- }, [onClose])
+ return () => {
+ document.removeEventListener('mousedown', handler)
+ document.removeEventListener('keydown', keyHandler)
+ }
+ }, [onClose, subForm]) // re-run reposition when subForm opens (menu height changes)
- // Initial rough position (will be corrected after mount above)
- const menuX = Math.min(x, window.innerWidth - 220)
+ const handleApplyStatus = (status: RoomStatus) => {
+ if (status === 'maintenance' || status === 'blocked') {
+ if (subForm === status) {
+ // confirm
+ onStatusChange(room.id, status, dateFrom || null, indefinite ? null : (dateTo || null))
+ onClose()
+ } else {
+ setSubForm(status)
+ }
+ } else {
+ onStatusChange(room.id, status, null, null)
+ onClose()
+ }
+ }
+
+ const menuX = Math.min(x, window.innerWidth - 260)
const menuY = y
return createPortal(
e.preventDefault()}
>
@@ -69,26 +86,82 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
{/* Room status */}
Статус номера
- {STATUS_ITEMS.map(item => (
-
- ))}
+
+ {/* Available */}
+
+
+ {/* Maintenance */}
+
+
+ {/* Maintenance date form */}
+ {subForm === 'maintenance' && (
+
{
+ onStatusChange(room.id, 'maintenance', dateFrom || null, indefinite ? null : (dateTo || null))
+ onClose()
+ }}
+ onCancel={() => setSubForm(null)}
+ />
+ )}
+
+ {/* Blocked */}
+
+
+ {/* Blocked date form */}
+ {subForm === 'blocked' && (
+ {
+ onStatusChange(room.id, 'blocked', dateFrom || null, indefinite ? null : (dateTo || null))
+ onClose()
+ }}
+ onCancel={() => setSubForm(null)}
+ />
+ )}
- {/* Housekeeping status */}
+ {/* Housekeeping */}
Уборка
{HK_ITEMS.map(item => (
@@ -126,3 +199,69 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
document.body,
)
}
+
+function DateRangeForm({
+ dateFrom, setDateFrom, dateTo, setDateTo,
+ indefinite, setIndefinite, label, onApply, onCancel,
+}: {
+ dateFrom: string; setDateFrom: (v: string) => void
+ dateTo: string; setDateTo: (v: string) => void
+ indefinite: boolean; setIndefinite: (v: boolean) => void
+ label: string
+ onApply: () => void
+ onCancel: () => void
+}) {
+ return (
+
+
{label}
+
+
+
+ setDateFrom(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 3e36d49..4d0b414 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -442,6 +442,7 @@ export interface RoomPayload {
extraPlace?: unknown; childPolicy?: unknown
description?: string; photos?: string[]
earlyCheckinFee?: number | null; lateCheckoutFee?: number | null
+ maintenanceFrom?: string | null; maintenanceTo?: string | null
}
function toRoomPayload(r: Partial
): Record {
@@ -467,6 +468,8 @@ function toRoomPayload(r: Partial): Record {
if (r.photos !== undefined) out.photos = r.photos
if (r.earlyCheckinFee !== undefined) out.early_checkin_fee = r.earlyCheckinFee
if (r.lateCheckoutFee !== undefined) out.late_checkout_fee = r.lateCheckoutFee
+ if (r.maintenanceFrom !== undefined) out.maintenance_from = r.maintenanceFrom
+ if (r.maintenanceTo !== undefined) out.maintenance_to = r.maintenanceTo
return out
}
diff --git a/src/pages/RoomsPage.tsx b/src/pages/RoomsPage.tsx
index 38fb130..703a0dd 100644
--- a/src/pages/RoomsPage.tsx
+++ b/src/pages/RoomsPage.tsx
@@ -263,7 +263,7 @@ export function RoomsPage() {
x={ctxMenu.x}
y={ctxMenu.y}
onClose={() => setCtxMenu(null)}
- onStatusChange={(id, status: RoomStatus) => handleRoomQuickUpdate(id, { status })}
+ onStatusChange={(id, status: RoomStatus, from, to) => handleRoomQuickUpdate(id, { status, maintenanceFrom: from, maintenanceTo: to })}
onHkStatusChange={(id, status: HousekeepingStatus) => handleRoomQuickUpdate(id, { housekeepingStatus: status })}
onEdit={(room) => openEdit(room)}
/>
diff --git a/src/types/index.ts b/src/types/index.ts
index 826d305..e681976 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -101,6 +101,8 @@ export interface Room {
childPolicy?: ChildPolicy
earlyCheckinFee?: number
lateCheckoutFee?: number
+ maintenanceFrom?: string | null // ISO date, null = indefinite start
+ maintenanceTo?: string | null // ISO date, null = indefinite end
}
export interface DocumentTemplate {