Files
nademitex_admin/src/components/layout/AdminHeader.vue
T

730 lines
19 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { onClickOutside, useEventListener } from '@vueuse/core'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { dashboardApi, notificationsApi } from '@/services/api'
import { formatDateTime } from '@/utils/format'
import { resolveAdminInboxLink } from '@/utils/inboxLinks'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore, type ThemePreference } from '@/stores/theme'
import type { AdminNotification, InternalMessage } from '@/types'
defineEmits<{
menuClick: []
}>()
type OpenPanel = 'messages' | 'notifications' | 'theme' | null
const router = useRouter()
const auth = useAuthStore()
const theme = useThemeStore()
const openPanel = ref<OpenPanel>(null)
const actionsRef = ref<HTMLElement | null>(null)
const messageList = ref<InternalMessage[]>([])
const notificationList = ref<AdminNotification[]>([])
const unreadMessages = computed(() => messageList.value.filter((m) => !m.isRead).length)
const unreadNotifications = computed(() => notificationList.value.filter((n) => !n.isRead).length)
const themeOptions: { value: ThemePreference; label: string; icon: string }[] = [
{ value: 'light', label: 'روشن', icon: 'vuesax-linear:sun-1' },
{ value: 'dark', label: 'تاریک', icon: 'vuesax-linear:moon' },
{ value: 'system', label: 'سیستم', icon: 'vuesax-linear:mobile' },
]
const themeLabel = computed(
() => themeOptions.find((o) => o.value === theme.preference)?.label ?? 'سیستم',
)
const themeTriggerIcon = computed(
() => themeOptions.find((o) => o.value === theme.preference)?.icon ?? 'vuesax-linear:sun-1',
)
void dashboardApi
.get()
.then((data) => {
messageList.value = data.messages ?? []
notificationList.value = data.notifications ?? []
})
.catch(() => {
messageList.value = []
notificationList.value = []
})
onClickOutside(actionsRef, () => {
openPanel.value = null
})
useEventListener(document, 'keydown', (event: KeyboardEvent) => {
if (event.key === 'Escape') openPanel.value = null
})
function togglePanel(panel: Exclude<OpenPanel, null>) {
openPanel.value = openPanel.value === panel ? null : panel
}
function closePanel() {
openPanel.value = null
}
function markMessageRead(message: InternalMessage) {
message.isRead = true
}
function markNotificationRead(notification: AdminNotification) {
if (notification.isRead) return
notification.isRead = true
void notificationsApi.markRead(notification.id).catch(() => {
notification.isRead = false
})
}
function markAllMessagesRead() {
messageList.value.forEach((m) => {
m.isRead = true
})
}
function markAllNotificationsRead() {
const previouslyUnread = notificationList.value.filter((n) => !n.isRead)
if (!previouslyUnread.length) return
previouslyUnread.forEach((n) => {
n.isRead = true
})
void notificationsApi.markAllRead().catch(() => {
previouslyUnread.forEach((n) => {
n.isRead = false
})
})
}
function navigateInboxTarget(target: string | null) {
closePanel()
if (!target) return
void router.push(target)
}
function openMessage(message: InternalMessage) {
markMessageRead(message)
navigateInboxTarget(
resolveAdminInboxLink({
link: message.link,
orderId: message.orderId,
title: message.subject,
body: message.preview,
}),
)
}
function openNotification(notification: AdminNotification) {
markNotificationRead(notification)
navigateInboxTarget(
resolveAdminInboxLink({
link: notification.link,
orderId: notification.orderId,
title: notification.title,
body: notification.body,
}),
)
}
function selectTheme(value: ThemePreference) {
theme.setPreference(value)
closePanel()
}
</script>
<template>
<header class="admin-header">
<div class="admin-header__right">
<button
type="button"
class="admin-header__menu-btn compact-only"
aria-label="باز کردن منو"
@click="$emit('menuClick')"
>
<AdminIcon name="menu" :size="22" />
</button>
</div>
<div ref="actionsRef" class="admin-header__left">
<div class="admin-header__dropdown">
<button
type="button"
class="admin-header__icon-btn"
:class="{ 'admin-header__icon-btn--active': openPanel === 'messages' }"
title="پیام‌های داخلی"
:aria-label="`پیام‌های داخلی — ${unreadMessages} پیام خوانده‌نشده`"
:aria-expanded="openPanel === 'messages'"
aria-haspopup="true"
@click.stop="togglePanel('messages')"
>
<AdminIcon name="message" :size="22" />
<span v-if="unreadMessages" class="admin-header__badge" aria-hidden="true">
{{ unreadMessages }}
</span>
<span class="admin-header__icon-label">پیام</span>
</button>
<div
v-if="openPanel === 'messages'"
class="admin-header__panel"
role="dialog"
aria-label="پیام‌های داخلی"
@click.stop
>
<div class="admin-header__panel-header">
<h2 class="admin-header__panel-title">پیام‌های داخلی</h2>
<button
v-if="unreadMessages"
type="button"
class="admin-header__panel-action"
@click="markAllMessagesRead"
>
علامت‌گذاری همه به‌عنوان خوانده‌شده
</button>
</div>
<ul v-if="messageList.length" class="admin-header__panel-list">
<li
v-for="message in messageList"
:key="message.id"
class="admin-header__panel-item"
:class="{ unread: !message.isRead }"
>
<button type="button" class="admin-header__panel-item-btn" @click="openMessage(message)">
<span class="admin-header__panel-item-top">
<strong>{{ message.subject }}</strong>
<time :datetime="message.sentAt">{{ formatDateTime(message.sentAt) }}</time>
</span>
<span class="admin-header__panel-item-sender">از {{ message.senderName }}</span>
<span class="admin-header__panel-item-body">{{ message.preview }}</span>
</button>
</li>
</ul>
<p v-else class="admin-header__panel-empty">پیامی وجود ندارد.</p>
</div>
</div>
<div class="admin-header__dropdown">
<button
type="button"
class="admin-header__icon-btn"
:class="{ 'admin-header__icon-btn--active': openPanel === 'notifications' }"
title="اعلان‌ها"
:aria-label="`اعلان‌ها — ${unreadNotifications} اعلان جدید`"
:aria-expanded="openPanel === 'notifications'"
aria-haspopup="true"
@click.stop="togglePanel('notifications')"
>
<AdminIcon name="bell" :size="22" />
<span v-if="unreadNotifications" class="admin-header__badge" aria-hidden="true">
{{ unreadNotifications }}
</span>
<span class="admin-header__icon-label">اعلان</span>
</button>
<div
v-if="openPanel === 'notifications'"
class="admin-header__panel"
role="dialog"
aria-label="اعلان‌ها"
@click.stop
>
<div class="admin-header__panel-header">
<h2 class="admin-header__panel-title">اعلان‌ها</h2>
<button
v-if="unreadNotifications"
type="button"
class="admin-header__panel-action"
@click="markAllNotificationsRead"
>
علامت‌گذاری همه به‌عنوان خوانده‌شده
</button>
</div>
<ul v-if="notificationList.length" class="admin-header__panel-list">
<li
v-for="notification in notificationList"
:key="notification.id"
class="admin-header__panel-item"
:class="[
{ unread: !notification.isRead },
notification.tone ? `admin-header__panel-item--${notification.tone}` : '',
]"
>
<button
type="button"
class="admin-header__panel-item-btn"
@click="openNotification(notification)"
>
<span class="admin-header__panel-item-top">
<strong>{{ notification.title }}</strong>
<time :datetime="notification.sentAt">{{ formatDateTime(notification.sentAt) }}</time>
</span>
<span class="admin-header__panel-item-body">{{ notification.body }}</span>
</button>
</li>
</ul>
<p v-else class="admin-header__panel-empty">اعلانی وجود ندارد.</p>
</div>
</div>
<div class="admin-header__dropdown">
<button
type="button"
class="admin-header__icon-btn"
:class="{ 'admin-header__icon-btn--active': openPanel === 'theme' }"
title="حالت نمایش"
:aria-label="`حالت نمایش — ${themeLabel}`"
:aria-expanded="openPanel === 'theme'"
aria-haspopup="true"
@click.stop="togglePanel('theme')"
>
<span class="admin-header__theme-glyph" aria-hidden="true">
<BasicIcon :icon="themeTriggerIcon" :size="20" :stroke-width="1.5" />
</span>
<span class="admin-header__icon-label">تم</span>
</button>
<div
v-if="openPanel === 'theme'"
class="admin-header__panel admin-header__panel--theme"
role="dialog"
aria-label="انتخاب حالت نمایش"
@click.stop
>
<div class="admin-header__panel-header">
<h2 class="admin-header__panel-title">حالت نمایش</h2>
</div>
<ul class="admin-header__theme-list">
<li v-for="option in themeOptions" :key="option.value">
<button
type="button"
class="admin-header__theme-option"
:class="{ 'admin-header__theme-option--active': theme.preference === option.value }"
@click="selectTheme(option.value)"
>
<span class="admin-header__theme-option-icon" aria-hidden="true">
<BasicIcon :icon="option.icon" :size="18" :stroke-width="1.5" />
</span>
<span class="admin-header__theme-option-label">{{ option.label }}</span>
<BasicIcon
v-if="theme.preference === option.value"
class="admin-header__theme-option-check"
icon="vuesax-linear:tick-circle"
:size="16"
:stroke-width="1.75"
/>
</button>
</li>
</ul>
</div>
</div>
<div class="admin-header__user">
<div class="admin-header__avatar">{{ auth.fullName.charAt(0) || '؟' }}</div>
<div class="admin-header__user-info">
<strong>{{ auth.fullName || 'مدیر' }}</strong>
<span>{{ auth.roleLabel || 'پنل مدیریت' }}</span>
</div>
</div>
</div>
</header>
</template>
<style scoped>
.admin-header {
height: var(--header-height);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 0 28px;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.admin-header__right {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.admin-header__menu-btn {
width: 40px;
height: 40px;
flex-shrink: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
display: flex;
align-items: center;
justify-content: center;
}
.admin-header__menu-btn:hover {
background: var(--color-border-light);
}
.admin-header__left {
display: flex;
align-items: center;
gap: 8px;
}
.admin-header__dropdown {
position: relative;
}
.admin-header__icon-btn {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-width: 52px;
height: 52px;
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
transition: background 0.15s, border-color 0.15s;
}
.admin-header__icon-btn:hover,
.admin-header__icon-btn--active {
background: var(--color-border-light);
border-color: #cbd5e1;
}
.admin-header__icon-label {
font-size: 0.625rem;
font-weight: 600;
color: var(--color-text-muted);
line-height: 1;
}
.admin-header__badge {
position: absolute;
top: 2px;
left: 2px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--color-danger);
color: #fff;
font-size: 0.625rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--color-surface);
line-height: 1;
}
.admin-header__panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 950;
width: min(360px, calc(100vw - 32px));
max-height: min(420px, calc(100dvh - var(--header-height) - 24px));
display: flex;
flex-direction: column;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.admin-header__panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.admin-header__panel-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 700;
color: var(--color-text);
}
.admin-header__panel-action {
border: none;
background: transparent;
color: var(--color-primary);
font-size: 0.75rem;
font-weight: 600;
padding: 0;
white-space: nowrap;
}
.admin-header__panel-action:hover {
text-decoration: underline;
}
.admin-header__panel-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
}
.admin-header__panel-item {
border-bottom: 1px solid var(--color-border-light);
}
.admin-header__panel-item:last-child {
border-bottom: none;
}
.admin-header__panel-item.unread {
background: var(--color-info-light);
}
.admin-header__panel-item-btn {
width: 100%;
border: none;
background: transparent;
text-align: right;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 4px;
color: var(--color-text);
}
.admin-header__panel-item-btn:hover {
background: var(--color-border-light);
}
.admin-header__panel-item-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.admin-header__panel-item-top strong {
font-size: 0.8125rem;
font-weight: 700;
line-height: 1.4;
}
.admin-header__panel-item-top time {
flex-shrink: 0;
font-size: 0.6875rem;
color: var(--color-text-muted);
}
.admin-header__panel-item-sender {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.admin-header__panel-item-body {
font-size: 0.8125rem;
color: var(--color-text-muted);
line-height: 1.5;
}
.admin-header__panel-item--warning.unread {
background: var(--color-warning-light);
}
.admin-header__panel-item--danger.unread {
background: var(--color-danger-light);
}
.admin-header__panel-item--success.unread {
background: var(--color-success-light);
}
.admin-header__panel-empty {
margin: 0;
padding: 24px 16px;
text-align: center;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.admin-header__user {
display: flex;
align-items: center;
gap: 10px;
padding-right: 12px;
margin-right: 4px;
border-right: 1px solid var(--color-border);
}
.admin-header__avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.875rem;
}
.admin-header__user-info {
display: flex;
flex-direction: column;
gap: 1px;
}
.admin-header__user-info strong {
font-size: 0.8125rem;
font-weight: 600;
}
.admin-header__user-info span {
font-size: 0.6875rem;
color: var(--color-text-muted);
}
.admin-header__theme-glyph {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
color: inherit;
}
.admin-header__panel--theme {
min-width: 200px;
}
.admin-header__theme-list {
list-style: none;
margin: 0;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.admin-header__theme-option {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
text-align: right;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
color: var(--color-text);
padding: 10px 12px;
font-family: inherit;
font-size: 0.875rem;
cursor: pointer;
}
.admin-header__theme-option-icon {
width: 36px;
height: 36px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: var(--color-bg);
border: 1px solid var(--color-border);
color: var(--color-text);
}
.admin-header__theme-option-label {
flex: 1;
min-width: 0;
font-weight: 600;
}
.admin-header__theme-option-check {
flex-shrink: 0;
color: var(--color-primary);
}
.admin-header__theme-option:hover {
background: var(--color-border-light);
}
.admin-header__theme-option--active {
border-color: var(--color-primary);
background: var(--color-primary-soft);
color: var(--color-primary);
}
.admin-header__theme-option--active .admin-header__theme-option-icon {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
@media (max-width: 767px) {
.admin-header {
padding: 0 16px;
gap: 12px;
}
.admin-header__icon-label {
display: none;
}
.admin-header__icon-btn {
min-width: 40px;
height: 40px;
padding: 0;
}
.admin-header__panel {
position: fixed;
top: var(--header-height);
left: 16px;
right: 16px;
width: auto;
max-height: calc(100dvh - var(--header-height) - 16px);
}
.admin-header__panel-header {
flex-direction: column;
align-items: stretch;
}
.admin-header__panel-action {
align-self: flex-start;
}
.admin-header__user-info {
display: none;
}
.admin-header__user {
border-right: none;
padding-right: 0;
margin-right: 0;
}
}
</style>