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

730 lines
19 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>