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:
@@ -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 { useTheme } from '../../contexts/ThemeContext'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
import { ROLE_LABELS } from '../../lib/utils'
|
import { ROLE_LABELS } from '../../lib/utils'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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<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 {
|
interface TopbarProps {
|
||||||
onMenuToggle?: () => void
|
onMenuToggle?: () => void
|
||||||
@@ -12,9 +303,14 @@ interface TopbarProps {
|
|||||||
|
|
||||||
export function Topbar({ onMenuToggle, title }: TopbarProps) {
|
export function Topbar({ onMenuToggle, title }: TopbarProps) {
|
||||||
const { theme, toggle } = useTheme()
|
const { theme, toggle } = useTheme()
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
|
||||||
|
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 = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
@@ -24,27 +320,41 @@ export function Topbar({ onMenuToggle, title }: TopbarProps) {
|
|||||||
return (
|
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">
|
<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 */}
|
{/* Mobile menu button */}
|
||||||
<button
|
<button onClick={onMenuToggle} className="lg:hidden btn-ghost p-2">
|
||||||
onClick={onMenuToggle}
|
|
||||||
className="lg:hidden btn-ghost p-2"
|
|
||||||
>
|
|
||||||
<Menu size={20} />
|
<Menu size={20} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Page title (mobile) */}
|
|
||||||
{title && (
|
{title && (
|
||||||
<span className="lg:hidden font-semibold text-slate-900 dark:text-slate-100 truncate">
|
<span className="lg:hidden font-semibold text-slate-900 dark:text-slate-100 truncate">{title}</span>
|
||||||
{title}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* Notification bell */}
|
{/* Notification bell */}
|
||||||
<button className="btn-ghost p-2 relative">
|
<div className="relative">
|
||||||
<Bell size={18} />
|
<button
|
||||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-red-500" />
|
onClick={() => { setNotifPanelOpen(v => !v); setUserMenuOpen(false) }}
|
||||||
</button>
|
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 */}
|
{/* Theme toggle */}
|
||||||
<button onClick={toggle} className="btn-ghost p-2">
|
<button onClick={toggle} className="btn-ghost p-2">
|
||||||
@@ -54,19 +364,15 @@ export function Topbar({ onMenuToggle, title }: TopbarProps) {
|
|||||||
{/* User menu */}
|
{/* User menu */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<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"
|
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">
|
<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'}
|
{user?.name.charAt(0) ?? 'U'}
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:block text-left">
|
<div className="hidden sm:block text-left">
|
||||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 leading-tight">
|
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 leading-tight">{user?.name}</p>
|
||||||
{user?.name}
|
<p className="text-xs text-slate-500 dark:text-slate-400">{user ? ROLE_LABELS[user.role] : ''}</p>
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
|
||||||
{user ? ROLE_LABELS[user.role] : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown size={14} className="text-slate-400 hidden sm:block" />
|
<ChevronDown size={14} className="text-slate-400 hidden sm:block" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user