- Notifications panel: fixed positioning on mobile (full-width below topbar, no more overflow behind calendar) - BookingModal: two-column layout stacks vertically on mobile (flex-col sm:flex-row) - TariffsPage cards: price + actions merged into right column, no text overlap - DiscountsPage cards: same fix, price text reduced to text-lg - ReviewsPage: action buttons moved inside content div (no longer overlap guest name) - RoomCategoriesPage: edit/delete buttons stack as column, 'Номера' text hidden on mobile - PosPage: show desktop-only message on mobile instead of broken layout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
302 lines
14 KiB
TypeScript
302 lines
14 KiB
TypeScript
import { Sun, Moon, 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 { theme, 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">
|
||
{theme === 'light' ? <Moon size={18} /> : <Sun 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>
|
||
)
|
||
}
|