Add notifications panel to topbar bell icon

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 22:55:25 +03:00
parent c3723dec90
commit 39aeafe276

View File

@@ -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 (Делюкс) на 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,
},
]
// ── Helpers ────────────────────────────────────────────────────────────────────
const NOTIF_META: Record<NotifType, {
icon: React.ElementType
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_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<Notification[]>(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 (
<div className="absolute right-0 top-full mt-2 w-96 max-w-[calc(100vw-1rem)] bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-2xl z-20 flex flex-col" style={{ maxHeight: 'calc(100vh - 80px)' }}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100 dark:border-slate-700 shrink-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Уведомления</h3>
{unreadCount > 0 && (
<span className="text-xs font-bold bg-red-500 text-white rounded-full px-1.5 py-0.5 leading-none">
{unreadCount}
</span>
)}
</div>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="flex items-center gap-1 text-xs text-brand-600 dark:text-brand-400 hover:underline px-2 py-1 rounded-lg hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
>
<CheckCheck size={13} />
Прочитать все
</button>
)}
<button onClick={onClose} className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
<X size={15} />
</button>
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-slate-100 dark:border-slate-700 shrink-0">
{([
{ id: 'all' as const, label: 'Все', count: notifications.length },
{ id: 'unread' as const, label: 'Непрочитанные', count: unreadCount },
]).map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cn(
'flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
tab === t.id
? 'border-brand-600 text-brand-600 dark:text-brand-400 dark:border-brand-400'
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300',
)}
>
{t.label}
{t.count > 0 && (
<span className={cn(
'text-xs font-semibold px-1.5 py-0.5 rounded-full leading-none',
tab === t.id
? 'bg-brand-100 dark:bg-brand-900/40 text-brand-700 dark:text-brand-300'
: 'bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400',
)}>
{t.count}
</span>
)}
</button>
))}
</div>
{/* List */}
<div className="overflow-y-auto flex-1">
{shown.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center px-4">
<div className="w-12 h-12 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mb-3">
<Bell size={20} className="text-slate-400" />
</div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Нет уведомлений</p>
<p className="text-xs text-slate-400 mt-1">
{tab === 'unread' ? 'Все уведомления прочитаны' : 'Пока ничего нет'}
</p>
</div>
) : (
<div className="divide-y divide-slate-100 dark:divide-slate-700/60">
{shown.map(n => {
const meta = NOTIF_META[n.type]
const Icon = meta.icon
return (
<div
key={n.id}
onClick={() => 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 && (
<div className="absolute left-1.5 top-1/2 -translate-y-1/2 w-1.5 h-1.5 rounded-full bg-brand-600" />
)}
{/* Icon */}
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center shrink-0 mt-0.5', meta.iconBg)}>
<Icon size={16} className={meta.iconColor} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className={cn('text-sm leading-snug', n.isRead ? 'font-normal text-slate-700 dark:text-slate-300' : 'font-semibold text-slate-900 dark:text-slate-100')}>
{n.title}
</p>
<div className="flex items-center gap-1 shrink-0">
<span className="text-[10px] text-slate-400 dark:text-slate-500 whitespace-nowrap">{relativeTime(n.time)}</span>
<button
onClick={e => { e.stopPropagation(); removeNotif(n.id) }}
className="opacity-0 group-hover:opacity-100 p-0.5 rounded text-slate-300 hover:text-slate-500 dark:text-slate-600 dark:hover:text-slate-400 transition-all"
>
<X size={12} />
</button>
</div>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 leading-relaxed line-clamp-2">{n.body}</p>
{n.link && (
<p className="text-[11px] text-brand-600 dark:text-brand-400 mt-1 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
Перейти <ArrowRight size={10} />
</p>
)}
</div>
</div>
)
})}
</div>
)}
</div>
{/* Footer */}
{notifications.length > 0 && (
<div className="px-4 py-2.5 border-t border-slate-100 dark:border-slate-700 shrink-0 text-center">
<button className="text-xs text-slate-400 dark:text-slate-500 hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
Журнал всех уведомлений
</button>
</div>
)}
</div>
)
}
// ── Topbar ─────────────────────────────────────────────────────────────────────
interface TopbarProps {
onMenuToggle?: () => void
@@ -14,7 +305,12 @@ export function Topbar({ onMenuToggle, title }: TopbarProps) {
const { theme, toggle } = useTheme()
const { user, logout } = useAuth()
const navigate = useNavigate()
const [userMenuOpen, setUserMenuOpen] = useState(false)
const [notifPanelOpen, setNotifPanelOpen] = useState(false)
const [notifications, setNotifications] = useState<Notification[]>(MOCK_NOTIFICATIONS)
const unreadCount = notifications.filter(n => !n.isRead).length
const handleLogout = () => {
logout()
@@ -24,28 +320,42 @@ export function Topbar({ onMenuToggle, title }: TopbarProps) {
return (
<header className="h-16 shrink-0 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 flex items-center px-4 gap-3 z-30">
{/* Mobile menu button */}
<button
onClick={onMenuToggle}
className="lg:hidden btn-ghost p-2"
>
<button onClick={onMenuToggle} className="lg:hidden btn-ghost p-2">
<Menu size={20} />
</button>
{/* Page title (mobile) */}
{title && (
<span className="lg:hidden font-semibold text-slate-900 dark:text-slate-100 truncate">
{title}
</span>
<span className="lg:hidden font-semibold text-slate-900 dark:text-slate-100 truncate">{title}</span>
)}
<div className="flex-1" />
{/* Notification bell */}
<button className="btn-ghost p-2 relative">
<div className="relative">
<button
onClick={() => { setNotifPanelOpen(v => !v); setUserMenuOpen(false) }}
className={cn('btn-ghost p-2 relative transition-colors', notifPanelOpen && 'bg-slate-100 dark:bg-slate-700')}
>
<Bell size={18} />
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-red-500" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 min-w-[16px] h-4 rounded-full bg-red-500 text-white text-[10px] font-bold flex items-center justify-center px-0.5 leading-none">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{notifPanelOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setNotifPanelOpen(false)} />
<div className="relative z-20">
<NotificationsPanel
onClose={() => setNotifPanelOpen(false)}
/>
</div>
</>
)}
</div>
{/* Theme toggle */}
<button onClick={toggle} className="btn-ghost p-2">
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
@@ -54,19 +364,15 @@ export function Topbar({ onMenuToggle, title }: TopbarProps) {
{/* User menu */}
<div className="relative">
<button
onClick={() => setUserMenuOpen(v => !v)}
onClick={() => { setUserMenuOpen(v => !v); setNotifPanelOpen(false) }}
className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-brand-600 flex items-center justify-center text-white text-sm font-semibold shrink-0">
{user?.name.charAt(0) ?? 'U'}
</div>
<div className="hidden sm:block text-left">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 leading-tight">
{user?.name}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{user ? ROLE_LABELS[user.role] : ''}
</p>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 leading-tight">{user?.name}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{user ? ROLE_LABELS[user.role] : ''}</p>
</div>
<ChevronDown size={14} className="text-slate-400 hidden sm:block" />
</button>