Major UX improvements across multiple pages

Шахматка:
- Date/period picker dropdown on navigation button (choose start date + days window)
- Cancelled bookings fade out with animation after 1 second

Бронирования:
- Clickable column headers with sort asc/desc (Гость, Заезд, Выезд, Статус, Источник, Сумма)

Страница входа:
- Removed role-based account selector — just email + password
- System auto-detects role/hotel from credentials

Настройки:
- New "Бронирование" section with room assignment strategy (spread/together/sequential/manual)
- Notifications: SMTP email config + SMS provider config (SMSC, SMS.ru, МТС, etc.)

Модули:
- Added Housekeeping and Channel Manager as proper modules
- Channel Manager lists: Booking.com, Airbnb, Яндекс Путешествия, Островок, Суточно.ру, OneTwoTrip
- Housekeeping visible to all roles (including housekeeper) via module status
- Sidebar now uses module status to show/hide Уборка and Каналы

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 15:20:05 +03:00
parent 4e615359eb
commit c233590c4b
29 changed files with 2171 additions and 149 deletions

View File

@@ -1,22 +1,23 @@
import { useState, useRef, useCallback } from 'react'
import { useState, useRef, useCallback, useEffect } from 'react'
import { addDays, format, startOfDay, differenceInDays, parseISO, isToday } from 'date-fns'
import { ru } from 'date-fns/locale'
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'
import { ChevronLeft, ChevronRight, Plus, CalendarDays, ChevronDown } from 'lucide-react'
import { cn, BOOKING_STATUS_COLORS, BOOKING_STATUS_LABELS, SOURCE_LABELS } from '../../lib/utils'
import type { Room, Booking, DraftBooking } from '../../types'
import { BookingModal } from '../bookings/BookingModal'
import { BookingDetailPanel } from '../bookings/BookingDetailPanel'
const CELL_WIDTH = 52 // px per day column
const ROW_HEIGHT = 56 // px per room row
const LABEL_WIDTH = 160 // px for room label column
const DAYS_VISIBLE = 30 // default window
const CELL_WIDTH = 52
const ROW_HEIGHT = 56
const LABEL_WIDTH = 160
const DAYS_VISIBLE = 30
interface BookingCalendarProps {
rooms: Room[]
bookings: Booking[]
onBookingCreate: (b: Partial<Booking>) => void
onBookingUpdate: (id: string, b: Partial<Booking>) => void
fadingBookingIds?: Set<string>
}
function getRoomTypeColor(type: string): string {
@@ -31,9 +32,25 @@ function getRoomTypeColor(type: string): string {
return map[type] ?? 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300'
}
export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpdate }: BookingCalendarProps) {
export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpdate, fadingBookingIds }: BookingCalendarProps) {
const [startDate, setStartDate] = useState(() => startOfDay(new Date()))
const [days] = useState(DAYS_VISIBLE)
const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE)
// Date picker state
const [showNavPicker, setShowNavPicker] = useState(false)
const [pickerDateInput, setPickerDateInput] = useState(format(new Date(), 'yyyy-MM-dd'))
const navPickerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!showNavPicker) return
const handler = (e: MouseEvent) => {
if (navPickerRef.current && !navPickerRef.current.contains(e.target as Node)) {
setShowNavPicker(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [showNavPicker])
// Drag-to-book state
const [draft, setDraft] = useState<DraftBooking | null>(null)
@@ -46,23 +63,17 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
const gridRef = useRef<HTMLDivElement>(null)
// Generate date array
const dates = Array.from({ length: days }, (_, i) => addDays(startDate, i))
const dates = Array.from({ length: visibleDays }, (_, i) => addDays(startDate, i))
// Navigate
const shiftDays = (n: number) => setStartDate(d => addDays(d, n))
const jumpToToday = () => setStartDate(startOfDay(new Date()))
// Compute booking block position
const getBlockStyle = (booking: Booking) => {
const start = parseISO(booking.checkIn)
const end = parseISO(booking.checkOut)
const windowEnd = addDays(startDate, days)
const colStart = Math.max(0, differenceInDays(start, startDate))
const colEnd = Math.min(days, differenceInDays(end, startDate))
if (colStart >= days || colEnd <= 0) return null
const colEnd = Math.min(visibleDays, differenceInDays(end, startDate))
if (colStart >= visibleDays || colEnd <= 0) return null
return {
left: colStart * CELL_WIDTH,
@@ -70,7 +81,6 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
}
}
// Mouse handlers for drag-to-book
const handleCellMouseDown = useCallback((roomId: string, dayIdx: number, e: React.MouseEvent) => {
if (e.button !== 0) return
e.preventDefault()
@@ -95,7 +105,6 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
setDraft(null)
}, [dragStart, dragEnd, startDate])
// Draft overlay bounds
const getDraftStyle = (roomId: string) => {
if (!dragStart || dragEnd === null || dragStart.roomId !== roomId) return null
const minDay = Math.min(dragStart.dayIdx, dragEnd)
@@ -112,9 +121,82 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shrink-0 flex-wrap gap-y-2">
<div className="flex items-center gap-1.5">
<button onClick={() => shiftDays(-7)} className="btn-ghost p-2"><ChevronLeft size={16} /></button>
<button onClick={jumpToToday} className="btn-secondary px-3 py-1.5 text-xs">Сегодня</button>
{/* Date / period picker */}
<div className="relative" ref={navPickerRef}>
<button
onClick={() => setShowNavPicker(v => !v)}
className={cn(
'btn-secondary flex items-center gap-1.5 px-2.5 py-1.5 text-xs',
showNavPicker && 'bg-slate-200 dark:bg-slate-600',
)}
>
<CalendarDays size={12} className={isToday(startDate) ? 'text-brand-600' : 'text-slate-400'} />
<span className={cn('font-medium', isToday(startDate) ? 'text-brand-600' : '')}>
{isToday(startDate) ? 'Сегодня' : format(startDate, 'd MMM', { locale: ru })}
</span>
<span className="text-slate-400">·</span>
<span className="text-slate-400">{visibleDays}д</span>
<ChevronDown size={11} className="text-slate-400" />
</button>
{showNavPicker && (
<div className="absolute top-full left-0 mt-1 z-50 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 shadow-xl p-4 w-64">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Начало периода</p>
<div className="flex gap-2 mb-3">
<input
type="date"
value={pickerDateInput}
onChange={e => setPickerDateInput(e.target.value)}
className="input text-sm py-1.5 flex-1"
/>
<button
onClick={() => {
const today = format(new Date(), 'yyyy-MM-dd')
setPickerDateInput(today)
setStartDate(startOfDay(new Date()))
}}
className="btn-secondary text-xs px-2.5 shrink-0"
>
Сег.
</button>
</div>
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Дней в окне</p>
<div className="flex gap-1.5 flex-wrap mb-4">
{[14, 21, 30, 45, 60].map(d => (
<button
key={d}
onClick={() => setVisibleDays(d)}
className={cn(
'px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors',
visibleDays === d
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400',
)}
>
{d}д
</button>
))}
</div>
<button
onClick={() => {
const parsed = new Date(pickerDateInput)
if (!isNaN(parsed.getTime())) setStartDate(startOfDay(parsed))
setShowNavPicker(false)
}}
className="btn-primary w-full justify-center text-sm py-1.5"
>
Показать
</button>
</div>
)}
</div>
<button onClick={() => shiftDays(7)} className="btn-ghost p-2"><ChevronRight size={16} /></button>
</div>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 capitalize">
{format(startDate, 'LLLL yyyy', { locale: ru })}
</span>
@@ -146,21 +228,19 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
{/* Grid */}
<div className="flex-1 overflow-auto" ref={gridRef}>
<div style={{ minWidth: LABEL_WIDTH + days * CELL_WIDTH }}>
<div style={{ minWidth: LABEL_WIDTH + visibleDays * CELL_WIDTH }}>
{/* Date header row */}
<div
className="flex sticky top-0 z-20 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 shadow-sm"
style={{ height: 48 }}
>
{/* Room label header */}
<div
className="shrink-0 sticky left-0 z-30 bg-white dark:bg-slate-800 border-r border-slate-200 dark:border-slate-700 flex items-center px-3"
style={{ width: LABEL_WIDTH }}
>
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Номер</span>
</div>
{/* Date cells */}
{dates.map((date, i) => {
const isWe = date.getDay() === 0 || date.getDay() === 6
const isTod = isToday(date)
@@ -182,9 +262,7 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
</span>
<span className={cn(
'text-sm font-bold',
isTod
? 'text-brand-600 dark:text-brand-400'
: 'text-slate-700 dark:text-slate-200',
isTod ? 'text-brand-600 dark:text-brand-400' : 'text-slate-700 dark:text-slate-200',
)}>
{format(date, 'd')}
</span>
@@ -211,17 +289,10 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
>
<div>
<div className="flex items-center gap-1.5">
<span className="text-sm font-bold text-slate-900 dark:text-slate-100">
{room.number}
</span>
{room.name && (
<span className="text-xs text-slate-500 dark:text-slate-400">{room.name}</span>
)}
<span className="text-sm font-bold text-slate-900 dark:text-slate-100">{room.number}</span>
{room.name && <span className="text-xs text-slate-500 dark:text-slate-400">{room.name}</span>}
</div>
<span className={cn(
'text-xs px-1.5 py-0.5 rounded-md font-medium',
getRoomTypeColor(room.type),
)}>
<span className={cn('text-xs px-1.5 py-0.5 rounded-md font-medium', getRoomTypeColor(room.type))}>
{room.type}
</span>
</div>
@@ -232,7 +303,6 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
{/* Day cells + booking blocks */}
<div className="relative flex-1">
{/* Grid cells */}
<div className="flex h-full">
{dates.map((date, i) => {
const isWe = date.getDay() === 0 || date.getDay() === 6
@@ -258,12 +328,14 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
const style = getBlockStyle(booking)
if (!style) return null
const nights = differenceInDays(parseISO(booking.checkOut), parseISO(booking.checkIn))
const isFading = fadingBookingIds?.has(booking.id)
return (
<div
key={booking.id}
className={cn(
'booking-block border-l-4',
'booking-block border-l-4 transition-all duration-700',
BOOKING_STATUS_COLORS[booking.status],
isFading && 'opacity-0 scale-y-0',
)}
style={{
left: style.left + 2,