Configuring access rights in the CRM Super Admin
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user