Creating a CRM Super Admin

This commit is contained in:
2026-09-19 21:54:21 +03:30
parent ff5ad21eb9
commit b016b4754f
28 changed files with 2977 additions and 6 deletions
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink, RouterView, useRouter } from 'vue-router';
import { usePlatformAuthStore } from '@/stores/platform-auth';
import UiIcon from '@/components/UiIcon.vue';
import MentoLogo from '@/components/brand/MentoLogo.vue';
import BrandName from '@/components/brand/BrandName.vue';
const platform = usePlatformAuthStore();
const router = useRouter();
const displayName = computed(() => platform.admin?.fullName || 'سوپرادمین');
function onLogout() {
platform.logout();
void router.push({ name: 'adm-login' });
}
</script>
<template>
<div class="adm-shell">
<aside class="sidebar">
<div class="brand">
<MentoLogo :size="32" :show-text="false" />
<div class="brand-copy">
<BrandName />
<span>پنل سوپرادمین</span>
</div>
</div>
<nav class="nav">
<RouterLink :to="{ name: 'adm-dashboard' }" class="nav-item">
<UiIcon name="home" :size="18" />
<span>داشبورد</span>
</RouterLink>
<RouterLink :to="{ name: 'adm-organizations' }" class="nav-item">
<UiIcon name="building" :size="18" />
<span>محیط‌ها</span>
</RouterLink>
<RouterLink :to="{ name: 'adm-users' }" class="nav-item">
<UiIcon name="users" :size="18" />
<span>کاربران</span>
</RouterLink>
</nav>
<div class="side-foot">
<p class="who">{{ displayName }}</p>
<button type="button" class="logout" @click="onLogout">
<UiIcon name="logout" :size="16" />
<span>خروج</span>
</button>
</div>
</aside>
<main class="main">
<RouterView />
</main>
</div>
</template>
<style scoped>
.adm-shell {
min-height: 100vh;
min-height: 100dvh;
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
background: var(--bg);
}
.sidebar {
display: flex;
flex-direction: column;
gap: 1.2rem;
padding: 1.15rem 0.9rem;
background: #fff;
border-inline-end: 1px solid var(--line);
}
.brand {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.2rem 0.35rem;
}
.brand-copy {
display: grid;
min-width: 0;
}
.brand-copy span {
color: var(--muted);
font-size: 0.75rem;
font-weight: 700;
}
.nav {
display: grid;
gap: 0.25rem;
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.7rem 0.8rem;
border-radius: 12px;
color: var(--ink-secondary);
text-decoration: none;
font-weight: 700;
font-size: 0.9rem;
}
.nav-item.router-link-active {
background: var(--accent-subtle);
color: var(--accent-deep);
}
.side-foot {
display: grid;
gap: 0.45rem;
padding: 0.35rem;
}
.who {
margin: 0;
color: var(--muted);
font-size: 0.8rem;
font-weight: 700;
}
.logout {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: 0;
background: transparent;
color: #94a3b8;
font: inherit;
font-size: 0.8rem;
cursor: pointer;
padding: 0;
}
.main {
min-width: 0;
padding: 1.4rem 1.5rem 2rem;
}
@media (max-width: 800px) {
.adm-shell {
grid-template-columns: 1fr;
}
.sidebar {
border-inline-end: 0;
border-bottom: 1px solid var(--line);
padding-bottom: 0.75rem;
}
.nav {
grid-template-columns: repeat(3, 1fr);
flex: none;
}
.nav-item {
justify-content: center;
font-size: 0.8rem;
}
.side-foot {
grid-template-columns: 1fr auto;
align-items: center;
}
.main {
padding: 1rem 0.9rem 1.6rem;
}
}
</style>
+1 -1
View File
@@ -26,7 +26,7 @@ api.interceptors.response.use(
(error) => {
if (axios.isAxiosError(error) && error.response?.status === 401) {
const path = window.location.pathname;
if (path.startsWith('/p/')) {
if (path.startsWith('/p/') || path.startsWith('/adm')) {
return Promise.reject(error);
}
localStorage.removeItem('mento_token');
+31
View File
@@ -0,0 +1,31 @@
import axios from 'axios';
export const platformApi = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});
platformApi.interceptors.request.use((config) => {
const token = localStorage.getItem('mento_platform_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
platformApi.interceptors.response.use(
(response) => response,
(error) => {
if (axios.isAxiosError(error) && error.response?.status === 401) {
const path = window.location.pathname;
localStorage.removeItem('mento_platform_token');
localStorage.removeItem('mento_platform_admin');
if (path.startsWith('/adm') && path !== '/adm') {
window.location.assign('/adm');
}
}
return Promise.reject(error);
},
);
+39
View File
@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router';
import type { UserRole } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { usePlatformAuthStore } from '@/stores/platform-auth';
export const router = createRouter({
history: createWebHistory(),
@@ -29,6 +30,34 @@ export const router = createRouter({
component: () => import('@/views/PortalView.vue'),
meta: { public: true },
},
{
path: '/adm',
name: 'adm-login',
component: () => import('@/views/adm/AdmLoginView.vue'),
meta: { public: true, platformPublic: true },
},
{
path: '/adm',
component: () => import('@/layouts/AdmLayout.vue'),
meta: { public: true, platform: true },
children: [
{
path: 'dashboard',
name: 'adm-dashboard',
component: () => import('@/views/adm/AdmDashboardView.vue'),
},
{
path: 'organizations',
name: 'adm-organizations',
component: () => import('@/views/adm/AdmOrganizationsView.vue'),
},
{
path: 'users',
name: 'adm-users',
component: () => import('@/views/adm/AdmUsersView.vue'),
},
],
},
{
path: '/',
component: () => import('@/layouts/AppLayout.vue'),
@@ -291,6 +320,16 @@ export const router = createRouter({
});
router.beforeEach((to) => {
if (to.path === '/adm' || to.path.startsWith('/adm/')) {
const platform = usePlatformAuthStore();
if (to.meta.platformPublic) {
if (platform.isAuthenticated) return { name: 'adm-dashboard' };
return true;
}
if (!platform.isAuthenticated) return { name: 'adm-login' };
return true;
}
const auth = useAuthStore();
if (!to.meta.public && !auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } };
+67
View File
@@ -0,0 +1,67 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import type { PlatformAdminDto, PlatformLoginResponse } from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
const TOKEN_KEY = 'mento_platform_token';
const ADMIN_KEY = 'mento_platform_admin';
function readStoredAdmin(): PlatformAdminDto | null {
const raw = localStorage.getItem(ADMIN_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as PlatformAdminDto;
} catch {
return null;
}
}
export const usePlatformAuthStore = defineStore('platform-auth', () => {
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY));
const admin = ref<PlatformAdminDto | null>(readStoredAdmin());
const isAuthenticated = computed(() => Boolean(token.value));
function persist(payload: PlatformLoginResponse) {
token.value = payload.accessToken;
admin.value = payload.admin;
localStorage.setItem(TOKEN_KEY, payload.accessToken);
localStorage.setItem(ADMIN_KEY, JSON.stringify(payload.admin));
}
async function login(email: string, password: string) {
const { data } = await platformApi.post<PlatformLoginResponse>(
'/platform/auth/login',
{ email, password },
);
persist(data);
}
function logout() {
token.value = null;
admin.value = null;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(ADMIN_KEY);
}
async function hydrate() {
if (!token.value) return;
try {
const { data } = await platformApi.get<PlatformAdminDto>('/platform/auth/me');
admin.value = data;
localStorage.setItem(ADMIN_KEY, JSON.stringify(data));
} catch {
logout();
}
}
if (token.value) void hydrate();
return {
token,
admin,
isAuthenticated,
login,
logout,
hydrate,
};
});
+187
View File
@@ -0,0 +1,187 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import type { PlatformStatsDto } from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { toFaDigits } from '@/lib/jalali';
import { usePlatformAuthStore } from '@/stores/platform-auth';
import UiIcon from '@/components/UiIcon.vue';
const platform = usePlatformAuthStore();
const loading = ref(true);
const error = ref('');
const stats = ref<PlatformStatsDto | null>(null);
async function load() {
error.value = '';
loading.value = true;
try {
const { data } = await platformApi.get<PlatformStatsDto>('/platform/stats');
stats.value = data;
} catch (e) {
error.value = axios.isAxiosError(e)
? 'بارگذاری آمار ناموفق بود'
: 'بارگذاری آمار ناموفق بود';
} finally {
loading.value = false;
}
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<header class="head">
<div>
<p class="eyebrow">سوپرادمین</p>
<h1>سلام {{ platform.admin?.fullName || '' }}</h1>
<p class="lead">نمای کلی حساب‌ها و محیط‌های ثبت‌شده روی پلتفرم.</p>
</div>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else-if="stats" class="kpis">
<article class="kpi">
<span class="ico"><UiIcon name="building" :size="20" /></span>
<p>محیط‌ها</p>
<strong>{{ toFaDigits(stats.organizationCount) }}</strong>
<small>{{ toFaDigits(stats.activeOrganizationCount) }} فعال</small>
</article>
<article class="kpi">
<span class="ico"><UiIcon name="users" :size="20" /></span>
<p>کاربران</p>
<strong>{{ toFaDigits(stats.userCount) }}</strong>
<small>{{ toFaDigits(stats.activeUserCount) }} فعال</small>
</article>
<article class="kpi">
<span class="ico"><UiIcon name="layers" :size="20" /></span>
<p>عضویت‌ها</p>
<strong>{{ toFaDigits(stats.membershipCount) }}</strong>
<small>نقش در محیط‌ها</small>
</article>
</div>
<div class="shortcuts">
<RouterLink :to="{ name: 'adm-organizations' }" class="card">
<UiIcon name="building" :size="18" />
<div>
<strong>مدیریت محیط‌ها</strong>
<span>ساخت، ویرایش و فعال/غیرفعال کردن سازمان‌ها</span>
</div>
</RouterLink>
<RouterLink :to="{ name: 'adm-users' }" class="card">
<UiIcon name="users" :size="18" />
<div>
<strong>مدیریت کاربران</strong>
<span>نقش، دسترسی و ریست رمز عبور</span>
</div>
</RouterLink>
</div>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 1.2rem;
max-width: 980px;
}
.head h1 {
margin: 0.15rem 0 0.35rem;
font-size: 1.45rem;
}
.eyebrow {
margin: 0;
color: var(--accent-deep);
font-size: 0.78rem;
font-weight: 800;
}
.lead,
.muted {
margin: 0;
color: var(--muted);
}
.error {
margin: 0;
color: var(--danger);
}
.kpis {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.85rem;
}
.kpi {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 14px;
padding: 1rem 1.05rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.kpi p,
.kpi small {
margin: 0;
color: var(--muted);
font-size: 0.82rem;
}
.kpi strong {
display: block;
margin: 0.2rem 0 0.15rem;
font-size: 1.55rem;
}
.ico {
display: grid;
place-items: center;
width: 40px;
height: 40px;
margin-bottom: 0.55rem;
border-radius: 10px;
background: var(--accent-subtle);
color: var(--accent-deep);
}
.shortcuts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.85rem;
}
.card {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 1rem 1.05rem;
background: #fff;
border: 1px solid var(--line);
border-radius: 14px;
text-decoration: none;
color: inherit;
}
.card span {
display: block;
color: var(--muted);
font-size: 0.82rem;
}
@media (max-width: 720px) {
.kpis,
.shortcuts {
grid-template-columns: 1fr;
}
}
</style>
+324
View File
@@ -0,0 +1,324 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { usePlatformAuthStore } from '@/stores/platform-auth';
import AuthBrandPanel from '@/components/auth/AuthBrandPanel.vue';
import MentoLogo from '@/components/brand/MentoLogo.vue';
import BrandName from '@/components/brand/BrandName.vue';
import UiIcon from '@/components/UiIcon.vue';
import { brand } from '@mento/shared';
const platform = usePlatformAuthStore();
const router = useRouter();
const email = ref('');
const password = ref('');
const showPassword = ref(false);
const error = ref('');
const loading = ref(false);
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
if (!e.response) {
return 'اتصال به سرور برقرار نشد.';
}
const msg = (e.response.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
async function submit() {
error.value = '';
loading.value = true;
try {
await platform.login(email.value.trim(), password.value);
await router.push({ name: 'adm-dashboard' });
} catch (e) {
error.value = apiError(e, 'ورود ناموفق بود');
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="auth-page">
<div class="auth-shell">
<AuthBrandPanel
headline="کنترل حساب‌ها و دسترسی‌ها"
description="پنل سوپرادمین برای مدیریت محیط‌ها، کاربران و وضعیت دسترسی‌ها."
/>
<section class="form-panel">
<header class="mobile-brand">
<MentoLogo :size="52" :show-text="false" />
<BrandName />
<span>پنل سوپرادمین</span>
</header>
<div class="form-inner">
<h1>ورود سوپرادمین</h1>
<p class="lead">فقط حساب پلتفرم می‌تواند وارد این بخش شود.</p>
<form class="form" @submit.prevent="submit">
<div class="field">
<label for="adm-email">ایمیل</label>
<div class="input-wrap">
<input
id="adm-email"
v-model="email"
type="email"
required
autocomplete="username"
placeholder="ایمیل سوپرادمین را وارد کنید"
class="ltr-value"
/>
<span class="field-ico" aria-hidden="true">
<UiIcon name="mail" :size="18" />
</span>
</div>
</div>
<div class="field">
<label for="adm-password">رمز عبور</label>
<div class="input-wrap has-eye">
<input
id="adm-password"
v-model="password"
:type="showPassword ? 'text' : 'password'"
required
autocomplete="current-password"
placeholder="رمز عبور را وارد کنید"
class="ltr-value"
/>
<span class="field-ico" aria-hidden="true">
<UiIcon name="lock" :size="18" />
</span>
<button
type="button"
class="eye"
:aria-label="showPassword ? 'مخفی کردن رمز' : 'نمایش رمز'"
@click="showPassword = !showPassword"
>
<UiIcon :name="showPassword ? 'eye-off' : 'eye'" :size="18" />
</button>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<button class="btn submit" type="submit" :disabled="loading">
{{ loading ? 'در حال ورود…' : 'ورود به پنل' }}
</button>
</form>
</div>
</section>
</div>
<footer class="auth-footer">
<p>۱۴۰۵ {{ brand.nameFa }}. تمامی حقوق محفوظ است.</p>
</footer>
</div>
</template>
<style scoped>
.auth-page {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
padding: 1rem;
padding-bottom: max(1rem, env(safe-area-inset-bottom));
background:
radial-gradient(ellipse 70% 45% at 50% 0%, rgba(45, 212, 191, 0.12), transparent 55%),
radial-gradient(rgba(13, 148, 136, 0.12) 1px, transparent 1.2px),
#f5fbfb;
background-size: auto, 20px 20px, auto;
}
.auth-shell {
flex: 1;
width: min(1100px, 100%);
margin: 0 auto;
display: grid;
overflow: hidden;
min-height: min(680px, calc(100dvh - 5.5rem));
}
.form-panel {
display: flex;
flex-direction: column;
padding: clamp(1.2rem, 3vw, 2.4rem);
min-width: 0;
background: #fff;
border-radius: 28px;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.06);
}
.mobile-brand {
display: grid;
justify-items: center;
text-align: center;
gap: 0.35rem;
margin-bottom: 1.4rem;
padding-top: 0.4rem;
}
.mobile-brand span {
color: var(--muted);
font-size: 0.82rem;
}
.form-inner {
width: min(420px, 100%);
margin: 0 auto;
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
}
h1 {
margin: 0 0 0.4rem;
font-size: clamp(1.45rem, 3vw, 1.85rem);
}
.lead {
margin: 0 0 1.2rem;
color: var(--muted);
line-height: 1.7;
}
.form {
display: grid;
gap: 0.95rem;
}
.form input {
direction: rtl;
text-align: right;
}
.form input::placeholder {
direction: rtl;
text-align: right;
color: #cdd4de;
}
.form input.ltr-value:not(:placeholder-shown) {
direction: ltr;
text-align: left;
}
.input-wrap {
position: relative;
}
.input-wrap input {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.85rem 2.6rem 0.85rem 0.95rem;
width: 100%;
background: #fff;
}
.input-wrap.has-eye input {
padding-left: 2.7rem;
}
.input-wrap input:focus {
outline: 2px solid var(--accent-soft);
border-color: var(--accent);
}
.field-ico {
position: absolute;
top: 50%;
right: 0.75rem;
left: auto;
transform: translateY(-50%);
color: #94a3b8;
display: grid;
place-items: center;
pointer-events: none;
}
.eye {
position: absolute;
top: 50%;
left: 0.45rem;
right: auto;
transform: translateY(-50%);
border: 0;
background: transparent;
color: #64748b;
cursor: pointer;
padding: 0;
width: 36px;
height: 36px;
display: grid;
place-items: center;
}
.submit {
width: 100%;
padding: 0.9rem 1.1rem;
border-radius: var(--radius);
font-size: 1rem;
}
.error {
margin: 0;
color: var(--danger);
font-size: 0.88rem;
}
.auth-footer {
width: min(1040px, 100%);
margin: 0.85rem auto 0;
color: var(--muted);
font-size: 0.82rem;
padding-inline: 0.35rem;
}
@media (min-width: 900px) {
.auth-page {
padding: 0;
background: #fff;
}
.auth-shell {
width: 100%;
min-height: 100dvh;
grid-template-columns: minmax(420px, 1fr) minmax(440px, 1.05fr);
grid-template-areas: "form brand";
}
.form-panel {
grid-area: form;
border-radius: 0;
box-shadow: none;
justify-content: center;
}
.auth-shell :deep(.brand-panel) {
grid-area: brand;
height: 100dvh;
position: sticky;
top: 0;
}
.mobile-brand {
display: none;
}
.form-inner {
width: min(440px, 100%);
}
.auth-footer {
display: none;
}
}
</style>
@@ -0,0 +1,611 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import type {
CreatePlatformOrganizationRequest,
PlatformOrganizationDto,
UpdatePlatformOrganizationRequest,
UserRole,
} from '@mento/shared';
import { 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';
const query = ref('');
const loading = ref(false);
const error = ref('');
const success = ref('');
const items = ref<PlatformOrganizationDto[]>([]);
const openId = ref<string | null>(null);
const detail = ref<PlatformOrganizationDto | null>(null);
const busyId = ref('');
const showCreate = ref(false);
const creating = ref(false);
const createForm = reactive<CreatePlatformOrganizationRequest>({
name: '',
businessField: 'personal',
ownerFullName: '',
ownerEmail: '',
ownerPassword: '',
ownerMobile: '',
});
const editingId = ref<string | null>(null);
const savingEdit = ref(false);
const editForm = reactive({
name: '',
slug: '',
businessField: '' as string,
});
function fieldLabel(value?: string | null) {
if (!value) return '—';
return isBusinessField(value) ? BUSINESS_FIELD_LABELS[value] : value;
}
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
function resetCreateForm() {
createForm.name = '';
createForm.businessField = 'personal';
createForm.ownerFullName = '';
createForm.ownerEmail = '';
createForm.ownerPassword = '';
createForm.ownerMobile = '';
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await platformApi.get<PlatformOrganizationDto[]>(
'/platform/organizations',
{ params: query.value.trim() ? { q: query.value.trim() } : undefined },
);
items.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری محیط‌ها ناموفق بود');
} finally {
loading.value = false;
}
}
async function toggleOpen(org: PlatformOrganizationDto) {
if (openId.value === org.id) {
openId.value = null;
detail.value = null;
editingId.value = null;
return;
}
openId.value = org.id;
editingId.value = null;
try {
const { data } = await platformApi.get<PlatformOrganizationDto>(
`/platform/organizations/${org.id}`,
);
detail.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری اعضا ناموفق بود');
}
}
function startEdit(org: PlatformOrganizationDto) {
editingId.value = org.id;
editForm.name = org.name;
editForm.slug = org.slug;
editForm.businessField = org.businessField ?? '';
success.value = '';
error.value = '';
}
function cancelEdit() {
editingId.value = null;
}
async function saveEdit(orgId: string) {
savingEdit.value = true;
error.value = '';
success.value = '';
try {
const body: UpdatePlatformOrganizationRequest = {
name: editForm.name.trim(),
slug: editForm.slug.trim(),
businessField: editForm.businessField || null,
};
const { data } = await platformApi.patch<PlatformOrganizationDto>(
`/platform/organizations/${orgId}`,
body,
);
items.value = items.value.map((row) =>
row.id === orgId
? {
...row,
name: data.name,
slug: data.slug,
businessField: data.businessField,
isActive: data.isActive,
}
: row,
);
if (detail.value?.id === orgId) {
detail.value = { ...detail.value, ...data };
}
editingId.value = null;
success.value = 'اطلاعات محیط ذخیره شد';
} catch (e) {
error.value = apiError(e, 'ذخیره تغییرات ناموفق بود');
} finally {
savingEdit.value = false;
}
}
async function createOrganization() {
creating.value = true;
error.value = '';
success.value = '';
try {
const body: CreatePlatformOrganizationRequest = {
name: createForm.name.trim(),
businessField: createForm.businessField || undefined,
ownerFullName: createForm.ownerFullName.trim(),
ownerEmail: createForm.ownerEmail.trim(),
ownerPassword: createForm.ownerPassword,
ownerMobile: createForm.ownerMobile?.trim() || undefined,
};
const { data } = await platformApi.post<PlatformOrganizationDto>(
'/platform/organizations',
body,
);
items.value = [data, ...items.value];
showCreate.value = false;
resetCreateForm();
success.value = `محیط «${data.name}» ساخته شد`;
} catch (e) {
error.value = apiError(e, 'ساخت محیط ناموفق بود');
} finally {
creating.value = false;
}
}
async function setOrgActive(org: PlatformOrganizationDto, isActive: boolean) {
busyId.value = org.id;
error.value = '';
try {
const { data } = await platformApi.patch<PlatformOrganizationDto>(
`/platform/organizations/${org.id}`,
{ isActive },
);
items.value = items.value.map((row) => (row.id === org.id ? { ...row, isActive } : row));
if (detail.value?.id === org.id) {
detail.value = { ...detail.value, ...data };
}
} catch (e) {
error.value = apiError(e, 'تغییر وضعیت محیط ناموفق بود');
} finally {
busyId.value = '';
}
}
async function setMembershipActive(membershipId: string, isActive: boolean) {
busyId.value = membershipId;
error.value = '';
try {
await platformApi.patch(`/platform/memberships/${membershipId}`, { isActive });
if (openId.value) {
const { data } = await platformApi.get<PlatformOrganizationDto>(
`/platform/organizations/${openId.value}`,
);
detail.value = data;
}
} catch (e) {
error.value = apiError(e, 'تغییر عضویت ناموفق بود');
} finally {
busyId.value = '';
}
}
async function setMembershipRole(membershipId: string, role: UserRole) {
busyId.value = `role-${membershipId}`;
error.value = '';
try {
await platformApi.patch(`/platform/memberships/${membershipId}/role`, { role });
if (openId.value) {
const { data } = await platformApi.get<PlatformOrganizationDto>(
`/platform/organizations/${openId.value}`,
);
detail.value = data;
}
} catch (e) {
error.value = apiError(e, 'تغییر نقش ناموفق بود');
} finally {
busyId.value = '';
}
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<header class="head">
<div>
<h1>محیط‌ها</h1>
<p>ساخت، ویرایش و فعال/غیرفعال کردن سازمان‌ها و نقش اعضا.</p>
</div>
<div class="head-actions">
<form class="search" @submit.prevent="load">
<input v-model="query" type="search" placeholder="جستجوی نام یا اسلاگ" />
<button class="btn" type="submit" :disabled="loading">جستجو</button>
</form>
<button class="btn" type="button" @click="showCreate = !showCreate">
{{ showCreate ? 'بستن فرم' : 'محیط جدید' }}
</button>
</div>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="success" class="ok">{{ success }}</p>
<form v-if="showCreate" class="panel" @submit.prevent="createOrganization">
<h2>ساخت محیط جدید</h2>
<div class="grid">
<label>
<span>نام محیط</span>
<input v-model="createForm.name" required minlength="2" />
</label>
<label>
<span>زمینه فعالیت</span>
<select v-model="createForm.businessField">
<option v-for="field in BUSINESS_FIELDS" :key="field" :value="field">
{{ BUSINESS_FIELD_LABELS[field] }}
</option>
</select>
</label>
<label>
<span>نام مالک</span>
<input v-model="createForm.ownerFullName" required minlength="2" />
</label>
<label>
<span>ایمیل مالک</span>
<input v-model="createForm.ownerEmail" type="email" required />
</label>
<label>
<span>رمز مالک</span>
<input v-model="createForm.ownerPassword" type="password" required minlength="8" />
</label>
<label>
<span>موبایل مالک (اختیاری)</span>
<input v-model="createForm.ownerMobile" type="tel" dir="ltr" />
</label>
</div>
<div class="panel-actions">
<button class="btn" type="submit" :disabled="creating">
{{ creating ? 'در حال ساخت…' : 'ساخت محیط' }}
</button>
</div>
</form>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="!items.length" class="muted">محیطی پیدا نشد.</p>
<div v-else class="list">
<article v-for="org in items" :key="org.id" class="row">
<button type="button" class="row-main" @click="toggleOpen(org)">
<div>
<strong>{{ org.name }}</strong>
<span>{{ org.slug }} · {{ fieldLabel(org.businessField) }}</span>
</div>
<div class="meta">
<em :class="org.isActive ? 'on' : 'off'">{{ org.isActive ? 'فعال' : 'غیرفعال' }}</em>
<small>
{{ toFaDigits(org.memberCount) }} عضو ·
{{ toFaDigits(org.personCount) }} شخص ·
{{ formatJalaliNumeric(new Date(org.createdAt)) }}
</small>
</div>
</button>
<div class="actions">
<button class="btn btn-ghost" type="button" @click="startEdit(org)">ویرایش</button>
<button
class="btn"
type="button"
:disabled="busyId === org.id"
@click="setOrgActive(org, !org.isActive)"
>
{{ org.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
</button>
</div>
<form
v-if="editingId === org.id"
class="edit-panel"
@submit.prevent="saveEdit(org.id)"
>
<label>
<span>نام</span>
<input v-model="editForm.name" required minlength="2" />
</label>
<label>
<span>اسلاگ</span>
<input v-model="editForm.slug" required minlength="2" dir="ltr" />
</label>
<label>
<span>زمینه فعالیت</span>
<select v-model="editForm.businessField">
<option value="">—</option>
<option v-for="field in BUSINESS_FIELDS" :key="field" :value="field">
{{ BUSINESS_FIELD_LABELS[field] }}
</option>
</select>
</label>
<div class="panel-actions">
<button class="btn" type="submit" :disabled="savingEdit">ذخیره</button>
<button class="btn btn-ghost" type="button" @click="cancelEdit">انصراف</button>
</div>
</form>
<div v-if="openId === org.id" class="detail">
<p v-if="!detail?.members?.length" class="muted">عضوی ثبت نشده.</p>
<ul v-else>
<li v-for="member in detail.members" :key="member.membershipId">
<div>
<strong>{{ member.fullName }}</strong>
<span>
{{ member.email }}
· حساب {{ member.userIsActive ? 'فعال' : 'غیرفعال' }}
</span>
</div>
<div class="member-actions">
<select
:value="member.role"
:disabled="busyId === `role-${member.membershipId}`"
@change="
setMembershipRole(
member.membershipId,
($event.target as HTMLSelectElement).value as UserRole,
)
"
>
<option v-for="role in USER_ROLES" :key="role" :value="role">
{{ USER_ROLE_LABELS[role] }}
</option>
</select>
<button
class="btn btn-ghost"
type="button"
:disabled="busyId === member.membershipId"
@click="setMembershipActive(member.membershipId, !member.isActive)"
>
{{ member.isActive ? 'قطع دسترسی' : 'بازگشت دسترسی' }}
</button>
</div>
</li>
</ul>
</div>
</article>
</div>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
max-width: 1040px;
}
.head {
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.head h1 {
margin: 0 0 0.3rem;
}
.head p,
.muted {
margin: 0;
color: var(--muted);
}
.head-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
align-items: flex-start;
}
.search {
display: flex;
gap: 0.5rem;
}
.search input,
.panel input,
.panel select,
.edit-panel input,
.edit-panel select,
.member-actions select {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.65rem 0.8rem;
font: inherit;
background: #fff;
}
.search input {
min-width: 220px;
}
.error {
margin: 0;
color: var(--danger);
}
.ok {
margin: 0;
color: var(--success-deep);
}
.panel,
.edit-panel {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 14px;
padding: 1rem;
display: grid;
gap: 0.85rem;
}
.panel h2 {
margin: 0;
font-size: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
label {
display: grid;
gap: 0.35rem;
}
label span {
font-size: 0.82rem;
color: var(--muted);
font-weight: 700;
}
.panel-actions {
display: flex;
gap: 0.5rem;
}
.list {
display: grid;
gap: 0.7rem;
}
.row {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 14px;
padding: 0.85rem 1rem;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.7rem 1rem;
}
.row-main {
display: flex;
justify-content: space-between;
gap: 1rem;
border: 0;
background: transparent;
text-align: start;
cursor: pointer;
padding: 0;
font: inherit;
color: inherit;
}
.row-main span,
.row-main small {
display: block;
color: var(--muted);
font-size: 0.8rem;
margin-top: 0.2rem;
}
.meta {
text-align: end;
}
.actions {
display: flex;
gap: 0.45rem;
align-items: flex-start;
}
.on,
.off {
font-style: normal;
font-size: 0.78rem;
font-weight: 800;
}
.on {
color: var(--success-deep);
}
.off {
color: var(--danger);
}
.edit-panel {
grid-column: 1 / -1;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: end;
}
.edit-panel .panel-actions {
grid-column: 1 / -1;
}
.detail {
grid-column: 1 / -1;
border-top: 1px solid var(--line);
padding-top: 0.75rem;
}
.detail ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.55rem;
}
.detail li {
display: flex;
justify-content: space-between;
gap: 0.8rem;
align-items: center;
flex-wrap: wrap;
}
.detail span {
display: block;
color: var(--muted);
font-size: 0.8rem;
}
.member-actions {
display: flex;
gap: 0.45rem;
align-items: center;
}
@media (max-width: 720px) {
.grid,
.edit-panel {
grid-template-columns: 1fr;
}
}
</style>
+420
View File
@@ -0,0 +1,420 @@
<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 { platformApi } from '@/lib/platform-api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
const query = ref('');
const loading = ref(false);
const error = ref('');
const success = ref('');
const items = ref<PlatformUserDto[]>([]);
const openId = ref<string | null>(null);
const busyId = ref('');
const resetUserId = ref<string | null>(null);
const resetPassword = ref('');
const resetting = ref(false);
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await platformApi.get<PlatformUserDto[]>('/platform/users', {
params: query.value.trim() ? { q: query.value.trim() } : undefined,
});
items.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری کاربران ناموفق بود');
} finally {
loading.value = false;
}
}
function toggleOpen(id: string) {
openId.value = openId.value === id ? null : id;
if (resetUserId.value !== id) {
resetUserId.value = null;
resetPassword.value = '';
}
}
function startReset(userId: string) {
resetUserId.value = resetUserId.value === userId ? null : userId;
resetPassword.value = '';
error.value = '';
success.value = '';
}
async function submitReset(userId: string) {
resetting.value = true;
error.value = '';
success.value = '';
try {
await platformApi.patch(`/platform/users/${userId}/password`, {
password: resetPassword.value,
});
success.value = 'رمز عبور به‌روز شد';
resetUserId.value = null;
resetPassword.value = '';
} catch (e) {
error.value = apiError(e, 'ریست رمز ناموفق بود');
} finally {
resetting.value = false;
}
}
async function setUserActive(user: PlatformUserDto, isActive: boolean) {
busyId.value = user.id;
error.value = '';
try {
const { data } = await platformApi.patch<PlatformUserDto>(
`/platform/users/${user.id}`,
{ isActive },
);
items.value = items.value.map((row) => (row.id === user.id ? data : row));
} catch (e) {
error.value = apiError(e, 'تغییر وضعیت کاربر ناموفق بود');
} finally {
busyId.value = '';
}
}
async function setMembershipActive(userId: string, membershipId: string, isActive: boolean) {
busyId.value = membershipId;
error.value = '';
try {
const { data } = await platformApi.patch<PlatformUserDto>(
`/platform/memberships/${membershipId}`,
{ isActive },
);
items.value = items.value.map((row) => (row.id === userId ? data : row));
} catch (e) {
error.value = apiError(e, 'تغییر عضویت ناموفق بود');
} finally {
busyId.value = '';
}
}
async function setMembershipRole(userId: string, membershipId: string, role: UserRole) {
busyId.value = `role-${membershipId}`;
error.value = '';
try {
const { data } = await platformApi.patch<PlatformUserDto>(
`/platform/memberships/${membershipId}/role`,
{ role },
);
items.value = items.value.map((row) => (row.id === userId ? data : row));
} catch (e) {
error.value = apiError(e, 'تغییر نقش ناموفق بود');
} finally {
busyId.value = '';
}
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<header class="head">
<div>
<h1>کاربران</h1>
<p>فعال/غیرفعال حساب، نقش عضویت و ریست رمز عبور.</p>
</div>
<form class="search" @submit.prevent="load">
<input v-model="query" type="search" placeholder="نام، ایمیل یا موبایل" />
<button class="btn" type="submit" :disabled="loading">جستجو</button>
</form>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="success" class="ok">{{ success }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="!items.length" class="muted">کاربری پیدا نشد.</p>
<div v-else class="list">
<article v-for="user in items" :key="user.id" class="row">
<button type="button" class="row-main" @click="toggleOpen(user.id)">
<div>
<strong>{{ user.fullName }}</strong>
<span>{{ user.email }}{{ user.mobile ? ` · ${user.mobile}` : '' }}</span>
</div>
<div class="meta">
<em :class="user.isActive ? 'on' : 'off'">{{ user.isActive ? 'فعال' : 'غیرفعال' }}</em>
<small>
{{ toFaDigits(user.memberships.length) }} محیط
<template v-if="user.lastLoginAt">
· آخرین ورود {{ formatJalaliNumeric(new Date(user.lastLoginAt)) }}
</template>
</small>
</div>
</button>
<div class="actions">
<button class="btn btn-ghost" type="button" @click="startReset(user.id)">
ریست رمز
</button>
<button
class="btn"
type="button"
:disabled="busyId === user.id"
@click="setUserActive(user, !user.isActive)"
>
{{ user.isActive ? 'غیرفعال کردن حساب' : 'فعال کردن حساب' }}
</button>
</div>
<form
v-if="resetUserId === user.id"
class="reset-panel"
@submit.prevent="submitReset(user.id)"
>
<label>
<span>رمز جدید (حداقل ۸ کاراکتر)</span>
<input
v-model="resetPassword"
type="password"
required
minlength="8"
autocomplete="new-password"
/>
</label>
<div class="panel-actions">
<button class="btn" type="submit" :disabled="resetting">
{{ resetting ? 'در حال ذخیره…' : 'ذخیره رمز' }}
</button>
<button class="btn btn-ghost" type="button" @click="startReset(user.id)">
انصراف
</button>
</div>
</form>
<div v-if="openId === user.id" class="detail">
<p v-if="!user.memberships.length" class="muted">عضو هیچ محیطی نیست.</p>
<ul v-else>
<li v-for="mem in user.memberships" :key="mem.membershipId">
<div>
<strong>{{ mem.organizationName }}</strong>
<span>
محیط {{ mem.organizationIsActive ? 'فعال' : 'غیرفعال' }}
</span>
</div>
<div class="member-actions">
<select
:value="mem.role"
:disabled="busyId === `role-${mem.membershipId}`"
@change="
setMembershipRole(
user.id,
mem.membershipId,
($event.target as HTMLSelectElement).value as UserRole,
)
"
>
<option v-for="role in USER_ROLES" :key="role" :value="role">
{{ USER_ROLE_LABELS[role] }}
</option>
</select>
<button
class="btn btn-ghost"
type="button"
:disabled="busyId === mem.membershipId"
@click="setMembershipActive(user.id, mem.membershipId, !mem.isActive)"
>
{{ mem.isActive ? 'قطع دسترسی' : 'بازگشت دسترسی' }}
</button>
</div>
</li>
</ul>
</div>
</article>
</div>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
max-width: 1040px;
}
.head {
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.head h1 {
margin: 0 0 0.3rem;
}
.head p,
.muted {
margin: 0;
color: var(--muted);
}
.search {
display: flex;
gap: 0.5rem;
}
.search input,
.reset-panel input,
.member-actions select {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.65rem 0.8rem;
font: inherit;
background: #fff;
}
.search input {
min-width: 220px;
}
.error {
margin: 0;
color: var(--danger);
}
.ok {
margin: 0;
color: var(--success-deep);
}
.list {
display: grid;
gap: 0.7rem;
}
.row {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 14px;
padding: 0.85rem 1rem;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.7rem 1rem;
}
.row-main {
display: flex;
justify-content: space-between;
gap: 1rem;
border: 0;
background: transparent;
text-align: start;
cursor: pointer;
padding: 0;
font: inherit;
color: inherit;
}
.row-main span,
.row-main small {
display: block;
color: var(--muted);
font-size: 0.8rem;
margin-top: 0.2rem;
}
.meta {
text-align: end;
}
.actions {
display: flex;
gap: 0.45rem;
align-items: flex-start;
flex-wrap: wrap;
}
.on,
.off {
font-style: normal;
font-size: 0.78rem;
font-weight: 800;
}
.on {
color: var(--success-deep);
}
.off {
color: var(--danger);
}
.reset-panel {
grid-column: 1 / -1;
background: #f8fafc;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.85rem;
display: grid;
gap: 0.7rem;
}
.reset-panel label {
display: grid;
gap: 0.35rem;
}
.reset-panel span {
font-size: 0.82rem;
color: var(--muted);
font-weight: 700;
}
.panel-actions {
display: flex;
gap: 0.5rem;
}
.detail {
grid-column: 1 / -1;
border-top: 1px solid var(--line);
padding-top: 0.75rem;
}
.detail ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.55rem;
}
.detail li {
display: flex;
justify-content: space-between;
gap: 0.8rem;
align-items: center;
flex-wrap: wrap;
}
.detail span {
display: block;
color: var(--muted);
font-size: 0.8rem;
}
.member-actions {
display: flex;
gap: 0.45rem;
align-items: center;
}
</style>