feat: maintenance/blocked date ranges with booking overlap warning
- DB migration 020: add maintenance_from, maintenance_to to rooms - Room type + RoomPayload: maintenanceFrom, maintenanceTo fields - Context menu: clicking "На ремонт"/"Закрыт" expands inline date picker (С / По + "Без срока" checkbox), confirms with "Применить" button - Calendar: stripe overlay covers only the maintenance date range (full row if no dates set, partial if date range specified) - Booking creation: if drag-selected dates overlap with maintenance period, shows warning dialog with "Отмена" / "Всё равно забронировать" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
3
backend/migrations/020_room_maintenance_dates.sql
Normal file
3
backend/migrations/020_room_maintenance_dates.sql
Normal file
@@ -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;
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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 */}
|
||||
<div className="relative flex-1">
|
||||
{/* Maintenance / blocked overlay */}
|
||||
{(room.status === 'maintenance' || room.status === 'blocked') && (
|
||||
<div
|
||||
className="absolute inset-0 z-[1] pointer-events-none"
|
||||
style={{
|
||||
background: room.status === 'maintenance'
|
||||
? 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(251,146,60,0.12) 6px, rgba(251,146,60,0.12) 12px)'
|
||||
: 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(148,163,184,0.15) 6px, rgba(148,163,184,0.15) 12px)',
|
||||
backgroundColor: room.status === 'maintenance'
|
||||
? 'rgba(251,146,60,0.05)'
|
||||
: 'rgba(148,163,184,0.08)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* 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 (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 z-[1] pointer-events-none"
|
||||
style={{
|
||||
left, width,
|
||||
background: room.status === 'maintenance'
|
||||
? 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(251,146,60,0.12) 6px, rgba(251,146,60,0.12) 12px)'
|
||||
: 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(148,163,184,0.15) 6px, rgba(148,163,184,0.15) 12px)',
|
||||
backgroundColor: room.status === 'maintenance'
|
||||
? 'rgba(251,146,60,0.05)'
|
||||
: 'rgba(148,163,184,0.08)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
<div className="flex h-full">
|
||||
{dates.map((date, i) => {
|
||||
const isWe = date.getDay() === 0 || date.getDay() === 6
|
||||
@@ -781,6 +820,38 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Maintenance warning before booking */}
|
||||
{maintenanceWarningDraft && (
|
||||
<div className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/40" onClick={() => setMaintenanceWarningDraft(null)}>
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl p-6 w-80 max-w-[90vw]" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<span className="text-2xl">{maintenanceWarningDraft.roomStatus === 'maintenance' ? '🔧' : '🔒'}</span>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
Номер {maintenanceWarningDraft.roomStatus === 'maintenance' ? 'на ремонте' : 'закрыт'}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||
Выбранные даты пересекаются с периодом
|
||||
{maintenanceWarningDraft.roomStatus === 'maintenance' ? ' ремонта' : ' закрытия'}
|
||||
{maintenanceWarningDraft.period}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="flex-1 btn-secondary text-sm" onClick={() => setMaintenanceWarningDraft(null)}>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 py-2 px-3 rounded-xl text-sm font-medium bg-orange-500 hover:bg-orange-600 text-white transition-colors"
|
||||
onClick={() => { setBookingModalDraft(maintenanceWarningDraft.draft); setMaintenanceWarningDraft(null) }}
|
||||
>
|
||||
Всё равно забронировать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Booking create modal */}
|
||||
{bookingModalDraft && (
|
||||
<BookingModal
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Wrench, Ban, CheckCircle, Sparkles, ClipboardList, Pencil } from 'lucide-react'
|
||||
import { Wrench, Ban, CheckCircle, Sparkles, ClipboardList, Pencil, ChevronRight, X as XIcon } from 'lucide-react'
|
||||
import type { Room, RoomStatus, HousekeepingStatus } from '../../types'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
@@ -9,26 +9,26 @@ interface RoomContextMenuProps {
|
||||
x: number
|
||||
y: number
|
||||
onClose: () => 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: <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' },
|
||||
]
|
||||
|
||||
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' },
|
||||
]
|
||||
type SubForm = 'maintenance' | 'blocked' | null
|
||||
|
||||
export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatusChange, onEdit }: RoomContextMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [subForm, setSubForm] = useState<SubForm>(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(
|
||||
<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"
|
||||
className="fixed z-[9999] w-60 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()}
|
||||
>
|
||||
@@ -69,26 +86,82 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
|
||||
{/* 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>
|
||||
))}
|
||||
|
||||
{/* Available */}
|
||||
<button
|
||||
onClick={() => handleApplyStatus('available')}
|
||||
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 text-emerald-600 dark:text-emerald-400',
|
||||
room.status === 'available' && subForm === null && 'bg-slate-100 dark:bg-slate-700 font-medium',
|
||||
)}
|
||||
>
|
||||
<CheckCircle size={14} />
|
||||
Свободен
|
||||
{room.status === 'available' && subForm === null && <span className="ml-auto text-[10px] text-slate-400">✓</span>}
|
||||
</button>
|
||||
|
||||
{/* Maintenance */}
|
||||
<button
|
||||
onClick={() => handleApplyStatus('maintenance')}
|
||||
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 text-orange-600 dark:text-orange-400',
|
||||
(room.status === 'maintenance' || subForm === 'maintenance') && 'bg-slate-100 dark:bg-slate-700 font-medium',
|
||||
)}
|
||||
>
|
||||
<Wrench size={14} />
|
||||
На ремонт
|
||||
{room.status === 'maintenance' && subForm === null && <span className="ml-auto text-[10px] text-slate-400">✓</span>}
|
||||
{subForm !== 'maintenance' && <ChevronRight size={12} className="ml-auto text-slate-400" />}
|
||||
</button>
|
||||
|
||||
{/* Maintenance date form */}
|
||||
{subForm === 'maintenance' && (
|
||||
<DateRangeForm
|
||||
dateFrom={dateFrom} setDateFrom={setDateFrom}
|
||||
dateTo={dateTo} setDateTo={setDateTo}
|
||||
indefinite={indefinite} setIndefinite={setIndefinite}
|
||||
label="Срок ремонта"
|
||||
onApply={() => {
|
||||
onStatusChange(room.id, 'maintenance', dateFrom || null, indefinite ? null : (dateTo || null))
|
||||
onClose()
|
||||
}}
|
||||
onCancel={() => setSubForm(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Blocked */}
|
||||
<button
|
||||
onClick={() => handleApplyStatus('blocked')}
|
||||
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 text-slate-500 dark:text-slate-400',
|
||||
(room.status === 'blocked' || subForm === 'blocked') && 'bg-slate-100 dark:bg-slate-700 font-medium',
|
||||
)}
|
||||
>
|
||||
<Ban size={14} />
|
||||
Закрыт
|
||||
{room.status === 'blocked' && subForm === null && <span className="ml-auto text-[10px] text-slate-400">✓</span>}
|
||||
{subForm !== 'blocked' && <ChevronRight size={12} className="ml-auto text-slate-400" />}
|
||||
</button>
|
||||
|
||||
{/* Blocked date form */}
|
||||
{subForm === 'blocked' && (
|
||||
<DateRangeForm
|
||||
dateFrom={dateFrom} setDateFrom={setDateFrom}
|
||||
dateTo={dateTo} setDateTo={setDateTo}
|
||||
indefinite={indefinite} setIndefinite={setIndefinite}
|
||||
label="Срок закрытия"
|
||||
onApply={() => {
|
||||
onStatusChange(room.id, 'blocked', dateFrom || null, indefinite ? null : (dateTo || null))
|
||||
onClose()
|
||||
}}
|
||||
onCancel={() => setSubForm(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-100 dark:border-slate-700 mx-2" />
|
||||
|
||||
{/* Housekeeping status */}
|
||||
{/* Housekeeping */}
|
||||
<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 => (
|
||||
@@ -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 (
|
||||
<div className="mx-1 mb-1 mt-0.5 p-2.5 rounded-lg bg-slate-50 dark:bg-slate-700/50 border border-slate-200 dark:border-slate-600 space-y-2">
|
||||
<p className="text-[11px] font-semibold text-slate-600 dark:text-slate-300">{label}</p>
|
||||
<div className="space-y-1.5">
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-500 dark:text-slate-400">С (начало)</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full text-xs border border-slate-200 dark:border-slate-600 rounded-lg px-2 py-1 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 mt-0.5"
|
||||
value={dateFrom}
|
||||
onChange={e => setDateFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] text-slate-500 dark:text-slate-400">По (конец)</label>
|
||||
<label className="flex items-center gap-1 text-[10px] text-slate-500 dark:text-slate-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={indefinite}
|
||||
onChange={e => setIndefinite(e.target.checked)}
|
||||
className="w-3 h-3"
|
||||
/>
|
||||
Без срока
|
||||
</label>
|
||||
</div>
|
||||
{!indefinite && (
|
||||
<input
|
||||
type="date"
|
||||
className="w-full text-xs border border-slate-200 dark:border-slate-600 rounded-lg px-2 py-1 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 mt-0.5"
|
||||
value={dateTo}
|
||||
min={dateFrom}
|
||||
onChange={e => setDateTo(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5 pt-0.5">
|
||||
<button
|
||||
onClick={onApply}
|
||||
className="flex-1 py-1 rounded-lg text-xs font-medium bg-brand-600 text-white hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
Применить
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-2 py-1 rounded-lg text-xs text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
|
||||
>
|
||||
<XIcon size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<RoomPayload>): Record<string, unknown> {
|
||||
@@ -467,6 +468,8 @@ function toRoomPayload(r: Partial<RoomPayload>): Record<string, unknown> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user