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:
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
CalendarDays, BookOpen, BedDouble, Sparkles, Globe, Settings,
|
||||
CalendarDays, BookOpen, BedDouble, Globe, Settings,
|
||||
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
@@ -41,9 +41,17 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
const isAdmin = user?.role === 'super_admin'
|
||||
const isHousekeeper = user?.role === 'housekeeper'
|
||||
|
||||
// Активные модули у которых есть sidebarItem
|
||||
const isModuleActive = (id: string) => statuses[id] === 'active' || statuses[id] === 'trial'
|
||||
|
||||
// Core modules with dedicated nav positions (not shown in generic module list)
|
||||
const CORE_MODULE_IDS = ['housekeeping', 'channel-manager']
|
||||
|
||||
// Extra module items for the "Модули" sidebar section (excluding core ones)
|
||||
const activeModuleItems = MODULES_DATA.filter(
|
||||
m => m.sidebarItem && (statuses[m.id] === 'active' || statuses[m.id] === 'trial'),
|
||||
m => m.sidebarItem &&
|
||||
!m.sidebarItem.showForHousekeeper &&
|
||||
!CORE_MODULE_IDS.includes(m.id) &&
|
||||
isModuleActive(m.id),
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -98,33 +106,48 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
<p className="px-3 pt-2 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Администрирование
|
||||
</p>
|
||||
<NavItem to="/admin" icon={LayoutDashboard} label="Дашборд" onClick={onClose} />
|
||||
<NavItem to="/admin/hotels" icon={Building2} label="Отели" onClick={onClose} />
|
||||
<NavItem to="/admin/users" icon={Users} label="Пользователи" onClick={onClose} />
|
||||
<NavItem to="/admin" icon={LayoutDashboard} label="Дашборд" onClick={onClose} />
|
||||
<NavItem to="/admin/hotels" icon={Building2} label="Отели" onClick={onClose} />
|
||||
<NavItem to="/admin/users" icon={Users} label="Пользователи" onClick={onClose} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="px-3 pt-2 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Основное
|
||||
</p>
|
||||
<NavItem to="/calendar" icon={CalendarDays} label="Шахматка" onClick={onClose} />
|
||||
<NavItem to="/calendar" icon={CalendarDays} label="Шахматка" onClick={onClose} />
|
||||
{!isHousekeeper && (
|
||||
<NavItem to="/bookings" icon={BookOpen} label="Бронирования" onClick={onClose} />
|
||||
<NavItem to="/bookings" icon={BookOpen} label="Бронирования" onClick={onClose} />
|
||||
)}
|
||||
{/* Housekeeping — controlled by module status, visible to all roles */}
|
||||
{isModuleActive('housekeeping') && (
|
||||
<NavItem
|
||||
to="/housekeeping"
|
||||
icon={MODULES_DATA.find(m => m.id === 'housekeeping')!.sidebarItem!.icon}
|
||||
label="Уборка"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
<NavItem to="/housekeeping" icon={Sparkles} label="Уборка" onClick={onClose} />
|
||||
|
||||
{!isHousekeeper && (
|
||||
<>
|
||||
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Управление
|
||||
</p>
|
||||
<NavItem to="/rooms" icon={BedDouble} label="Номера" onClick={onClose} />
|
||||
<NavItem to="/availability" icon={CalendarRange} label="Доступность" onClick={onClose} />
|
||||
<NavItem to="/channels" icon={Globe} label="Каналы" onClick={onClose} />
|
||||
<NavItem to="/modules" icon={Puzzle} label="Модули" onClick={onClose} />
|
||||
<NavItem to="/settings" icon={Settings} label="Настройки" onClick={onClose} />
|
||||
<NavItem to="/rooms" icon={BedDouble} label="Номера" onClick={onClose} />
|
||||
<NavItem to="/availability" icon={CalendarRange} label="Доступность" onClick={onClose} />
|
||||
{isModuleActive('channel-manager') && (
|
||||
<NavItem
|
||||
to="/channels"
|
||||
icon={MODULES_DATA.find(m => m.id === 'channel-manager')!.sidebarItem!.icon}
|
||||
label="Каналы"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
<NavItem to="/modules" icon={Puzzle} label="Модули" onClick={onClose} />
|
||||
<NavItem to="/settings" icon={Settings} label="Настройки" onClick={onClose} />
|
||||
|
||||
{/* Активные модули с разделами */}
|
||||
{/* Active addon modules */}
|
||||
{activeModuleItems.length > 0 && (
|
||||
<>
|
||||
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
|
||||
Reference in New Issue
Block a user