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:
@@ -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 (Делюкс) на 14–18 марта',
|
||||
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 на 20–22 марта',
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user