Files
CRM_Montzar/apps/web/src/layouts/AppLayout.vue
T
2026-09-13 19:11:47 +03:30

1666 lines
38 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, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api';
import { formatJalaliShort, toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import MentoLogo from '@/components/brand/MentoLogo.vue';
import QuickCreate from '@/components/QuickCreate.vue';
import BrandName from '@/components/brand/BrandName.vue';
import NavHubPanel from '@/components/nav/NavHubPanel.vue';
import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs';
import type {
ActivitySummaryDto,
NotificationDto,
NotificationsListDto,
} from '@mento/shared';
import { brand } from '@mento/shared';
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
const canSettings = computed(() => canManageSettings(auth.user?.role));
const showOwnRecordsBanner = computed(
() => Boolean(auth.user?.role) && !canViewAllRecords(auth.user?.role),
);
const todayLabel = computed(() => formatJalaliShort(new Date()));
const SIDEBAR_KEY = 'mento_sidebar_collapsed';
const collapsed = ref(false);
const openHubKey = ref<NavHubKey | null>(null);
const globalSearch = ref('');
const activityBadge = ref(0);
function pathIn(prefixes: string[]) {
return prefixes.some(
(p) => route.path === p || route.path.startsWith(`${p}/`),
);
}
const customersActive = computed(() => pathIn(['/contacts']));
const salesActive = computed(() =>
pathIn(['/pipeline', '/deals', '/quotes', '/invoices', '/commissions', '/sales/forecast', '/settings/products']),
);
const activitiesActive = computed(() => pathIn(['/activities']));
const notesActive = computed(() => route.path === '/notes' || route.path.startsWith('/notes/'));
const supportActive = computed(() => pathIn(['/tickets']));
const automationsActive = computed(() => route.path.startsWith('/settings/automations'));
const messageAutomationsActive = computed(() =>
route.path.startsWith('/settings/message-automations'),
);
const toolsActive = computed(() =>
pathIn(['/settings/lead-forms', '/projects', '/project-cards', '/inventory', '/cheques', '/attachments']),
);
const settingsActive = computed(() =>
pathIn([
'/settings/team',
'/settings/custom-fields',
'/settings/currencies',
'/settings/tahesab',
'/settings/faq',
]),
);
const openHub = computed<NavHubDef | null>(() => {
if (!openHubKey.value) return null;
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
if (!hub) return null;
return {
...hub,
cards: hub.cards.filter((card) => !card.admin || canSettings.value),
shortcuts: hub.shortcuts.filter((item) => !item.admin || canSettings.value),
};
});
function givenName(fullName: string): string {
const parts = fullName.trim().split(/\s+/).filter(Boolean);
if (parts.length <= 1) return fullName.trim();
return parts.slice(0, -1).join(' ');
}
const profileNameEl = ref<HTMLElement | null>(null);
const useShortProfileName = ref(false);
const profileFullName = computed(() => auth.user?.fullName?.trim() || '');
const profileDisplayName = computed(() => {
if (!profileFullName.value) return '';
return useShortProfileName.value
? givenName(profileFullName.value)
: profileFullName.value;
});
async function fitProfileName() {
if (collapsed.value) {
useShortProfileName.value = false;
return;
}
useShortProfileName.value = false;
await nextTick();
const el = profileNameEl.value;
if (!el) return;
if (el.scrollWidth > el.clientWidth + 1) {
useShortProfileName.value = true;
}
}
let profileNameObserver: ResizeObserver | null = null;
watch([profileFullName, collapsed], () => {
void fitProfileName();
});
function closeHub() {
openHubKey.value = null;
}
function onHubClick(key: NavHubKey) {
openHubKey.value = openHubKey.value === key ? null : key;
closeMenu();
}
function goHub(to: string) {
closeHub();
closeMenu();
void router.push(to);
}
const menuOpen = ref(false);
const notifOpen = ref(false);
const loading = ref(false);
const unreadCount = ref(0);
const items = ref<NotificationDto[]>([]);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const hasUnread = computed(() => unreadCount.value > 0);
function closeMenu() {
menuOpen.value = false;
}
function toggleCollapsed() {
collapsed.value = !collapsed.value;
try {
localStorage.setItem(SIDEBAR_KEY, collapsed.value ? '1' : '0');
} catch {
// ignore
}
}
function closeNotif() {
notifOpen.value = false;
}
function onLogout() {
closeHub();
closeMenu();
closeNotif();
auth.logout();
router.push({ name: 'login' });
}
async function refreshNotifs() {
if (!auth.isAuthenticated) return;
loading.value = true;
try {
const { data } = await api.get<NotificationsListDto>('/notifications', {
params: { limit: 20 },
});
items.value = data.items;
unreadCount.value = data.unreadCount;
} catch {
// keep previous
} finally {
loading.value = false;
}
}
async function loadActivityBadge() {
try {
const { data } = await api.get<ActivitySummaryDto>('/activities/summary');
activityBadge.value = data.todayCount + data.overdueCount;
} catch {
activityBadge.value = 0;
}
}
async function markRead(item: NotificationDto) {
if (!item.readAt) {
await api.patch(`/notifications/${item.id}/read`);
item.readAt = new Date().toISOString();
unreadCount.value = Math.max(0, unreadCount.value - 1);
}
closeNotif();
closeMenu();
if (item.link) await router.push(item.link);
}
async function markAll() {
await api.patch('/notifications/read-all');
items.value = items.value.map((n) => ({
...n,
readAt: n.readAt ?? new Date().toISOString(),
}));
unreadCount.value = 0;
}
function toggleMenu() {
notifOpen.value = false;
menuOpen.value = !menuOpen.value;
}
function toggleNotif() {
menuOpen.value = false;
notifOpen.value = !notifOpen.value;
if (notifOpen.value) void refreshNotifs();
}
function formatTime(iso: string) {
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(iso));
} catch {
return iso;
}
}
function submitSearch() {
const q = globalSearch.value.trim();
if (!q) return;
void router.push({ name: 'contacts', query: { q } });
}
function syncBodyLock() {
document.body.style.overflow =
menuOpen.value || notifOpen.value || Boolean(openHubKey.value) ? 'hidden' : '';
}
watch(menuOpen, syncBodyLock);
watch(notifOpen, syncBodyLock);
watch(openHubKey, syncBodyLock);
watch(
() => route.fullPath,
() => {
closeMenu();
closeNotif();
closeHub();
},
);
onMounted(() => {
try {
collapsed.value = localStorage.getItem(SIDEBAR_KEY) === '1';
} catch {
collapsed.value = false;
}
void refreshNotifs();
void loadActivityBadge();
pollTimer = setInterval(() => void refreshNotifs(), 45000);
void fitProfileName();
if (typeof ResizeObserver !== 'undefined') {
profileNameObserver = new ResizeObserver(() => {
void fitProfileName();
});
const meta = profileNameEl.value?.parentElement;
if (meta) profileNameObserver.observe(meta);
}
});
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer);
document.body.style.overflow = '';
profileNameObserver?.disconnect();
});
</script>
<template>
<div class="shell" :class="{ 'menu-open': menuOpen, collapsed, 'hub-open': Boolean(openHub) }">
<div v-if="menuOpen" class="backdrop" aria-hidden="true" @click="closeMenu" />
<aside id="app-sidebar" class="sidebar" :class="{ open: menuOpen, collapsed }">
<div class="brand-row">
<div class="brand-cluster">
<MentoLogo class="sidebar-logo" :size="32" />
<RouterLink
class="profile-card"
:to="{ name: 'profile' }"
:title="auth.user?.fullName || 'پروفایل'"
@click="closeMenu"
>
<span class="avatar-wrap">
<span class="avatar">
<img
src="/brand/user-avatar.png"
:alt="auth.user?.fullName || 'کاربر'"
width="36"
height="36"
/>
</span>
<i class="online-dot" title="آنلاین" aria-hidden="true" />
</span>
<span class="profile-meta">
<strong ref="profileNameEl">{{ profileDisplayName }}</strong>
</span>
</RouterLink>
</div>
<button
class="icon-btn ghost mobile-only"
type="button"
aria-label="بستن منو"
@click="closeMenu"
>
✕
</button>
</div>
<div class="rail-stem">
<nav class="side-nav">
<div class="nav-primary">
<RouterLink to="/" class="nav-item" title="داشبورد" @click="closeHub(); closeMenu()">
<UiIcon name="home" :size="collapsed ? 20 : 18" />
<span>داشبورد</span>
</RouterLink>
<div class="nav-fold" :class="{ active: customersActive || openHubKey === 'customers' }">
<button
class="nav-item nav-fold-btn"
type="button"
title="مشتریان"
:class="{ 'router-link-active': customersActive || openHubKey === 'customers', 'hub-on': openHubKey === 'customers' }"
:aria-expanded="openHubKey === 'customers'"
@click="onHubClick('customers')"
>
<UiIcon name="users" :size="collapsed ? 20 : 18" />
<span>مشتریان</span>
<i class="chev" aria-hidden="true" />
</button>
</div>
<div class="nav-fold" :class="{ active: salesActive || openHubKey === 'sales' }">
<button
class="nav-item nav-fold-btn"
type="button"
title="فروش"
:class="{ 'router-link-active': salesActive || openHubKey === 'sales', 'hub-on': openHubKey === 'sales' }"
:aria-expanded="openHubKey === 'sales'"
@click="onHubClick('sales')"
>
<UiIcon name="briefcase" :size="collapsed ? 20 : 18" />
<span>فروش</span>
<i class="chev" aria-hidden="true" />
</button>
</div>
<div class="nav-fold" :class="{ active: activitiesActive || openHubKey === 'activities' }">
<button
class="nav-item nav-fold-btn"
type="button"
title="فعالیت‌ها"
:class="{ 'router-link-active': activitiesActive || openHubKey === 'activities', 'hub-on': openHubKey === 'activities' }"
:aria-expanded="openHubKey === 'activities'"
@click="onHubClick('activities')"
>
<UiIcon name="check" :size="collapsed ? 20 : 18" />
<span>فعالیت‌ها</span>
<span v-if="activityBadge" class="nav-badge">{{ toFaDigits(activityBadge) }}</span>
<i class="chev" aria-hidden="true" />
</button>
</div>
<RouterLink
to="/notes"
class="nav-item"
title="یادداشت‌ها"
:class="{ 'router-link-active': notesActive }"
@click="closeHub(); closeMenu()"
>
<UiIcon name="document" :size="collapsed ? 20 : 18" />
<span>یادداشت‌ها</span>
</RouterLink>
<div class="nav-fold" :class="{ active: supportActive || openHubKey === 'support' }">
<button
class="nav-item nav-fold-btn"
type="button"
title="پشتیبانی"
:class="{ 'router-link-active': supportActive || openHubKey === 'support', 'hub-on': openHubKey === 'support' }"
:aria-expanded="openHubKey === 'support'"
@click="onHubClick('support')"
>
<UiIcon name="chat" :size="collapsed ? 20 : 18" />
<span>پشتیبانی</span>
<i class="chev" aria-hidden="true" />
</button>
</div>
<RouterLink to="/loyalty" class="nav-item" title="باشگاه مشتریان" @click="closeHub(); closeMenu()">
<UiIcon name="gift" :size="collapsed ? 20 : 18" />
<span>باشگاه مشتریان</span>
</RouterLink>
<RouterLink to="/reports" class="nav-item" title="گزارش‌ها" @click="closeHub(); closeMenu()">
<UiIcon name="chart" :size="collapsed ? 20 : 18" />
<span>گزارش‌ها</span>
</RouterLink>
<RouterLink
v-if="canSettings"
to="/settings/automations"
class="nav-item"
title="اتوماسیون"
:class="{ 'router-link-active': automationsActive }"
@click="closeHub(); closeMenu()"
>
<UiIcon name="layers" :size="collapsed ? 20 : 18" />
<span>اتوماسیون</span>
</RouterLink>
<RouterLink
v-if="canSettings"
to="/settings/message-automations"
class="nav-item"
title="پیام خودکار"
:class="{ 'router-link-active': messageAutomationsActive }"
@click="closeHub(); closeMenu()"
>
<UiIcon name="mail" :size="collapsed ? 20 : 18" />
<span>پیام خودکار</span>
</RouterLink>
</div>
<div class="nav-sep" aria-hidden="true" />
<div class="nav-utility">
<div class="nav-fold" :class="{ active: toolsActive || openHubKey === 'tools' }">
<button
class="nav-item nav-fold-btn"
type="button"
title="ابزارها"
:class="{ 'router-link-active': toolsActive || openHubKey === 'tools', 'hub-on': openHubKey === 'tools' }"
:aria-expanded="openHubKey === 'tools'"
@click="onHubClick('tools')"
>
<UiIcon name="apps-plus" :size="collapsed ? 20 : 18" />
<span>ابزارها</span>
<i class="chev" aria-hidden="true" />
</button>
</div>
<div
v-if="canSettings"
class="nav-fold"
:class="{ active: settingsActive || openHubKey === 'settings' }"
>
<button
class="nav-item nav-fold-btn"
type="button"
title="تنظیمات"
:class="{ 'router-link-active': settingsActive || openHubKey === 'settings', 'hub-on': openHubKey === 'settings' }"
:aria-expanded="openHubKey === 'settings'"
@click="onHubClick('settings')"
>
<UiIcon name="settings" :size="collapsed ? 20 : 18" />
<span>تنظیمات</span>
<i class="chev" aria-hidden="true" />
</button>
</div>
</div>
</nav>
<p v-if="showOwnRecordsBanner" class="scope-banner">
فقط پرونده‌های خودتان را می‌بینید.
</p>
<div class="sidebar-foot">
<div class="promo-card">
<span class="promo-icons" aria-hidden="true">
<UiIcon name="send" :size="20" />
<UiIcon name="sparkle" :size="18" />
</span>
<p>
<span>با {{ brand.nameFa }} فروشات را</span>
<span>چند برابر کن</span>
</p>
</div>
<button class="logout-link" type="button" title="خروج از حساب" @click="onLogout">
<UiIcon name="logout" :size="18" />
<span>خروج از حساب</span>
</button>
<button
class="collapse-btn desktop-only"
type="button"
:aria-pressed="collapsed"
:aria-label="collapsed ? 'باز کردن منو' : 'جمع کردن منو'"
@click="toggleCollapsed"
>
<UiIcon name="chevron" :size="16" />
</button>
</div>
</div>
</aside>
<div class="workspace">
<header class="mobile-topbar mobile-only">
<button
class="round-btn"
type="button"
:aria-expanded="menuOpen"
aria-controls="app-sidebar"
aria-label="منو"
@click="toggleMenu"
>
<span class="burger" :class="{ open: menuOpen }" />
</button>
<div class="top-brand">
<MentoLogo :size="32" :show-text="false" variant="mark" />
<BrandName lang="en" />
</div>
<button class="round-btn" type="button" aria-label="اعلان‌ها" @click="toggleNotif">
<UiIcon name="bell" :size="18" />
<span v-if="hasUnread" class="badge">{{ toFaDigits(unreadCount) }}</span>
</button>
</header>
<header class="desktop-topbar desktop-only">
<form class="global-search" @submit.prevent="submitSearch">
<UiIcon name="search" :size="18" class="search-leading" />
<input
v-model="globalSearch"
type="search"
placeholder="جستجو در مشتریان، معاملات و..."
/>
</form>
<div class="topbar-actions">
<RouterLink class="round-btn accent" :to="{ name: 'notes' }" title="هوش مصنوعی">
<UiIcon name="sparkle" :size="18" />
</RouterLink>
<button class="round-btn" type="button" aria-label="اعلان‌ها" @click="toggleNotif">
<UiIcon name="bell" :size="18" />
<span v-if="hasUnread" class="badge">{{ toFaDigits(unreadCount) }}</span>
</button>
<RouterLink class="round-btn" :to="{ name: 'activities-calendar' }" title="تقویم">
<UiIcon name="calendar" :size="18" />
</RouterLink>
<RouterLink class="round-btn" :to="{ name: 'tickets' }" title="پیام‌ها">
<UiIcon name="mail" :size="18" />
</RouterLink>
<div class="date-chip">
<UiIcon name="calendar" :size="16" />
<span>{{ todayLabel }}</span>
</div>
</div>
</header>
<div v-if="notifOpen" class="notif-layer">
<div class="notif-backdrop" @click="closeNotif" />
<div class="notif-panel" role="dialog" aria-label="اعلان‌ها">
<header class="notif-head">
<strong>اعلان‌ها</strong>
<div class="notif-head-actions">
<button v-if="hasUnread" class="linkish" type="button" @click="markAll">
همه خوانده شد
</button>
<button class="round-btn sm" type="button" aria-label="بستن" @click="closeNotif">
✕
</button>
</div>
</header>
<p v-if="loading && !items.length" class="muted">در حال بارگذاری…</p>
<ul v-else-if="items.length" class="notif-list">
<li v-for="item in items" :key="item.id" :class="{ unread: !item.readAt }">
<button type="button" @click="markRead(item)">
<span class="title">{{ item.title }}</span>
<span v-if="item.body" class="body">{{ item.body }}</span>
<span class="time">{{ formatTime(item.createdAt) }}</span>
</button>
</li>
</ul>
<p v-else class="muted">اعلانی نیست.</p>
</div>
</div>
<main class="content">
<RouterView />
</main>
</div>
<div v-if="openHub" class="hub-layer">
<button class="hub-dim" type="button" aria-label="بستن منو" @click="closeHub" />
<NavHubPanel :hub="openHub" @close="closeHub" @go="goHub" />
</div>
<QuickCreate />
</div>
</template>
<style scoped>
.shell {
--sidebar-card: 220px;
--sidebar-gutter-start: 14px;
--sidebar-gutter-end: 10px;
--hub-offset: calc(var(--sidebar-card) + var(--sidebar-gutter-start));
display: grid;
grid-template-columns: calc(var(--sidebar-card) + var(--sidebar-gutter-start) + var(--sidebar-gutter-end)) minmax(0, 1fr);
min-height: 100vh;
min-height: 100dvh;
background: var(--bg);
gap: 0;
transition: grid-template-columns 0.2s ease;
}
.workspace {
min-width: 0;
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
}
.backdrop,
.mobile-only {
display: none;
}
.desktop-only {
display: flex;
}
.sidebar {
position: sticky;
top: 14px;
align-self: start;
z-index: 20;
height: calc(100dvh - 28px);
margin: 14px;
margin-inline-end: 10px;
background: #fff;
border: 0;
border-radius: 28px;
padding: 0.35rem 0.85rem 1.1rem;
display: flex;
flex-direction: column;
gap: 1.35rem;
overflow: hidden;
box-sizing: border-box;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.06);
}
.rail-stem {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
gap: inherit;
}
.brand-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.45rem;
padding: 0.1rem 0.2rem 0.2rem;
flex-shrink: 0;
}
.brand-cluster {
display: flex;
align-items: center;
gap: 1.5rem;
min-width: 0;
flex: 1;
}
.brand-cluster :deep(.brand-logo.full) {
gap: 0.35rem;
flex-shrink: 0;
min-width: max-content;
}
.sidebar-logo {
flex-shrink: 0;
}
.brand-cluster :deep(.text) {
flex-shrink: 0;
}
.brand-cluster :deep(.text strong) {
font-size: 0.92rem;
white-space: nowrap;
}
.brand-cluster :deep(.text small) {
font-size: 0.62rem;
white-space: nowrap;
}
.side-nav {
display: grid;
gap: 0.45rem;
overflow-y: auto;
min-height: 0;
flex: 1;
scrollbar-width: none;
padding-inline: 0.15rem;
}
.side-nav::-webkit-scrollbar {
display: none;
}
.nav-primary,
.nav-sub {
display: grid;
gap: 0.35rem;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.82rem 1rem;
border-radius: 18px;
color: #334155;
text-decoration: none;
font-weight: 600;
font-size: 0.94rem;
transition: background 0.15s ease, color 0.15s ease;
}
.nav-item:hover {
background: #f8fafc;
color: #0f172a;
}
.nav-item.router-link-active {
background: #d8faf3;
color: #0f766e;
box-shadow: none;
}
.nav-item.router-link-active :deep(.ico) {
color: #0f766e;
}
.shell.hub-open .nav-item.router-link-active:not(.hub-on) {
background: transparent;
color: #334155;
box-shadow: none;
}
.shell.hub-open .nav-item.router-link-active:not(.hub-on) :deep(.ico) {
color: inherit;
}
.shell.hub-open .sidebar {
z-index: 70;
}
.hub-layer {
position: fixed;
inset: 0;
z-index: 60;
pointer-events: none;
}
.hub-dim {
pointer-events: auto;
position: absolute;
inset: 0;
border: 0;
padding: 0;
background: rgba(15, 23, 42, 0.28);
cursor: default;
}
.hub-layer :deep(.hub) {
pointer-events: auto;
position: absolute;
top: 42px;
inset-inline-start: var(--hub-offset);
inset-inline-end: auto;
}
.nav-badge {
margin-inline-start: auto;
min-width: 1.35rem;
height: 1.35rem;
border-radius: 999px;
background: #14b8a6;
color: #fff;
font-size: 0.7rem;
display: grid;
place-items: center;
padding: 0 0.3rem;
}
.nav-item.router-link-active .nav-badge {
background: #14b8a6;
color: #fff;
}
.nav-fold {
display: grid;
gap: 0.15rem;
}
.nav-fold-btn {
width: 100%;
border: 0;
background: transparent;
cursor: pointer;
font: inherit;
text-align: right;
}
.nav-fold-btn .chev {
margin-inline-start: auto;
}
.nav-sep {
height: 1px;
margin: 0.55rem 0.85rem;
background: #e8eef4;
}
.nav-utility {
display: grid;
gap: 0.35rem;
}
.chev {
width: 0.42rem;
height: 0.42rem;
border-right: 1.6px solid currentColor;
border-bottom: 1.6px solid currentColor;
transform: rotate(135deg);
transition: transform 0.15s ease;
flex-shrink: 0;
}
.nav-fold-btn.hub-on .chev {
transform: rotate(-45deg);
}
.nav-sub {
margin-inline-end: 0.55rem;
padding-inline-end: 0.25rem;
border-inline-end: 1px solid #eef2f7;
}
.nav-sub a {
display: block;
padding: 0.58rem 0.9rem;
border-radius: 14px;
color: #64748b;
text-decoration: none;
font-size: 0.88rem;
}
.nav-sub a.router-link-active {
background: #ecfdf8;
color: #0f766e;
font-weight: 700;
}
.scope-banner {
margin: 0;
padding: 0.55rem 0.75rem;
border-radius: 12px;
background: #f8fafc;
color: #94a3b8;
font-size: 0.75rem;
}
.sidebar-foot {
margin-top: auto;
display: grid;
gap: 0.85rem;
flex-shrink: 0;
padding-top: 0.35rem;
}
.profile-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.22rem;
padding: 0.15rem 0.2rem;
border-radius: 12px;
background: transparent;
color: inherit;
text-decoration: none;
flex: 1 1 0;
min-width: 0;
max-width: 4.8rem;
}
.profile-card:hover {
background: #f0fdfa;
}
.profile-card.router-link-active {
background: #ecfdf5;
}
.avatar-wrap {
position: relative;
width: 36px;
height: 36px;
flex-shrink: 0;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 999px;
overflow: hidden;
box-shadow: 0 3px 8px rgba(15, 23, 42, 0.1);
display: block;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.profile-meta {
min-width: 0;
width: 100%;
display: grid;
text-align: center;
}
.profile-meta strong {
font-size: 0.78rem;
font-weight: 800;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.online-dot {
position: absolute;
bottom: 0;
inset-inline-start: 0;
width: 10px;
height: 10px;
border-radius: 999px;
background: #22c55e;
border: 2px solid #fff;
box-sizing: border-box;
}
.promo-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.05rem 1.05rem;
border-radius: 22px;
background: linear-gradient(105deg, #c4e84a 0%, #2dd4bf 42%, #1d4ed8 100%);
color: #fff;
box-shadow: 0 12px 26px rgba(29, 78, 216, 0.18);
}
.promo-icons {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
flex-shrink: 0;
opacity: 0.95;
}
.promo-card p {
margin: 0;
flex: 1;
min-width: 0;
font-size: 0.98rem;
font-weight: 800;
line-height: 1.5;
display: grid;
gap: 0.1rem;
}
.promo-card p span {
display: block;
}
.logout-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: 0;
background: transparent;
color: #94a3b8;
font: inherit;
font-size: 0.78rem;
cursor: pointer;
text-align: right;
padding: 0 0.45rem 0.15rem;
}
.collapse-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
width: 100%;
border: 1px solid #e2e8f0;
background: #fff;
color: #64748b;
font: inherit;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
border-radius: 12px;
min-height: 36px;
padding: 0.35rem 0.5rem;
}
.collapse-btn :deep(.ico) {
transition: transform 0.15s ease;
transform: scaleX(-1);
}
.more-btn {
width: 100%;
border: 0;
background: transparent;
cursor: pointer;
font: inherit;
color: inherit;
}
.more-flyout {
position: absolute;
top: 4.6rem;
inset-inline-start: calc(100% + 8px);
inset-inline-end: auto;
width: 13.5rem;
max-height: min(70vh, 28rem);
overflow: auto;
background: #fff;
border: 1px solid #e8eef3;
border-radius: 16px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12);
padding: 0.55rem;
display: grid;
gap: 0.15rem;
z-index: 40;
}
.more-flyout p {
margin: 0.35rem 0 0.15rem;
padding: 0 0.55rem;
color: #94a3b8;
font-size: 0.72rem;
font-weight: 800;
}
.more-flyout p:first-child {
margin-top: 0;
}
.more-flyout a {
display: block;
padding: 0.48rem 0.55rem;
border-radius: 10px;
color: #334155;
text-decoration: none;
font-size: 0.84rem;
}
.more-flyout a.router-link-active {
background: #ecfdf8;
color: #0f766e;
font-weight: 700;
}
@media (min-width: 901px) {
.shell.collapsed {
--hub-offset: 72px;
grid-template-columns: 80px minmax(0, 1fr);
}
.sidebar.collapsed {
width: 64px;
margin-inline: 8px;
padding: 0;
gap: 0;
align-items: flex-start;
overflow: visible;
background: transparent;
box-shadow: none;
border-radius: 0;
z-index: 24;
}
.sidebar.collapsed .brand-row {
width: max-content;
max-width: none;
height: var(--control-h);
min-height: var(--control-h);
box-sizing: border-box;
overflow: hidden;
align-self: flex-start;
align-items: center;
justify-content: flex-start;
z-index: 3;
padding: 0 0.5rem;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
}
.sidebar.collapsed .brand-cluster {
flex: 0 0 auto;
width: max-content;
flex-direction: row;
align-items: center;
gap: 0.55rem;
}
.sidebar.collapsed .sidebar-logo,
.sidebar.collapsed .brand-cluster :deep(.brand-logo) {
display: inline-flex;
flex-shrink: 0;
}
.sidebar.collapsed .brand-cluster :deep(.mark) {
width: 28px !important;
height: 28px !important;
}
.sidebar.collapsed .brand-cluster :deep(.text strong) {
font-size: 0.82rem;
line-height: 1.05;
}
.sidebar.collapsed .brand-cluster :deep(.text small) {
font-size: 0.58rem;
line-height: 1;
}
.sidebar.collapsed .profile-card {
flex-direction: row;
align-items: center;
max-width: none;
flex: 0 0 auto;
gap: 0;
padding: 0;
}
.sidebar.collapsed .profile-meta {
display: none;
}
.sidebar.collapsed .avatar-wrap,
.sidebar.collapsed .avatar {
width: 28px;
height: 28px;
}
.sidebar.collapsed .online-dot {
width: 8px;
height: 8px;
border-width: 1.5px;
}
.shell.collapsed .desktop-topbar {
padding-inline-start: 8rem;
}
.shell.collapsed .global-search {
margin-inline-start: 0;
}
.sidebar.collapsed .rail-stem {
width: 64px;
align-self: flex-start;
flex: 1 1 0;
min-height: 0;
overflow: hidden;
margin-top: 8px;
padding: 0.7rem 0.2rem 0.3rem;
background: #fff;
border-radius: 16px;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
gap: 0.1rem;
z-index: 1;
}
.sidebar.collapsed .side-nav {
overflow-x: hidden;
overflow-y: auto;
min-height: 0;
flex: 1 1 0;
gap: 0.08rem;
padding-inline: 0;
scrollbar-width: none;
}
.sidebar.collapsed .nav-primary,
.sidebar.collapsed .nav-utility {
display: grid;
width: 100%;
gap: 0.08rem;
}
.sidebar.collapsed .nav-sep {
margin: 0.25rem 0.55rem;
}
.sidebar.collapsed .nav-fold {
gap: 0;
}
.sidebar.collapsed .nav-sub,
.sidebar.collapsed .nav-fold-btn .chev {
display: none;
}
.sidebar.collapsed .nav-item {
display: grid;
place-items: center;
flex-shrink: 0;
min-height: var(--control-h);
height: var(--control-h);
padding: 0;
border-radius: var(--radius);
position: relative;
}
.sidebar.collapsed .nav-item :deep(.ico) {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.sidebar.collapsed .nav-item > span:not(.nav-badge) {
display: none;
}
.sidebar.collapsed .nav-item.router-link-active {
background: #d8faf3;
color: #0f766e;
}
.sidebar.collapsed .nav-badge {
position: absolute;
top: 0.18rem;
inset-inline-end: 0.1rem;
margin: 0;
min-width: 0.95rem;
height: 0.95rem;
font-size: 0.58rem;
}
.sidebar.collapsed .promo-card,
.sidebar.collapsed .scope-banner {
display: none;
}
.sidebar.collapsed .sidebar-foot {
margin-top: 0.15rem;
padding-top: 0;
gap: 0.1rem;
flex-shrink: 0;
}
.sidebar.collapsed .logout-link {
display: grid;
place-items: center;
width: 100%;
min-height: var(--control-h);
padding: 0;
border-radius: var(--radius);
color: #94a3b8;
}
.sidebar.collapsed .logout-link:hover {
background: #f8fafc;
color: #0f172a;
}
.sidebar.collapsed .logout-link span {
display: none;
}
.sidebar.collapsed .collapse-btn {
min-height: 32px;
font-size: 0.7rem;
padding: 0.15rem;
border-radius: 10px;
}
.sidebar.collapsed .collapse-btn :deep(.ico) {
transform: none;
}
}
.global-search {
width: min(100%, 420px);
max-width: 420px;
min-width: 0;
justify-self: start;
margin: 0;
display: flex;
align-items: center;
gap: 0.7rem;
background: #fff;
border-radius: var(--radius);
min-height: var(--control-h);
padding: 0 0.95rem;
border: 1px solid transparent;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
box-sizing: border-box;
}
.desktop-topbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 1rem;
min-width: 0;
/* همان فاصله بالای سایدبار شناور (14px) */
padding: 14px 1.5rem 0.85rem;
position: sticky;
top: 0;
z-index: 20;
background: color-mix(in srgb, var(--bg) 92%, transparent);
backdrop-filter: blur(10px);
}
.topbar-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
/* مرکز عمودی نسبت به ارتفاع جستجو (~49px) */
min-height: var(--control-h);
}
.global-search:focus-within {
background: #fff;
border-color: #e2e8f0;
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
}
.global-search input {
border: 0;
background: transparent;
width: 100%;
min-width: 0;
outline: none;
font-size: 0.92rem;
color: #0f172a;
}
.global-search input::placeholder {
color: #94a3b8;
}
.search-leading {
color: #94a3b8;
}
.round-btn {
position: relative;
width: var(--control-h);
height: var(--control-h);
border-radius: var(--radius);
border: 0;
background: #fff;
color: #64748b;
display: grid;
place-items: center;
cursor: pointer;
text-decoration: none;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.round-btn.accent {
color: #14b8a6;
background: #ecfdf8;
}
.round-btn.sm {
width: 34px;
height: 34px;
}
.date-chip {
display: inline-flex;
align-items: center;
gap: 0.45rem;
height: var(--control-h);
box-sizing: border-box;
padding: 0 0.75rem;
border-radius: var(--radius);
background: #fff;
color: #64748b;
font-size: 0.84rem;
font-weight: 600;
white-space: nowrap;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.badge {
position: absolute;
top: -2px;
inset-inline-start: -2px;
min-width: 1.05rem;
height: 1.05rem;
border-radius: 999px;
background: #ef4444;
color: #fff;
font-size: 0.65rem;
display: grid;
place-items: center;
padding: 0 0.22rem;
border: 2px solid #fff;
}
.notif-layer {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: start end;
padding: 5rem 1rem 1rem;
pointer-events: none;
}
.notif-backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.22);
pointer-events: auto;
}
.notif-panel {
position: relative;
width: min(360px, calc(100vw - 2rem));
max-height: min(70vh, 480px);
overflow: auto;
background: #fff;
border: 1px solid #e8eef3;
border-radius: 18px;
padding: 0.85rem;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.12);
pointer-events: auto;
}
.notif-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.55rem;
}
.notif-head-actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
.linkish {
border: 0;
background: transparent;
color: #14b8a6;
cursor: pointer;
font: inherit;
font-size: 0.82rem;
}
.notif-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.notif-list button {
width: 100%;
text-align: right;
border: 0;
background: #f8fafc;
border-radius: 12px;
padding: 0.65rem 0.7rem;
cursor: pointer;
display: grid;
gap: 0.2rem;
font: inherit;
}
.notif-list li.unread button {
background: #ecfdf8;
}
.title {
font-weight: 700;
font-size: 0.9rem;
}
.body,
.time,
.muted {
color: #94a3b8;
font-size: 0.8rem;
}
.content {
padding: 0 1.5rem 1.5rem;
min-width: 0;
overflow-x: clip;
}
@media (max-width: 1280px) and (min-width: 901px) {
.content {
padding-inline: 1rem;
}
.desktop-topbar {
padding-inline: 1rem;
}
}
.burger {
width: 16px;
height: 2px;
background: currentColor;
position: relative;
display: block;
}
.burger::before,
.burger::after {
content: '';
position: absolute;
inset-inline: 0;
height: 2px;
background: currentColor;
}
.burger::before {
top: -5px;
}
.burger::after {
top: 5px;
}
.burger.open {
background: transparent;
}
.burger.open::before {
transform: translateY(5px) rotate(45deg);
}
.burger.open::after {
transform: translateY(-5px) rotate(-45deg);
}
.mobile-topbar {
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 0.9rem;
background: #fff;
border-bottom: 1px solid #e8eef3;
position: sticky;
top: 0;
z-index: 30;
}
.top-brand {
display: flex;
align-items: center;
gap: 0.5rem;
}
@media (max-width: 900px) {
.shell {
grid-template-columns: minmax(0, 1fr);
}
.desktop-only {
display: none !important;
}
.mobile-only {
display: flex;
}
.backdrop {
display: block;
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
z-index: 34;
}
.sidebar {
position: fixed;
top: 0;
bottom: 0;
inset-inline-start: 0;
width: min(300px, 86vw);
height: 100dvh;
margin: 0;
border-radius: 0;
z-index: 35;
transform: translateX(110%);
transition: transform 0.2s ease;
}
.sidebar.open {
transform: translateX(0);
box-shadow: 0 0 40px rgba(0, 0, 0, 0.15);
}
.hub-layer {
z-index: 80;
}
.hub-layer :deep(.hub) {
top: auto;
bottom: 0;
inset-inline: 0;
width: 100%;
margin: 0;
animation: hub-sheet-up 0.28s ease;
}
@keyframes hub-sheet-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.content {
padding: 1rem 0.85rem 1.4rem;
}
.notif-layer {
padding: 4.25rem 0.65rem 1rem;
place-items: start stretch;
}
.notif-panel {
width: 100%;
}
}
</style>