Configuring access rights in the CRM Super Admin

This commit is contained in:
2026-09-20 11:24:14 +03:30
parent 04d37a9d12
commit 4a9fe0b07c
29 changed files with 1235 additions and 35 deletions
+4 -1
View File
@@ -68,7 +68,9 @@ function onLogout() {
min-height: 100dvh;
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
background: var(--bg);
background:
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(45, 212, 191, 0.08), transparent 55%),
var(--bg);
}
.sidebar {
@@ -78,6 +80,7 @@ function onLogout() {
padding: 1.15rem 0.9rem;
background: #fff;
border-inline-end: 1px solid var(--line);
box-shadow: 8px 0 24px rgba(15, 23, 42, 0.03);
}
.brand {
+60 -5
View File
@@ -79,17 +79,43 @@ const moreSectionActive = computed(
settingsActive.value,
);
const enabledModules = computed(() => auth.user?.enabledModules ?? null);
const subscription = computed(() => auth.user?.subscription ?? null);
function moduleAllowed(module?: string) {
if (!module) return true;
if (!enabledModules.value) return true;
return enabledModules.value.includes(module as never);
}
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),
cards: hub.cards.filter(
(card) =>
(!card.admin || canSettings.value) && moduleAllowed(card.module),
),
shortcuts: hub.shortcuts.filter(
(item) =>
(!item.admin || canSettings.value) && moduleAllowed(item.module),
),
};
});
const hubVisible = computed(() => {
const map: Record<string, boolean> = {};
for (const hub of NAV_HUBS) {
map[hub.key] = hub.cards.some(
(card) =>
(!card.admin || canSettings.value) && moduleAllowed(card.module),
);
}
return map;
});
function givenName(fullName: string): string {
const parts = fullName.trim().split(/\s+/).filter(Boolean);
if (parts.length <= 1) return fullName.trim();
@@ -368,7 +394,7 @@ onUnmounted(() => {
<span>داشبورد</span>
</RouterLink>
<div class="nav-fold" :class="{ active: customersActive || openHubKey === 'customers' }">
<div class="nav-fold" v-if="hubVisible.customers" :class="{ active: customersActive || openHubKey === 'customers' }">
<button
class="nav-item nav-fold-btn"
type="button"
@@ -383,7 +409,7 @@ onUnmounted(() => {
</button>
</div>
<div class="nav-fold" :class="{ active: salesActive || openHubKey === 'sales' }">
<div class="nav-fold" v-if="hubVisible.sales" :class="{ active: salesActive || openHubKey === 'sales' }">
<button
class="nav-item nav-fold-btn"
type="button"
@@ -398,7 +424,7 @@ onUnmounted(() => {
</button>
</div>
<div class="nav-fold" :class="{ active: activitiesActive || openHubKey === 'activities' }">
<div class="nav-fold" v-if="hubVisible.activities" :class="{ active: activitiesActive || openHubKey === 'activities' }">
<button
class="nav-item nav-fold-btn"
type="button"
@@ -600,6 +626,17 @@ onUnmounted(() => {
<RouterLink class="round-btn" :to="{ name: 'tickets' }" title="پیام‌ها">
<UiIcon name="mail" :size="18" />
</RouterLink>
<div
v-if="subscription"
class="sub-chip"
:class="{ warn: subscription.warn }"
:title="`انقضا: ${subscription.expiresAt}`"
>
<UiIcon name="clock" :size="15" />
<span>
{{ toFaDigits(subscription.daysRemaining) }} روز باقی‌مانده
</span>
</div>
<div class="date-chip">
<UiIcon name="calendar" :size="16" />
<span>{{ todayLabel }}</span>
@@ -1601,6 +1638,24 @@ onUnmounted(() => {
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;
+57 -14
View File
@@ -1,3 +1,5 @@
import type { OrgModuleKey } from '@mento/shared';
export type NavHubKey =
| 'customers'
| 'sales'
@@ -43,6 +45,7 @@ export type NavHubCard = {
icon: NavIconName;
tint: NavHubTint;
admin?: boolean;
module?: OrgModuleKey;
};
export type NavHubShortcut = {
@@ -50,6 +53,7 @@ export type NavHubShortcut = {
title: string;
icon: NavIconName;
admin?: boolean;
module?: OrgModuleKey;
};
export type NavHubDef = {
@@ -70,8 +74,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'users',
tint: 'mint',
shortcuts: [
{ to: '/contacts/people/new', title: 'شخص جدید', icon: 'user-plus' },
{ to: '/contacts/companies/new', title: 'شرکت جدید', icon: 'building' },
{ to: '/contacts/people/new', title: 'شخص جدید', icon: 'user-plus', module: 'customers' },
{ to: '/contacts/companies/new', title: 'شرکت جدید', icon: 'building', module: 'customers' },
],
cards: [
{
@@ -80,6 +84,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'مخاطبان حقیقی و پرونده‌های مشتری',
icon: 'user',
tint: 'mint',
module: 'customers',
},
{
to: '/contacts?tab=companies',
@@ -87,6 +92,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'سازمان‌ها و مشتریان حقوقی',
icon: 'building',
tint: 'violet',
module: 'customers',
},
],
},
@@ -97,8 +103,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'briefcase',
tint: 'mint',
shortcuts: [
{ to: '/deals/new', title: 'معامله جدید', icon: 'plus' },
{ to: '/quotes/new', title: 'پیش‌فاکتور جدید', icon: 'document' },
{ to: '/deals/new', title: 'معامله جدید', icon: 'plus', module: 'pipeline' },
{ to: '/quotes/new', title: 'پیش‌فاکتور جدید', icon: 'document', module: 'quotes' },
],
cards: [
{
@@ -107,6 +113,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'سرنخ‌ها، معاملات و مراحل فروش',
icon: 'filter',
tint: 'mint',
module: 'pipeline',
},
{
to: '/settings/products',
@@ -115,6 +122,7 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'box',
tint: 'peach',
admin: true,
module: 'products',
},
{
to: '/invoices',
@@ -122,6 +130,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'صدور فاکتور و پیگیری پرداخت‌ها',
icon: 'mail',
tint: 'sky',
module: 'invoices',
},
{
to: '/quotes',
@@ -129,6 +138,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'پیشنهاد قیمت و پیگیری تایید',
icon: 'document',
tint: 'amber',
module: 'quotes',
},
{
to: '/commissions',
@@ -136,6 +146,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'سهم فروشندگان از فاکتورهای صادرشده',
icon: 'dollar',
tint: 'rose',
module: 'commissions',
},
{
to: '/sales/forecast',
@@ -143,6 +154,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'تعیین اهداف و تحلیل عملکرد فروش',
icon: 'target',
tint: 'mint',
module: 'sales_forecast',
},
],
},
@@ -153,8 +165,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'check',
tint: 'sky',
shortcuts: [
{ to: '/activities/new', title: 'فعالیت جدید', icon: 'plus' },
{ to: '/activities/calendar', title: 'تقویم', icon: 'calendar' },
{ to: '/activities/new', title: 'فعالیت جدید', icon: 'plus', module: 'activities' },
{ to: '/activities/calendar', title: 'تقویم', icon: 'calendar', module: 'activities' },
],
cards: [
{
@@ -163,6 +175,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'کارها، تماس‌ها و پیگیری‌های جاری',
icon: 'check',
tint: 'mint',
module: 'activities',
},
{
to: '/activities/calendar',
@@ -170,6 +183,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'نمای روز، هفته و زمان‌بندی کارها',
icon: 'calendar',
tint: 'violet',
module: 'activities',
},
],
},
@@ -180,8 +194,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'chat',
tint: 'peach',
shortcuts: [
{ to: '/tickets/new', title: 'تیکت جدید', icon: 'plus' },
{ to: '/tickets', title: 'تیکت‌ها', icon: 'chat' },
{ to: '/tickets/new', title: 'تیکت جدید', icon: 'plus', module: 'tickets' },
{ to: '/tickets', title: 'تیکت‌ها', icon: 'chat', module: 'tickets' },
],
cards: [
{
@@ -190,6 +204,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'درخواست‌ها و پیام‌های پشتیبانی',
icon: 'chat',
tint: 'peach',
module: 'tickets',
},
],
},
@@ -200,8 +215,18 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'sparkle',
tint: 'violet',
shortcuts: [
{ to: '/settings/automations', title: 'قانون گردش کار', icon: 'layers' },
{ to: '/settings/message-automations', title: 'قانون پیامک', icon: 'send' },
{
to: '/settings/automations',
title: 'قانون گردش کار',
icon: 'layers',
module: 'workflow_automations',
},
{
to: '/settings/message-automations',
title: 'قانون پیامک',
icon: 'send',
module: 'message_automations',
},
],
cards: [
{
@@ -211,6 +236,7 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'layers',
tint: 'violet',
admin: true,
module: 'workflow_automations',
},
{
to: '/settings/message-automations',
@@ -219,6 +245,7 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'send',
tint: 'sky',
admin: true,
module: 'message_automations',
},
],
},
@@ -229,8 +256,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'apps-plus',
tint: 'violet',
shortcuts: [
{ to: '/project-cards/new', title: 'کارت پروژه جدید', icon: 'plus' },
{ to: '/cheques', title: 'ثبت چک', icon: 'document' },
{ to: '/project-cards/new', title: 'کارت پروژه جدید', icon: 'plus', module: 'project_boards' },
{ to: '/cheques', title: 'ثبت چک', icon: 'document', module: 'cheques' },
],
cards: [
{
@@ -240,6 +267,7 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'layers',
tint: 'mint',
admin: true,
module: 'lead_forms',
},
{
to: '/projects',
@@ -247,6 +275,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'کارت‌ها و مراحل اجرای کار',
icon: 'flag',
tint: 'violet',
module: 'project_boards',
},
{
to: '/inventory',
@@ -254,6 +283,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'موجودی کالا و گردش داخلی',
icon: 'box',
tint: 'peach',
module: 'inventory',
},
{
to: '/cheques',
@@ -261,6 +291,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'چک‌های دریافتی و پرداختی',
icon: 'document',
tint: 'sky',
module: 'cheques',
},
{
to: '/attachments',
@@ -268,6 +299,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'فایل‌های متصل به یادداشت‌ها',
icon: 'document',
tint: 'amber',
module: 'attachments',
},
],
},
@@ -278,8 +310,13 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'settings',
tint: 'sky',
shortcuts: [
{ to: '/settings/team#settings-team', title: 'تیم و نقش‌ها', icon: 'users' },
{ to: '/settings/tahesab', title: 'ته‌حساب', icon: 'chart' },
{
to: '/settings/team#settings-team',
title: 'تیم و نقش‌ها',
icon: 'users',
module: 'team_settings',
},
{ to: '/settings/tahesab', title: 'ته‌حساب', icon: 'chart', module: 'tahesab' },
],
cards: [
{
@@ -288,6 +325,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'نام سازمان و تنظیمات پایه',
icon: 'building',
tint: 'mint',
module: 'team_settings',
},
{
to: '/settings/team#settings-team',
@@ -295,6 +333,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'اعضا، دسترسی و نقش‌ها',
icon: 'users',
tint: 'violet',
module: 'team_settings',
},
{
to: '/settings/custom-fields',
@@ -302,6 +341,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'فیلدهای اضافه روی پرونده‌ها',
icon: 'pencil',
tint: 'peach',
module: 'custom_fields',
},
{
to: '/settings/currencies',
@@ -309,6 +349,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'ارز پایه و نرخ تبدیل',
icon: 'dollar',
tint: 'sky',
module: 'currencies',
},
{
to: '/settings/tahesab',
@@ -316,6 +357,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'اتصال مالی و تنظیمات حسابداری',
icon: 'chart',
tint: 'amber',
module: 'tahesab',
},
{
to: '/settings/faq',
@@ -323,6 +365,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'پرسش‌های پرتکرار سازمان',
icon: 'lightbulb',
tint: 'rose',
module: 'faq',
},
],
},
+23 -1
View File
@@ -65,6 +65,18 @@ onMounted(() => {
<strong>{{ toFaDigits(stats.membershipCount) }}</strong>
<small>نقش در محیط‌ها</small>
</article>
<article class="kpi warn">
<span class="ico"><UiIcon name="clock" :size="20" /></span>
<p>در آستانه انقضا</p>
<strong>{{ toFaDigits(stats.expiringSoonCount ?? 0) }}</strong>
<small>کمتر از ۳ روز</small>
</article>
<article class="kpi danger">
<span class="ico"><UiIcon name="warning" :size="20" /></span>
<p>منقضی</p>
<strong>{{ toFaDigits(stats.expiredBillingCount ?? 0) }}</strong>
<small>نیاز به تمدید</small>
</article>
</div>
<div class="shortcuts">
@@ -125,10 +137,20 @@ onMounted(() => {
.kpis {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.85rem;
}
.kpi.warn .ico {
background: color-mix(in srgb, #f59e0b 18%, white);
color: #b45309;
}
.kpi.danger .ico {
background: color-mix(in srgb, var(--danger) 14%, white);
color: var(--danger);
}
.kpi {
background: #fff;
border: 1px solid #eef2f7;
+120 -1
View File
@@ -3,11 +3,18 @@ import { onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import type {
CreatePlatformOrganizationRequest,
OrgModuleKey,
PlatformOrganizationDto,
UpdatePlatformOrganizationRequest,
UserRole,
} from '@mento/shared';
import { USER_ROLE_LABELS, USER_ROLES, isBusinessField } from '@mento/shared';
import {
ORG_MODULE_GROUPS,
ORG_MODULE_LABELS,
USER_ROLE_LABELS,
USER_ROLES,
isBusinessField,
} from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
@@ -40,6 +47,9 @@ const editForm = reactive({
businessField: '' as string,
});
const moduleDraft = ref<OrgModuleKey[]>([]);
const savingModules = ref(false);
function fieldLabel(value?: string | null) {
if (!value) return '—';
return isBusinessField(value) ? BUSINESS_FIELD_LABELS[value] : value;
@@ -92,6 +102,7 @@ async function toggleOpen(org: PlatformOrganizationDto) {
`/platform/organizations/${org.id}`,
);
detail.value = data;
moduleDraft.value = [...(data.enabledModules ?? [])];
} catch (e) {
error.value = apiError(e, 'بارگذاری اعضا ناموفق بود');
}
@@ -222,6 +233,7 @@ async function setMembershipRole(membershipId: string, role: UserRole) {
`/platform/organizations/${openId.value}`,
);
detail.value = data;
moduleDraft.value = [...(data.enabledModules ?? [])];
}
} catch (e) {
error.value = apiError(e, 'تغییر نقش ناموفق بود');
@@ -230,6 +242,41 @@ async function setMembershipRole(membershipId: string, role: UserRole) {
}
}
function toggleModule(key: OrgModuleKey) {
if (moduleDraft.value.includes(key)) {
moduleDraft.value = moduleDraft.value.filter((k) => k !== key);
} else {
moduleDraft.value = [...moduleDraft.value, key];
}
}
function selectGroup(keys: OrgModuleKey[], on: boolean) {
if (on) {
moduleDraft.value = [...new Set([...moduleDraft.value, ...keys])];
} else {
moduleDraft.value = moduleDraft.value.filter((k) => !keys.includes(k));
}
}
async function saveModules(orgId: string) {
savingModules.value = true;
error.value = '';
success.value = '';
try {
const { data } = await platformApi.patch<PlatformOrganizationDto>(
`/platform/organizations/${orgId}/modules`,
{ enabledModules: moduleDraft.value },
);
detail.value = data;
moduleDraft.value = [...(data.enabledModules ?? [])];
success.value = 'ماژول‌های محیط ذخیره شد';
} catch (e) {
error.value = apiError(e, 'ذخیره ماژول‌ها ناموفق بود');
} finally {
savingModules.value = false;
}
}
onMounted(() => {
void load();
});
@@ -355,6 +402,47 @@ onMounted(() => {
</form>
<div v-if="openId === org.id" class="detail">
<div class="modules">
<div class="modules-head">
<strong>ماژول‌های فعال</strong>
<button
class="btn"
type="button"
:disabled="savingModules"
@click="saveModules(org.id)"
>
{{ savingModules ? 'ذخیره…' : 'ذخیره ماژول‌ها' }}
</button>
</div>
<div v-for="group in ORG_MODULE_GROUPS" :key="group.title" class="mod-group">
<div class="mod-group-head">
<span>{{ group.title }}</span>
<button
class="btn btn-ghost"
type="button"
@click="selectGroup(group.keys, true)"
>
همه
</button>
<button
class="btn btn-ghost"
type="button"
@click="selectGroup(group.keys, false)"
>
هیچ‌کدام
</button>
</div>
<label v-for="key in group.keys" :key="key" class="mod-item">
<input
type="checkbox"
:checked="moduleDraft.includes(key)"
@change="toggleModule(key)"
/>
{{ ORG_MODULE_LABELS[key] }}
</label>
</div>
</div>
<p v-if="!detail?.members?.length" class="muted">عضوی ثبت نشده.</p>
<ul v-else>
<li v-for="member in detail.members" :key="member.membershipId">
@@ -572,6 +660,37 @@ label span {
grid-column: 1 / -1;
border-top: 1px solid var(--line);
padding-top: 0.75rem;
display: grid;
gap: 1rem;
}
.modules {
display: grid;
gap: 0.75rem;
padding: 0.85rem;
border-radius: 12px;
background: #f8fafc;
border: 1px solid var(--line);
}
.modules-head,
.mod-group-head {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.mod-group {
display: grid;
gap: 0.35rem;
}
.mod-item {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.88rem;
}
.detail ul {
+70 -2
View File
@@ -1,8 +1,18 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import type { PlatformUserDto, UserRole } from '@mento/shared';
import { USER_ROLE_LABELS, USER_ROLES } from '@mento/shared';
import type {
ApplyBillingPlanRequest,
BillingPlanCode,
PlatformUserDto,
UserRole,
} from '@mento/shared';
import {
BILLING_PLAN_CODES,
BILLING_PLAN_LABELS,
USER_ROLE_LABELS,
USER_ROLES,
} from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
@@ -121,6 +131,25 @@ async function setMembershipRole(userId: string, membershipId: string, role: Use
}
}
async function applyPlan(userId: string, planCode: BillingPlanCode) {
busyId.value = `plan-${userId}-${planCode}`;
error.value = '';
success.value = '';
try {
const body: ApplyBillingPlanRequest = { planCode };
const { data } = await platformApi.post<PlatformUserDto>(
`/platform/users/${userId}/billing/apply-plan`,
body,
);
items.value = items.value.map((row) => (row.id === userId ? data : row));
success.value = `پلن «${BILLING_PLAN_LABELS[planCode]}» اعمال شد`;
} catch (e) {
error.value = apiError(e, 'اعمال پلن ناموفق بود');
} finally {
busyId.value = '';
}
}
onMounted(() => {
void load();
});
@@ -201,6 +230,29 @@ onMounted(() => {
</form>
<div v-if="openId === user.id" class="detail">
<div class="billing">
<strong>اشتراک</strong>
<p v-if="user.billing" class="muted">
{{ BILLING_PLAN_LABELS[user.billing.planCode] }}
· {{ user.billing.status }}
· {{ toFaDigits(user.billing.daysRemaining) }} روز
· سقف محیط {{ toFaDigits(user.billing.maxOrganizations) }}
</p>
<p v-else class="muted">هنوز اشتراک ثبت نشده — با اعمال پلن ساخته می‌شود.</p>
<div class="plan-row">
<button
v-for="code in BILLING_PLAN_CODES.filter((c) => c !== 'custom')"
:key="code"
class="btn btn-ghost"
type="button"
:disabled="busyId === `plan-${user.id}-${code}`"
@click="applyPlan(user.id, code)"
>
{{ BILLING_PLAN_LABELS[code] }}
</button>
</div>
</div>
<p v-if="!user.memberships.length" class="muted">عضو هیچ محیطی نیست.</p>
<ul v-else>
<li v-for="mem in user.memberships" :key="mem.membershipId">
@@ -412,6 +464,22 @@ onMounted(() => {
font-size: 0.8rem;
}
.billing {
margin-bottom: 0.85rem;
padding: 0.75rem;
border-radius: 12px;
background: #f8fafc;
border: 1px solid var(--line);
display: grid;
gap: 0.45rem;
}
.plan-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.member-actions {
display: flex;
gap: 0.45rem;