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:
2026-03-23 14:49:19 +03:00
parent 4a39d5f731
commit 63da3d21cc
7 changed files with 274 additions and 55 deletions

View File

@@ -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>
)
}