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)} + /> +
+
+
+ + +
+ {!indefinite && ( + setDateTo(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 {