Add maintenance severity levels, notifications context, and widget payment step

- HousekeepingPage: 3-level severity for maintenance reports (Не срочно / Средняя / Экстренно!); Экстренно automatically blocks the room and triggers an urgent notification; severity displayed as colored badge on task card
- NotificationsContext: extract shared notifications state from Topbar into context so any page can push notifications; add 'maintenance' type with wrench icon
- Topbar: migrate to NotificationsContext; add maintenance notification type with red Wrench icon
- BookingWidgetPage: add payment step after guest form when payment provider is connected (card number/expiry/CVV/name form); Без оплаты mode skips payment and shows "pay on site" message; hourly services (unit='ч') show date + time-from/time-to picker in form step

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 16:04:19 +03:00
parent f62ccdd3f7
commit c431ea1486
6 changed files with 461 additions and 157 deletions

View File

@@ -1,7 +1,7 @@
import { Sun, Moon, Bell, LogOut, ChevronDown, Menu,
BookOpen, X, CheckCheck, CalendarCheck2, CalendarX2,
Sparkles, AlertTriangle, Star, CreditCard, Info,
ArrowRight,
ArrowRight, Wrench,
} from 'lucide-react'
import { useTheme } from '../../contexts/ThemeContext'
import { useAuth } from '../../contexts/AuthContext'
@@ -9,97 +9,7 @@ import { ROLE_LABELS } from '../../lib/utils'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { cn } from '../../lib/utils'
// ── Notification types ─────────────────────────────────────────────────────────
type NotifType =
| 'booking_new'
| 'booking_cancelled'
| 'booking_checkin'
| 'booking_checkout'
| 'housekeeping'
| 'channel_error'
| 'payment'
| 'review'
| 'system'
interface Notification {
id: string
type: NotifType
title: string
body: string
time: string // ISO string
isRead: boolean
link?: string
}
// ── Mock data ──────────────────────────────────────────────────────────────────
const MOCK_NOTIFICATIONS: Notification[] = [
{
id: 'n1', type: 'booking_new',
title: 'Новое бронирование',
body: 'Алексей Смирнов забронировал номер 205 (Делюкс) на 1418 марта',
time: new Date(Date.now() - 4 * 60000).toISOString(),
isRead: false, link: '/bookings',
},
{
id: 'n2', type: 'channel_error',
title: 'Ошибка синхронизации',
body: 'Booking.com: не удалось обновить доступность. Проверьте подключение.',
time: new Date(Date.now() - 18 * 60000).toISOString(),
isRead: false, link: '/channels',
},
{
id: 'n3', type: 'booking_checkin',
title: 'Заезд сегодня',
body: 'Мария Иванова · номер 101 · заезд в 14:00',
time: new Date(Date.now() - 45 * 60000).toISOString(),
isRead: false, link: '/bookings',
},
{
id: 'n4', type: 'review',
title: 'Новый отзыв требует модерации',
body: 'Гость оставил оценку 2/5 — отзыв ждёт вашего ответа',
time: new Date(Date.now() - 2 * 3600000).toISOString(),
isRead: false, link: '/reviews',
},
{
id: 'n5', type: 'housekeeping',
title: 'Уборка завершена',
body: 'Горничная Козлова Н. завершила уборку номеров 101, 102, 203',
time: new Date(Date.now() - 3 * 3600000).toISOString(),
isRead: true, link: '/housekeeping',
},
{
id: 'n6', type: 'payment',
title: 'Оплата получена',
body: 'Иван Петров оплатил бронирование #B-2847 — 12 400 ₽',
time: new Date(Date.now() - 5 * 3600000).toISOString(),
isRead: true,
},
{
id: 'n7', type: 'booking_cancelled',
title: 'Бронирование отменено',
body: 'Дмитрий Волков отменил бронь номера 304 на 2022 марта',
time: new Date(Date.now() - 26 * 3600000).toISOString(),
isRead: true, link: '/bookings',
},
{
id: 'n8', type: 'booking_checkout',
title: 'Выезд завершён',
body: 'Семья Соколовых выехала из номера 206. Нужна уборка.',
time: new Date(Date.now() - 28 * 3600000).toISOString(),
isRead: true, link: '/housekeeping',
},
{
id: 'n9', type: 'system',
title: 'Обновление системы',
body: 'HotelSync обновлён до версии 0.2.0. Что нового — в журнале изменений.',
time: new Date(Date.now() - 3 * 86400000).toISOString(),
isRead: true,
},
]
import { useNotifications, type NotifType, type Notification } from '../../contexts/NotificationsContext'
// ── Helpers ────────────────────────────────────────────────────────────────────
@@ -108,15 +18,16 @@ const NOTIF_META: Record<NotifType, {
iconBg: string
iconColor: string
}> = {
booking_new: { icon: BookOpen, iconBg: 'bg-brand-100 dark:bg-brand-900/40', iconColor: 'text-brand-600 dark:text-brand-400' },
booking_cancelled: { icon: CalendarX2, iconBg: 'bg-red-100 dark:bg-red-900/30', iconColor: 'text-red-600 dark:text-red-400' },
booking_new: { icon: BookOpen, iconBg: 'bg-brand-100 dark:bg-brand-900/40', iconColor: 'text-brand-600 dark:text-brand-400' },
booking_cancelled: { icon: CalendarX2, iconBg: 'bg-red-100 dark:bg-red-900/30', iconColor: 'text-red-600 dark:text-red-400' },
booking_checkin: { icon: CalendarCheck2, iconBg: 'bg-emerald-100 dark:bg-emerald-900/30', iconColor: 'text-emerald-600 dark:text-emerald-400' },
booking_checkout: { icon: ArrowRight, iconBg: 'bg-slate-100 dark:bg-slate-700', iconColor: 'text-slate-600 dark:text-slate-400' },
housekeeping: { icon: Sparkles, iconBg: 'bg-sky-100 dark:bg-sky-900/30', iconColor: 'text-sky-600 dark:text-sky-400' },
channel_error: { icon: AlertTriangle, iconBg: 'bg-orange-100 dark:bg-orange-900/30', iconColor: 'text-orange-600 dark:text-orange-400' },
payment: { icon: CreditCard, iconBg: 'bg-violet-100 dark:bg-violet-900/30', iconColor: 'text-violet-600 dark:text-violet-400' },
review: { icon: Star, iconBg: 'bg-yellow-100 dark:bg-yellow-900/30', iconColor: 'text-yellow-600 dark:text-yellow-400' },
system: { icon: Info, iconBg: 'bg-slate-100 dark:bg-slate-700', iconColor: 'text-slate-500 dark:text-slate-400' },
booking_checkout: { icon: ArrowRight, iconBg: 'bg-slate-100 dark:bg-slate-700', iconColor: 'text-slate-600 dark:text-slate-400' },
housekeeping: { icon: Sparkles, iconBg: 'bg-sky-100 dark:bg-sky-900/30', iconColor: 'text-sky-600 dark:text-sky-400' },
channel_error: { icon: AlertTriangle, iconBg: 'bg-orange-100 dark:bg-orange-900/30', iconColor: 'text-orange-600 dark:text-orange-400' },
payment: { icon: CreditCard, iconBg: 'bg-violet-100 dark:bg-violet-900/30', iconColor: 'text-violet-600 dark:text-violet-400' },
review: { icon: Star, iconBg: 'bg-yellow-100 dark:bg-yellow-900/30', iconColor: 'text-yellow-600 dark:text-yellow-400' },
system: { icon: Info, iconBg: 'bg-slate-100 dark:bg-slate-700', iconColor: 'text-slate-500 dark:text-slate-400' },
maintenance: { icon: Wrench, iconBg: 'bg-red-100 dark:bg-red-900/30', iconColor: 'text-red-600 dark:text-red-400' },
}
function relativeTime(iso: string): string {
@@ -132,20 +43,11 @@ function relativeTime(iso: string): string {
function NotificationsPanel({ onClose }: { onClose: () => void }) {
const navigate = useNavigate()
const [notifications, setNotifications] = useState<Notification[]>(MOCK_NOTIFICATIONS)
const { notifications, markRead, markAllRead, removeNotif } = useNotifications()
const [tab, setTab] = useState<'all' | 'unread'>('all')
const unreadCount = notifications.filter(n => !n.isRead).length
const markRead = (id: string) =>
setNotifications(prev => prev.map(n => n.id === id ? { ...n, isRead: true } : n))
const markAllRead = () =>
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })))
const removeNotif = (id: string) =>
setNotifications(prev => prev.filter(n => n.id !== id))
const handleClick = (n: Notification) => {
markRead(n.id)
if (n.link) { navigate(n.link); onClose() }
@@ -305,10 +207,10 @@ export function Topbar({ onMenuToggle, title }: TopbarProps) {
const { theme, toggle } = useTheme()
const { user, logout } = useAuth()
const navigate = useNavigate()
const { notifications } = useNotifications()
const [userMenuOpen, setUserMenuOpen] = useState(false)
const [notifPanelOpen, setNotifPanelOpen] = useState(false)
const [notifications, setNotifications] = useState<Notification[]>(MOCK_NOTIFICATIONS)
const [userMenuOpen, setUserMenuOpen] = useState(false)
const [notifPanelOpen, setNotifPanelOpen] = useState(false)
const unreadCount = notifications.filter(n => !n.isRead).length
@@ -348,9 +250,7 @@ export function Topbar({ onMenuToggle, title }: TopbarProps) {
<>
<div className="fixed inset-0 z-10" onClick={() => setNotifPanelOpen(false)} />
<div className="relative z-20">
<NotificationsPanel
onClose={() => setNotifPanelOpen(false)}
/>
<NotificationsPanel onClose={() => setNotifPanelOpen(false)} />
</div>
</>
)}