Files
hotelsync/src/components/layout/Topbar.tsx

302 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Sun, Moon, Monitor, Bell, LogOut, ChevronDown, Menu,
BookOpen, X, CheckCheck, CalendarCheck2, CalendarX2,
Sparkles, AlertTriangle, Star, CreditCard, Info,
ArrowRight, Wrench,
} 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'
import { useNotifications, type NotifType, type Notification } from '../../contexts/NotificationsContext'
// ── 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' },
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 {
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, markRead, markAllRead, removeNotif } = useNotifications()
const [tab, setTab] = useState<'all' | 'unread'>('all')
const unreadCount = notifications.filter(n => !n.isRead).length
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="fixed left-2 right-2 top-16 sm:absolute sm:left-auto sm:right-0 sm:top-full sm:mt-2 sm:w-96 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-2xl z-50 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
title?: string
}
export function Topbar({ onMenuToggle, title }: TopbarProps) {
const { mode, toggle } = useTheme()
const { user, logout } = useAuth()
const navigate = useNavigate()
const { notifications } = useNotifications()
const [userMenuOpen, setUserMenuOpen] = useState(false)
const [notifPanelOpen, setNotifPanelOpen] = useState(false)
const unreadCount = notifications.filter(n => !n.isRead).length
const handleLogout = () => {
logout()
navigate('/login')
}
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">
<Menu size={20} />
</button>
{title && (
<span className="lg:hidden font-semibold text-slate-900 dark:text-slate-100 truncate">{title}</span>
)}
<div className="flex-1" />
{/* Notification bell */}
<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} />
{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" title={mode === 'light' ? 'Светлая тема' : mode === 'dark' ? 'Тёмная тема' : 'Авто (системная тема)'}>
{mode === 'light' ? <Moon size={18} /> : mode === 'dark' ? <Sun size={18} /> : <Monitor size={18} />}
</button>
{/* User menu */}
<div className="relative">
<button
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>
</div>
<ChevronDown size={14} className="text-slate-400 hidden sm:block" />
</button>
{userMenuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setUserMenuOpen(false)} />
<div className="absolute right-0 top-full mt-1 w-52 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl shadow-xl z-20 py-1 animate-fade-in">
<div className="px-3 py-2 border-b border-slate-100 dark:border-slate-700">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{user?.name}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{user?.email}</p>
</div>
<button
onClick={handleLogout}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<LogOut size={14} />
Выйти
</button>
</div>
</>
)}
</div>
</header>
)
}