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,

View File

@@ -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">

View File

@@ -1,15 +1,16 @@
import { createContext, useContext, useState } from 'react'
import type { ModuleStatus } from '../data/modulesData'
// Default statuses for demo
const DEFAULT_STATUSES: Record<string, ModuleStatus> = {
'wifi-auth': 'active',
'payments': 'trial',
'tv-welcome': 'inactive',
'smart-locks': 'inactive',
'olap-reports': 'active', // active в демо — виден в сайдбаре
'website-builder':'inactive',
'booking-widget': 'inactive',
'housekeeping': 'active',
'channel-manager': 'active',
'wifi-auth': 'active',
'payments': 'trial',
'tv-welcome': 'inactive',
'smart-locks': 'inactive',
'olap-reports': 'active',
'website-builder': 'inactive',
'booking-widget': 'inactive',
}
interface ModulesContextValue {

View File

@@ -1,6 +1,6 @@
import {
Wifi, CreditCard, Tv2, KeyRound,
BarChart3, Globe, CalendarCheck2,
BarChart3, Globe, CalendarCheck2, Sparkles, Network,
} from 'lucide-react'
import type { ElementType } from 'react'
@@ -10,6 +10,7 @@ export interface ModuleSidebarItem {
path: string
label: string
icon: ElementType
showForHousekeeper?: boolean
}
export interface ModuleDef {
@@ -26,11 +27,63 @@ export interface ModuleDef {
features: string[]
stats?: { label: string; value: string }[]
badge?: string
/** Если задан — при активации модуля этот пункт появляется в сайдбаре */
sidebarItem?: ModuleSidebarItem
}
export const MODULES_DATA: ModuleDef[] = [
{
id: 'housekeeping',
name: 'Управление уборкой',
tagline: 'Расписание горничных и статус номеров',
description:
'Управление задачами уборки в реальном времени. Расписание горничных, статусы номеров, уведомления при заезде и выезде. Мобильный интерфейс для персонала.',
icon: Sparkles,
iconBg: 'bg-emerald-100 dark:bg-emerald-900/40',
iconColor: 'text-emerald-600 dark:text-emerald-400',
accentColor: 'bg-emerald-500',
price: 0,
badge: 'Входит в тариф',
features: [
'Список задач уборки по номерам',
'Статусы: грязный / убирается / чистый / проверено',
'Назначение горничных на комнаты',
'Уведомление о заезде и выезде',
'Мобильный интерфейс для горничных',
'Отчёт по выполненным уборкам',
],
sidebarItem: {
path: '/housekeeping',
label: 'Уборка',
icon: Sparkles,
showForHousekeeper: true,
},
},
{
id: 'channel-manager',
name: 'Менеджер каналов',
tagline: 'Синхронизация с OTA-платформами',
description:
'Управление ценами и доступностью на всех OTA одновременно. Booking.com, Airbnb, Яндекс Путешествия, Островок, Суточно.ру, OneTwoTrip и другие платформы.',
icon: Network,
iconBg: 'bg-sky-100 dark:bg-sky-900/40',
iconColor: 'text-sky-600 dark:text-sky-400',
accentColor: 'bg-sky-500',
price: 2500,
features: [
'Booking.com, Airbnb, Expedia',
'Яндекс Путешествия, Островок',
'Суточно.ру, OneTwoTrip',
'Синхронизация цен и доступности в реальном времени',
'Автоматическое закрытие дат при заполнении',
'Маппинг номеров по каналам',
'Уведомления об ошибках синхронизации',
],
sidebarItem: {
path: '/channels',
label: 'Каналы',
icon: Network,
},
},
{
id: 'wifi-auth',
name: 'Wi-Fi Авторизация',

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Search, Filter, Plus, ArrowUpDown } from 'lucide-react'
import { useState, useMemo } from 'react'
import { Search, Plus, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react'
import { MOCK_BOOKINGS, MOCK_ROOMS } from '../data/mockData'
import type { Booking, BookingStatus } from '../types'
import { cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS, SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount } from '../lib/utils'
@@ -17,12 +17,32 @@ const STATUS_FILTERS: { label: string; value: BookingStatus | 'all' }[] = [
{ label: 'Отменены', value: 'cancelled' },
]
type SortKey = 'guestName' | 'checkIn' | 'checkOut' | 'status' | 'source' | 'totalAmount'
const COLUMNS: { key: SortKey | null; label: string }[] = [
{ key: 'guestName', label: 'Гость' },
{ key: null, label: 'Номер' },
{ key: 'checkIn', label: 'Заезд' },
{ key: 'checkOut', label: 'Выезд' },
{ key: 'status', label: 'Статус' },
{ key: 'source', label: 'Источник' },
{ key: 'totalAmount', label: 'Сумма' },
{ key: null, label: '' },
]
export function BookingsPage() {
const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<BookingStatus | 'all'>('all')
const [selected, setSelected] = useState<Booking | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [sortKey, setSortKey] = useState<SortKey | null>(null)
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
const handleSort = (key: SortKey) => {
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
else { setSortKey(key); setSortDir('asc') }
}
const filtered = bookings.filter(b => {
const matchSearch = search === '' ||
@@ -33,6 +53,21 @@ export function BookingsPage() {
return matchSearch && matchStatus
})
const sorted = useMemo(() => {
if (!sortKey) return filtered
return [...filtered].sort((a, b) => {
const av = a[sortKey]
const bv = b[sortKey]
let cmp = 0
if (typeof av === 'string' && typeof bv === 'string') {
cmp = av.localeCompare(bv, 'ru')
} else {
cmp = (av as number) - (bv as number)
}
return sortDir === 'asc' ? cmp : -cmp
})
}, [filtered, sortKey, sortDir])
const room = (id: string) => MOCK_ROOMS.find(r => r.id === id)
return (
@@ -41,7 +76,7 @@ export function BookingsPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Бронирования</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">{filtered.length} из {bookings.length}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">{sorted.length} из {bookings.length}</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
@@ -88,15 +123,31 @@ export function BookingsPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-700/40">
{['Гость', 'Номер', 'Заезд', 'Выезд', 'Статус', 'Источник', 'Сумма', ''].map(h => (
<th key={h} className="text-left px-4 py-3 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
{h}
{COLUMNS.map(col => (
<th
key={col.label}
className="text-left px-4 py-3 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide"
>
{col.key ? (
<button
onClick={() => handleSort(col.key!)}
className="flex items-center gap-1 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
>
{col.label}
{sortKey === col.key
? sortDir === 'asc'
? <ArrowUp size={12} className="text-brand-600" />
: <ArrowDown size={12} className="text-brand-600" />
: <ArrowUpDown size={12} className="opacity-30" />
}
</button>
) : col.label}
</th>
))}
</tr>
</thead>
<tbody>
{filtered.map(b => {
{sorted.map(b => {
const r = room(b.roomId)
const nights = nightsCount(b.checkIn, b.checkOut)
return (
@@ -152,7 +203,7 @@ export function BookingsPage() {
})}
</tbody>
</table>
{filtered.length === 0 && (
{sorted.length === 0 && (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
Бронирования не найдены
</div>

View File

@@ -5,6 +5,7 @@ import type { Booking } from '../types'
export function CalendarPage() {
const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS)
const [fadingBookings, setFadingBookings] = useState<Set<string>>(new Set())
const handleCreate = (data: Partial<Booking>) => {
setBookings(prev => [...prev, data as Booking])
@@ -12,6 +13,17 @@ export function CalendarPage() {
const handleUpdate = (id: string, data: Partial<Booking>) => {
setBookings(prev => prev.map(b => b.id === id ? { ...b, ...data } : b))
if (data.status === 'cancelled') {
setFadingBookings(prev => new Set([...prev, id]))
setTimeout(() => {
setBookings(prev => prev.filter(b => b.id !== id))
setFadingBookings(prev => {
const next = new Set(prev)
next.delete(id)
return next
})
}, 900)
}
}
return (
@@ -30,6 +42,7 @@ export function CalendarPage() {
bookings={bookings}
onBookingCreate={handleCreate}
onBookingUpdate={handleUpdate}
fadingBookingIds={fadingBookings}
/>
</div>
</div>

View File

@@ -5,10 +5,10 @@ import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../contexts/ThemeContext'
import { cn } from '../lib/utils'
const DEMO_ACCOUNTS = [
{ label: 'Менеджер отеля', email: 'manager@grand-palace.ru', role: 'hotel_manager' },
{ label: 'Горничная', email: 'cleaner@grand-palace.ru', role: 'housekeeper' },
{ label: 'Супер-администратор', email: 'admin@hotelsync.io', role: 'super_admin' },
const DEMO_EMAILS = [
'manager@grand-palace.ru',
'cleaner@grand-palace.ru',
'admin@hotelsync.io',
]
export function LoginPage() {
@@ -16,8 +16,8 @@ export function LoginPage() {
const { theme, toggle } = useTheme()
const navigate = useNavigate()
const [email, setEmail] = useState('manager@grand-palace.ru')
const [password, setPassword] = useState('demo')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPass, setShowPass] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
@@ -41,12 +41,6 @@ export function LoginPage() {
else navigate('/calendar')
}
const fillDemo = (acc: typeof DEMO_ACCOUNTS[number]) => {
setEmail(acc.email)
setPassword('demo')
setError('')
}
return (
<div className="min-h-screen bg-gradient-to-br from-brand-50 via-white to-slate-100 dark:from-slate-900 dark:via-slate-900 dark:to-brand-950 flex">
{/* Left panel — branding */}
@@ -88,7 +82,6 @@ export function LoginPage() {
{/* Right panel — login form */}
<div className="flex-1 flex flex-col">
{/* Theme toggle */}
<div className="flex justify-end p-4">
<button onClick={toggle} className="btn-ghost p-2">
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
@@ -114,29 +107,6 @@ export function LoginPage() {
Войдите в свой аккаунт для доступа к панели управления
</p>
{/* Demo account chips */}
<div className="mb-5">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">
Демо-аккаунты:
</p>
<div className="flex flex-wrap gap-2">
{DEMO_ACCOUNTS.map(acc => (
<button
key={acc.email}
onClick={() => fillDemo(acc)}
className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
email === acc.email
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
)}
>
{acc.label}
</button>
))}
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
@@ -194,9 +164,28 @@ export function LoginPage() {
</button>
</form>
<p className="text-center text-xs text-slate-400 dark:text-slate-500 mt-6">
Пароль для демо: <code className="bg-slate-100 dark:bg-slate-700 px-1.5 py-0.5 rounded">demo</code>
</p>
{/* Demo hint */}
<div className="mt-6 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">
Демо-аккаунты (пароль: <code className="bg-slate-100 dark:bg-slate-700 px-1 rounded">demo</code>):
</p>
<div className="flex flex-col gap-1">
{DEMO_EMAILS.map(e => (
<button
key={e}
onClick={() => { setEmail(e); setPassword('demo'); setError('') }}
className={cn(
'text-left text-xs px-2 py-1 rounded transition-colors',
email === e
? 'text-brand-700 dark:text-brand-300 bg-brand-50 dark:bg-brand-900/30 font-medium'
: 'text-slate-500 dark:text-slate-400 hover:text-brand-600 dark:hover:text-brand-400',
)}
>
{e}
</button>
))}
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Save, Building2, Bell, Shield, Globe, CreditCard } from 'lucide-react'
import { Save, Building2, Bell, Shield, Globe, CreditCard, BedDouble, Mail, MessageSquare } from 'lucide-react'
import { MOCK_HOTELS } from '../data/mockData'
import { cn, PLAN_COLORS, PLAN_LABELS } from '../lib/utils'
import { Badge } from '../components/ui/Badge'
@@ -7,12 +7,24 @@ import { useTheme } from '../contexts/ThemeContext'
const SECTIONS = [
{ id: 'general', label: 'Основные', icon: Building2 },
{ id: 'booking', label: 'Бронирование', icon: BedDouble },
{ id: 'theme', label: 'Внешний вид', icon: Globe },
{ id: 'notify', label: 'Уведомления', icon: Bell },
{ id: 'security', label: 'Безопасность', icon: Shield },
{ id: 'billing', label: 'Тарифный план', icon: CreditCard },
]
function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
return (
<button
onClick={onChange}
className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', on ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
>
<div className={cn('absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-sm transition-transform', on ? 'left-[22px]' : 'left-0.5')} />
</button>
)
}
export function SettingsPage() {
const [section, setSection] = useState('general')
const [saved, setSaved] = useState(false)
@@ -28,6 +40,32 @@ export function SettingsPage() {
checkOutTime: '12:00',
})
// Booking / assignment settings
const [assignmentStrategy, setAssignmentStrategy] = useState<'spread' | 'together' | 'sequential' | 'manual'>('spread')
// Notification toggles
const [notifyToggles, setNotifyToggles] = useState({
newBooking: true,
cancellation: true,
channelError: true,
dailyReport: false,
})
// SMTP settings
const [smtp, setSmtp] = useState({
host: '',
port: '587',
user: '',
password: '',
fromEmail: '',
fromName: '',
})
// SMS settings
const [smsProvider, setSmsProvider] = useState('')
const [smsApiKey, setSmsApiKey] = useState('')
const [smsSender, setSmsSender] = useState('')
const handleSave = () => {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
@@ -65,17 +103,15 @@ export function SettingsPage() {
{/* Mobile nav */}
<div className="md:hidden w-full mb-4">
<select
value={section}
onChange={e => setSection(e.target.value)}
className="input"
>
<select value={section} onChange={e => setSection(e.target.value)} className="input">
{SECTIONS.map(s => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
</div>
{/* Content */}
<div className="flex-1 card p-5 space-y-5">
{/* ── GENERAL ── */}
{section === 'general' && (
<>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Основная информация</h2>
@@ -120,6 +156,75 @@ export function SettingsPage() {
</>
)}
{/* ── BOOKING ── */}
{section === 'booking' && (
<>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Настройки бронирования</h2>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Стратегия автоматического расселения
</label>
<p className="text-xs text-slate-400 mb-3">
Как система выбирает номер при автоматическом бронировании (кнопка «Забронировать» без выбора номера вручную)
</p>
<div className="space-y-2">
{([
{
id: 'spread',
label: 'Разброс (шахматный порядок)',
desc: 'Максимальное расстояние между гостями — номера заполняются через один. Рекомендуется для большинства отелей.',
},
{
id: 'together',
label: 'Рядом',
desc: 'Соседние номера подряд. Удобно для семей и групп, которые хотят быть близко.',
},
{
id: 'sequential',
label: 'Последовательно',
desc: 'Следующий свободный номер в порядке нумерации. Упрощает навигацию персонала.',
},
{
id: 'manual',
label: 'Вручную',
desc: 'Менеджер всегда сам выбирает номер при создании брони. Автоподбора нет.',
},
] as const).map(opt => (
<label
key={opt.id}
className={cn(
'flex items-start gap-3 p-3.5 rounded-xl border-2 cursor-pointer transition-all',
assignmentStrategy === opt.id
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
: 'border-slate-200 dark:border-slate-600 hover:border-slate-300 dark:hover:border-slate-500',
)}
>
<input
type="radio"
name="assignment"
value={opt.id}
checked={assignmentStrategy === opt.id}
onChange={() => setAssignmentStrategy(opt.id)}
className="mt-0.5 accent-brand-600"
/>
<div>
<p className={cn(
'text-sm font-medium',
assignmentStrategy === opt.id ? 'text-brand-700 dark:text-brand-300' : 'text-slate-900 dark:text-slate-100',
)}>
{opt.label}
</p>
<p className="text-xs text-slate-400 mt-0.5">{opt.desc}</p>
</div>
</label>
))}
</div>
</div>
</>
)}
{/* ── THEME ── */}
{section === 'theme' && (
<>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Внешний вид</h2>
@@ -128,17 +233,15 @@ export function SettingsPage() {
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">Тема интерфейса</p>
<div className="grid grid-cols-2 gap-3">
{([
{ id: 'light', label: 'Светлая', preview: 'bg-white border-2' },
{ id: 'dark', label: 'Тёмная', preview: 'bg-slate-900 border-2' },
{ id: 'light', label: 'Светлая' },
{ id: 'dark', label: 'Тёмная' },
] as const).map(t => (
<button
key={t.id}
onClick={() => { if (theme !== t.id) toggle() }}
className={cn(
'p-4 rounded-xl border-2 transition-all text-left',
theme === t.id
? 'border-brand-500'
: 'border-slate-200 dark:border-slate-600 hover:border-slate-300',
theme === t.id ? 'border-brand-500' : 'border-slate-200 dark:border-slate-600 hover:border-slate-300',
)}
>
<div className={cn('w-full h-14 rounded-lg mb-2', t.id === 'light' ? 'bg-white border border-slate-200' : 'bg-slate-800')}>
@@ -146,9 +249,7 @@ export function SettingsPage() {
<div className={cn('h-2 w-1/2 rounded mx-2 mt-1', t.id === 'light' ? 'bg-slate-100' : 'bg-slate-700')} />
</div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{t.label}</p>
{theme === t.id && (
<p className="text-xs text-brand-600 dark:text-brand-400">Активна</p>
)}
{theme === t.id && <p className="text-xs text-brand-600 dark:text-brand-400">Активна</p>}
</button>
))}
</div>
@@ -157,30 +258,112 @@ export function SettingsPage() {
</>
)}
{/* ── NOTIFICATIONS ── */}
{section === 'notify' && (
<>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Уведомления</h2>
<div className="space-y-3">
{[
{ label: 'Новые бронирования', sub: 'Email при создании нового бронирования' },
{ label: 'Отмены', sub: 'Email при отмене бронирования' },
{ label: 'Ошибки синхронизации каналов', sub: 'Уведомление при сбое синхронизации' },
{ label: 'Ежедневный отчёт', sub: 'Сводка загрузки на день в 8:00' },
].map((n, i) => (
<div key={i} className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-700/40">
{/* Toggles */}
<div className="space-y-2">
{([
{ key: 'newBooking' as const, label: 'Новые бронирования', sub: 'При создании нового бронирования' },
{ key: 'cancellation' as const, label: 'Отмены', sub: 'При отмене бронирования' },
{ key: 'channelError' as const, label: 'Ошибки синхронизации каналов', sub: 'При сбое синхронизации с OTA' },
{ key: 'dailyReport' as const, label: 'Ежедневный отчёт', sub: 'Сводка загрузки на день в 8:00' },
] as const).map(n => (
<div key={n.key} className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-700/40">
<div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{n.label}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{n.sub}</p>
</div>
<button className="relative w-11 h-6 rounded-full bg-brand-600">
<div className="absolute top-0.5 left-[22px] w-5 h-5 rounded-full bg-white shadow-sm" />
</button>
<Toggle on={notifyToggles[n.key]} onChange={() => setNotifyToggles(p => ({ ...p, [n.key]: !p[n.key] }))} />
</div>
))}
</div>
{/* SMTP */}
<div className="pt-2 border-t border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-2 mb-1">
<Mail size={15} className="text-slate-500" />
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">Email-уведомления (SMTP)</h3>
</div>
<p className="text-xs text-slate-400 mb-3">
Если не заполнено письма отправляются с ящика HotelSync (<code className="bg-slate-100 dark:bg-slate-700 px-1 rounded">noreply@hotelsync.ru</code>)
</p>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">SMTP-хост</label>
<input type="text" className="input text-sm" placeholder="smtp.gmail.com" value={smtp.host} onChange={e => setSmtp(p => ({ ...p, host: e.target.value }))} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Порт</label>
<input type="number" className="input text-sm" placeholder="587" value={smtp.port} onChange={e => setSmtp(p => ({ ...p, port: e.target.value }))} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Пользователь</label>
<input type="text" className="input text-sm" placeholder="user@gmail.com" value={smtp.user} onChange={e => setSmtp(p => ({ ...p, user: e.target.value }))} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Пароль</label>
<input type="password" className="input text-sm" placeholder="••••••••" value={smtp.password} onChange={e => setSmtp(p => ({ ...p, password: e.target.value }))} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Email отправителя</label>
<input type="email" className="input text-sm" placeholder="hotel@myhotel.ru" value={smtp.fromEmail} onChange={e => setSmtp(p => ({ ...p, fromEmail: e.target.value }))} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Имя отправителя</label>
<input type="text" className="input text-sm" placeholder="Grand Palace Hotel" value={smtp.fromName} onChange={e => setSmtp(p => ({ ...p, fromName: e.target.value }))} />
</div>
</div>
</div>
</div>
{/* SMS */}
<div className="pt-2 border-t border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-2 mb-1">
<MessageSquare size={15} className="text-slate-500" />
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">SMS-уведомления</h3>
</div>
<p className="text-xs text-slate-400 mb-3">
SMS отправляются гостям при подтверждении брони, заезде и выезде
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">SMS-провайдер</label>
<select className="input text-sm" value={smsProvider} onChange={e => setSmsProvider(e.target.value)}>
<option value="">Не настроено</option>
<option value="smsc">SMSC.ru</option>
<option value="smsru">SMS.ru</option>
<option value="mts">МТС Коммуникатор</option>
<option value="beeline">Beeline Business</option>
<option value="smsaero">SMS Aero</option>
</select>
</div>
{smsProvider && (
<>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">API-ключ</label>
<input type="password" className="input text-sm" placeholder="Ваш API-ключ" value={smsApiKey} onChange={e => setSmsApiKey(e.target.value)} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Имя отправителя</label>
<input type="text" className="input text-sm" placeholder="MYHOTEL" maxLength={11} value={smsSender} onChange={e => setSmsSender(e.target.value)} />
<p className="text-xs text-slate-400 mt-1">Латиницей, до 11 символов. Требует регистрации у провайдера.</p>
</div>
</>
)}
</div>
</div>
</>
)}
{/* ── SECURITY ── */}
{section === 'security' && (
<>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Безопасность</h2>
@@ -201,6 +384,7 @@ export function SettingsPage() {
</>
)}
{/* ── BILLING ── */}
{section === 'billing' && (
<>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Тарифный план</h2>
@@ -208,18 +392,16 @@ export function SettingsPage() {
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">Текущий план</p>
<div className="flex items-center gap-2 mt-1">
<Badge className={PLAN_COLORS[hotel.plan]}>
{PLAN_LABELS[hotel.plan]}
</Badge>
<Badge className={PLAN_COLORS[hotel.plan]}>{PLAN_LABELS[hotel.plan]}</Badge>
</div>
</div>
<button className="btn-primary">Улучшить план</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{([
{ plan: 'starter', price: '990 ₽/мес', rooms: '10 номеров', channels: '1 канал', support: 'Email' },
{ plan: 'pro', price: '3 490 ₽/мес', rooms: 'До 50 номеров', channels: '5 каналов', support: 'Чат + Email' },
{ plan: 'enterprise', price: 'Договорная', rooms: 'Неограничено', channels: 'Все каналы', support: 'Выделенный менеджер' },
{ plan: 'starter', price: '990 ₽/мес', rooms: '10 номеров', channels: '1 канал', support: 'Email' },
{ plan: 'pro', price: '3 490 ₽/мес', rooms: 'До 50 номеров', channels: '5 каналов', support: 'Чат + Email' },
{ plan: 'enterprise', price: 'Договорная', rooms: 'Неограничено', channels: 'Все каналы', support: 'Выделенный менеджер' },
] as const).map(p => (
<div key={p.plan} className={cn(
'p-4 rounded-xl border-2 transition-all',