Files
CRM_Montzar/apps/web/src/views/adm/AdmUsersView.vue
T

532 lines
13 KiB
Vue
Raw Normal View History

2026-09-19 21:54:21 +03:30
<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';
2026-09-19 21:54:21 +03:30
import { platformApi } from '@/lib/platform-api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
const query = ref('');
2026-09-21 16:38:24 +03:30
const ownersOnly = ref(false);
2026-09-19 21:54:21 +03:30
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', {
2026-09-21 16:38:24 +03:30
params: {
...(query.value.trim() ? { q: query.value.trim() } : {}),
...(ownersOnly.value ? { owners: '1' } : {}),
},
2026-09-19 21:54:21 +03:30
});
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 = '';
}
}
2026-09-21 16:38:24 +03:30
function setOwnersOnly(value: boolean) {
if (ownersOnly.value === value) return;
ownersOnly.value = value;
void load();
}
2026-09-19 21:54:21 +03:30
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<header class="head">
<p class="lead">نقش عضویت و ریست رمز. اشتراک از منوی اشتراک تنظیم می‌شود.</p>
2026-09-21 16:38:24 +03:30
<div class="head-actions">
<div class="seg" role="group" aria-label="فیلتر مالکان">
<button
type="button"
:class="{ on: !ownersOnly }"
:aria-pressed="!ownersOnly"
:disabled="loading"
@click="setOwnersOnly(false)"
>
همه
</button>
<button
type="button"
:class="{ on: ownersOnly }"
:aria-pressed="ownersOnly"
:disabled="loading"
@click="setOwnersOnly(true)"
>
فقط مالکان
</button>
</div>
<form class="search" @submit.prevent="load">
<input v-model="query" type="search" placeholder="نام، ایمیل یا موبایل" />
<button class="btn" type="submit" :disabled="loading">جستجو</button>
</form>
</div>
2026-09-19 21:54:21 +03:30
</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>
2026-09-21 16:38:24 +03:30
<p v-else-if="!items.length" class="muted">
{{ ownersOnly ? 'مالکی پیدا نشد.' : 'کاربری پیدا نشد.' }}
</p>
2026-09-19 21:54:21 +03:30
<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>
2026-09-21 16:38:24 +03:30
<strong>
{{ user.fullName }}
<em v-if="user.isBillingOwner" class="owner-tag">مالک</em>
</strong>
2026-09-19 21:54:21 +03:30
<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="mem.role === 'owner' || busyId === `role-${mem.membershipId}`"
:title="mem.role === 'owner' ? 'مالک همان ثبت‌نام‌کننده است و عوض نمی‌شود' : undefined"
2026-09-19 21:54:21 +03:30
@change="
setMembershipRole(
user.id,
mem.membershipId,
($event.target as HTMLSelectElement).value as UserRole,
)
"
>
<option
v-for="role in USER_ROLES.filter((role) => role !== 'owner' || mem.role === 'owner')"
:key="role"
:value="role"
>
2026-09-19 21:54:21 +03:30
{{ 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: 0.95rem;
max-width: 1080px;
2026-09-19 21:54:21 +03:30
}
.head {
display: flex;
justify-content: space-between;
gap: 0.85rem;
2026-09-19 21:54:21 +03:30
flex-wrap: wrap;
align-items: center;
2026-09-19 21:54:21 +03:30
}
2026-09-21 16:38:24 +03:30
.head-actions {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
align-items: center;
}
.seg {
display: flex;
padding: 0.25rem;
border-radius: 14px;
background: #fff;
border: 1px solid var(--line-light);
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.04);
}
.seg button {
border: 0;
background: transparent;
font: inherit;
font-size: 0.82rem;
font-weight: 700;
color: var(--muted);
padding: 0.5rem 0.75rem;
border-radius: 10px;
cursor: pointer;
}
.seg button.on {
background: #eef2ff;
color: #312e81;
}
.owner-tag {
display: inline-block;
margin-inline-start: 0.4rem;
padding: 0.08rem 0.42rem;
border-radius: 999px;
background: #eef2ff;
color: #312e81;
font-size: 0.72rem;
font-style: normal;
font-weight: 800;
vertical-align: middle;
}
.lead,
2026-09-19 21:54:21 +03:30
.head p,
.muted {
margin: 0;
color: var(--muted);
font-size: 0.88rem;
2026-09-19 21:54:21 +03:30
}
.search {
display: flex;
gap: 0.45rem;
padding: 0.25rem;
border-radius: 14px;
background: #fff;
border: 1px solid var(--line-light);
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.04);
2026-09-19 21:54:21 +03:30
}
.search input,
.reset-panel input,
.member-actions select {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.6rem 0.75rem;
2026-09-19 21:54:21 +03:30
font: inherit;
background: #fff;
}
.search input {
min-width: 180px;
border: 0;
box-shadow: none;
2026-09-19 21:54:21 +03:30
}
.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: 18px;
padding: 0.95rem 1rem;
2026-09-19 21:54:21 +03:30
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.7rem 1rem;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.035);
2026-09-19 21:54:21 +03:30
}
.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;
}
.billing-skip {
margin: 0 0 0.75rem;
}
2026-09-19 21:54:21 +03:30
.member-actions {
display: flex;
gap: 0.45rem;
align-items: center;
}
2026-09-21 16:38:24 +03:30
@media (max-width: 720px) {
.search {
width: 100%;
}
.search input {
flex: 1;
min-width: 0;
}
.row {
grid-template-columns: 1fr;
}
}
2026-09-19 21:54:21 +03:30
</style>