From 39aeafe276dcd05631e585cd9dddaf79d6541602 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Wed, 11 Mar 2026 22:55:25 +0300 Subject: [PATCH] Add notifications panel to topbar bell icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Click bell → dropdown panel (396px wide, scrollable, max-height ~100vh) - Unread count badge on bell (shows 9+ when >9) - Two tabs: «Все» and «Непрочитанные» with counts - Notification types with distinct icons+colors: booking_new, booking_cancelled, booking_checkin, booking_checkout, housekeeping, channel_error, payment, review, system - Unread items: highlighted background + left blue dot + bold title - «Прочитать все» button when there are unread items - Per-item: click marks as read and navigates to linked page; hover shows × to dismiss; «Перейти» hint on hover - relativeTime helper: только что / N мин. назад / N ч. назад / вчера / дата - Empty state for both tabs - Closes when clicking backdrop or opening user menu Co-Authored-By: Claude Sonnet 4.6 --- src/components/layout/Topbar.tsx | 350 +++++++++++++++++++++++++++++-- 1 file changed, 328 insertions(+), 22 deletions(-) diff --git a/src/components/layout/Topbar.tsx b/src/components/layout/Topbar.tsx index 1912415..4e26ecb 100644 --- a/src/components/layout/Topbar.tsx +++ b/src/components/layout/Topbar.tsx @@ -1,9 +1,300 @@ -import { Sun, Moon, Bell, LogOut, ChevronDown, Menu } from 'lucide-react' +import { Sun, Moon, Bell, LogOut, ChevronDown, Menu, + BookOpen, X, CheckCheck, CalendarCheck2, CalendarX2, + Sparkles, AlertTriangle, Star, CreditCard, Info, + ArrowRight, +} from 'lucide-react' import { useTheme } from '../../contexts/ThemeContext' import { useAuth } from '../../contexts/AuthContext' 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, + }, +] + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const NOTIF_META: Record = { + 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' }, +} + +function relativeTime(iso: string): string { + const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000) + if (diff < 60) return 'только что' + if (diff < 3600) return `${Math.floor(diff / 60)} мин. назад` + if (diff < 86400) return `${Math.floor(diff / 3600)} ч. назад` + if (diff < 172800) return 'вчера' + return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) +} + +// ── NotificationsPanel ───────────────────────────────────────────────────────── + +function NotificationsPanel({ onClose }: { onClose: () => void }) { + const navigate = useNavigate() + const [notifications, setNotifications] = useState(MOCK_NOTIFICATIONS) + 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() } + } + + const shown = tab === 'unread' + ? notifications.filter(n => !n.isRead) + : notifications + + return ( +
+ {/* Header */} +
+
+

Уведомления

+ {unreadCount > 0 && ( + + {unreadCount} + + )} +
+
+ {unreadCount > 0 && ( + + )} + +
+
+ + {/* Tabs */} +
+ {([ + { id: 'all' as const, label: 'Все', count: notifications.length }, + { id: 'unread' as const, label: 'Непрочитанные', count: unreadCount }, + ]).map(t => ( + + ))} +
+ + {/* List */} +
+ {shown.length === 0 ? ( +
+
+ +
+

Нет уведомлений

+

+ {tab === 'unread' ? 'Все уведомления прочитаны' : 'Пока ничего нет'} +

+
+ ) : ( +
+ {shown.map(n => { + const meta = NOTIF_META[n.type] + const Icon = meta.icon + return ( +
handleClick(n)} + className={cn( + 'group flex gap-3 px-4 py-3.5 cursor-pointer transition-colors relative', + n.isRead + ? 'hover:bg-slate-50 dark:hover:bg-slate-700/40' + : 'bg-brand-50/60 dark:bg-brand-900/10 hover:bg-brand-50 dark:hover:bg-brand-900/20', + )} + > + {/* Unread dot */} + {!n.isRead && ( +
+ )} + + {/* Icon */} +
+ +
+ + {/* Content */} +
+
+

+ {n.title} +

+
+ {relativeTime(n.time)} + +
+
+

{n.body}

+ {n.link && ( +

+ Перейти +

+ )} +
+
+ ) + })} +
+ )} +
+ + {/* Footer */} + {notifications.length > 0 && ( +
+ +
+ )} +
+ ) +} + +// ── Topbar ───────────────────────────────────────────────────────────────────── interface TopbarProps { onMenuToggle?: () => void @@ -12,9 +303,14 @@ interface TopbarProps { export function Topbar({ onMenuToggle, title }: TopbarProps) { const { theme, toggle } = useTheme() - const { user, logout } = useAuth() + const { user, logout } = useAuth() const navigate = useNavigate() - const [userMenuOpen, setUserMenuOpen] = useState(false) + + const [userMenuOpen, setUserMenuOpen] = useState(false) + const [notifPanelOpen, setNotifPanelOpen] = useState(false) + const [notifications, setNotifications] = useState(MOCK_NOTIFICATIONS) + + const unreadCount = notifications.filter(n => !n.isRead).length const handleLogout = () => { logout() @@ -24,27 +320,41 @@ export function Topbar({ onMenuToggle, title }: TopbarProps) { return (
{/* Mobile menu button */} - - {/* Page title (mobile) */} {title && ( - - {title} - + {title} )}
{/* Notification bell */} - +
+ + + {notifPanelOpen && ( + <> +
setNotifPanelOpen(false)} /> +
+ setNotifPanelOpen(false)} + /> +
+ + )} +
{/* Theme toggle */}