Files
CRM_Montzar/apps/web/src/layouts/AppLayout.vue
T

1574 lines
37 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, 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 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 moreFlyout = ref(false);
const salesOpen = ref(false);
const moreOpen = ref(false);
const settingsOpen = ref(false);
const globalSearch = ref('');
const activityBadge = ref(0);
const salesPaths = ['/quotes', '/invoices', '/sales/forecast', '/reports', '/notes', '/tickets'];
const morePaths = ['/activities/calendar', '/projects', '/project-cards', '/loyalty'];
const settingsPaths = [
'/settings/team',
'/settings/lead-forms',
'/settings/custom-fields',
'/settings/products',
'/settings/currencies',
'/settings/tahesab',
'/settings/faq',
];
function pathIn(prefixes: string[]) {
return prefixes.some(
(p) => route.path === p || route.path.startsWith(`${p}/`),
);
}
const salesActive = computed(() => pathIn(salesPaths));
const moreActive = computed(() => pathIn(morePaths));
const settingsActive = computed(() => pathIn(settingsPaths));
const productsActive = computed(() => route.path.startsWith('/settings/products'));
const automationsActive = computed(() => route.path.startsWith('/settings/automations'));
const extraActive = computed(() =>
pathIn([
'/quotes',
'/invoices',
'/sales/forecast',
'/notes',
'/tickets',
'/settings/lead-forms',
'/settings/custom-fields',
'/settings/currencies',
'/settings/tahesab',
'/settings/faq',
]) || moreActive.value,
);
watch(
() => route.path,
() => {
if (salesActive.value) salesOpen.value = true;
if (moreActive.value) moreOpen.value = true;
if (settingsActive.value) settingsOpen.value = true;
},
{ immediate: true },
);
function toggleGroup(key: 'sales' | 'more' | 'settings', event: Event) {
event.preventDefault();
event.stopPropagation();
if (key === 'sales') salesOpen.value = !salesOpen.value;
if (key === 'more') moreOpen.value = !moreOpen.value;
if (key === 'settings') settingsOpen.value = !settingsOpen.value;
}
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;
moreFlyout.value = false;
}
function toggleCollapsed() {
collapsed.value = !collapsed.value;
moreFlyout.value = false;
try {
localStorage.setItem(SIDEBAR_KEY, collapsed.value ? '1' : '0');
} catch {
// ignore
}
}
const moreBtnRef = ref<HTMLElement | null>(null);
const flyoutRef = ref<HTMLElement | null>(null);
function toggleMoreFlyout() {
moreFlyout.value = !moreFlyout.value;
}
function onDocClick(event: MouseEvent) {
if (!moreFlyout.value) return;
const target = event.target;
if (!(target instanceof Node)) return;
if (moreBtnRef.value?.contains(target) || flyoutRef.value?.contains(target)) return;
moreFlyout.value = false;
}
function closeNotif() {
notifOpen.value = false;
}
function onLogout() {
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 } });
}
watch(menuOpen, (open) => {
document.body.style.overflow = open || notifOpen.value ? 'hidden' : '';
});
watch(notifOpen, (open) => {
document.body.style.overflow = open || menuOpen.value ? 'hidden' : '';
});
watch(
() => route.fullPath,
() => {
closeMenu();
closeNotif();
},
);
onMounted(() => {
try {
collapsed.value = localStorage.getItem(SIDEBAR_KEY) === '1';
} catch {
collapsed.value = false;
}
void refreshNotifs();
void loadActivityBadge();
pollTimer = setInterval(() => void refreshNotifs(), 45000);
document.addEventListener('click', onDocClick, true);
});
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer);
document.removeEventListener('click', onDocClick, true);
document.body.style.overflow = '';
});
</script>
<template>
<div class="shell" :class="{ 'menu-open': menuOpen, collapsed }">
<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>{{ auth.user?.fullName }}</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="closeMenu">
<UiIcon name="home" :size="collapsed ? 20 : 18" />
<span>داشبورد</span>
</RouterLink>
<RouterLink to="/contacts" class="nav-item" title="مشتریان" @click="closeMenu">
<UiIcon name="users" :size="collapsed ? 20 : 18" />
<span>مشتریان</span>
</RouterLink>
<RouterLink to="/pipeline" class="nav-item" title="معاملات" @click="closeMenu">
<UiIcon name="briefcase" :size="collapsed ? 20 : 18" />
<span>معاملات</span>
</RouterLink>
<RouterLink
to="/activities"
class="nav-item"
title="فعالیت‌ها"
active-class=""
exact-active-class=""
:class="{
'router-link-active':
route.path === '/activities' ||
route.path.startsWith('/activities/new') ||
(route.path.startsWith('/activities/') &&
!route.path.startsWith('/activities/calendar')),
}"
@click="closeMenu"
>
<UiIcon name="check" :size="collapsed ? 20 : 18" />
<span>فعالیت‌ها</span>
<span v-if="activityBadge" class="nav-badge">{{ toFaDigits(activityBadge) }}</span>
</RouterLink>
<RouterLink
to="/settings/products"
class="nav-item"
title="محصولات"
:class="{ 'router-link-active': productsActive }"
@click="closeMenu"
>
<UiIcon name="box" :size="collapsed ? 20 : 18" />
<span>محصولات</span>
</RouterLink>
<RouterLink to="/reports" class="nav-item" title="گزارش‌ها" @click="closeMenu">
<UiIcon name="chart" :size="collapsed ? 20 : 18" />
<span>گزارش‌ها</span>
</RouterLink>
<RouterLink
v-if="canSettings"
to="/settings/team"
class="nav-item"
title="تنظیمات"
:class="{ 'router-link-active': settingsActive && !productsActive }"
@click="closeMenu"
>
<UiIcon name="settings" :size="collapsed ? 20 : 18" />
<span>تنظیمات</span>
</RouterLink>
<RouterLink
v-if="canSettings"
to="/settings/automations"
class="nav-item"
title="اتوماسیون"
:class="{ 'router-link-active': automationsActive }"
@click="closeMenu"
>
<UiIcon name="layers" :size="collapsed ? 20 : 18" />
<span>اتوماسیون</span>
</RouterLink>
</div>
<div v-if="collapsed" ref="moreBtnRef" class="nav-more desktop-only">
<button
class="nav-item more-btn"
type="button"
title="بیشتر"
:class="{ 'router-link-active': extraActive }"
:aria-expanded="moreFlyout"
@click="toggleMoreFlyout"
>
<UiIcon name="dots" :size="20" />
<span>بیشتر</span>
</button>
</div>
<div class="nav-group" :class="{ open: salesOpen, active: salesActive }">
<button
class="nav-group-btn"
type="button"
:aria-expanded="salesOpen"
@click="toggleGroup('sales', $event)"
>
<span>فروش و مالی</span>
<span class="chev" aria-hidden="true" />
</button>
<div v-show="salesOpen" class="nav-sub">
<RouterLink
to="/invoices"
:class="{ 'router-link-active': route.path.startsWith('/invoices') || route.path.startsWith('/quotes') }"
@click="closeMenu"
>فاکتورها</RouterLink>
<RouterLink to="/sales/forecast" @click="closeMenu">هدف و پیش‌بینی</RouterLink>
<RouterLink to="/notes" @click="closeMenu">یادداشت‌ها</RouterLink>
<RouterLink to="/tickets" @click="closeMenu">تیکت‌ها</RouterLink>
</div>
</div>
<div class="nav-group" :class="{ open: moreOpen, active: moreActive }">
<button
class="nav-group-btn"
type="button"
:aria-expanded="moreOpen"
@click="toggleGroup('more', $event)"
>
<span>موارد بیشتر</span>
<span class="chev" aria-hidden="true" />
</button>
<div v-show="moreOpen" class="nav-sub">
<RouterLink to="/activities/calendar" @click="closeMenu">تقویم فعالیت‌ها</RouterLink>
<RouterLink to="/projects" @click="closeMenu">کاریز پروژه</RouterLink>
<RouterLink to="/loyalty" @click="closeMenu">باشگاه مشتریان</RouterLink>
</div>
</div>
<div
v-if="canSettings"
class="nav-group"
:class="{ open: settingsOpen, active: settingsActive }"
>
<button
class="nav-group-btn"
type="button"
:aria-expanded="settingsOpen"
@click="toggleGroup('settings', $event)"
>
<span>جزئیات تنظیمات</span>
<span class="chev" aria-hidden="true" />
</button>
<div v-show="settingsOpen" class="nav-sub">
<RouterLink to="/settings/team" @click="closeMenu">تیم و نقش‌ها</RouterLink>
<RouterLink to="/settings/lead-forms" @click="closeMenu">فرم‌ساز سرنخ</RouterLink>
<RouterLink to="/settings/custom-fields" @click="closeMenu">فیلدهای سفارشی</RouterLink>
<RouterLink to="/settings/currencies" @click="closeMenu">ارزها</RouterLink>
<RouterLink to="/settings/tahesab" @click="closeMenu">ته‌حساب</RouterLink>
<RouterLink to="/settings/faq" @click="closeMenu">FAQ</RouterLink>
</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>
<div v-if="collapsed && moreFlyout" ref="flyoutRef" class="more-flyout desktop-only">
<p>فروش و مالی</p>
<RouterLink
to="/invoices"
:class="{ 'router-link-active': route.path.startsWith('/invoices') || route.path.startsWith('/quotes') }"
@click="closeMenu"
>فاکتورها</RouterLink>
<RouterLink to="/sales/forecast" @click="closeMenu">هدف و پیش‌بینی</RouterLink>
<RouterLink to="/notes" @click="closeMenu">یادداشت‌ها</RouterLink>
<RouterLink to="/tickets" @click="closeMenu">تیکت‌ها</RouterLink>
<p>موارد بیشتر</p>
<RouterLink to="/activities/calendar" @click="closeMenu">تقویم فعالیت‌ها</RouterLink>
<RouterLink to="/projects" @click="closeMenu">کاریز پروژه</RouterLink>
<RouterLink to="/loyalty" @click="closeMenu">باشگاه مشتریان</RouterLink>
<template v-if="canSettings">
<p>تنظیمات</p>
<RouterLink to="/settings/lead-forms" @click="closeMenu">فرم‌ساز سرنخ</RouterLink>
<RouterLink to="/settings/custom-fields" @click="closeMenu">فیلدهای سفارشی</RouterLink>
<RouterLink to="/settings/currencies" @click="closeMenu">ارزها</RouterLink>
<RouterLink to="/settings/tahesab" @click="closeMenu">ته‌حساب</RouterLink>
<RouterLink to="/settings/faq" @click="closeMenu">FAQ</RouterLink>
</template>
</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>
<QuickCreate />
</div>
</template>
<style scoped>
.shell {
--sidebar-card: 220px;
--sidebar-gutter-start: 14px;
--sidebar-gutter-end: 10px;
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;
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;
min-width: 0;
}
.brand-cluster :deep(.text strong) {
font-size: 0.92rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand-cluster :deep(.text small) {
font-size: 0.62rem;
}
.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;
}
.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-group {
margin-top: 0.55rem;
}
.nav-group-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border: 0;
background: transparent;
color: #94a3b8;
font: inherit;
font-size: 0.78rem;
font-weight: 700;
padding: 0.55rem 1rem;
border-radius: 14px;
cursor: pointer;
text-align: right;
}
.chev {
width: 0.42rem;
height: 0.42rem;
border-right: 1.6px solid currentColor;
border-bottom: 1.6px solid currentColor;
transform: rotate(45deg);
transition: transform 0.15s ease;
}
.nav-group.open .chev {
transform: rotate(-135deg);
}
.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-shrink: 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 {
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: 49px;
box-sizing: border-box;
align-self: flex-start;
align-items: center;
justify-content: flex-start;
z-index: 3;
padding: 0.25rem 0.55rem;
background: #fff;
border-radius: 18px;
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: 1.5rem;
}
.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: column;
align-items: center;
max-width: 4.8rem;
flex: 0 0 auto;
gap: 0.05rem;
padding: 0;
}
.sidebar.collapsed .profile-meta {
text-align: center;
}
.sidebar.collapsed .profile-meta strong {
font-size: 0.62rem;
line-height: 1.1;
white-space: nowrap;
}
.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-more {
display: grid;
width: 100%;
gap: 0.08rem;
}
.sidebar.collapsed .more-btn {
width: 100%;
border: 0;
background: transparent;
cursor: pointer;
font: inherit;
color: inherit;
}
.sidebar.collapsed .nav-item {
display: grid;
place-items: center;
flex-shrink: 0;
min-height: 42px;
height: 42px;
padding: 0;
border-radius: 12px;
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 .nav-group,
.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: 42px;
padding: 0;
border-radius: 12px;
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: start;
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: 42px;
height: 42px;
border-radius: 999px;
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;
padding: 0.55rem 0.85rem;
border-radius: 999px;
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);
}
.content {
padding: 1rem 0.85rem 1.4rem;
}
.notif-layer {
padding: 4.25rem 0.65rem 1rem;
place-items: start stretch;
}
.notif-panel {
width: 100%;
}
}
</style>