Files
CRM_Montzar/apps/web/src/layouts/AppLayout.vue
T
MahdiandCursor 0e1bc4a1fe fix(access): honor finance sections and team-member data scope
Role treasury keys now open the first allowed finance page, and team-member ownership limits lists, dashboard, and reports instead of the whole organization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 11:20:20 +03:30

2566 lines
62 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 { formatJalaliDateTime, 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 OrgSwitcher from '@/components/OrgSwitcher.vue';
import NavHubPanel from '@/components/nav/NavHubPanel.vue';
import GamificationToaster from '@/components/gamification/GamificationToaster.vue';
import SmsHeaderWallet from '@/components/SmsHeaderWallet.vue';
import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs';
import type {
ActivitySummaryDto,
AuthUserDto,
NotificationDto,
NotificationsListDto,
} from '@mento/shared';
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
import {
canOpenPath,
firstAllowedFinancePath,
hasSectionPermission,
sectionPermissionForPath,
} from '@/lib/section-permissions';
import { refreshUnreadTotal, useOrgChat } from '@/lib/useOrgChat';
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
const { unreadTotal: chatUnreadTotal, ensureConnected: ensureChatConnected } = useOrgChat();
const chatBadge = computed(() => chatUnreadTotal.value);
const canOpenTeamWhileViewing = computed(() =>
Boolean(auth.user?.viewAs?.canOpenTeamSettings),
);
/** تنظیمات مدیریتی واقعی (نقش مشاهده‌شده) */
const canSettings = computed(() => canManageSettings(auth.user?.role));
/** نمایش هاب تنظیمات؛ در مشاهده فقط‌خواندنی فقط صفحهٔ تیم باز می‌ماند */
const showSettingsNav = computed(
() => canSettings.value || canOpenTeamWhileViewing.value,
);
const showOwnRecordsBanner = computed(
() => Boolean(auth.user?.role) && !canViewAllRecords(auth.user?.role),
);
const todayLabel = computed(() => formatJalaliShort(new Date()));
function formatBytes(bytes: number): string {
const n = Math.max(0, bytes);
const units: Array<[number, string]> = [
[1024 ** 3, 'گیگابایت'],
[1024 ** 2, 'مگابایت'],
[1024, 'کیلوبایت'],
];
for (const [size, label] of units) {
if (n >= size) {
const value = n / size;
const text = value >= 10 ? value.toFixed(0) : value.toFixed(1);
return `${toFaDigits(text)} ${label}`;
}
}
return `${toFaDigits(Math.round(n))} بایت`;
}
const storageBanner = computed(() => {
const storage = auth.user?.storage;
if (!storage || storage.level === 'ok' || storage.level === 'unlimited' || storage.quotaBytes == null) {
return null;
}
return storage;
});
const SIDEBAR_KEY = 'mento_sidebar_collapsed';
function readSidebarCollapsed() {
try {
return localStorage.getItem(SIDEBAR_KEY) === '1';
} catch {
return false;
}
}
const collapsed = ref(readSidebarCollapsed());
const openHubKey = ref<NavHubKey | null>(null);
const hubStack = ref<NavHubKey[]>([]);
const isMobileNav = ref(false);
const globalSearch = ref('');
const activityBadge = ref(0);
const routineBadge = ref(0);
const priorSaving = ref(false);
const priorLens = computed(() => auth.user?.priorArchive?.lens ?? 'current');
async function setPriorLens(lens: 'current' | 'archive') {
if (!auth.user?.priorArchive || priorLens.value === lens || priorSaving.value) return;
priorSaving.value = true;
try {
const { data } = await api.patch<AuthUserDto>('/auth/me/prior-archive', { lens });
auth.setUser(data);
window.location.reload();
} finally {
priorSaving.value = false;
}
}
function pathIn(prefixes: string[]) {
return prefixes.some(
(p) => route.path === p || route.path.startsWith(`${p}/`),
);
}
const dashboardActive = computed(() => route.path === '/');
const loyaltyActive = computed(() => pathIn(['/loyalty']));
const reportsActive = computed(() =>
pathIn(['/reports', '/reports/tahesab-sales']),
);
const clubActive = computed(() => pathIn(['/club']));
const leadsActive = computed(() => pathIn(['/leads', '/settings/lead-stages']));
const customersActive = computed(
() => pathIn(['/contacts']) || (isMobileNav.value && leadsActive.value),
);
const salesActive = computed(() =>
pathIn([
'/pipeline',
'/deals',
'/quotes',
'/invoices',
'/commissions',
'/sales/forecast',
'/settings/products',
'/settings/pipelines',
]),
);
const activitiesActive = computed(() => pathIn(['/activities']));
const notesActive = computed(() => route.path === '/notes' || route.path.startsWith('/notes/'));
const routinesActive = computed(() => pathIn(['/routines']));
const conversationsActive = computed(() => pathIn(['/conversations']));
const supportActive = computed(() => pathIn(['/tickets']));
const automationsActive = computed(
() =>
route.path.startsWith('/settings/automations') ||
route.path.startsWith('/settings/message-automations') ||
route.path.startsWith('/settings/sms-campaigns'),
);
const toolsActive = computed(() =>
pathIn([
'/projects',
'/project-cards',
'/inventory',
'/cheques',
'/attachments',
'/reservations',
'/settings/lead-forms',
]),
);
const settingsActive = computed(() =>
pathIn([
'/settings/team',
'/settings/audit-logs',
'/settings/public-api',
'/settings/pipelines',
'/settings/custom-fields',
'/settings/currencies',
'/settings/tahesab',
'/settings/woocommerce',
'/settings/montify',
'/settings/voip',
'/settings/faq',
]),
);
const moreSectionActive = computed(
() =>
routinesActive.value ||
clubActive.value ||
notesActive.value ||
conversationsActive.value ||
supportActive.value ||
loyaltyActive.value ||
reportsActive.value ||
automationsActive.value ||
toolsActive.value ||
settingsActive.value,
);
const enabledModules = computed(() => auth.user?.enabledModules ?? null);
const subscription = computed(() => auth.user?.subscription ?? null);
/** لینک‌های مستقیم منو (بیرون از هاب‌ها) با مجوز بخش */
function canOpen(path: string) {
return canOpenPath(auth.user?.role, auth.user?.permissions, path);
}
function moduleAllowed(module?: string) {
if (!module) return true;
if (!enabledModules.value) return true;
return enabledModules.value.includes(module as never);
}
function cardAllowedForNav(card: {
ownerOnly?: boolean;
admin?: boolean;
module?: string;
to?: string;
}) {
if (card.ownerOnly && auth.user?.role !== 'owner') return false;
const perms = auth.user?.permissions ?? [];
if (card.to?.startsWith('/finance')) {
return (
moduleAllowed(card.module ?? 'treasury')
&& Boolean(firstAllowedFinancePath(auth.user?.role, perms))
);
}
const section = card.to ? sectionPermissionForPath(card.to) : null;
const hasSection = section
? hasSectionPermission(auth.user?.role, auth.user?.permissions, section)
: true;
if (!hasSection) return false;
// لاگ فعالیت با تیک «لاگ فعالیت کاربران» برای غیرمدیر هم باز می‌شود
const grantedBySection = section === 'audit' && hasSection;
if (card.admin && !canSettings.value && !canOpenTeamWhileViewing.value && !grantedBySection) {
return false;
}
if (!moduleAllowed(card.module)) return false;
if (
card.admin &&
canOpenTeamWhileViewing.value &&
!canSettings.value &&
card.to &&
!card.to.startsWith('/settings/team')
) {
return false;
}
return true;
}
const hubVisible = computed(() => {
const map: Record<string, boolean> = {};
for (const hub of NAV_HUBS) {
map[hub.key] = hub.cards.some((card) => cardAllowedForNav(card));
}
return map;
});
const openHub = computed<NavHubDef | null>(() => {
if (!openHubKey.value) return null;
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
if (!hub) return null;
const financeTo = firstAllowedFinancePath(auth.user?.role, auth.user?.permissions);
const cards = hub.cards
.filter((card) => cardAllowedForNav(card))
.map((card) => {
if (!financeTo || !card.to.startsWith('/finance') || card.to === financeTo) return card;
return { ...card, to: financeTo };
});
if (
hub.key === 'customers' &&
isMobileNav.value &&
hubVisible.value.leads
) {
cards.push({
to: '/leads',
hub: 'leads',
title: 'لیدها',
desc: 'مخاطبین بالقوه قبل از تبدیل به مشتری',
icon: 'user-plus',
tint: 'sky',
module: 'leads',
});
}
return {
...hub,
cards,
shortcuts: hub.shortcuts.filter((item) => cardAllowedForNav(item)),
};
});
const hubCanBack = computed(() => hubStack.value.length > 0);
const profileFullName = computed(() => auth.user?.fullName?.trim() || '');
const profileDisplayName = computed(() => profileFullName.value);
function closeHub() {
openHubKey.value = null;
hubStack.value = [];
}
function onHubClick(key: NavHubKey) {
hubStack.value = [];
openHubKey.value = openHubKey.value === key ? null : key;
}
function openNestedHub(key: NavHubKey) {
if (openHubKey.value) {
hubStack.value = [...hubStack.value, openHubKey.value];
}
openHubKey.value = key;
}
function backHub() {
const prev = hubStack.value[hubStack.value.length - 1];
if (!prev) {
closeHub();
return;
}
hubStack.value = hubStack.value.slice(0, -1);
openHubKey.value = prev;
}
function openTabHub(key: NavHubKey) {
moreOpen.value = false;
notifOpen.value = false;
onHubClick(key);
}
function openMoreHub(key: NavHubKey) {
notifOpen.value = false;
onHubClick(key);
}
function goHub(to: string) {
closeHub();
closeMenu();
void router.push(to);
}
const moreOpen = 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;
let mobileMq: MediaQueryList | null = null;
const hasUnread = computed(() => unreadCount.value > 0);
const moreTabActive = computed(() => moreOpen.value || moreSectionActive.value);
function closeMore() {
moreOpen.value = false;
}
function closeMenu() {
closeMore();
}
function toggleCollapsed() {
collapsed.value = !collapsed.value;
try {
localStorage.setItem(SIDEBAR_KEY, collapsed.value ? '1' : '0');
} catch {
// ignore
}
}
function closeNotif() {
notifOpen.value = false;
}
function onMobileMqChange(event: MediaQueryListEvent) {
isMobileNav.value = event.matches;
if (!event.matches) {
closeMore();
hubStack.value = [];
}
}
async function onLogout() {
closeHub();
closeMenu();
closeNotif();
await auth.logout();
await 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', {
params: { desk: 'mine' },
});
activityBadge.value = data.todayCount;
} catch {
activityBadge.value = 0;
}
}
async function loadRoutineBadge() {
if (!moduleAllowed('routines')) return;
try {
const { data } = await api.get<{ remaining: number; overdue: number }>('/routine-tasks/badge');
routineBadge.value = data.remaining + data.overdue;
} catch {
routineBadge.value = 0;
}
}
async function loadChatBadge() {
if (!moduleAllowed('conversations')) return;
try {
await refreshUnreadTotal();
await ensureChatConnected();
} catch {
// keep previous
}
}
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 toggleMore() {
notifOpen.value = false;
if (moreOpen.value) {
moreOpen.value = false;
return;
}
closeHub();
moreOpen.value = true;
}
function toggleNotif() {
moreOpen.value = false;
notifOpen.value = !notifOpen.value;
if (notifOpen.value) void refreshNotifs();
}
function formatTime(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return formatJalaliDateTime(d);
}
function submitSearch() {
const q = globalSearch.value.trim();
if (!q) return;
void router.push({ name: 'contacts', query: { q } });
}
function syncBodyLock() {
const overlay = moreOpen.value || Boolean(openHubKey.value);
document.body.style.overflow =
overlay || notifOpen.value ? 'hidden' : '';
document.body.classList.toggle('mobile-nav-overlay', overlay);
}
watch(moreOpen, 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();
void loadRoutineBadge();
void loadChatBadge();
pollTimer = setInterval(() => {
void refreshNotifs();
void loadRoutineBadge();
void refreshUnreadTotal().catch(() => undefined);
}, 45000);
mobileMq = window.matchMedia('(max-width: 900px)');
isMobileNav.value = mobileMq.matches;
mobileMq.addEventListener('change', onMobileMqChange);
});
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer);
document.body.style.overflow = '';
document.body.classList.remove('mobile-nav-overlay');
mobileMq?.removeEventListener('change', onMobileMqChange);
});
</script>
<template>
<div class="shell app-frame" :class="{ collapsed, 'hub-open': Boolean(openHub), 'more-open': moreOpen }">
<aside id="app-sidebar" class="sidebar" :class="{ collapsed }">
<div class="brand-row">
<div class="brand-cluster">
<MentoLogo class="sidebar-logo" :size="52" />
<RouterLink
class="profile-card profile-brand"
:to="{ name: 'profile' }"
:title="auth.user?.fullName || 'پروفایل'"
@click="closeMenu"
>
<span class="avatar-wrap">
<span class="avatar">
<img
:src="auth.avatarSrc"
:alt="auth.user?.fullName || 'کاربر'"
width="36"
height="36"
/>
</span>
<i class="online-dot" title="آنلاین" aria-hidden="true" />
</span>
<span class="profile-meta">
<strong>{{ profileDisplayName }}</strong>
</span>
</RouterLink>
</div>
<button
class="icon-btn ghost mobile-only"
type="button"
aria-label="بستن منو"
@click="closeMenu"
>
✕
</button>
<button
class="collapse-btn desktop-only"
type="button"
:aria-pressed="collapsed"
:aria-label="collapsed ? 'باز کردن منو' : 'جمع کردن منو'"
:title="collapsed ? 'باز کردن منو' : 'جمع کردن منو'"
@click="toggleCollapsed"
>
<UiIcon name="chevron" :size="12" />
</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" v-if="hubVisible.customers" :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" v-if="hubVisible.leads" :class="{ active: leadsActive || openHubKey === 'leads' }">
<button
class="nav-item nav-fold-btn"
type="button"
title="لیدها"
:class="{ 'router-link-active': leadsActive || openHubKey === 'leads', 'hub-on': openHubKey === 'leads' }"
:aria-expanded="openHubKey === 'leads'"
@click="onHubClick('leads')"
>
<UiIcon name="user-plus" :size="collapsed ? 20 : 18" />
<span>لیدها</span>
<i class="chev" aria-hidden="true" />
</button>
</div>
<div class="nav-fold" v-if="hubVisible.sales" :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" v-if="hubVisible.activities" :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
v-if="moduleAllowed('routines')"
to="/routines"
class="nav-item"
title="کارهای روتین"
:class="{ 'router-link-active': routinesActive }"
@click="closeHub(); closeMenu()"
>
<UiIcon name="refresh" :size="collapsed ? 20 : 18" />
<span>کارهای روتین</span>
<span v-if="routineBadge" class="nav-badge">{{ toFaDigits(routineBadge) }}</span>
</RouterLink>
<RouterLink
v-if="moduleAllowed('gamification')"
to="/club"
class="nav-item"
title="باشگاه امتیازات"
:class="{ 'router-link-active': clubActive }"
@click="closeHub(); closeMenu()"
>
<UiIcon name="trophy" :size="collapsed ? 20 : 18" />
<span>باشگاه امتیازات</span>
</RouterLink>
<RouterLink
v-if="moduleAllowed('conversations')"
to="/conversations"
class="nav-item"
title="گفتگو"
:class="{ 'router-link-active': conversationsActive }"
@click="closeHub(); closeMenu()"
>
<UiIcon name="chat" :size="collapsed ? 20 : 18" />
<span>گفتگو</span>
<span v-if="chatBadge" class="nav-badge">{{ toFaDigits(chatBadge) }}</span>
</RouterLink>
<RouterLink
v-if="canOpen('/notes')"
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
v-if="canOpen('/reports')"
to="/reports"
class="nav-item"
title="گزارش‌ها"
@click="closeHub(); closeMenu()"
>
<UiIcon name="chart" :size="collapsed ? 20 : 18" />
<span>گزارش‌ها</span>
</RouterLink>
<div
v-if="canSettings"
class="nav-fold"
:class="{ active: automationsActive || openHubKey === 'automations' }"
>
<button
class="nav-item nav-fold-btn"
type="button"
title="اتوماسیون"
:class="{ 'router-link-active': automationsActive || openHubKey === 'automations', 'hub-on': openHubKey === 'automations' }"
:aria-expanded="openHubKey === 'automations'"
@click="onHubClick('automations')"
>
<UiIcon name="layers" :size="collapsed ? 20 : 18" />
<span>اتوماسیون</span>
<i class="chev" aria-hidden="true" />
</button>
</div>
</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="showSettingsNav"
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 v-if="auth.user?.priorArchive" class="prior-switch" role="group" aria-label="اطلاعات قبل">
<button
type="button"
:class="{ on: priorLens === 'current' }"
:disabled="priorSaving"
@click="setPriorLens('current')"
>
کار جاری
</button>
<button
type="button"
:class="{ on: priorLens === 'archive' }"
:disabled="priorSaving"
@click="setPriorLens('archive')"
>
اطلاعات قبل
</button>
</div>
<div class="foot-actions">
<RouterLink
class="profile-card profile-foot"
:to="{ name: 'profile' }"
:title="auth.user?.fullName || 'پروفایل'"
@click="closeMenu"
>
<span class="avatar-wrap">
<span class="avatar">
<img
:src="auth.avatarSrc"
:alt="auth.user?.fullName || 'کاربر'"
width="36"
height="36"
/>
</span>
<i class="online-dot" title="آنلاین" aria-hidden="true" />
</span>
<span class="profile-meta">
<strong>{{ profileDisplayName }}</strong>
</span>
</RouterLink>
<div class="foot-meta">
<OrgSwitcher :compact="collapsed" />
<button
class="logout-link"
type="button"
title="خروج از حساب"
aria-label="خروج از حساب"
@click="onLogout"
>
<UiIcon name="logout" :size="18" />
<span>خروج از حساب</span>
</button>
</div>
</div>
</div>
</div>
</aside>
<div class="workspace">
<header class="mobile-topbar mobile-only">
<div class="top-brand">
<MentoLogo :size="32" :show-text="false" variant="mark" />
<BrandName lang="en" />
</div>
<div class="top-tools">
<SmsHeaderWallet />
<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="top-avatar"
:to="{ name: 'profile' }"
:title="auth.user?.fullName || 'پروفایل'"
:aria-label="auth.user?.fullName || 'پروفایل'"
>
<span class="avatar-wrap">
<span class="avatar">
<img
:src="auth.avatarSrc"
:alt="auth.user?.fullName || 'کاربر'"
width="36"
height="36"
/>
</span>
<i class="online-dot" title="آنلاین" aria-hidden="true" />
</span>
</RouterLink>
</div>
</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
v-if="canOpen('/notes')"
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
v-if="canOpen('/activities')"
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>
<SmsHeaderWallet />
<div
v-if="subscription"
class="sub-chip"
:class="{ warn: subscription.warn }"
:title="`انقضا: ${subscription.expiresAt}`"
>
<UiIcon name="clock" :size="15" />
<span v-if="subscription.planCode === 'lifetime'">اشتراک دائمی</span>
<span v-else>
{{ toFaDigits(subscription.daysRemaining) }} روز باقی‌مانده
</span>
</div>
<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>
<p
v-if="storageBanner"
class="storage-banner"
:class="storageBanner.level === 'full' ? 'full' : 'warn'"
>
<template v-if="storageBanner.level === 'full'">
سقف حجم فایل پر شده است ({{ formatBytes(storageBanner.usedBytes) }} از
{{ formatBytes(storageBanner.quotaBytes ?? 0) }}). تا شارژ حجم، آپلود جدید ممکن نیست.
</template>
<template v-else>
حجم فایل به {{ toFaDigits(storageBanner.percent ?? 80) }}٪ رسیده
({{ formatBytes(storageBanner.usedBytes) }} از {{ formatBytes(storageBanner.quotaBytes ?? 0) }}).
</template>
</p>
<main class="content">
<RouterView />
<GamificationToaster v-if="moduleAllowed('gamification')" />
</main>
</div>
<div v-if="openHub" class="hub-layer">
<button class="hub-dim" type="button" aria-label="بستن منو" @click="closeHub" />
<NavHubPanel
:hub="openHub"
:can-back="hubCanBack"
@close="closeHub"
@back="backHub"
@go="goHub"
@open-hub="openNestedHub"
/>
</div>
<nav class="mobile-tabbar mobile-only" aria-label="منوی اصلی">
<RouterLink
to="/"
class="tab"
:class="{ on: dashboardActive && !moreOpen }"
@click="closeHub(); closeMore()"
>
<UiIcon name="home" :size="20" />
<span>داشبورد</span>
</RouterLink>
<button
v-if="hubVisible.customers || hubVisible.leads"
class="tab"
type="button"
:class="{
on:
!moreOpen &&
(customersActive ||
openHubKey === 'customers' ||
(isMobileNav && openHubKey === 'leads')),
}"
:aria-expanded="
openHubKey === 'customers' || (isMobileNav && openHubKey === 'leads')
"
@click="openTabHub('customers')"
>
<UiIcon name="users" :size="20" />
<span>مشتریان</span>
</button>
<button
v-if="hubVisible.sales"
class="tab"
type="button"
:class="{ on: !moreOpen && (salesActive || openHubKey === 'sales') }"
:aria-expanded="openHubKey === 'sales'"
@click="openTabHub('sales')"
>
<UiIcon name="briefcase" :size="20" />
<span>فروش</span>
</button>
<button
v-if="hubVisible.activities"
class="tab"
type="button"
:class="{ on: !moreOpen && (activitiesActive || openHubKey === 'activities') }"
:aria-expanded="openHubKey === 'activities'"
title="فعالیت‌ها"
@click="openTabHub('activities')"
>
<span class="tab-ico">
<UiIcon name="check" :size="20" />
<span v-if="activityBadge" class="tab-badge">{{ toFaDigits(activityBadge) }}</span>
</span>
<span>فعالیت</span>
</button>
<button
class="tab"
type="button"
:class="{ on: moreTabActive }"
:aria-expanded="moreOpen"
aria-controls="mobile-more"
@click="toggleMore"
>
<UiIcon name="grid" :size="20" />
<span>بیشتر</span>
</button>
</nav>
<div
v-if="moreOpen"
id="mobile-more"
class="more-layer mobile-only"
role="dialog"
aria-modal="true"
aria-label="منو"
>
<header class="more-head">
<h2>منو</h2>
<button class="round-btn sm" type="button" aria-label="بستن" @click="closeMore">✕</button>
</header>
<nav class="more-body">
<RouterLink
v-if="moduleAllowed('routines')"
to="/routines"
class="nav-item"
:class="{ 'router-link-active': routinesActive }"
@click="closeHub(); closeMore()"
>
<UiIcon name="refresh" :size="18" />
<span>کارهای روتین</span>
<span v-if="routineBadge" class="nav-badge">{{ toFaDigits(routineBadge) }}</span>
</RouterLink>
<RouterLink
v-if="moduleAllowed('gamification')"
to="/club"
class="nav-item"
:class="{ 'router-link-active': clubActive }"
@click="closeHub(); closeMore()"
>
<UiIcon name="trophy" :size="18" />
<span>باشگاه امتیازات</span>
</RouterLink>
<RouterLink v-if="canOpen('/notes')" to="/notes" class="nav-item" @click="closeHub(); closeMore()">
<UiIcon name="document" :size="18" />
<span>یادداشت‌ها</span>
</RouterLink>
<RouterLink
v-if="moduleAllowed('conversations')"
to="/conversations"
class="nav-item"
:class="{ 'router-link-active': conversationsActive }"
@click="closeHub(); closeMore()"
>
<UiIcon name="chat" :size="18" />
<span>گفتگو</span>
<span v-if="chatBadge" class="nav-badge">{{ toFaDigits(chatBadge) }}</span>
</RouterLink>
<button
class="nav-item nav-fold-btn"
type="button"
:class="{
'router-link-active': supportActive || openHubKey === 'support',
'hub-on': openHubKey === 'support',
}"
:aria-expanded="openHubKey === 'support'"
@click="openMoreHub('support')"
>
<UiIcon name="chat" :size="18" />
<span>پشتیبانی</span>
<i class="chev" aria-hidden="true" />
</button>
<RouterLink to="/loyalty" class="nav-item" @click="closeHub(); closeMore()">
<UiIcon name="gift" :size="18" />
<span>باشگاه مشتریان</span>
</RouterLink>
<RouterLink v-if="canOpen('/reports')" to="/reports" class="nav-item" @click="closeHub(); closeMore()">
<UiIcon name="chart" :size="18" />
<span>گزارش‌ها</span>
</RouterLink>
<button
v-if="canSettings"
class="nav-item nav-fold-btn"
type="button"
:class="{
'router-link-active': automationsActive || openHubKey === 'automations',
'hub-on': openHubKey === 'automations',
}"
:aria-expanded="openHubKey === 'automations'"
@click="openMoreHub('automations')"
>
<UiIcon name="layers" :size="18" />
<span>اتوماسیون</span>
<i class="chev" aria-hidden="true" />
</button>
<button
class="nav-item nav-fold-btn"
type="button"
:class="{
'router-link-active': toolsActive || openHubKey === 'tools',
'hub-on': openHubKey === 'tools',
}"
:aria-expanded="openHubKey === 'tools'"
@click="openMoreHub('tools')"
>
<UiIcon name="apps-plus" :size="18" />
<span>ابزارها</span>
<i class="chev" aria-hidden="true" />
</button>
<button
v-if="showSettingsNav"
class="nav-item nav-fold-btn"
type="button"
:class="{
'router-link-active': settingsActive || openHubKey === 'settings',
'hub-on': openHubKey === 'settings',
}"
:aria-expanded="openHubKey === 'settings'"
@click="openMoreHub('settings')"
>
<UiIcon name="settings" :size="18" />
<span>تنظیمات</span>
<i class="chev" aria-hidden="true" />
</button>
</nav>
<button class="logout-link more-logout" type="button" @click="onLogout">
<UiIcon name="logout" :size="18" />
<span>خروج از حساب</span>
</button>
</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;
min-height: 100dvh;
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: 0.65rem;
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-width: 0;
min-height: 0;
gap: 0.85rem;
}
.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.55rem;
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: 1.28rem;
white-space: nowrap;
}
.brand-cluster :deep(.text small) {
font-size: 0.82rem;
white-space: nowrap;
}
.side-nav {
display: grid;
gap: 0.18rem;
align-content: start;
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.2rem;
align-content: start;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.62rem 0.9rem;
border-radius: 16px;
color: #334155;
text-decoration: none;
font-weight: 600;
font-size: 0.9rem;
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.35rem 0.85rem;
background: #e8eef4;
}
.nav-utility {
display: grid;
gap: 0.2rem;
align-content: start;
}
.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;
}
.storage-banner {
margin: 0 0 0.75rem;
padding: 0.7rem 0.9rem;
border-radius: 12px;
font-size: 0.88rem;
}
.storage-banner.warn {
background: #fff7ed;
color: #9a3412;
}
.storage-banner.full {
background: #fef2f2;
color: #991b1b;
}
.sidebar-foot {
margin-top: auto;
display: grid;
gap: 0.85rem;
flex-shrink: 0;
min-width: 0;
padding-top: 0.35rem;
}
.foot-actions {
display: grid;
gap: 0.55rem;
min-width: 0;
}
.prior-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.3rem;
}
.prior-switch button {
border: 1px solid #e2e8f0;
background: #fff;
color: #475569;
border-radius: 8px;
padding: 0.35rem 0.3rem;
font: inherit;
font-size: 0.68rem;
font-weight: 700;
cursor: pointer;
}
.prior-switch button.on {
background: #f0fdfa;
border-color: #99f6e4;
color: #0f766e;
}
.prior-switch button:disabled {
opacity: 0.6;
cursor: wait;
}
.foot-meta {
display: grid;
gap: 0.15rem;
min-width: 0;
max-width: 100%;
}
.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-foot {
display: none;
}
.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;
}
.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;
width: 22px;
height: 22px;
min-height: 0;
padding: 0;
border: 1px solid #e2e8f0;
background: #fff;
color: #94a3b8;
font: inherit;
cursor: pointer;
border-radius: 999px;
flex-shrink: 0;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
transition:
background 0.15s ease,
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.collapse-btn:hover {
background: #f8fafc;
color: #0f172a;
border-color: #cbd5e1;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
}
.collapse-btn :deep(.ico) {
width: 12px;
height: 12px;
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) {
.sidebar:not(.collapsed) .profile-brand {
display: none;
}
.sidebar:not(.collapsed) .brand-cluster {
gap: 0;
}
.sidebar:not(.collapsed) .profile-foot {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.65rem;
max-width: none;
flex: 1 1 auto;
width: 100%;
padding: 0.45rem 0.55rem;
border-radius: 14px;
background: #f8fafc;
border: 1px solid #eef2f7;
}
.sidebar:not(.collapsed) .profile-foot:hover {
background: #f0fdfa;
border-color: #ccfbf1;
}
.sidebar:not(.collapsed) .profile-foot .profile-meta {
text-align: start;
flex: 1;
min-width: 0;
}
.sidebar:not(.collapsed) .profile-foot .profile-meta strong {
font-size: 0.84rem;
}
.sidebar:not(.collapsed) .foot-meta {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
max-width: 100%;
}
.sidebar:not(.collapsed) .foot-meta :deep(.org-switch),
.sidebar:not(.collapsed) .foot-meta :deep(.org-btn) {
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
}
.sidebar:not(.collapsed) .foot-meta :deep(.org-btn) {
align-items: flex-start;
height: auto;
padding: 0.2rem 0.15rem;
}
.sidebar:not(.collapsed) .foot-meta :deep(.org-btn span) {
white-space: normal;
overflow: visible;
text-overflow: unset;
line-height: 1.35;
overflow-wrap: anywhere;
}
.sidebar:not(.collapsed) .logout-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border-radius: 12px;
background: #fff;
border: 1px solid #e2e8f0;
color: #64748b;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
flex-shrink: 0;
transition:
background 0.15s ease,
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.sidebar:not(.collapsed) .logout-link:hover {
background: #fef2f2;
border-color: #fecaca;
color: #dc2626;
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.12);
}
.sidebar:not(.collapsed) .logout-link span {
display: none;
}
.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: 34px !important;
height: 34px !important;
}
.sidebar.collapsed .brand-cluster :deep(.text strong) {
font-size: 1rem;
line-height: 1.05;
}
.sidebar.collapsed .brand-cluster :deep(.text small) {
font-size: 0.7rem;
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 .scope-banner {
display: none;
}
.sidebar.collapsed .sidebar-foot {
margin-top: 0.15rem;
padding-top: 0;
gap: 0.1rem;
flex-shrink: 0;
}
.sidebar.collapsed .prior-switch {
display: none;
}
.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 {
width: 20px;
height: 20px;
margin-inline-start: 0.15rem;
}
.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: 16px;
min-width: 0;
/* هم‌تراز با پدینگ ۱۶px محتوای workspace */
padding: 16px;
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;
}
.sub-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.7rem;
border-radius: 999px;
background: var(--accent-subtle);
color: var(--accent-deep);
font-size: 0.78rem;
font-weight: 800;
white-space: nowrap;
}
.sub-chip.warn {
background: color-mix(in srgb, var(--danger) 12%, white);
color: var(--danger);
}
.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 {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
padding: 16px;
overflow-x: clip;
}
/* ریشهٔ RouterView ارتفاع workspace را پر کند */
.content > *:first-child {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
}
@media (max-width: 1280px) and (min-width: 901px) {
.content {
padding: 16px;
}
.desktop-topbar {
padding: 16px;
}
}
.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;
}
.top-tools {
display: flex;
align-items: center;
gap: 0.45rem;
}
.top-avatar {
display: grid;
place-items: center;
width: var(--control-h);
height: var(--control-h);
text-decoration: none;
flex-shrink: 0;
}
.top-avatar .avatar-wrap,
.top-avatar .avatar {
width: 36px;
height: 36px;
}
.mobile-tabbar,
.more-layer {
display: none;
}
@media (max-width: 900px) {
.shell {
--mobile-tabbar-h: calc(3.65rem + env(safe-area-inset-bottom, 0px));
grid-template-columns: minmax(0, 1fr);
}
.desktop-only {
display: none !important;
}
.mobile-only {
display: flex;
}
.sidebar {
display: none;
}
.hub-layer {
z-index: 80;
bottom: var(--mobile-tabbar-h);
}
.hub-layer :deep(.hub) {
top: auto;
bottom: 0;
inset-inline: 0;
width: 100%;
margin: 0;
padding-bottom: 0.9rem;
max-height: min(86dvh, calc(100dvh - var(--mobile-tabbar-h) - 2rem));
animation: hub-sheet-up 0.28s ease;
}
@keyframes hub-sheet-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.content {
padding: 16px 16px calc(16px + var(--mobile-tabbar-h));
}
.notif-layer {
padding: 4.25rem 0.65rem calc(1rem + var(--mobile-tabbar-h));
place-items: start stretch;
}
.notif-panel {
width: 100%;
}
.mobile-tabbar {
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 45;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
align-items: stretch;
height: var(--mobile-tabbar-h);
min-height: var(--mobile-tabbar-h);
padding: 0.28rem 0.2rem calc(0.28rem + env(safe-area-inset-bottom, 0px));
background: #fff;
border-top: 1px solid #e8eef3;
box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.06);
}
.tab {
appearance: none;
border: 0;
background: transparent;
color: #334155;
display: grid;
justify-items: center;
align-content: center;
gap: 0.12rem;
min-width: 0;
margin: 0.12rem;
padding: 0.28rem 0.1rem;
border-radius: 14px;
font: inherit;
font-size: 0.68rem;
font-weight: 600;
line-height: 1.15;
text-decoration: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s ease, color 0.15s ease;
}
.tab > span:not(.tab-ico) {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab:hover {
background: #f8fafc;
color: #0f172a;
}
.tab:active,
.tab.on {
background: #d8faf3;
color: #0f766e;
}
.tab:active :deep(.ico),
.tab.on :deep(.ico) {
color: #0f766e;
}
.tab-ico {
position: relative;
display: grid;
place-items: center;
}
.tab-badge {
position: absolute;
top: -0.35rem;
inset-inline-start: 0.85rem;
min-width: 1rem;
height: 1rem;
padding: 0 0.2rem;
border-radius: 999px;
background: #14b8a6;
color: #fff;
font-size: 0.62rem;
font-weight: 800;
display: grid;
place-items: center;
border: 2px solid #fff;
}
.more-layer {
position: fixed;
inset: 0 0 var(--mobile-tabbar-h) 0;
z-index: 40;
display: flex;
flex-direction: column;
background: #fff;
}
.more-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: calc(0.75rem + env(safe-area-inset-top, 0px)) 1rem 0.85rem;
background: #fff;
border-bottom: 1px solid #e8eef3;
}
.more-head h2 {
margin: 0;
font-size: 1.05rem;
font-weight: 800;
color: #0f172a;
}
.more-body {
flex: 1;
overflow: auto;
display: grid;
align-content: start;
gap: 0.35rem;
padding: 0.45rem 0.55rem 0.5rem;
-webkit-tap-highlight-color: transparent;
}
.more-body .nav-item:active {
background: #d8faf3;
color: #0f766e;
}
.more-logout {
justify-self: start;
margin: 0.15rem 0.85rem 0.85rem;
}
}
</style>
<style>
body.mobile-nav-overlay .qc {
display: none;
}
</style>