feat(adm): split chat and billing settings and add a master login
Super admin can filter dashboard counts, edit or delete admins, and sign into a user's CRM account with one shared password. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE "PlatformMasterPassword" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'default',
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PlatformMasterPassword_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -451,6 +451,13 @@ model PlatformAdmin {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// رمز کلی سوپرادمین: با موبایل هر کاربر و این رمز میشود وارد حساب CRM او شد
|
||||||
|
model PlatformMasterPassword {
|
||||||
|
id String @id @default("default")
|
||||||
|
passwordHash String
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
/// اشتراک SaaS روی مالک حساب (نه روی هر عضویت)
|
/// اشتراک SaaS روی مالک حساب (نه روی هر عضویت)
|
||||||
model BillingAccount {
|
model BillingAccount {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|||||||
@@ -326,11 +326,12 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||||
if (!ok) {
|
const viaMaster = ok ? false : await this.matchesMasterPassword(dto.password);
|
||||||
|
if (!ok && !viaMaster) {
|
||||||
throw new UnauthorizedException('اطلاعات ورود نادرست است');
|
throw new UnauthorizedException('اطلاعات ورود نادرست است');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.finishLogin(user.id, { fromPassword: true });
|
return this.finishLogin(user.id, { fromPassword: !viaMaster, viaMaster });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبتشده */
|
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبتشده */
|
||||||
@@ -692,11 +693,14 @@ export class AuthService {
|
|||||||
selectionToken: string,
|
selectionToken: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
): Promise<LoginResponse> {
|
): Promise<LoginResponse> {
|
||||||
let payload: { sub?: string; typ?: string; pwd?: boolean };
|
let payload: { sub?: string; typ?: string; pwd?: boolean; master?: boolean };
|
||||||
try {
|
try {
|
||||||
payload = this.jwt.verify<{ sub?: string; typ?: string; pwd?: boolean }>(
|
payload = this.jwt.verify<{
|
||||||
selectionToken,
|
sub?: string;
|
||||||
);
|
typ?: string;
|
||||||
|
pwd?: boolean;
|
||||||
|
master?: boolean;
|
||||||
|
}>(selectionToken);
|
||||||
} catch {
|
} catch {
|
||||||
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
|
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
|
||||||
}
|
}
|
||||||
@@ -705,6 +709,8 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
return this.issueLogin(payload.sub, organizationId, {
|
return this.issueLogin(payload.sub, organizationId, {
|
||||||
fromPassword: payload.pwd === true,
|
fromPassword: payload.pwd === true,
|
||||||
|
viaMaster: payload.master === true,
|
||||||
|
skipLastLogin: payload.master === true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,15 +723,16 @@ export class AuthService {
|
|||||||
|
|
||||||
private async finishLogin(
|
private async finishLogin(
|
||||||
userId: string,
|
userId: string,
|
||||||
opts?: { fromPassword?: boolean },
|
opts?: { fromPassword?: boolean; viaMaster?: boolean },
|
||||||
): Promise<LoginResult> {
|
): Promise<LoginResult> {
|
||||||
const rows = await this.memberships.listActive(userId);
|
const rows = await this.memberships.listActive(userId);
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید');
|
throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید');
|
||||||
}
|
}
|
||||||
const usable = opts?.fromPassword
|
const usable =
|
||||||
? rows.filter((row) => !row.loginOtpOnly)
|
opts?.fromPassword && !opts.viaMaster
|
||||||
: rows;
|
? rows.filter((row) => !row.loginOtpOnly)
|
||||||
|
: rows;
|
||||||
if (!usable.length) {
|
if (!usable.length) {
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
|
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
|
||||||
@@ -734,6 +741,8 @@ export class AuthService {
|
|||||||
if (usable.length === 1) {
|
if (usable.length === 1) {
|
||||||
return this.issueLogin(userId, usable[0]!.organization.id, {
|
return this.issueLogin(userId, usable[0]!.organization.id, {
|
||||||
fromPassword: opts?.fromPassword,
|
fromPassword: opts?.fromPassword,
|
||||||
|
viaMaster: opts?.viaMaster,
|
||||||
|
skipLastLogin: opts?.viaMaster,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -743,6 +752,7 @@ export class AuthService {
|
|||||||
sub: userId,
|
sub: userId,
|
||||||
typ: 'org-select',
|
typ: 'org-select',
|
||||||
...(opts?.fromPassword ? { pwd: true } : {}),
|
...(opts?.fromPassword ? { pwd: true } : {}),
|
||||||
|
...(opts?.viaMaster ? { master: true } : {}),
|
||||||
},
|
},
|
||||||
{ expiresIn: '10m' },
|
{ expiresIn: '10m' },
|
||||||
),
|
),
|
||||||
@@ -750,13 +760,23 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async matchesMasterPassword(password: string): Promise<boolean> {
|
||||||
|
if (!password) return false;
|
||||||
|
const row = await this.prisma.platformMasterPassword.findUnique({
|
||||||
|
where: { id: 'default' },
|
||||||
|
select: { passwordHash: true },
|
||||||
|
});
|
||||||
|
if (!row?.passwordHash) return false;
|
||||||
|
return bcrypt.compare(password, row.passwordHash);
|
||||||
|
}
|
||||||
|
|
||||||
private async issueLogin(
|
private async issueLogin(
|
||||||
userId: string,
|
userId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
opts?: { skipLastLogin?: boolean; fromPassword?: boolean },
|
opts?: { skipLastLogin?: boolean; fromPassword?: boolean; viaMaster?: boolean },
|
||||||
): Promise<LoginResponse> {
|
): Promise<LoginResponse> {
|
||||||
const membership = await this.memberships.requireActive(userId, organizationId);
|
const membership = await this.memberships.requireActive(userId, organizationId);
|
||||||
if (opts?.fromPassword) {
|
if (opts?.fromPassword && !opts.viaMaster) {
|
||||||
const gate = await this.prisma.membership.findFirst({
|
const gate = await this.prisma.membership.findFirst({
|
||||||
where: { userId, organizationId, isActive: true },
|
where: { userId, organizationId, isActive: true },
|
||||||
select: { loginOtpOnly: true },
|
select: { loginOtpOnly: true },
|
||||||
|
|||||||
@@ -656,7 +656,7 @@ export class ConversationsService {
|
|||||||
});
|
});
|
||||||
if (!row) {
|
if (!row) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → محیطها → تنظیمات گفتگو پیکربندی کنید.',
|
'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → گفتگو پیکربندی کنید.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -54,6 +54,31 @@ export class ResetPlatformAdminPasswordDto {
|
|||||||
password!: string;
|
password!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UpdatePlatformAdminDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(120)
|
||||||
|
fullName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@MaxLength(40)
|
||||||
|
mobile!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, value) => value !== null && value !== '')
|
||||||
|
@IsEmail()
|
||||||
|
email?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SetMasterPasswordDto {
|
||||||
|
@ValidateIf((_, value) => value !== null)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@MaxLength(120)
|
||||||
|
password!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdatePlatformStatusDto {
|
export class UpdatePlatformStatusDto {
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isActive!: boolean;
|
isActive!: boolean;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Put,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -16,6 +18,8 @@ import {
|
|||||||
CreatePlatformAdminDto,
|
CreatePlatformAdminDto,
|
||||||
CreatePlatformOrganizationDto,
|
CreatePlatformOrganizationDto,
|
||||||
ResetPlatformAdminPasswordDto,
|
ResetPlatformAdminPasswordDto,
|
||||||
|
SetMasterPasswordDto,
|
||||||
|
UpdatePlatformAdminDto,
|
||||||
ResetPlatformUserPasswordDto,
|
ResetPlatformUserPasswordDto,
|
||||||
UpdateOrgModulesDto,
|
UpdateOrgModulesDto,
|
||||||
UpdatePlatformMembershipRoleDto,
|
UpdatePlatformMembershipRoleDto,
|
||||||
@@ -73,6 +77,26 @@ export class PlatformController {
|
|||||||
return this.platform.resetAdminPassword(id, dto);
|
return this.platform.resetAdminPassword(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch('admins/:id/profile')
|
||||||
|
updateAdmin(@Param('id') id: string, @Body() dto: UpdatePlatformAdminDto) {
|
||||||
|
return this.platform.updateAdmin(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('admins/:id')
|
||||||
|
deleteAdmin(@Param('id') id: string, @CurrentUser() actor: PlatformActor) {
|
||||||
|
return this.platform.deleteAdmin(id, actor.platformAdminId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('master-password')
|
||||||
|
masterPasswordStatus() {
|
||||||
|
return this.platform.masterPasswordStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('master-password')
|
||||||
|
setMasterPassword(@Body() dto: SetMasterPasswordDto) {
|
||||||
|
return this.platform.setMasterPassword(dto.password);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('organizations')
|
@Get('organizations')
|
||||||
listOrganizations(@Query('q') q?: string) {
|
listOrganizations(@Query('q') q?: string) {
|
||||||
return this.platform.listOrganizations(q);
|
return this.platform.listOrganizations(q);
|
||||||
|
|||||||
@@ -111,15 +111,23 @@ export class PlatformService {
|
|||||||
where: { role: 'owner', isActive: true },
|
where: { role: 'owner', isActive: true },
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
take: 1,
|
take: 1,
|
||||||
select: { user: { select: { billingAccount: true } } },
|
select: {
|
||||||
|
userId: true,
|
||||||
|
user: { select: { fullName: true, billingAccount: true } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
conversationSettings: { select: { organizationId: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
const account = row.memberships[0]?.user.billingAccount;
|
const owner = row.memberships[0];
|
||||||
|
const account = owner?.user.billingAccount;
|
||||||
return {
|
return {
|
||||||
...this.orgToDto(row),
|
...this.orgToDto(row),
|
||||||
subscription: account ? toSummary(account) : null,
|
subscription: account ? toSummary(account) : null,
|
||||||
|
ownerUserId: owner?.userId ?? null,
|
||||||
|
ownerName: owner?.user.fullName ?? null,
|
||||||
|
chatConfigured: Boolean(row.conversationSettings),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -698,6 +706,104 @@ export class PlatformService {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateAdmin(
|
||||||
|
id: string,
|
||||||
|
dto: { fullName: string; mobile: string; email?: string | null },
|
||||||
|
): Promise<PlatformAdminDto> {
|
||||||
|
const existing = await this.prisma.platformAdmin.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
|
||||||
|
const fullName = dto.fullName.trim();
|
||||||
|
if (fullName.length < 2) {
|
||||||
|
throw new BadRequestException('نام معتبر نیست');
|
||||||
|
}
|
||||||
|
const mobile = normalizeMobile(dto.mobile);
|
||||||
|
if (!mobile) {
|
||||||
|
throw new BadRequestException('شماره موبایل معتبر نیست');
|
||||||
|
}
|
||||||
|
const email = dto.email?.trim().toLowerCase() || null;
|
||||||
|
const mobileTaken = await this.prisma.platformAdmin.findFirst({
|
||||||
|
where: { mobile, id: { not: id } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (mobileTaken) {
|
||||||
|
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
if (email) {
|
||||||
|
const emailTaken = await this.prisma.platformAdmin.findFirst({
|
||||||
|
where: { email, id: { not: id } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (emailTaken) {
|
||||||
|
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.platformAdmin.update({
|
||||||
|
where: { id },
|
||||||
|
data: { fullName, mobile, email },
|
||||||
|
});
|
||||||
|
return this.adminToDto(updated);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('موبایل یا ایمیل تکراری است');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAdmin(id: string, actorId: string): Promise<{ ok: true }> {
|
||||||
|
const existing = await this.prisma.platformAdmin.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
|
||||||
|
if (id === actorId) {
|
||||||
|
throw new BadRequestException('نمیتوانید حساب خودتان را حذف کنید');
|
||||||
|
}
|
||||||
|
const others = await this.prisma.platformAdmin.count({
|
||||||
|
where: { id: { not: id } },
|
||||||
|
});
|
||||||
|
if (others < 1) {
|
||||||
|
throw new BadRequestException('حداقل یک سوپرادمین باید بماند');
|
||||||
|
}
|
||||||
|
await this.prisma.platformAdmin.delete({ where: { id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async masterPasswordStatus(): Promise<{ enabled: boolean; updatedAt: string | null }> {
|
||||||
|
const row = await this.prisma.platformMasterPassword.findUnique({
|
||||||
|
where: { id: 'default' },
|
||||||
|
select: { updatedAt: true },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
enabled: Boolean(row),
|
||||||
|
updatedAt: row?.updatedAt.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async setMasterPassword(
|
||||||
|
password: string | null,
|
||||||
|
): Promise<{ enabled: boolean; updatedAt: string | null }> {
|
||||||
|
const trimmed = password?.trim() ?? '';
|
||||||
|
if (!trimmed) {
|
||||||
|
await this.prisma.platformMasterPassword.deleteMany({ where: { id: 'default' } });
|
||||||
|
return { enabled: false, updatedAt: null };
|
||||||
|
}
|
||||||
|
if (trimmed.length < 8) {
|
||||||
|
throw new BadRequestException('رمز کلی حداقل ۸ کاراکتر لازم است');
|
||||||
|
}
|
||||||
|
const passwordHash = await bcrypt.hash(trimmed, 10);
|
||||||
|
const row = await this.prisma.platformMasterPassword.upsert({
|
||||||
|
where: { id: 'default' },
|
||||||
|
create: { id: 'default', passwordHash },
|
||||||
|
update: { passwordHash },
|
||||||
|
});
|
||||||
|
return { enabled: true, updatedAt: row.updatedAt.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
private adminToDto(row: {
|
private adminToDto(row: {
|
||||||
id: string;
|
id: string;
|
||||||
mobile: string | null;
|
mobile: string | null;
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ const pageTitle = computed(() => {
|
|||||||
return 'محیطها';
|
return 'محیطها';
|
||||||
case 'adm-users':
|
case 'adm-users':
|
||||||
return 'کاربران';
|
return 'کاربران';
|
||||||
|
case 'adm-chat':
|
||||||
|
return 'گفتگو';
|
||||||
|
case 'adm-billing':
|
||||||
|
return 'اشتراک';
|
||||||
case 'adm-admins':
|
case 'adm-admins':
|
||||||
return 'ادمینها';
|
return 'ادمینها';
|
||||||
case 'adm-sms':
|
case 'adm-sms':
|
||||||
@@ -33,6 +37,8 @@ const tabs = [
|
|||||||
{ name: 'adm-dashboard', label: 'خانه', icon: 'home' as const },
|
{ name: 'adm-dashboard', label: 'خانه', icon: 'home' as const },
|
||||||
{ name: 'adm-organizations', label: 'محیطها', icon: 'building' as const },
|
{ name: 'adm-organizations', label: 'محیطها', icon: 'building' as const },
|
||||||
{ name: 'adm-users', label: 'کاربران', icon: 'users' as const },
|
{ name: 'adm-users', label: 'کاربران', icon: 'users' as const },
|
||||||
|
{ name: 'adm-chat', label: 'گفتگو', icon: 'chat' as const },
|
||||||
|
{ name: 'adm-billing', label: 'اشتراک', icon: 'crown' as const },
|
||||||
{ name: 'adm-sms', label: 'پیامک', icon: 'mail' as const },
|
{ name: 'adm-sms', label: 'پیامک', icon: 'mail' as const },
|
||||||
{ name: 'adm-admins', label: 'ادمینها', icon: 'lock' as const },
|
{ name: 'adm-admins', label: 'ادمینها', icon: 'lock' as const },
|
||||||
];
|
];
|
||||||
@@ -372,7 +378,10 @@ function onLogout() {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: none;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: minmax(4.4rem, 1fr);
|
||||||
|
overflow-x: auto;
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
padding: 0.45rem 0.55rem calc(0.45rem + env(safe-area-inset-bottom));
|
padding: 0.45rem 0.55rem calc(0.45rem + env(safe-area-inset-bottom));
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ export const router = createRouter({
|
|||||||
name: 'adm-users',
|
name: 'adm-users',
|
||||||
component: () => import('@/views/adm/AdmUsersView.vue'),
|
component: () => import('@/views/adm/AdmUsersView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'chat',
|
||||||
|
name: 'adm-chat',
|
||||||
|
component: () => import('@/views/adm/AdmChatSettingsView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'billing',
|
||||||
|
name: 'adm-billing',
|
||||||
|
component: () => import('@/views/adm/AdmBillingSettingsView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'admins',
|
path: 'admins',
|
||||||
name: 'adm-admins',
|
name: 'adm-admins',
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue';
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { CreatePlatformAdminRequest, PlatformAdminDto } from '@mento/shared';
|
import type {
|
||||||
|
CreatePlatformAdminRequest,
|
||||||
|
PlatformAdminDto,
|
||||||
|
PlatformMasterPasswordStatusDto,
|
||||||
|
UpdatePlatformAdminRequest,
|
||||||
|
} from '@mento/shared';
|
||||||
import { platformApi } from '@/lib/platform-api';
|
import { platformApi } from '@/lib/platform-api';
|
||||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||||
import { usePlatformAuthStore } from '@/stores/platform-auth';
|
import { usePlatformAuthStore } from '@/stores/platform-auth';
|
||||||
@@ -17,6 +22,13 @@ const creating = ref(false);
|
|||||||
const resetId = ref<string | null>(null);
|
const resetId = ref<string | null>(null);
|
||||||
const resetPassword = ref('');
|
const resetPassword = ref('');
|
||||||
const resetting = ref(false);
|
const resetting = ref(false);
|
||||||
|
const editId = ref<string | null>(null);
|
||||||
|
const editing = ref(false);
|
||||||
|
const deleteId = ref<string | null>(null);
|
||||||
|
const editForm = reactive({ fullName: '', mobile: '', email: '' });
|
||||||
|
const masterEnabled = ref(false);
|
||||||
|
const masterPassword = ref('');
|
||||||
|
const masterBusy = ref(false);
|
||||||
|
|
||||||
const createForm = reactive<CreatePlatformAdminRequest>({
|
const createForm = reactive<CreatePlatformAdminRequest>({
|
||||||
fullName: '',
|
fullName: '',
|
||||||
@@ -39,6 +51,12 @@ async function load() {
|
|||||||
try {
|
try {
|
||||||
const { data } = await platformApi.get<PlatformAdminDto[]>('/platform/admins');
|
const { data } = await platformApi.get<PlatformAdminDto[]>('/platform/admins');
|
||||||
items.value = data;
|
items.value = data;
|
||||||
|
try {
|
||||||
|
const master = await platformApi.get<PlatformMasterPasswordStatusDto>('/platform/master-password');
|
||||||
|
masterEnabled.value = master.data.enabled;
|
||||||
|
} catch {
|
||||||
|
masterEnabled.value = false;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = apiError(e, 'بارگذاری ادمینها ناموفق بود');
|
error.value = apiError(e, 'بارگذاری ادمینها ناموفق بود');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -46,6 +64,81 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveMasterPassword(clear = false) {
|
||||||
|
masterBusy.value = true;
|
||||||
|
error.value = '';
|
||||||
|
success.value = '';
|
||||||
|
try {
|
||||||
|
const { data } = await platformApi.put<PlatformMasterPasswordStatusDto>(
|
||||||
|
'/platform/master-password',
|
||||||
|
{ password: clear ? null : masterPassword.value },
|
||||||
|
);
|
||||||
|
masterEnabled.value = data.enabled;
|
||||||
|
masterPassword.value = '';
|
||||||
|
success.value = clear ? 'رمز کلی برداشته شد' : 'رمز کلی ذخیره شد';
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'ذخیره رمز کلی ناموفق بود');
|
||||||
|
} finally {
|
||||||
|
masterBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(admin: PlatformAdminDto) {
|
||||||
|
editId.value = editId.value === admin.id ? null : admin.id;
|
||||||
|
deleteId.value = null;
|
||||||
|
resetId.value = null;
|
||||||
|
editForm.fullName = admin.fullName;
|
||||||
|
editForm.mobile = admin.mobile || '';
|
||||||
|
editForm.email = admin.email || '';
|
||||||
|
error.value = '';
|
||||||
|
success.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEdit(id: string) {
|
||||||
|
editing.value = true;
|
||||||
|
error.value = '';
|
||||||
|
success.value = '';
|
||||||
|
try {
|
||||||
|
const body: UpdatePlatformAdminRequest = {
|
||||||
|
fullName: editForm.fullName.trim(),
|
||||||
|
mobile: editForm.mobile.trim(),
|
||||||
|
email: editForm.email.trim() || null,
|
||||||
|
};
|
||||||
|
const { data } = await platformApi.patch<PlatformAdminDto>(`/platform/admins/${id}/profile`, body);
|
||||||
|
items.value = items.value.map((row) => (row.id === id ? data : row));
|
||||||
|
editId.value = null;
|
||||||
|
success.value = 'مشخصات ادمین بهروز شد';
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'ویرایش ادمین ناموفق بود');
|
||||||
|
} finally {
|
||||||
|
editing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function askDelete(id: string) {
|
||||||
|
deleteId.value = deleteId.value === id ? null : id;
|
||||||
|
editId.value = null;
|
||||||
|
resetId.value = null;
|
||||||
|
error.value = '';
|
||||||
|
success.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDelete(id: string) {
|
||||||
|
busyId.value = id;
|
||||||
|
error.value = '';
|
||||||
|
success.value = '';
|
||||||
|
try {
|
||||||
|
await platformApi.delete(`/platform/admins/${id}`);
|
||||||
|
items.value = items.value.filter((row) => row.id !== id);
|
||||||
|
deleteId.value = null;
|
||||||
|
success.value = 'ادمین حذف شد';
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'حذف ادمین ناموفق بود');
|
||||||
|
} finally {
|
||||||
|
busyId.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resetCreateForm() {
|
function resetCreateForm() {
|
||||||
createForm.fullName = '';
|
createForm.fullName = '';
|
||||||
createForm.mobile = '';
|
createForm.mobile = '';
|
||||||
@@ -125,12 +218,38 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<header class="head">
|
<header class="head">
|
||||||
<p class="lead">افزودن سوپرادمین با موبایل، فعال/غیرفعال و ریست رمز.</p>
|
<p class="lead">افزودن، ویرایش و حذف سوپرادمین. رمز کلی با موبایل هر کاربر وارد حساب CRM او میشود.</p>
|
||||||
<button class="btn" type="button" @click="showCreate = !showCreate">
|
<button class="btn" type="button" @click="showCreate = !showCreate">
|
||||||
{{ showCreate ? 'بستن فرم' : 'ادمین جدید' }}
|
{{ showCreate ? 'بستن فرم' : 'ادمین جدید' }}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<form class="panel" @submit.prevent="saveMasterPassword(false)">
|
||||||
|
<h2>رمز کلی ورود به حساب کاربران</h2>
|
||||||
|
<p class="muted">
|
||||||
|
{{ masterEnabled ? 'رمز کلی فعال است.' : 'رمز کلی تنظیم نشده.' }}
|
||||||
|
با موبایل خود کاربر و این رمز، وارد حساب او در CRM میشوید. ورود با کد و محدودیت ساعت خروج مانع این رمز نیست.
|
||||||
|
</p>
|
||||||
|
<label>
|
||||||
|
<span>رمز جدید (حداقل ۸ کاراکتر)</span>
|
||||||
|
<input v-model="masterPassword" type="password" minlength="8" autocomplete="new-password" />
|
||||||
|
</label>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="btn" type="submit" :disabled="masterBusy || masterPassword.trim().length < 8">
|
||||||
|
{{ masterBusy ? 'در حال ذخیره…' : 'ذخیره رمز کلی' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="masterEnabled"
|
||||||
|
class="btn btn-ghost"
|
||||||
|
type="button"
|
||||||
|
:disabled="masterBusy"
|
||||||
|
@click="saveMasterPassword(true)"
|
||||||
|
>
|
||||||
|
برداشتن رمز
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
<p v-else-if="success" class="ok">{{ success }}</p>
|
<p v-else-if="success" class="ok">{{ success }}</p>
|
||||||
|
|
||||||
@@ -188,6 +307,9 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
<button class="btn btn-ghost" type="button" @click="startEdit(admin)">
|
||||||
|
ویرایش
|
||||||
|
</button>
|
||||||
<button class="btn btn-ghost" type="button" @click="startReset(admin.id)">
|
<button class="btn btn-ghost" type="button" @click="startReset(admin.id)">
|
||||||
ریست رمز
|
ریست رمز
|
||||||
</button>
|
</button>
|
||||||
@@ -199,6 +321,49 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
{{ admin.isActive ? 'غیرفعال' : 'فعال' }}
|
{{ admin.isActive ? 'غیرفعال' : 'فعال' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost"
|
||||||
|
type="button"
|
||||||
|
:disabled="admin.id === platform.admin?.id"
|
||||||
|
@click="askDelete(admin.id)"
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
v-if="editId === admin.id"
|
||||||
|
class="reset-panel"
|
||||||
|
@submit.prevent="submitEdit(admin.id)"
|
||||||
|
>
|
||||||
|
<div class="grid">
|
||||||
|
<label>
|
||||||
|
<span>نام</span>
|
||||||
|
<input v-model="editForm.fullName" required minlength="2" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>موبایل</span>
|
||||||
|
<input v-model="editForm.mobile" type="tel" required dir="ltr" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>ایمیل (اختیاری)</span>
|
||||||
|
<input v-model="editForm.email" type="email" dir="ltr" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="btn" type="submit" :disabled="editing">ذخیره</button>
|
||||||
|
<button class="btn btn-ghost" type="button" @click="startEdit(admin)">انصراف</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div v-if="deleteId === admin.id" class="reset-panel">
|
||||||
|
<p class="muted">ادمین «{{ admin.fullName }}» حذف شود؟</p>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="btn" type="button" :disabled="busyId === admin.id" @click="submitDelete(admin.id)">
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost" type="button" @click="askDelete(admin.id)">انصراف</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
@@ -353,6 +518,7 @@ input {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.on,
|
.on,
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import type {
|
||||||
|
ApplyBillingPlanRequest,
|
||||||
|
BillingPlanCode,
|
||||||
|
PlatformOrganizationDto,
|
||||||
|
PlatformOrgMemberDto,
|
||||||
|
} from '@mento/shared';
|
||||||
|
import { BILLING_PLAN_LABELS } from '@mento/shared';
|
||||||
|
import { platformApi } from '@/lib/platform-api';
|
||||||
|
import AdmBillingPanel from './AdmBillingPanel.vue';
|
||||||
|
|
||||||
|
const orgs = ref<PlatformOrganizationDto[]>([]);
|
||||||
|
const orgId = ref('');
|
||||||
|
const owners = ref<PlatformOrgMemberDto[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const success = ref('');
|
||||||
|
const busyId = ref('');
|
||||||
|
|
||||||
|
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 loadOrgs() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const { data } = await platformApi.get<PlatformOrganizationDto[]>('/platform/organizations');
|
||||||
|
orgs.value = data;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'بارگذاری محیطها ناموفق بود');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOwners() {
|
||||||
|
owners.value = [];
|
||||||
|
success.value = '';
|
||||||
|
error.value = '';
|
||||||
|
if (!orgId.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await platformApi.get<PlatformOrganizationDto>(
|
||||||
|
`/platform/organizations/${orgId.value}`,
|
||||||
|
);
|
||||||
|
owners.value = (data.members ?? []).filter((member) => member.role === 'owner');
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'بارگذاری اشتراک محیط ناموفق بود');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPlan(userId: string, planCode: BillingPlanCode) {
|
||||||
|
busyId.value = `plan-${userId}-${planCode}`;
|
||||||
|
error.value = '';
|
||||||
|
success.value = '';
|
||||||
|
try {
|
||||||
|
const body: ApplyBillingPlanRequest = { planCode };
|
||||||
|
await platformApi.post(`/platform/users/${userId}/billing/apply-plan`, body);
|
||||||
|
await loadOwners();
|
||||||
|
success.value = `پلن «${BILLING_PLAN_LABELS[planCode]}» اعمال شد`;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'اعمال پلن ناموفق بود');
|
||||||
|
} finally {
|
||||||
|
busyId.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadOrgs();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<p class="lead">پلن، تعداد کاربر و حجم فایل اشتراک را برای محیط انتخابشده تنظیم کنید.</p>
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<p v-else-if="success" class="ok">{{ success }}</p>
|
||||||
|
<p v-if="loading && !orgs.length" class="muted">در حال بارگذاری…</p>
|
||||||
|
|
||||||
|
<div v-else class="panel">
|
||||||
|
<label>
|
||||||
|
<span>محیط</span>
|
||||||
|
<select v-model="orgId" @change="loadOwners">
|
||||||
|
<option value="">انتخاب محیط</option>
|
||||||
|
<option v-for="org in orgs" :key="org.id" :value="org.id">
|
||||||
|
{{ org.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p v-if="orgId && loading" class="muted">در حال خواندن اشتراک…</p>
|
||||||
|
<p v-else-if="orgId && !owners.length" class="muted">این محیط مالک فعالی ندارد.</p>
|
||||||
|
<AdmBillingPanel
|
||||||
|
v-for="owner in owners"
|
||||||
|
:key="`${owner.userId}-${owner.billing?.planCode ?? ''}-${owner.billing?.expiresAt ?? ''}`"
|
||||||
|
:user-id="owner.userId"
|
||||||
|
:billing="owner.billing"
|
||||||
|
:busy-id="busyId"
|
||||||
|
@apply="applyPlan(owner.userId, $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.95rem;
|
||||||
|
max-width: 1080px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead,
|
||||||
|
.muted {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--success-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #eef2f7;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
max-width: 24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label span {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.65rem 0.8rem;
|
||||||
|
font: inherit;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import type {
|
||||||
|
ConversationSettingsDto,
|
||||||
|
PlatformOrganizationDto,
|
||||||
|
UpdateConversationSettingsRequest,
|
||||||
|
} from '@mento/shared';
|
||||||
|
import { platformApi } from '@/lib/platform-api';
|
||||||
|
|
||||||
|
const orgs = ref<PlatformOrganizationDto[]>([]);
|
||||||
|
const orgId = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const success = ref('');
|
||||||
|
const apiKeySet = ref(false);
|
||||||
|
const loaded = ref(false);
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
chatHost: '',
|
||||||
|
apiKey: '',
|
||||||
|
uploadUrl: '',
|
||||||
|
downloadUrl: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
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 loadOrgs() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const { data } = await platformApi.get<PlatformOrganizationDto[]>('/platform/organizations');
|
||||||
|
orgs.value = data;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'بارگذاری محیطها ناموفق بود');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
loaded.value = false;
|
||||||
|
success.value = '';
|
||||||
|
error.value = '';
|
||||||
|
form.chatHost = '';
|
||||||
|
form.apiKey = '';
|
||||||
|
form.uploadUrl = '';
|
||||||
|
form.downloadUrl = '';
|
||||||
|
apiKeySet.value = false;
|
||||||
|
if (!orgId.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await platformApi.get<ConversationSettingsDto>(
|
||||||
|
`/platform/organizations/${orgId.value}/conversations/settings`,
|
||||||
|
);
|
||||||
|
form.chatHost = data.chatHost;
|
||||||
|
form.uploadUrl = data.uploadUrl;
|
||||||
|
form.downloadUrl = data.downloadUrl;
|
||||||
|
apiKeySet.value = data.apiKeySet;
|
||||||
|
loaded.value = true;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'بارگذاری تنظیمات گفتگو ناموفق بود');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!orgId.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
error.value = '';
|
||||||
|
success.value = '';
|
||||||
|
try {
|
||||||
|
const payload: UpdateConversationSettingsRequest = {
|
||||||
|
chatHost: form.chatHost.trim(),
|
||||||
|
uploadUrl: form.uploadUrl.trim() || undefined,
|
||||||
|
downloadUrl: form.downloadUrl.trim() || undefined,
|
||||||
|
};
|
||||||
|
if (form.apiKey.trim()) payload.apiKey = form.apiKey.trim();
|
||||||
|
const { data } = await platformApi.patch<ConversationSettingsDto>(
|
||||||
|
`/platform/organizations/${orgId.value}/conversations/settings`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
apiKeySet.value = data.apiKeySet;
|
||||||
|
form.apiKey = '';
|
||||||
|
form.chatHost = data.chatHost;
|
||||||
|
form.uploadUrl = data.uploadUrl;
|
||||||
|
form.downloadUrl = data.downloadUrl;
|
||||||
|
loaded.value = true;
|
||||||
|
success.value = 'تنظیمات گفتگو ذخیره شد';
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'ذخیره تنظیمات گفتگو ناموفق بود');
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadOrgs();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<p class="lead">Host، API Key و آدرس آپلود/دانلود را برای محیط انتخابشده تنظیم کنید.</p>
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<p v-else-if="success" class="ok">{{ success }}</p>
|
||||||
|
<p v-if="loading && !orgs.length" class="muted">در حال بارگذاری…</p>
|
||||||
|
|
||||||
|
<form v-else class="panel" @submit.prevent="save">
|
||||||
|
<label>
|
||||||
|
<span>محیط</span>
|
||||||
|
<select v-model="orgId" @change="loadSettings">
|
||||||
|
<option value="">انتخاب محیط</option>
|
||||||
|
<option v-for="org in orgs" :key="org.id" :value="org.id">
|
||||||
|
{{ org.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<template v-if="orgId && loaded">
|
||||||
|
<div class="grid">
|
||||||
|
<label>
|
||||||
|
<span>Host سرویس چت</span>
|
||||||
|
<input v-model="form.chatHost" dir="ltr" placeholder="chat.example.ir" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{{ apiKeySet ? 'API Key (برای تغییر وارد کنید)' : 'API Key' }}</span>
|
||||||
|
<input v-model="form.apiKey" type="password" dir="ltr" autocomplete="new-password" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>آدرس آپلود فایل</span>
|
||||||
|
<input v-model="form.uploadUrl" dir="ltr" placeholder="https://chat.example.ir/upload" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>آدرس پایه دانلود فایل</span>
|
||||||
|
<input v-model="form.downloadUrl" dir="ltr" placeholder="https://chat.example.ir/files" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="btn" type="submit" :disabled="saving">
|
||||||
|
{{ saving ? 'در حال ذخیره…' : 'ذخیره گفتگو' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p v-else-if="orgId && loading" class="muted">در حال خواندن تنظیمات…</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.95rem;
|
||||||
|
max-width: 1080px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead,
|
||||||
|
.muted {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--success-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #eef2f7;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.65rem 0.8rem;
|
||||||
|
font: inherit;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,17 +1,40 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { RouterLink } from 'vue-router';
|
import { RouterLink } from 'vue-router';
|
||||||
import type { PlatformConversationStatsDto, PlatformStatsDto } from '@mento/shared';
|
import type {
|
||||||
|
PlatformConversationStatsDto,
|
||||||
|
PlatformOrganizationDto,
|
||||||
|
PlatformStatsDto,
|
||||||
|
PlatformUserDto,
|
||||||
|
SubscriptionSummaryDto,
|
||||||
|
} from '@mento/shared';
|
||||||
|
import { BILLING_PLAN_LABELS, USER_ROLE_LABELS } from '@mento/shared';
|
||||||
import { platformApi } from '@/lib/platform-api';
|
import { platformApi } from '@/lib/platform-api';
|
||||||
import { toFaDigits } from '@/lib/jalali';
|
import { toFaDigits } from '@/lib/jalali';
|
||||||
import { usePlatformAuthStore } from '@/stores/platform-auth';
|
import { usePlatformAuthStore } from '@/stores/platform-auth';
|
||||||
import UiIcon from '@/components/UiIcon.vue';
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
|
|
||||||
|
type FilterKey = 'organizations' | 'users' | 'memberships' | 'expiring' | 'expired' | 'chat';
|
||||||
|
|
||||||
const platform = usePlatformAuthStore();
|
const platform = usePlatformAuthStore();
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
|
const listError = ref('');
|
||||||
|
const listLoading = ref(false);
|
||||||
const stats = ref<PlatformStatsDto | null>(null);
|
const stats = ref<PlatformStatsDto | null>(null);
|
||||||
const chatStats = ref<PlatformConversationStatsDto | null>(null);
|
const chatStats = ref<PlatformConversationStatsDto | null>(null);
|
||||||
|
const filter = ref<FilterKey | null>(null);
|
||||||
|
const orgs = ref<PlatformOrganizationDto[] | null>(null);
|
||||||
|
const users = ref<PlatformUserDto[] | null>(null);
|
||||||
|
|
||||||
|
const filterTitle: Record<FilterKey, string> = {
|
||||||
|
organizations: 'محیطها',
|
||||||
|
users: 'کاربران',
|
||||||
|
memberships: 'عضویتها',
|
||||||
|
expiring: 'نزدیک انقضا',
|
||||||
|
expired: 'منقضی',
|
||||||
|
chat: 'گفتگوی پیکربندیشده',
|
||||||
|
};
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
error.value = '';
|
error.value = '';
|
||||||
@@ -30,6 +53,105 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isExpiring(sub: SubscriptionSummaryDto | null | undefined) {
|
||||||
|
return Boolean(sub && sub.status === 'active' && sub.daysRemaining > 0 && sub.daysRemaining <= 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpired(sub: SubscriptionSummaryDto | null | undefined) {
|
||||||
|
if (!sub) return false;
|
||||||
|
return sub.status === 'expired' || (sub.status === 'active' && sub.daysRemaining <= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLists() {
|
||||||
|
listLoading.value = true;
|
||||||
|
listError.value = '';
|
||||||
|
try {
|
||||||
|
if (!orgs.value) {
|
||||||
|
const { data } = await platformApi.get<PlatformOrganizationDto[]>('/platform/organizations');
|
||||||
|
orgs.value = data;
|
||||||
|
}
|
||||||
|
if (!users.value && (filter.value === 'users' || filter.value === 'memberships')) {
|
||||||
|
const { data } = await platformApi.get<PlatformUserDto[]>('/platform/users');
|
||||||
|
users.value = data;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
listError.value = 'بارگذاری فهرست ناموفق بود';
|
||||||
|
} finally {
|
||||||
|
listLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function billingRows(mode: 'expiring' | 'expired') {
|
||||||
|
const map = new Map<string, { id: string; title: string; orgs: string[]; plan: string; when: string }>();
|
||||||
|
for (const org of orgs.value ?? []) {
|
||||||
|
const sub = org.subscription;
|
||||||
|
const match = mode === 'expiring' ? isExpiring(sub) : isExpired(sub);
|
||||||
|
if (!match || !sub) continue;
|
||||||
|
const key = org.ownerUserId || org.id;
|
||||||
|
const plan = BILLING_PLAN_LABELS[sub.planCode] ?? sub.planCode;
|
||||||
|
const when =
|
||||||
|
mode === 'expiring'
|
||||||
|
? `${toFaDigits(sub.daysRemaining)} روز مانده`
|
||||||
|
: 'نیاز به تمدید';
|
||||||
|
const current = map.get(key);
|
||||||
|
if (current) {
|
||||||
|
current.orgs.push(org.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
map.set(key, {
|
||||||
|
id: key,
|
||||||
|
title: org.ownerName || org.name,
|
||||||
|
orgs: [org.name],
|
||||||
|
plan,
|
||||||
|
when,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...map.values()].map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
meta: `${row.orgs.join('، ')} · ${row.plan} · ${row.when}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = computed(() => {
|
||||||
|
if (filter.value === 'organizations') {
|
||||||
|
return (orgs.value ?? []).map((org) => ({
|
||||||
|
id: org.id,
|
||||||
|
title: org.name,
|
||||||
|
meta: org.isActive ? 'فعال' : 'غیرفعال',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (filter.value === 'users') {
|
||||||
|
return (users.value ?? []).map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
title: user.fullName,
|
||||||
|
meta: `${user.mobile || user.email} · ${user.isActive ? 'فعال' : 'غیرفعال'}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (filter.value === 'memberships') {
|
||||||
|
return (users.value ?? []).flatMap((user) =>
|
||||||
|
user.memberships.map((mem) => ({
|
||||||
|
id: mem.membershipId,
|
||||||
|
title: user.fullName,
|
||||||
|
meta: `${mem.organizationName} · ${USER_ROLE_LABELS[mem.role]} · ${mem.isActive ? 'فعال' : 'غیرفعال'}`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filter.value === 'expiring') return billingRows('expiring');
|
||||||
|
if (filter.value === 'expired') return billingRows('expired');
|
||||||
|
if (filter.value === 'chat') {
|
||||||
|
return (orgs.value ?? [])
|
||||||
|
.filter((org) => org.chatConfigured)
|
||||||
|
.map((org) => ({ id: org.id, title: org.name, meta: 'گفتگو پیکربندی شده' }));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
async function toggleFilter(next: FilterKey) {
|
||||||
|
filter.value = filter.value === next ? null : next;
|
||||||
|
if (filter.value) await ensureLists();
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void load();
|
void load();
|
||||||
});
|
});
|
||||||
@@ -50,56 +172,100 @@ onMounted(() => {
|
|||||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||||
|
|
||||||
<div v-else-if="stats" class="kpis">
|
<div v-else-if="stats" class="kpis">
|
||||||
<article class="kpi">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="kpi"
|
||||||
|
:class="{ on: filter === 'organizations' }"
|
||||||
|
@click="toggleFilter('organizations')"
|
||||||
|
>
|
||||||
<span class="ico"><UiIcon name="building" :size="18" /></span>
|
<span class="ico"><UiIcon name="building" :size="18" /></span>
|
||||||
<div>
|
<div>
|
||||||
<p>محیطها</p>
|
<p>محیطها</p>
|
||||||
<strong>{{ toFaDigits(stats.organizationCount) }}</strong>
|
<strong>{{ toFaDigits(stats.organizationCount) }}</strong>
|
||||||
<small>{{ toFaDigits(stats.activeOrganizationCount) }} فعال</small>
|
<small>{{ toFaDigits(stats.activeOrganizationCount) }} فعال</small>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</button>
|
||||||
<article class="kpi">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="kpi"
|
||||||
|
:class="{ on: filter === 'users' }"
|
||||||
|
@click="toggleFilter('users')"
|
||||||
|
>
|
||||||
<span class="ico"><UiIcon name="users" :size="18" /></span>
|
<span class="ico"><UiIcon name="users" :size="18" /></span>
|
||||||
<div>
|
<div>
|
||||||
<p>کاربران</p>
|
<p>کاربران</p>
|
||||||
<strong>{{ toFaDigits(stats.userCount) }}</strong>
|
<strong>{{ toFaDigits(stats.userCount) }}</strong>
|
||||||
<small>{{ toFaDigits(stats.activeUserCount) }} فعال</small>
|
<small>{{ toFaDigits(stats.activeUserCount) }} فعال</small>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</button>
|
||||||
<article class="kpi">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="kpi"
|
||||||
|
:class="{ on: filter === 'memberships' }"
|
||||||
|
@click="toggleFilter('memberships')"
|
||||||
|
>
|
||||||
<span class="ico"><UiIcon name="layers" :size="18" /></span>
|
<span class="ico"><UiIcon name="layers" :size="18" /></span>
|
||||||
<div>
|
<div>
|
||||||
<p>عضویتها</p>
|
<p>عضویتها</p>
|
||||||
<strong>{{ toFaDigits(stats.membershipCount) }}</strong>
|
<strong>{{ toFaDigits(stats.membershipCount) }}</strong>
|
||||||
<small>نقش در محیطها</small>
|
<small>نقش در محیطها</small>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</button>
|
||||||
<article class="kpi warn">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="kpi warn"
|
||||||
|
:class="{ on: filter === 'expiring' }"
|
||||||
|
@click="toggleFilter('expiring')"
|
||||||
|
>
|
||||||
<span class="ico"><UiIcon name="clock" :size="18" /></span>
|
<span class="ico"><UiIcon name="clock" :size="18" /></span>
|
||||||
<div>
|
<div>
|
||||||
<p>نزدیک انقضا</p>
|
<p>نزدیک انقضا</p>
|
||||||
<strong>{{ toFaDigits(stats.expiringSoonCount ?? 0) }}</strong>
|
<strong>{{ toFaDigits(stats.expiringSoonCount ?? 0) }}</strong>
|
||||||
<small>کمتر از ۳ روز</small>
|
<small>کمتر از ۳ روز</small>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</button>
|
||||||
<article class="kpi danger">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="kpi danger"
|
||||||
|
:class="{ on: filter === 'expired' }"
|
||||||
|
@click="toggleFilter('expired')"
|
||||||
|
>
|
||||||
<span class="ico"><UiIcon name="warning" :size="18" /></span>
|
<span class="ico"><UiIcon name="warning" :size="18" /></span>
|
||||||
<div>
|
<div>
|
||||||
<p>منقضی</p>
|
<p>منقضی</p>
|
||||||
<strong>{{ toFaDigits(stats.expiredBillingCount ?? 0) }}</strong>
|
<strong>{{ toFaDigits(stats.expiredBillingCount ?? 0) }}</strong>
|
||||||
<small>نیاز به تمدید</small>
|
<small>نیاز به تمدید</small>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</button>
|
||||||
<article v-if="chatStats" class="kpi">
|
<button
|
||||||
|
v-if="chatStats"
|
||||||
|
type="button"
|
||||||
|
class="kpi"
|
||||||
|
:class="{ on: filter === 'chat' }"
|
||||||
|
@click="toggleFilter('chat')"
|
||||||
|
>
|
||||||
<span class="ico"><UiIcon name="chat" :size="18" /></span>
|
<span class="ico"><UiIcon name="chat" :size="18" /></span>
|
||||||
<div>
|
<div>
|
||||||
<p>گفتگو (پیکربندیشده)</p>
|
<p>گفتگو (پیکربندیشده)</p>
|
||||||
<strong>{{ toFaDigits(chatStats.organizationsWithChat) }}</strong>
|
<strong>{{ toFaDigits(chatStats.organizationsWithChat) }}</strong>
|
||||||
<small>{{ toFaDigits(chatStats.totalRoomsTracked) }} room ثبتشده</small>
|
<small>{{ toFaDigits(chatStats.totalRoomsTracked) }} room ثبتشده</small>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section v-if="filter" class="results">
|
||||||
|
<h3>{{ filterTitle[filter] }}</h3>
|
||||||
|
<p v-if="listLoading" class="muted">در حال خواندن…</p>
|
||||||
|
<p v-else-if="listError" class="error">{{ listError }}</p>
|
||||||
|
<p v-else-if="!rows.length" class="muted">موردی نیست.</p>
|
||||||
|
<ul v-else>
|
||||||
|
<li v-for="row in rows" :key="row.id">
|
||||||
|
<strong>{{ row.title }}</strong>
|
||||||
|
<span>{{ row.meta }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="actions">
|
<section class="actions">
|
||||||
<h3>میانبرها</h3>
|
<h3>میانبرها</h3>
|
||||||
<div class="shortcuts">
|
<div class="shortcuts">
|
||||||
@@ -202,6 +368,11 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
text-align: start;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #eef2f7;
|
border: 1px solid #eef2f7;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@@ -209,6 +380,11 @@ onMounted(() => {
|
|||||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04);
|
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.kpi.on {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 28%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.kpi p,
|
.kpi p,
|
||||||
.kpi small {
|
.kpi small {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -245,6 +421,39 @@ onMounted(() => {
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #eef2f7;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
.actions h3 {
|
.actions h3 {
|
||||||
margin: 0 0 0.65rem;
|
margin: 0 0 0.65rem;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
|||||||
@@ -2,14 +2,9 @@
|
|||||||
import { onMounted, reactive, ref } from 'vue';
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type {
|
import type {
|
||||||
ApplyBillingPlanRequest,
|
|
||||||
BillingPlanCode,
|
|
||||||
ConversationSettingsDto,
|
|
||||||
CreatePlatformOrganizationRequest,
|
CreatePlatformOrganizationRequest,
|
||||||
OrgModuleKey,
|
OrgModuleKey,
|
||||||
PlatformOrganizationDto,
|
PlatformOrganizationDto,
|
||||||
PlatformUserDto,
|
|
||||||
UpdateConversationSettingsRequest,
|
|
||||||
UpdatePlatformOrganizationRequest,
|
UpdatePlatformOrganizationRequest,
|
||||||
UserRole,
|
UserRole,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
@@ -25,7 +20,6 @@ import {
|
|||||||
import { platformApi } from '@/lib/platform-api';
|
import { platformApi } from '@/lib/platform-api';
|
||||||
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
|
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
|
||||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||||
import AdmBillingPanel from './AdmBillingPanel.vue';
|
|
||||||
|
|
||||||
const query = ref('');
|
const query = ref('');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -58,16 +52,6 @@ const editForm = reactive({
|
|||||||
const moduleDraft = ref<OrgModuleKey[]>([]);
|
const moduleDraft = ref<OrgModuleKey[]>([]);
|
||||||
const savingModules = ref(false);
|
const savingModules = ref(false);
|
||||||
|
|
||||||
const chatForm = reactive({
|
|
||||||
chatHost: '',
|
|
||||||
apiKey: '',
|
|
||||||
uploadUrl: '',
|
|
||||||
downloadUrl: '',
|
|
||||||
});
|
|
||||||
const chatApiKeySet = ref(false);
|
|
||||||
const savingChat = ref(false);
|
|
||||||
const chatLoadedFor = ref<string | null>(null);
|
|
||||||
|
|
||||||
function fieldLabel(value?: string | null) {
|
function fieldLabel(value?: string | null) {
|
||||||
if (!value) return '—';
|
if (!value) return '—';
|
||||||
return isBusinessField(value) ? BUSINESS_FIELD_LABELS[value] : value;
|
return isBusinessField(value) ? BUSINESS_FIELD_LABELS[value] : value;
|
||||||
@@ -132,7 +116,6 @@ async function toggleOpen(org: PlatformOrganizationDto) {
|
|||||||
openId.value = null;
|
openId.value = null;
|
||||||
detail.value = null;
|
detail.value = null;
|
||||||
editingId.value = null;
|
editingId.value = null;
|
||||||
chatLoadedFor.value = null;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openId.value = org.id;
|
openId.value = org.id;
|
||||||
@@ -143,56 +126,11 @@ async function toggleOpen(org: PlatformOrganizationDto) {
|
|||||||
);
|
);
|
||||||
detail.value = data;
|
detail.value = data;
|
||||||
moduleDraft.value = [...(data.enabledModules ?? [])];
|
moduleDraft.value = [...(data.enabledModules ?? [])];
|
||||||
await loadChatSettings(org.id);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = apiError(e, 'بارگذاری اعضا ناموفق بود');
|
error.value = apiError(e, 'بارگذاری اعضا ناموفق بود');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadChatSettings(orgId: string) {
|
|
||||||
try {
|
|
||||||
const { data } = await platformApi.get<ConversationSettingsDto>(
|
|
||||||
`/platform/organizations/${orgId}/conversations/settings`,
|
|
||||||
);
|
|
||||||
chatForm.chatHost = data.chatHost;
|
|
||||||
chatForm.uploadUrl = data.uploadUrl;
|
|
||||||
chatForm.downloadUrl = data.downloadUrl;
|
|
||||||
chatApiKeySet.value = data.apiKeySet;
|
|
||||||
chatForm.apiKey = '';
|
|
||||||
chatLoadedFor.value = orgId;
|
|
||||||
} catch (e) {
|
|
||||||
error.value = apiError(e, 'بارگذاری تنظیمات گفتگو ناموفق بود');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveChatSettings(orgId: string) {
|
|
||||||
savingChat.value = true;
|
|
||||||
error.value = '';
|
|
||||||
success.value = '';
|
|
||||||
try {
|
|
||||||
const payload: UpdateConversationSettingsRequest = {
|
|
||||||
chatHost: chatForm.chatHost.trim(),
|
|
||||||
uploadUrl: chatForm.uploadUrl.trim() || undefined,
|
|
||||||
downloadUrl: chatForm.downloadUrl.trim() || undefined,
|
|
||||||
};
|
|
||||||
if (chatForm.apiKey.trim()) payload.apiKey = chatForm.apiKey.trim();
|
|
||||||
const { data } = await platformApi.patch<ConversationSettingsDto>(
|
|
||||||
`/platform/organizations/${orgId}/conversations/settings`,
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
chatApiKeySet.value = data.apiKeySet;
|
|
||||||
chatForm.apiKey = '';
|
|
||||||
chatForm.chatHost = data.chatHost;
|
|
||||||
chatForm.uploadUrl = data.uploadUrl;
|
|
||||||
chatForm.downloadUrl = data.downloadUrl;
|
|
||||||
success.value = 'تنظیمات گفتگو ذخیره شد';
|
|
||||||
} catch (e) {
|
|
||||||
error.value = apiError(e, 'ذخیره تنظیمات گفتگو ناموفق بود');
|
|
||||||
} finally {
|
|
||||||
savingChat.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEdit(org: PlatformOrganizationDto) {
|
function startEdit(org: PlatformOrganizationDto) {
|
||||||
editingId.value = org.id;
|
editingId.value = org.id;
|
||||||
editForm.name = org.name;
|
editForm.name = org.name;
|
||||||
@@ -368,35 +306,6 @@ async function saveModules(orgId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyPlan(userId: string, planCode: BillingPlanCode) {
|
|
||||||
busyId.value = `plan-${userId}-${planCode}`;
|
|
||||||
error.value = '';
|
|
||||||
success.value = '';
|
|
||||||
try {
|
|
||||||
const body: ApplyBillingPlanRequest = { planCode };
|
|
||||||
await platformApi.post<PlatformUserDto>(
|
|
||||||
`/platform/users/${userId}/billing/apply-plan`,
|
|
||||||
body,
|
|
||||||
);
|
|
||||||
if (openId.value) {
|
|
||||||
const { data } = await platformApi.get<PlatformOrganizationDto>(
|
|
||||||
`/platform/organizations/${openId.value}`,
|
|
||||||
);
|
|
||||||
detail.value = data;
|
|
||||||
}
|
|
||||||
const { data: list } = await platformApi.get<PlatformOrganizationDto[]>(
|
|
||||||
'/platform/organizations',
|
|
||||||
{ params: query.value.trim() ? { q: query.value.trim() } : undefined },
|
|
||||||
);
|
|
||||||
items.value = list;
|
|
||||||
success.value = `پلن «${BILLING_PLAN_LABELS[planCode]}» اعمال شد`;
|
|
||||||
} catch (e) {
|
|
||||||
error.value = apiError(e, 'اعمال پلن ناموفق بود');
|
|
||||||
} finally {
|
|
||||||
busyId.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void load();
|
void load();
|
||||||
});
|
});
|
||||||
@@ -599,59 +508,6 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chat-settings">
|
|
||||||
<div class="modules-head">
|
|
||||||
<strong>تنظیمات گفتگو</strong>
|
|
||||||
<button
|
|
||||||
class="btn"
|
|
||||||
type="button"
|
|
||||||
:disabled="savingChat || chatLoadedFor !== org.id"
|
|
||||||
@click="saveChatSettings(org.id)"
|
|
||||||
>
|
|
||||||
{{ savingChat ? 'ذخیره…' : 'ذخیره گفتگو' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="chat-hint">
|
|
||||||
Host، API Key و آدرس آپلود/دانلود — فقط از سوپرادمین و برای همین محیط.
|
|
||||||
</p>
|
|
||||||
<div class="chat-grid">
|
|
||||||
<label>
|
|
||||||
<span>Host سرویس چت</span>
|
|
||||||
<input
|
|
||||||
v-model="chatForm.chatHost"
|
|
||||||
dir="ltr"
|
|
||||||
placeholder="chat.example.ir"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>{{ chatApiKeySet ? 'API Key (برای تغییر وارد کنید)' : 'API Key' }}</span>
|
|
||||||
<input
|
|
||||||
v-model="chatForm.apiKey"
|
|
||||||
type="password"
|
|
||||||
dir="ltr"
|
|
||||||
autocomplete="new-password"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>آدرس آپلود فایل</span>
|
|
||||||
<input
|
|
||||||
v-model="chatForm.uploadUrl"
|
|
||||||
dir="ltr"
|
|
||||||
placeholder="https://chat.example.ir/upload"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>آدرس پایه دانلود فایل</span>
|
|
||||||
<input
|
|
||||||
v-model="chatForm.downloadUrl"
|
|
||||||
dir="ltr"
|
|
||||||
placeholder="https://chat.example.ir/files"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="!detail?.members?.length" class="muted">عضوی ثبت نشده.</p>
|
<p v-if="!detail?.members?.length" class="muted">عضوی ثبت نشده.</p>
|
||||||
<ul v-else>
|
<ul v-else>
|
||||||
<li v-for="member in detail.members" :key="member.membershipId">
|
<li v-for="member in detail.members" :key="member.membershipId">
|
||||||
@@ -688,13 +544,6 @@ onMounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AdmBillingPanel
|
|
||||||
v-if="member.role === 'owner'"
|
|
||||||
:user-id="member.userId"
|
|
||||||
:billing="member.billing"
|
|
||||||
:busy-id="busyId"
|
|
||||||
@apply="applyPlan(member.userId, $event)"
|
|
||||||
/>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -933,33 +782,6 @@ label span {
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-settings {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.85rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: #f5f3ff;
|
|
||||||
border: 1px solid #e9e5ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-hint {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.chat-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modules-head,
|
.modules-head,
|
||||||
.mod-group-head {
|
.mod-group-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type {
|
import type { PlatformUserDto, UserRole } from '@mento/shared';
|
||||||
ApplyBillingPlanRequest,
|
import { USER_ROLE_LABELS, USER_ROLES } from '@mento/shared';
|
||||||
BillingPlanCode,
|
|
||||||
PlatformUserDto,
|
|
||||||
UserRole,
|
|
||||||
} from '@mento/shared';
|
|
||||||
import {
|
|
||||||
BILLING_PLAN_LABELS,
|
|
||||||
USER_ROLE_LABELS,
|
|
||||||
USER_ROLES,
|
|
||||||
} from '@mento/shared';
|
|
||||||
import { platformApi } from '@/lib/platform-api';
|
import { platformApi } from '@/lib/platform-api';
|
||||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||||
import AdmBillingPanel from './AdmBillingPanel.vue';
|
|
||||||
|
|
||||||
const query = ref('');
|
const query = ref('');
|
||||||
const ownersOnly = ref(false);
|
const ownersOnly = ref(false);
|
||||||
@@ -135,25 +125,6 @@ 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 = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setOwnersOnly(value: boolean) {
|
function setOwnersOnly(value: boolean) {
|
||||||
if (ownersOnly.value === value) return;
|
if (ownersOnly.value === value) return;
|
||||||
ownersOnly.value = value;
|
ownersOnly.value = value;
|
||||||
@@ -168,7 +139,7 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<header class="head">
|
<header class="head">
|
||||||
<p class="lead">اشتراک مالک، نقش عضویت و ریست رمز.</p>
|
<p class="lead">نقش عضویت و ریست رمز. اشتراک از منوی اشتراک تنظیم میشود.</p>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<div class="seg" role="group" aria-label="فیلتر مالکان">
|
<div class="seg" role="group" aria-label="فیلتر مالکان">
|
||||||
<button
|
<button
|
||||||
@@ -264,17 +235,6 @@ onMounted(() => {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div v-if="openId === user.id" class="detail">
|
<div v-if="openId === user.id" class="detail">
|
||||||
<AdmBillingPanel
|
|
||||||
v-if="user.isBillingOwner"
|
|
||||||
:user-id="user.id"
|
|
||||||
:billing="user.billing"
|
|
||||||
:busy-id="busyId"
|
|
||||||
@apply="applyPlan(user.id, $event)"
|
|
||||||
/>
|
|
||||||
<p v-else class="muted billing-skip">
|
|
||||||
این کاربر عضو زیرمجموعه است و اشتراک جدا ندارد؛ اشتراک روی مالک محیط است.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p v-if="!user.memberships.length" class="muted">عضو هیچ محیطی نیست.</p>
|
<p v-if="!user.memberships.length" class="muted">عضو هیچ محیطی نیست.</p>
|
||||||
<ul v-else>
|
<ul v-else>
|
||||||
<li v-for="mem in user.memberships" :key="mem.membershipId">
|
<li v-for="mem in user.memberships" :key="mem.membershipId">
|
||||||
|
|||||||
@@ -4613,6 +4613,22 @@ export interface ResetPlatformAdminPasswordRequest {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdatePlatformAdminRequest {
|
||||||
|
fullName: string;
|
||||||
|
mobile: string;
|
||||||
|
email?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformMasterPasswordStatusDto {
|
||||||
|
enabled: boolean;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetPlatformMasterPasswordRequest {
|
||||||
|
/** null یعنی رمز کلی برداشته شود */
|
||||||
|
password: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlatformStatsDto {
|
export interface PlatformStatsDto {
|
||||||
organizationCount: number;
|
organizationCount: number;
|
||||||
activeOrganizationCount: number;
|
activeOrganizationCount: number;
|
||||||
@@ -4649,6 +4665,9 @@ export interface PlatformOrganizationDto {
|
|||||||
enabledModules?: OrgModuleKey[];
|
enabledModules?: OrgModuleKey[];
|
||||||
/** اشتراک مالک اصلی محیط */
|
/** اشتراک مالک اصلی محیط */
|
||||||
subscription?: SubscriptionSummaryDto | null;
|
subscription?: SubscriptionSummaryDto | null;
|
||||||
|
ownerUserId?: string | null;
|
||||||
|
ownerName?: string | null;
|
||||||
|
chatConfigured?: boolean;
|
||||||
members?: PlatformOrgMemberDto[];
|
members?: PlatformOrgMemberDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user