fix: maintenance overlay + context menu UX improvements
- Calendar overlay: normalize date strings (slice 0-10) to handle full ISO timestamps from Postgres; fix off-by-one (to = exclusive); stronger stripe visibility; guard against vFrom >= vTo - Context menu: dateFrom defaults to today, indefinite defaults to false (both date inputs shown immediately) - DateRangeForm: extracted DateInput component with CalendarDays icon that triggers native date picker - HK status order: clean → inspect → cleaning → dirty Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -215,12 +215,14 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
|
|||||||
if (draftRoom && (draftRoom.status === 'maintenance' || draftRoom.status === 'blocked')) {
|
if (draftRoom && (draftRoom.status === 'maintenance' || draftRoom.status === 'blocked')) {
|
||||||
const bookIn = new Date(checkIn + 'T00:00:00')
|
const bookIn = new Date(checkIn + 'T00:00:00')
|
||||||
const bookOut = new Date(checkOut + 'T00:00:00')
|
const bookOut = new Date(checkOut + 'T00:00:00')
|
||||||
const mFrom = draftRoom.maintenanceFrom ? new Date(draftRoom.maintenanceFrom + 'T00:00:00') : null
|
const mFrom = draftRoom.maintenanceFrom ? new Date(draftRoom.maintenanceFrom.slice(0, 10) + 'T00:00:00') : null
|
||||||
const mTo = draftRoom.maintenanceTo ? new Date(draftRoom.maintenanceTo + 'T00:00:00') : null
|
const mTo = draftRoom.maintenanceTo ? new Date(draftRoom.maintenanceTo.slice(0, 10) + 'T00:00:00') : null
|
||||||
const overlaps = (!mFrom || bookOut > mFrom) && (!mTo || bookIn < mTo)
|
const overlaps = (!mFrom || bookOut > mFrom) && (!mTo || bookIn < mTo)
|
||||||
if (overlaps) {
|
if (overlaps) {
|
||||||
const period = mFrom
|
const fromStr = draftRoom.maintenanceFrom?.slice(0, 10)
|
||||||
? ` (${draftRoom.maintenanceFrom}${mTo ? ` — ${draftRoom.maintenanceTo}` : ' — без срока'})`
|
const toStr = draftRoom.maintenanceTo?.slice(0, 10)
|
||||||
|
const period = fromStr
|
||||||
|
? ` (${fromStr}${toStr ? ` — ${toStr}` : ' — без срока'})`
|
||||||
: ' (без срока)'
|
: ' (без срока)'
|
||||||
setMaintenanceWarningDraft({ draft, roomStatus: draftRoom.status, period })
|
setMaintenanceWarningDraft({ draft, roomStatus: draftRoom.status, period })
|
||||||
setDragStart(null); setDragEnd(null); setDraft(null)
|
setDragStart(null); setDragEnd(null); setDraft(null)
|
||||||
@@ -522,16 +524,16 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
|
|||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
{/* Maintenance / blocked overlay — with optional date range */}
|
{/* Maintenance / blocked overlay — with optional date range */}
|
||||||
{(room.status === 'maintenance' || room.status === 'blocked') && (() => {
|
{(room.status === 'maintenance' || room.status === 'blocked') && (() => {
|
||||||
const from = room.maintenanceFrom ? new Date(room.maintenanceFrom + 'T00:00:00') : null
|
// slice(0,10) normalises both 'YYYY-MM-DD' and full ISO timestamps from Postgres
|
||||||
const to = room.maintenanceTo ? new Date(room.maintenanceTo + 'T00:00:00') : null
|
const from = room.maintenanceFrom ? new Date(room.maintenanceFrom.slice(0, 10) + 'T00:00:00') : null
|
||||||
// Calculate left offset and width if dates are set
|
const to = room.maintenanceTo ? new Date(room.maintenanceTo.slice(0, 10) + 'T00:00:00') : null
|
||||||
let left = 0, width = '100%'
|
let left = 0, width: string | number = '100%'
|
||||||
if (from || to) {
|
if (from || to) {
|
||||||
const visibleFrom = from ? Math.max(0, differenceInDays(from, startDate)) : 0
|
const vFrom = from ? Math.max(0, differenceInDays(from, startDate)) : 0
|
||||||
const visibleTo = to ? Math.min(dates.length - 1, differenceInDays(to, startDate)) : dates.length - 1
|
const vTo = to ? Math.min(dates.length, differenceInDays(to, startDate)) : dates.length
|
||||||
if (visibleTo < 0 || visibleFrom >= dates.length) return null
|
if (vTo <= 0 || vFrom >= dates.length || vFrom >= vTo) return null
|
||||||
left = visibleFrom * CELL_WIDTH
|
left = vFrom * CELL_WIDTH
|
||||||
width = `${(visibleTo - visibleFrom + 1) * CELL_WIDTH}px`
|
width = (vTo - vFrom) * CELL_WIDTH
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -539,11 +541,11 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
|
|||||||
style={{
|
style={{
|
||||||
left, width,
|
left, width,
|
||||||
background: room.status === 'maintenance'
|
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 5px, rgba(251,146,60,0.22) 5px, rgba(251,146,60,0.22) 10px)'
|
||||||
: 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(148,163,184,0.15) 6px, rgba(148,163,184,0.15) 12px)',
|
: 'repeating-linear-gradient(45deg, transparent, transparent 5px, rgba(148,163,184,0.25) 5px, rgba(148,163,184,0.25) 10px)',
|
||||||
backgroundColor: room.status === 'maintenance'
|
backgroundColor: room.status === 'maintenance'
|
||||||
? 'rgba(251,146,60,0.05)'
|
? 'rgba(251,146,60,0.08)'
|
||||||
: 'rgba(148,163,184,0.08)',
|
: 'rgba(148,163,184,0.12)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Wrench, Ban, CheckCircle, Sparkles, ClipboardList, Pencil, ChevronRight, X as XIcon } from 'lucide-react'
|
import { Wrench, Ban, CheckCircle, Sparkles, ClipboardList, Pencil, ChevronRight, X as XIcon, CalendarDays } from 'lucide-react'
|
||||||
|
import { format } from 'date-fns'
|
||||||
import type { Room, RoomStatus, HousekeepingStatus } from '../../types'
|
import type { Room, RoomStatus, HousekeepingStatus } from '../../types'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
const today = () => format(new Date(), 'yyyy-MM-dd')
|
||||||
|
|
||||||
interface RoomContextMenuProps {
|
interface RoomContextMenuProps {
|
||||||
room: Room
|
room: Room
|
||||||
x: number
|
x: number
|
||||||
@@ -16,9 +19,9 @@ interface RoomContextMenuProps {
|
|||||||
|
|
||||||
const HK_ITEMS: { status: HousekeepingStatus; label: string; icon: React.ReactNode; color: string }[] = [
|
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: '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' },
|
{ status: 'inspect', label: 'Проверить', icon: <ClipboardList size={14} />, color: 'text-amber-600 dark:text-amber-400' },
|
||||||
|
{ status: 'cleaning', label: 'Убирается', icon: <ClipboardList size={14} />, color: 'text-blue-500 dark:text-blue-400' },
|
||||||
|
{ status: 'dirty', label: 'Убрать', icon: <ClipboardList size={14} />, color: 'text-red-500 dark:text-red-400' },
|
||||||
]
|
]
|
||||||
|
|
||||||
type SubForm = 'maintenance' | 'blocked' | null
|
type SubForm = 'maintenance' | 'blocked' | null
|
||||||
@@ -26,9 +29,9 @@ type SubForm = 'maintenance' | 'blocked' | null
|
|||||||
export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatusChange, onEdit }: RoomContextMenuProps) {
|
export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatusChange, onEdit }: RoomContextMenuProps) {
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const [subForm, setSubForm] = useState<SubForm>(null)
|
const [subForm, setSubForm] = useState<SubForm>(null)
|
||||||
const [dateFrom, setDateFrom] = useState(room.maintenanceFrom ?? '')
|
const [dateFrom, setDateFrom] = useState(room.maintenanceFrom ? room.maintenanceFrom.slice(0, 10) : today())
|
||||||
const [dateTo, setDateTo] = useState(room.maintenanceTo ?? '')
|
const [dateTo, setDateTo] = useState(room.maintenanceTo ? room.maintenanceTo.slice(0, 10) : '')
|
||||||
const [indefinite, setIndefinite] = useState(!room.maintenanceTo)
|
const [indefinite, setIndefinite] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
@@ -200,6 +203,30 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DateInput({ value, onChange, min }: { value: string; onChange: (v: string) => void; min?: string }) {
|
||||||
|
const ref = useRef<HTMLInputElement>(null)
|
||||||
|
return (
|
||||||
|
<div className="relative mt-0.5">
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="date"
|
||||||
|
className="w-full text-xs border border-slate-200 dark:border-slate-600 rounded-lg pl-2 pr-7 py-1 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200"
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-brand-500 transition-colors"
|
||||||
|
onClick={() => { ref.current?.showPicker?.() ?? ref.current?.focus() }}
|
||||||
|
>
|
||||||
|
<CalendarDays size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function DateRangeForm({
|
function DateRangeForm({
|
||||||
dateFrom, setDateFrom, dateTo, setDateTo,
|
dateFrom, setDateFrom, dateTo, setDateTo,
|
||||||
indefinite, setIndefinite, label, onApply, onCancel,
|
indefinite, setIndefinite, label, onApply, onCancel,
|
||||||
@@ -217,12 +244,7 @@ function DateRangeForm({
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-slate-500 dark:text-slate-400">С (начало)</label>
|
<label className="text-[10px] text-slate-500 dark:text-slate-400">С (начало)</label>
|
||||||
<input
|
<DateInput value={dateFrom} onChange={setDateFrom} />
|
||||||
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>
|
<div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -238,13 +260,7 @@ function DateRangeForm({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{!indefinite && (
|
{!indefinite && (
|
||||||
<input
|
<DateInput value={dateTo} onChange={setDateTo} min={dateFrom} />
|
||||||
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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user