Configuring access rights in the CRM Super Admin
This commit is contained in:
@@ -0,0 +1,64 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Organization" ADD COLUMN IF NOT EXISTS "enabledModules" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE IF NOT EXISTS "BillingAccount" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"ownerUserId" TEXT NOT NULL,
|
||||||
|
"planCode" TEXT NOT NULL DEFAULT 'trial',
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'active',
|
||||||
|
"startsAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"maxOrganizations" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"externalPaymentRef" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "BillingAccount_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "BillingAccount_ownerUserId_key" ON "BillingAccount"("ownerUserId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "BillingAccount_status_idx" ON "BillingAccount"("status");
|
||||||
|
CREATE INDEX IF NOT EXISTS "BillingAccount_expiresAt_idx" ON "BillingAccount"("expiresAt");
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'BillingAccount_ownerUserId_fkey'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "BillingAccount"
|
||||||
|
ADD CONSTRAINT "BillingAccount_ownerUserId_fkey"
|
||||||
|
FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Backfill: full module catalog for existing orgs (empty → all)
|
||||||
|
UPDATE "Organization"
|
||||||
|
SET "enabledModules" = ARRAY[
|
||||||
|
'customers','pipeline','products','invoices','quotes','commissions','sales_forecast',
|
||||||
|
'activities','tickets','workflow_automations','message_automations','lead_forms',
|
||||||
|
'project_boards','inventory','cheques','attachments','loyalty','reports','notes',
|
||||||
|
'tahesab','custom_fields','currencies','team_settings','faq'
|
||||||
|
]::TEXT[]
|
||||||
|
WHERE cardinality("enabledModules") = 0;
|
||||||
|
|
||||||
|
-- Backfill BillingAccount for every active owner membership (15-day trial)
|
||||||
|
INSERT INTO "BillingAccount" (
|
||||||
|
"id", "ownerUserId", "planCode", "status", "startsAt", "expiresAt",
|
||||||
|
"maxOrganizations", "createdAt", "updatedAt"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
md5(random()::text || clock_timestamp()::text || m."userId"),
|
||||||
|
m."userId",
|
||||||
|
'trial',
|
||||||
|
'active',
|
||||||
|
NOW(),
|
||||||
|
NOW() + INTERVAL '15 days',
|
||||||
|
GREATEST(1, COUNT(*)::int),
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
FROM "Membership" m
|
||||||
|
WHERE m."role" = 'owner' AND m."isActive" = true
|
||||||
|
GROUP BY m."userId"
|
||||||
|
ON CONFLICT ("ownerUserId") DO NOTHING;
|
||||||
@@ -147,6 +147,8 @@ model Organization {
|
|||||||
businessField String?
|
businessField String?
|
||||||
baseCurrency String @default("IRR")
|
baseCurrency String @default("IRR")
|
||||||
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
|
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
|
||||||
|
/// ماژولهای فعال منو برای این محیط
|
||||||
|
enabledModules String[] @default([])
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -230,6 +232,7 @@ model User {
|
|||||||
ticketMessages TicketMessage[] @relation("TicketMessageAuthor")
|
ticketMessages TicketMessage[] @relation("TicketMessageAuthor")
|
||||||
confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy")
|
confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy")
|
||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
|
billingAccount BillingAccount?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -293,6 +296,28 @@ model PlatformAdmin {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// اشتراک SaaS روی مالک حساب (نه روی هر عضویت)
|
||||||
|
model BillingAccount {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
ownerUserId String @unique
|
||||||
|
owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
|
||||||
|
/// trial | monthly | quarterly | semiannual | yearly | custom
|
||||||
|
planCode String @default("trial")
|
||||||
|
/// active | expired | suspended
|
||||||
|
status String @default("active")
|
||||||
|
startsAt DateTime @default(now())
|
||||||
|
expiresAt DateTime
|
||||||
|
maxOrganizations Int @default(1)
|
||||||
|
/// رزرو برای درگاه/لندینگ بعدی
|
||||||
|
externalPaymentRef String?
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
@@index([expiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
model Company {
|
model Company {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
organizationId String
|
organizationId String
|
||||||
|
|||||||
+38
-4
@@ -44,9 +44,19 @@ async function seedPlatformAdmin() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
const existingByMobile = await prisma.platformAdmin.findUnique({ where: { mobile } });
|
const existingByMobile = await prisma.platformAdmin.findUnique({ where: { mobile } });
|
||||||
if (existingByMobile) {
|
if (existingByMobile) {
|
||||||
console.log('Platform admin exists:', mobile);
|
await prisma.platformAdmin.update({
|
||||||
|
where: { id: existingByMobile.id },
|
||||||
|
data: {
|
||||||
|
passwordHash,
|
||||||
|
fullName: envName,
|
||||||
|
isActive: true,
|
||||||
|
...(email ? { email } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('Platform admin password refreshed:', mobile);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (email) {
|
if (email) {
|
||||||
@@ -54,14 +64,38 @@ async function seedPlatformAdmin() {
|
|||||||
if (existingByEmail) {
|
if (existingByEmail) {
|
||||||
await prisma.platformAdmin.update({
|
await prisma.platformAdmin.update({
|
||||||
where: { id: existingByEmail.id },
|
where: { id: existingByEmail.id },
|
||||||
data: { mobile, ...(envName ? { fullName: envName } : {}) },
|
data: {
|
||||||
|
mobile,
|
||||||
|
passwordHash,
|
||||||
|
fullName: envName,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
console.log('Platform admin linked mobile:', mobile, '→', email);
|
console.log('Platform admin linked mobile + password refreshed:', mobile, '→', email);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, 10);
|
const orphans = await prisma.platformAdmin.findMany({
|
||||||
|
where: { mobile: null },
|
||||||
|
take: 2,
|
||||||
|
});
|
||||||
|
if (orphans.length === 1) {
|
||||||
|
const orphan = orphans[0]!;
|
||||||
|
await prisma.platformAdmin.update({
|
||||||
|
where: { id: orphan.id },
|
||||||
|
data: {
|
||||||
|
mobile,
|
||||||
|
passwordHash,
|
||||||
|
fullName: envName,
|
||||||
|
isActive: true,
|
||||||
|
...(email ? { email } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('Platform admin orphan linked mobile:', mobile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.platformAdmin.create({
|
await prisma.platformAdmin.create({
|
||||||
data: {
|
data: {
|
||||||
mobile,
|
mobile,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { TahesabModule } from './integrations/tahesab/tahesab.module';
|
|||||||
import { InventoryModule } from './inventory/inventory.module';
|
import { InventoryModule } from './inventory/inventory.module';
|
||||||
import { FinanceModule } from './finance/finance.module';
|
import { FinanceModule } from './finance/finance.module';
|
||||||
import { PlatformModule } from './platform/platform.module';
|
import { PlatformModule } from './platform/platform.module';
|
||||||
|
import { BillingModule } from './billing/billing.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -45,6 +46,7 @@ import { PlatformModule } from './platform/platform.module';
|
|||||||
PrismaModule,
|
PrismaModule,
|
||||||
StorageModule,
|
StorageModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
BillingModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
PlatformModule,
|
PlatformModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { JwtModule } from '@nestjs/jwt';
|
|||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { MembershipsModule } from '../memberships/memberships.module';
|
import { MembershipsModule } from '../memberships/memberships.module';
|
||||||
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
@@ -13,6 +14,7 @@ import { SmsService } from '../common/sms.service';
|
|||||||
imports: [
|
imports: [
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
MembershipsModule,
|
MembershipsModule,
|
||||||
|
BillingModule,
|
||||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
|
|||||||
@@ -10,12 +10,19 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { AuthUserDto, LoginResponse, LoginResult } from '@mento/shared';
|
import type {
|
||||||
|
AuthUserDto,
|
||||||
|
LoginResponse,
|
||||||
|
LoginResult,
|
||||||
|
OrgModuleKey,
|
||||||
|
SubscriptionSummaryDto,
|
||||||
|
} from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SmsService } from '../common/sms.service';
|
||||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||||
import { MembershipsService } from '../memberships/memberships.service';
|
import { MembershipsService } from '../memberships/memberships.service';
|
||||||
|
import { BillingService } from '../billing/billing.service';
|
||||||
import {
|
import {
|
||||||
LoginDto,
|
LoginDto,
|
||||||
LoginOtpRequestDto,
|
LoginOtpRequestDto,
|
||||||
@@ -97,6 +104,7 @@ export class AuthService {
|
|||||||
private readonly sms: SmsService,
|
private readonly sms: SmsService,
|
||||||
private readonly files: LocalFileStorage,
|
private readonly files: LocalFileStorage,
|
||||||
private readonly memberships: MembershipsService,
|
private readonly memberships: MembershipsService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -260,6 +268,11 @@ export class AuthService {
|
|||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
role: 'owner',
|
role: 'owner',
|
||||||
});
|
});
|
||||||
|
await this.billing.ensureTrialForOwner(owner.id, { maxOrganizations: 1 });
|
||||||
|
await this.prisma.organization.update({
|
||||||
|
where: { id: organization.id },
|
||||||
|
data: { enabledModules: this.billing.defaultModulesForNewOrg() },
|
||||||
|
});
|
||||||
|
|
||||||
await this.prisma.authChallenge.update({
|
await this.prisma.authChallenge.update({
|
||||||
where: { id: challenge.id },
|
where: { id: challenge.id },
|
||||||
@@ -578,6 +591,9 @@ export class AuthService {
|
|||||||
if (!user || user.isActive === false) {
|
if (!user || user.isActive === false) {
|
||||||
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
||||||
}
|
}
|
||||||
|
const subscription = await this.billing.requireActiveForOrganization(
|
||||||
|
organizationId,
|
||||||
|
);
|
||||||
await this.memberships.syncUserLastOrg(
|
await this.memberships.syncUserLastOrg(
|
||||||
userId,
|
userId,
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -597,6 +613,7 @@ export class AuthService {
|
|||||||
user: await this.toAuthUserDto(
|
user: await this.toAuthUserDto(
|
||||||
{ ...user, role: membership.role, organizationId },
|
{ ...user, role: membership.role, organizationId },
|
||||||
organizationId,
|
organizationId,
|
||||||
|
subscription,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -611,19 +628,29 @@ export class AuthService {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
},
|
},
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
|
subscription?: SubscriptionSummaryDto | null,
|
||||||
): Promise<AuthUserDto> {
|
): Promise<AuthUserDto> {
|
||||||
const membership = await this.prisma.membership.findFirst({
|
const membership = await this.prisma.membership.findFirst({
|
||||||
where: { userId: user.id, organizationId, isActive: true },
|
where: { userId: user.id, organizationId, isActive: true },
|
||||||
include: { organization: { select: { name: true } } },
|
include: {
|
||||||
|
organization: { select: { name: true, enabledModules: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const role = membership?.role ?? user.role;
|
const role = membership?.role ?? user.role;
|
||||||
const orgName = membership?.organization.name;
|
const orgName = membership?.organization.name;
|
||||||
|
const sub =
|
||||||
|
subscription ??
|
||||||
|
(await this.billing.requireActiveForOrganization(organizationId));
|
||||||
return this.toAuthUser({
|
return this.toAuthUser({
|
||||||
...user,
|
...user,
|
||||||
role,
|
role,
|
||||||
organizationId,
|
organizationId,
|
||||||
organizationName: orgName,
|
organizationName: orgName,
|
||||||
avatarStorageKey: await this.readAvatarKey(user.id),
|
avatarStorageKey: await this.readAvatarKey(user.id),
|
||||||
|
enabledModules: this.billing.normalizeModules(
|
||||||
|
membership?.organization.enabledModules,
|
||||||
|
),
|
||||||
|
subscription: sub,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,6 +735,8 @@ export class AuthService {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
organizationName?: string;
|
organizationName?: string;
|
||||||
avatarStorageKey?: string | null;
|
avatarStorageKey?: string | null;
|
||||||
|
enabledModules?: OrgModuleKey[];
|
||||||
|
subscription?: SubscriptionSummaryDto | null;
|
||||||
}): AuthUserDto {
|
}): AuthUserDto {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -718,6 +747,8 @@ export class AuthService {
|
|||||||
organizationName: user.organizationName,
|
organizationName: user.organizationName,
|
||||||
hasAvatar: Boolean(user.avatarStorageKey),
|
hasAvatar: Boolean(user.avatarStorageKey),
|
||||||
...(user.mobile ? { mobile: user.mobile } : {}),
|
...(user.mobile ? { mobile: user.mobile } : {}),
|
||||||
|
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
|
||||||
|
...(user.subscription ? { subscription: user.subscription } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { BillingService } from '../billing/billing.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
export type JwtPayload = {
|
export type JwtPayload = {
|
||||||
@@ -15,6 +16,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
constructor(
|
constructor(
|
||||||
config: ConfigService,
|
config: ConfigService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
@@ -53,6 +55,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
if (!membership) {
|
if (!membership) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
await this.billing.requireActiveForOrganization(membership.organizationId);
|
||||||
return {
|
return {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
organizationId: membership.organizationId,
|
organizationId: membership.organizationId,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { BillingService } from './billing.service';
|
||||||
|
import { OrgModuleGuard } from './org-module.guard';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [BillingService, OrgModuleGuard],
|
||||||
|
exports: [BillingService, OrgModuleGuard],
|
||||||
|
})
|
||||||
|
export class BillingModule {}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
ApplyBillingPlanRequest,
|
||||||
|
BillingAccountDto,
|
||||||
|
BillingAccountStatus,
|
||||||
|
BillingPlanCode,
|
||||||
|
OrgModuleKey,
|
||||||
|
SubscriptionSummaryDto,
|
||||||
|
} from '@mento/shared';
|
||||||
|
import {
|
||||||
|
BILLING_PLAN_DAYS,
|
||||||
|
BILLING_PLAN_DEFAULT_MAX_ORGS,
|
||||||
|
defaultEnabledModules,
|
||||||
|
isOrgModuleKey,
|
||||||
|
} from '@mento/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
function daysRemaining(expiresAt: Date, now = new Date()): number {
|
||||||
|
const ms = expiresAt.getTime() - now.getTime();
|
||||||
|
return Math.max(0, Math.ceil(ms / (24 * 60 * 60 * 1000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSummary(row: {
|
||||||
|
planCode: string;
|
||||||
|
status: string;
|
||||||
|
startsAt: Date;
|
||||||
|
expiresAt: Date;
|
||||||
|
maxOrganizations: number;
|
||||||
|
}): SubscriptionSummaryDto {
|
||||||
|
const remaining = daysRemaining(row.expiresAt);
|
||||||
|
const status = (row.status as BillingAccountStatus) ?? 'active';
|
||||||
|
return {
|
||||||
|
planCode: row.planCode as BillingPlanCode,
|
||||||
|
status,
|
||||||
|
startsAt: row.startsAt.toISOString(),
|
||||||
|
expiresAt: row.expiresAt.toISOString(),
|
||||||
|
maxOrganizations: row.maxOrganizations,
|
||||||
|
daysRemaining: remaining,
|
||||||
|
warn: remaining > 0 && remaining <= 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BillingService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
toDto(row: {
|
||||||
|
id: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
planCode: string;
|
||||||
|
status: string;
|
||||||
|
startsAt: Date;
|
||||||
|
expiresAt: Date;
|
||||||
|
maxOrganizations: number;
|
||||||
|
externalPaymentRef: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}): BillingAccountDto {
|
||||||
|
const summary = toSummary(row);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
ownerUserId: row.ownerUserId,
|
||||||
|
...summary,
|
||||||
|
externalPaymentRef: row.externalPaymentRef,
|
||||||
|
notes: row.notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByOwnerUserId(ownerUserId: string) {
|
||||||
|
return this.prisma.billingAccount.findUnique({ where: { ownerUserId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureTrialForOwner(
|
||||||
|
ownerUserId: string,
|
||||||
|
opts?: { maxOrganizations?: number },
|
||||||
|
) {
|
||||||
|
const existing = await this.getByOwnerUserId(ownerUserId);
|
||||||
|
if (existing) return existing;
|
||||||
|
const now = new Date();
|
||||||
|
const expiresAt = new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000);
|
||||||
|
return this.prisma.billingAccount.create({
|
||||||
|
data: {
|
||||||
|
ownerUserId,
|
||||||
|
planCode: 'trial',
|
||||||
|
status: 'active',
|
||||||
|
startsAt: now,
|
||||||
|
expiresAt,
|
||||||
|
maxOrganizations: Math.max(1, opts?.maxOrganizations ?? 1),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** اشتراک محیط = BillingAccount اولین مالک فعال */
|
||||||
|
async resolveForOrganization(organizationId: string) {
|
||||||
|
const owner = await this.prisma.membership.findFirst({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
role: 'owner',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
if (!owner) return null;
|
||||||
|
let account = await this.getByOwnerUserId(owner.userId);
|
||||||
|
if (!account) {
|
||||||
|
account = await this.ensureTrialForOwner(owner.userId);
|
||||||
|
}
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
async requireActiveForOrganization(organizationId: string): Promise<SubscriptionSummaryDto> {
|
||||||
|
const account = await this.resolveForOrganization(organizationId);
|
||||||
|
if (!account) {
|
||||||
|
throw new UnauthorizedException('اشتراک این محیط یافت نشد');
|
||||||
|
}
|
||||||
|
return this.assertActive(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
async requireActiveForOwner(ownerUserId: string): Promise<SubscriptionSummaryDto> {
|
||||||
|
let account = await this.getByOwnerUserId(ownerUserId);
|
||||||
|
if (!account) {
|
||||||
|
account = await this.ensureTrialForOwner(ownerUserId);
|
||||||
|
}
|
||||||
|
return this.assertActive(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertActive(account: {
|
||||||
|
id: string;
|
||||||
|
planCode: string;
|
||||||
|
status: string;
|
||||||
|
startsAt: Date;
|
||||||
|
expiresAt: Date;
|
||||||
|
maxOrganizations: number;
|
||||||
|
}): Promise<SubscriptionSummaryDto> {
|
||||||
|
const now = new Date();
|
||||||
|
if (account.status === 'suspended') {
|
||||||
|
throw new UnauthorizedException('اشتراک این حساب معلق شده است');
|
||||||
|
}
|
||||||
|
if (account.status === 'expired' || account.expiresAt.getTime() < now.getTime()) {
|
||||||
|
if (account.status !== 'expired') {
|
||||||
|
await this.prisma.billingAccount.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data: { status: 'expired' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new UnauthorizedException('اشتراک منقضی شده است');
|
||||||
|
}
|
||||||
|
return toSummary(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertCanCreateOrganization(ownerUserId: string) {
|
||||||
|
const summary = await this.requireActiveForOwner(ownerUserId);
|
||||||
|
const ownedCount = await this.prisma.membership.count({
|
||||||
|
where: { userId: ownerUserId, role: 'owner', isActive: true },
|
||||||
|
});
|
||||||
|
if (ownedCount >= summary.maxOrganizations) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`سقف تعداد محیط برای این اشتراک ${summary.maxOrganizations} است`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyPlan(
|
||||||
|
ownerUserId: string,
|
||||||
|
dto: ApplyBillingPlanRequest,
|
||||||
|
): Promise<BillingAccountDto> {
|
||||||
|
const planCode = dto.planCode;
|
||||||
|
const now = new Date();
|
||||||
|
let expiresAt: Date;
|
||||||
|
let maxOrganizations: number;
|
||||||
|
|
||||||
|
if (planCode === 'custom') {
|
||||||
|
if (!dto.expiresAt) {
|
||||||
|
throw new BadRequestException('برای پلن سفارشی تاریخ انقضا لازم است');
|
||||||
|
}
|
||||||
|
expiresAt = new Date(dto.expiresAt);
|
||||||
|
if (Number.isNaN(expiresAt.getTime())) {
|
||||||
|
throw new BadRequestException('تاریخ انقضا معتبر نیست');
|
||||||
|
}
|
||||||
|
maxOrganizations = Math.max(1, dto.maxOrganizations ?? 1);
|
||||||
|
} else {
|
||||||
|
const days = BILLING_PLAN_DAYS[planCode];
|
||||||
|
expiresAt = dto.expiresAt
|
||||||
|
? new Date(dto.expiresAt)
|
||||||
|
: new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
||||||
|
if (Number.isNaN(expiresAt.getTime())) {
|
||||||
|
throw new BadRequestException('تاریخ انقضا معتبر نیست');
|
||||||
|
}
|
||||||
|
maxOrganizations = Math.max(
|
||||||
|
1,
|
||||||
|
dto.maxOrganizations ?? BILLING_PLAN_DEFAULT_MAX_ORGS[planCode],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status: BillingAccountStatus =
|
||||||
|
dto.status ??
|
||||||
|
(expiresAt.getTime() < now.getTime() ? 'expired' : 'active');
|
||||||
|
|
||||||
|
const existing = await this.getByOwnerUserId(ownerUserId);
|
||||||
|
const row = existing
|
||||||
|
? await this.prisma.billingAccount.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
planCode,
|
||||||
|
status,
|
||||||
|
startsAt: existing.startsAt ?? now,
|
||||||
|
expiresAt,
|
||||||
|
maxOrganizations,
|
||||||
|
...(dto.notes !== undefined ? { notes: dto.notes } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: await this.prisma.billingAccount.create({
|
||||||
|
data: {
|
||||||
|
ownerUserId,
|
||||||
|
planCode,
|
||||||
|
status,
|
||||||
|
startsAt: now,
|
||||||
|
expiresAt,
|
||||||
|
maxOrganizations,
|
||||||
|
notes: dto.notes ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.toDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setOrgModules(organizationId: string, modules: string[]) {
|
||||||
|
const cleaned = [
|
||||||
|
...new Set(
|
||||||
|
modules.filter(isOrgModuleKey),
|
||||||
|
),
|
||||||
|
] as OrgModuleKey[];
|
||||||
|
await this.prisma.organization.update({
|
||||||
|
where: { id: organizationId },
|
||||||
|
data: { enabledModules: cleaned },
|
||||||
|
});
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeModules(raw: string[] | null | undefined): OrgModuleKey[] {
|
||||||
|
if (!raw?.length) return defaultEnabledModules();
|
||||||
|
const cleaned = raw.filter(isOrgModuleKey) as OrgModuleKey[];
|
||||||
|
return cleaned.length ? cleaned : defaultEnabledModules();
|
||||||
|
}
|
||||||
|
|
||||||
|
async requireModule(organizationId: string, module: OrgModuleKey) {
|
||||||
|
const org = await this.prisma.organization.findUnique({
|
||||||
|
where: { id: organizationId },
|
||||||
|
select: { enabledModules: true },
|
||||||
|
});
|
||||||
|
if (!org) throw new ForbiddenException('محیط پیدا نشد');
|
||||||
|
const enabled = this.normalizeModules(org.enabledModules);
|
||||||
|
if (!enabled.includes(module)) {
|
||||||
|
throw new ForbiddenException('این ماژول برای این محیط فعال نیست');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultModulesForNewOrg(): OrgModuleKey[] {
|
||||||
|
return defaultEnabledModules();
|
||||||
|
}
|
||||||
|
|
||||||
|
async countExpiringSoon() {
|
||||||
|
const now = new Date();
|
||||||
|
const in3 = new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000);
|
||||||
|
return this.prisma.billingAccount.count({
|
||||||
|
where: {
|
||||||
|
status: 'active',
|
||||||
|
expiresAt: { gt: now, lte: in3 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async countExpired() {
|
||||||
|
return this.prisma.billingAccount.count({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ status: 'expired' },
|
||||||
|
{ status: 'active', expiresAt: { lt: new Date() } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import type { OrgModuleKey } from '@mento/shared';
|
||||||
|
|
||||||
|
export const ORG_MODULE_KEY = 'org_module';
|
||||||
|
|
||||||
|
export const RequireOrgModule = (module: OrgModuleKey) =>
|
||||||
|
SetMetadata(ORG_MODULE_KEY, module);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import type { OrgModuleKey } from '@mento/shared';
|
||||||
|
import { BillingService } from './billing.service';
|
||||||
|
import { ORG_MODULE_KEY } from './org-module.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OrgModuleGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly billing: BillingService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const module = this.reflector.getAllAndOverride<OrgModuleKey | undefined>(
|
||||||
|
ORG_MODULE_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
if (!module) return true;
|
||||||
|
const req = context.switchToHttp().getRequest<{
|
||||||
|
user?: { organizationId?: string };
|
||||||
|
}>();
|
||||||
|
const organizationId = req.user?.organizationId;
|
||||||
|
if (!organizationId) return false;
|
||||||
|
await this.billing.requireModule(organizationId, module);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,13 +18,16 @@ import type { Response } from 'express';
|
|||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
|
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||||
|
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||||
import { CreatePersonDto, ListQueryDto, UpdatePersonDto } from './dto';
|
import { CreatePersonDto, ListQueryDto, UpdatePersonDto } from './dto';
|
||||||
import { PeopleService } from './people.service';
|
import { PeopleService } from './people.service';
|
||||||
import { ContactsExcelService } from './contacts-excel.service';
|
import { ContactsExcelService } from './contacts-excel.service';
|
||||||
import { brand } from '@mento/shared';
|
import { brand } from '@mento/shared';
|
||||||
|
|
||||||
@Controller('people')
|
@Controller('people')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||||
|
@RequireOrgModule('customers')
|
||||||
export class PeopleController {
|
export class PeopleController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly peopleService: PeopleService,
|
private readonly peopleService: PeopleService,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import { Roles } from '../auth/roles.decorator';
|
import { Roles } from '../auth/roles.decorator';
|
||||||
import { RolesGuard } from '../auth/roles.guard';
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
|
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||||
|
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||||
import {
|
import {
|
||||||
CreateMessageAutomationRuleDto,
|
CreateMessageAutomationRuleDto,
|
||||||
UpdateMessageAutomationRuleDto,
|
UpdateMessageAutomationRuleDto,
|
||||||
@@ -19,7 +21,8 @@ import {
|
|||||||
import { MessageAutomationsService } from './message-automations.service';
|
import { MessageAutomationsService } from './message-automations.service';
|
||||||
|
|
||||||
@Controller('message-automations')
|
@Controller('message-automations')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||||
|
@RequireOrgModule('message_automations')
|
||||||
@Roles('owner', 'admin')
|
@Roles('owner', 'admin')
|
||||||
export class MessageAutomationsController {
|
export class MessageAutomationsController {
|
||||||
constructor(private readonly messages: MessageAutomationsService) {}
|
constructor(private readonly messages: MessageAutomationsService) {}
|
||||||
|
|||||||
@@ -3,11 +3,18 @@ import { AuthModule } from '../auth/auth.module';
|
|||||||
import { MembershipsModule } from '../memberships/memberships.module';
|
import { MembershipsModule } from '../memberships/memberships.module';
|
||||||
import { OrgRolesModule } from '../org-roles/org-roles.module';
|
import { OrgRolesModule } from '../org-roles/org-roles.module';
|
||||||
import { PipelinesModule } from '../pipelines/pipelines.module';
|
import { PipelinesModule } from '../pipelines/pipelines.module';
|
||||||
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { OrganizationsController } from './organizations.controller';
|
import { OrganizationsController } from './organizations.controller';
|
||||||
import { OrganizationsService } from './organizations.service';
|
import { OrganizationsService } from './organizations.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, MembershipsModule, OrgRolesModule, PipelinesModule],
|
imports: [
|
||||||
|
AuthModule,
|
||||||
|
MembershipsModule,
|
||||||
|
OrgRolesModule,
|
||||||
|
PipelinesModule,
|
||||||
|
BillingModule,
|
||||||
|
],
|
||||||
controllers: [OrganizationsController],
|
controllers: [OrganizationsController],
|
||||||
providers: [OrganizationsService],
|
providers: [OrganizationsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { isKnownCurrency } from '../common/currency';
|
|||||||
import { MembershipsService } from '../memberships/memberships.service';
|
import { MembershipsService } from '../memberships/memberships.service';
|
||||||
import { OrgRolesService } from '../org-roles/org-roles.service';
|
import { OrgRolesService } from '../org-roles/org-roles.service';
|
||||||
import { PipelinesService } from '../pipelines/pipelines.service';
|
import { PipelinesService } from '../pipelines/pipelines.service';
|
||||||
|
import { BillingService } from '../billing/billing.service';
|
||||||
|
|
||||||
function slugify(input: string): string {
|
function slugify(input: string): string {
|
||||||
return (
|
return (
|
||||||
@@ -34,6 +35,7 @@ export class OrganizationsService {
|
|||||||
private readonly memberships: MembershipsService,
|
private readonly memberships: MembershipsService,
|
||||||
private readonly orgRoles: OrgRolesService,
|
private readonly orgRoles: OrgRolesService,
|
||||||
private readonly pipelines: PipelinesService,
|
private readonly pipelines: PipelinesService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createForUser(
|
async createForUser(
|
||||||
@@ -57,6 +59,8 @@ export class OrganizationsService {
|
|||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.billing.assertCanCreateOrganization(userId);
|
||||||
|
|
||||||
const baseSlug = slugify(name);
|
const baseSlug = slugify(name);
|
||||||
let slug = baseSlug;
|
let slug = baseSlug;
|
||||||
let i = 1;
|
let i = 1;
|
||||||
@@ -69,6 +73,7 @@ export class OrganizationsService {
|
|||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
businessField,
|
businessField,
|
||||||
|
enabledModules: this.billing.defaultModulesForNewOrg(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.memberships.createMembership({
|
await this.memberships.createMembership({
|
||||||
@@ -76,6 +81,7 @@ export class OrganizationsService {
|
|||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
role: 'owner',
|
role: 'owner',
|
||||||
});
|
});
|
||||||
|
await this.billing.ensureTrialForOwner(userId);
|
||||||
await this.orgRoles.ensureDefaultRoles(organization.id);
|
await this.orgRoles.ensureDefaultRoles(organization.id);
|
||||||
await this.pipelines.ensureDefaultPipeline(organization.id);
|
await this.pipelines.ensureDefaultPipeline(organization.id);
|
||||||
return { id: organization.id };
|
return { id: organization.id };
|
||||||
@@ -219,6 +225,7 @@ export class OrganizationsService {
|
|||||||
businessField: string | null;
|
businessField: string | null;
|
||||||
baseCurrency: string;
|
baseCurrency: string;
|
||||||
enabledCurrencies: string[];
|
enabledCurrencies: string[];
|
||||||
|
enabledModules?: string[];
|
||||||
exchangeRates: Array<{
|
exchangeRates: Array<{
|
||||||
currency: string;
|
currency: string;
|
||||||
rateToBase: Prisma.Decimal;
|
rateToBase: Prisma.Decimal;
|
||||||
@@ -235,6 +242,7 @@ export class OrganizationsService {
|
|||||||
: null,
|
: null,
|
||||||
baseCurrency: org.baseCurrency,
|
baseCurrency: org.baseCurrency,
|
||||||
enabledCurrencies: org.enabledCurrencies,
|
enabledCurrencies: org.enabledCurrencies,
|
||||||
|
enabledModules: this.billing.normalizeModules(org.enabledModules),
|
||||||
exchangeRates: org.exchangeRates.map((r) => ({
|
exchangeRates: org.exchangeRates.map((r) => ({
|
||||||
currency: r.currency,
|
currency: r.currency,
|
||||||
rateToBase: Number(r.rateToBase),
|
rateToBase: Number(r.rateToBase),
|
||||||
|
|||||||
@@ -18,13 +18,16 @@ import type { Response } from 'express';
|
|||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
|
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||||
|
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||||
import { CreateDealDto, MoveDealDto, UpdateDealDto } from './dto';
|
import { CreateDealDto, MoveDealDto, UpdateDealDto } from './dto';
|
||||||
import { DealsService } from './deals.service';
|
import { DealsService } from './deals.service';
|
||||||
import { DealsExcelService } from './deals-excel.service';
|
import { DealsExcelService } from './deals-excel.service';
|
||||||
import { brand } from '@mento/shared';
|
import { brand } from '@mento/shared';
|
||||||
|
|
||||||
@Controller('deals')
|
@Controller('deals')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||||
|
@RequireOrgModule('pipeline')
|
||||||
export class DealsController {
|
export class DealsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly dealsService: DealsService,
|
private readonly dealsService: DealsService,
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsEmail,
|
IsEmail,
|
||||||
IsIn,
|
IsIn,
|
||||||
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
@@ -119,3 +122,32 @@ export class ResetPlatformUserPasswordDto {
|
|||||||
@MaxLength(120)
|
@MaxLength(120)
|
||||||
password!: string;
|
password!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ApplyBillingPlanDto {
|
||||||
|
@IsIn(['trial', 'monthly', 'quarterly', 'semiannual', 'yearly', 'custom'])
|
||||||
|
planCode!: 'trial' | 'monthly' | 'quarterly' | 'semiannual' | 'yearly' | 'custom';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
maxOrganizations?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
expiresAt?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
notes?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['active', 'expired', 'suspended'])
|
||||||
|
status?: 'active' | 'expired' | 'suspended';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateOrgModulesDto {
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
enabledModules!: string[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import {
|
import {
|
||||||
|
ApplyBillingPlanDto,
|
||||||
CreatePlatformAdminDto,
|
CreatePlatformAdminDto,
|
||||||
CreatePlatformOrganizationDto,
|
CreatePlatformOrganizationDto,
|
||||||
ResetPlatformAdminPasswordDto,
|
ResetPlatformAdminPasswordDto,
|
||||||
ResetPlatformUserPasswordDto,
|
ResetPlatformUserPasswordDto,
|
||||||
|
UpdateOrgModulesDto,
|
||||||
UpdatePlatformMembershipRoleDto,
|
UpdatePlatformMembershipRoleDto,
|
||||||
UpdatePlatformOrganizationDto,
|
UpdatePlatformOrganizationDto,
|
||||||
UpdatePlatformStatusDto,
|
UpdatePlatformStatusDto,
|
||||||
@@ -105,6 +107,19 @@ export class PlatformController {
|
|||||||
return this.platform.resetUserPassword(id, dto);
|
return this.platform.resetUserPassword(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('users/:id/billing/apply-plan')
|
||||||
|
applyBillingPlan(@Param('id') id: string, @Body() dto: ApplyBillingPlanDto) {
|
||||||
|
return this.platform.applyBillingPlan(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('organizations/:id/modules')
|
||||||
|
updateOrganizationModules(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateOrgModulesDto,
|
||||||
|
) {
|
||||||
|
return this.platform.updateOrganizationModules(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch('memberships/:id')
|
@Patch('memberships/:id')
|
||||||
setMembershipActive(
|
setMembershipActive(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AuthModule } from '../auth/auth.module';
|
|||||||
import { MembershipsModule } from '../memberships/memberships.module';
|
import { MembershipsModule } from '../memberships/memberships.module';
|
||||||
import { OrgRolesModule } from '../org-roles/org-roles.module';
|
import { OrgRolesModule } from '../org-roles/org-roles.module';
|
||||||
import { PipelinesModule } from '../pipelines/pipelines.module';
|
import { PipelinesModule } from '../pipelines/pipelines.module';
|
||||||
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { PlatformAuthController } from './platform-auth.controller';
|
import { PlatformAuthController } from './platform-auth.controller';
|
||||||
import { PlatformAuthGuard } from './platform-auth.guard';
|
import { PlatformAuthGuard } from './platform-auth.guard';
|
||||||
import { PlatformAuthService } from './platform-auth.service';
|
import { PlatformAuthService } from './platform-auth.service';
|
||||||
@@ -17,6 +18,7 @@ import { PlatformService } from './platform.service';
|
|||||||
MembershipsModule,
|
MembershipsModule,
|
||||||
OrgRolesModule,
|
OrgRolesModule,
|
||||||
PipelinesModule,
|
PipelinesModule,
|
||||||
|
BillingModule,
|
||||||
PassportModule,
|
PassportModule,
|
||||||
],
|
],
|
||||||
controllers: [PlatformAuthController, PlatformController],
|
controllers: [PlatformAuthController, PlatformController],
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ import { normalizeMobile } from '../auth/auth.service';
|
|||||||
import { MembershipsService } from '../memberships/memberships.service';
|
import { MembershipsService } from '../memberships/memberships.service';
|
||||||
import { OrgRolesService } from '../org-roles/org-roles.service';
|
import { OrgRolesService } from '../org-roles/org-roles.service';
|
||||||
import { PipelinesService } from '../pipelines/pipelines.service';
|
import { PipelinesService } from '../pipelines/pipelines.service';
|
||||||
|
import { BillingService } from '../billing/billing.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import type {
|
import type {
|
||||||
|
ApplyBillingPlanDto,
|
||||||
CreatePlatformAdminDto,
|
CreatePlatformAdminDto,
|
||||||
CreatePlatformOrganizationDto,
|
CreatePlatformOrganizationDto,
|
||||||
ResetPlatformAdminPasswordDto,
|
ResetPlatformAdminPasswordDto,
|
||||||
ResetPlatformUserPasswordDto,
|
ResetPlatformUserPasswordDto,
|
||||||
|
UpdateOrgModulesDto,
|
||||||
UpdatePlatformOrganizationDto,
|
UpdatePlatformOrganizationDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
|
|
||||||
@@ -45,6 +48,7 @@ export class PlatformService {
|
|||||||
private readonly memberships: MembershipsService,
|
private readonly memberships: MembershipsService,
|
||||||
private readonly orgRoles: OrgRolesService,
|
private readonly orgRoles: OrgRolesService,
|
||||||
private readonly pipelines: PipelinesService,
|
private readonly pipelines: PipelinesService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async stats(): Promise<PlatformStatsDto> {
|
async stats(): Promise<PlatformStatsDto> {
|
||||||
@@ -61,12 +65,18 @@ export class PlatformService {
|
|||||||
this.prisma.user.count({ where: { isActive: true } }),
|
this.prisma.user.count({ where: { isActive: true } }),
|
||||||
this.prisma.membership.count(),
|
this.prisma.membership.count(),
|
||||||
]);
|
]);
|
||||||
|
const [expiringSoonCount, expiredBillingCount] = await Promise.all([
|
||||||
|
this.billing.countExpiringSoon(),
|
||||||
|
this.billing.countExpired(),
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
organizationCount,
|
organizationCount,
|
||||||
activeOrganizationCount,
|
activeOrganizationCount,
|
||||||
userCount,
|
userCount,
|
||||||
activeUserCount,
|
activeUserCount,
|
||||||
membershipCount,
|
membershipCount,
|
||||||
|
expiringSoonCount,
|
||||||
|
expiredBillingCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +99,7 @@ export class PlatformService {
|
|||||||
businessField: true,
|
businessField: true,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
enabledModules: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
memberships: true,
|
memberships: true,
|
||||||
@@ -111,6 +122,7 @@ export class PlatformService {
|
|||||||
businessField: true,
|
businessField: true,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
enabledModules: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
memberships: true,
|
memberships: true,
|
||||||
@@ -213,6 +225,7 @@ export class PlatformService {
|
|||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
businessField,
|
businessField,
|
||||||
|
enabledModules: this.billing.defaultModulesForNewOrg(),
|
||||||
users: {
|
users: {
|
||||||
create: {
|
create: {
|
||||||
email: ownerEmail,
|
email: ownerEmail,
|
||||||
@@ -238,6 +251,7 @@ export class PlatformService {
|
|||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
role: 'owner',
|
role: 'owner',
|
||||||
});
|
});
|
||||||
|
await this.billing.ensureTrialForOwner(owner.id, { maxOrganizations: 1 });
|
||||||
await this.orgRoles.ensureDefaultRoles(organization.id);
|
await this.orgRoles.ensureDefaultRoles(organization.id);
|
||||||
await this.pipelines.ensureDefaultPipeline(organization.id);
|
await this.pipelines.ensureDefaultPipeline(organization.id);
|
||||||
|
|
||||||
@@ -331,6 +345,7 @@ export class PlatformService {
|
|||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 300,
|
take: 300,
|
||||||
include: {
|
include: {
|
||||||
|
billingAccount: true,
|
||||||
memberships: {
|
memberships: {
|
||||||
include: {
|
include: {
|
||||||
organization: {
|
organization: {
|
||||||
@@ -356,12 +371,33 @@ export class PlatformService {
|
|||||||
},
|
},
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
},
|
},
|
||||||
|
billingAccount: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!row) throw new NotFoundException('کاربر پیدا نشد');
|
if (!row) throw new NotFoundException('کاربر پیدا نشد');
|
||||||
return this.userToDto(row);
|
return this.userToDto(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async applyBillingPlan(userId: string, dto: ApplyBillingPlanDto) {
|
||||||
|
const existing = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException('کاربر پیدا نشد');
|
||||||
|
await this.billing.applyPlan(userId, dto);
|
||||||
|
return this.getUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOrganizationModules(id: string, dto: UpdateOrgModulesDto) {
|
||||||
|
const existing = await this.prisma.organization.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException('محیط پیدا نشد');
|
||||||
|
await this.billing.setOrgModules(id, dto.enabledModules ?? []);
|
||||||
|
return this.getOrganization(id);
|
||||||
|
}
|
||||||
|
|
||||||
async setUserActive(id: string, isActive: boolean): Promise<PlatformUserDto> {
|
async setUserActive(id: string, isActive: boolean): Promise<PlatformUserDto> {
|
||||||
const existing = await this.prisma.user.findUnique({
|
const existing = await this.prisma.user.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -609,6 +645,7 @@ export class PlatformService {
|
|||||||
businessField: string | null;
|
businessField: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
enabledModules?: string[];
|
||||||
_count: { memberships: number; people: number; deals: number };
|
_count: { memberships: number; people: number; deals: number };
|
||||||
}): PlatformOrganizationDto {
|
}): PlatformOrganizationDto {
|
||||||
return {
|
return {
|
||||||
@@ -621,6 +658,7 @@ export class PlatformService {
|
|||||||
memberCount: row._count.memberships,
|
memberCount: row._count.memberships,
|
||||||
personCount: row._count.people,
|
personCount: row._count.people,
|
||||||
dealCount: row._count.deals,
|
dealCount: row._count.deals,
|
||||||
|
enabledModules: this.billing.normalizeModules(row.enabledModules),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,6 +670,17 @@ export class PlatformService {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
lastLoginAt: Date | null;
|
lastLoginAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
billingAccount?: {
|
||||||
|
id: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
planCode: string;
|
||||||
|
status: string;
|
||||||
|
startsAt: Date;
|
||||||
|
expiresAt: Date;
|
||||||
|
maxOrganizations: number;
|
||||||
|
externalPaymentRef: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
} | null;
|
||||||
memberships: Array<{
|
memberships: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
role: PlatformUserDto['memberships'][number]['role'];
|
role: PlatformUserDto['memberships'][number]['role'];
|
||||||
@@ -647,6 +696,9 @@ export class PlatformService {
|
|||||||
isActive: row.isActive,
|
isActive: row.isActive,
|
||||||
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
|
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
billing: row.billingAccount
|
||||||
|
? this.billing.toDto(row.billingAccount)
|
||||||
|
: null,
|
||||||
memberships: row.memberships.map((m) => ({
|
memberships: row.memberships.map((m) => ({
|
||||||
membershipId: m.id,
|
membershipId: m.id,
|
||||||
organizationId: m.organization.id,
|
organizationId: m.organization.id,
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ function onLogout() {
|
|||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 240px minmax(0, 1fr);
|
grid-template-columns: 240px minmax(0, 1fr);
|
||||||
background: var(--bg);
|
background:
|
||||||
|
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(45, 212, 191, 0.08), transparent 55%),
|
||||||
|
var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -78,6 +80,7 @@ function onLogout() {
|
|||||||
padding: 1.15rem 0.9rem;
|
padding: 1.15rem 0.9rem;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-inline-end: 1px solid var(--line);
|
border-inline-end: 1px solid var(--line);
|
||||||
|
box-shadow: 8px 0 24px rgba(15, 23, 42, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
|
|||||||
@@ -79,17 +79,43 @@ const moreSectionActive = computed(
|
|||||||
settingsActive.value,
|
settingsActive.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const enabledModules = computed(() => auth.user?.enabledModules ?? null);
|
||||||
|
const subscription = computed(() => auth.user?.subscription ?? null);
|
||||||
|
|
||||||
|
function moduleAllowed(module?: string) {
|
||||||
|
if (!module) return true;
|
||||||
|
if (!enabledModules.value) return true;
|
||||||
|
return enabledModules.value.includes(module as never);
|
||||||
|
}
|
||||||
|
|
||||||
const openHub = computed<NavHubDef | null>(() => {
|
const openHub = computed<NavHubDef | null>(() => {
|
||||||
if (!openHubKey.value) return null;
|
if (!openHubKey.value) return null;
|
||||||
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
|
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
|
||||||
if (!hub) return null;
|
if (!hub) return null;
|
||||||
return {
|
return {
|
||||||
...hub,
|
...hub,
|
||||||
cards: hub.cards.filter((card) => !card.admin || canSettings.value),
|
cards: hub.cards.filter(
|
||||||
shortcuts: hub.shortcuts.filter((item) => !item.admin || canSettings.value),
|
(card) =>
|
||||||
|
(!card.admin || canSettings.value) && moduleAllowed(card.module),
|
||||||
|
),
|
||||||
|
shortcuts: hub.shortcuts.filter(
|
||||||
|
(item) =>
|
||||||
|
(!item.admin || canSettings.value) && moduleAllowed(item.module),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hubVisible = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
for (const hub of NAV_HUBS) {
|
||||||
|
map[hub.key] = hub.cards.some(
|
||||||
|
(card) =>
|
||||||
|
(!card.admin || canSettings.value) && moduleAllowed(card.module),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
function givenName(fullName: string): string {
|
function givenName(fullName: string): string {
|
||||||
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
||||||
if (parts.length <= 1) return fullName.trim();
|
if (parts.length <= 1) return fullName.trim();
|
||||||
@@ -368,7 +394,7 @@ onUnmounted(() => {
|
|||||||
<span>داشبورد</span>
|
<span>داشبورد</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<div class="nav-fold" :class="{ active: customersActive || openHubKey === 'customers' }">
|
<div class="nav-fold" v-if="hubVisible.customers" :class="{ active: customersActive || openHubKey === 'customers' }">
|
||||||
<button
|
<button
|
||||||
class="nav-item nav-fold-btn"
|
class="nav-item nav-fold-btn"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -383,7 +409,7 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-fold" :class="{ active: salesActive || openHubKey === 'sales' }">
|
<div class="nav-fold" v-if="hubVisible.sales" :class="{ active: salesActive || openHubKey === 'sales' }">
|
||||||
<button
|
<button
|
||||||
class="nav-item nav-fold-btn"
|
class="nav-item nav-fold-btn"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -398,7 +424,7 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-fold" :class="{ active: activitiesActive || openHubKey === 'activities' }">
|
<div class="nav-fold" v-if="hubVisible.activities" :class="{ active: activitiesActive || openHubKey === 'activities' }">
|
||||||
<button
|
<button
|
||||||
class="nav-item nav-fold-btn"
|
class="nav-item nav-fold-btn"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -600,6 +626,17 @@ onUnmounted(() => {
|
|||||||
<RouterLink class="round-btn" :to="{ name: 'tickets' }" title="پیامها">
|
<RouterLink class="round-btn" :to="{ name: 'tickets' }" title="پیامها">
|
||||||
<UiIcon name="mail" :size="18" />
|
<UiIcon name="mail" :size="18" />
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<div
|
||||||
|
v-if="subscription"
|
||||||
|
class="sub-chip"
|
||||||
|
:class="{ warn: subscription.warn }"
|
||||||
|
:title="`انقضا: ${subscription.expiresAt}`"
|
||||||
|
>
|
||||||
|
<UiIcon name="clock" :size="15" />
|
||||||
|
<span>
|
||||||
|
{{ toFaDigits(subscription.daysRemaining) }} روز باقیمانده
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="date-chip">
|
<div class="date-chip">
|
||||||
<UiIcon name="calendar" :size="16" />
|
<UiIcon name="calendar" :size="16" />
|
||||||
<span>{{ todayLabel }}</span>
|
<span>{{ todayLabel }}</span>
|
||||||
@@ -1601,6 +1638,24 @@ onUnmounted(() => {
|
|||||||
height: 34px;
|
height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sub-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent-subtle);
|
||||||
|
color: var(--accent-deep);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-chip.warn {
|
||||||
|
background: color-mix(in srgb, var(--danger) 12%, white);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
.date-chip {
|
.date-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { OrgModuleKey } from '@mento/shared';
|
||||||
|
|
||||||
export type NavHubKey =
|
export type NavHubKey =
|
||||||
| 'customers'
|
| 'customers'
|
||||||
| 'sales'
|
| 'sales'
|
||||||
@@ -43,6 +45,7 @@ export type NavHubCard = {
|
|||||||
icon: NavIconName;
|
icon: NavIconName;
|
||||||
tint: NavHubTint;
|
tint: NavHubTint;
|
||||||
admin?: boolean;
|
admin?: boolean;
|
||||||
|
module?: OrgModuleKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NavHubShortcut = {
|
export type NavHubShortcut = {
|
||||||
@@ -50,6 +53,7 @@ export type NavHubShortcut = {
|
|||||||
title: string;
|
title: string;
|
||||||
icon: NavIconName;
|
icon: NavIconName;
|
||||||
admin?: boolean;
|
admin?: boolean;
|
||||||
|
module?: OrgModuleKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NavHubDef = {
|
export type NavHubDef = {
|
||||||
@@ -70,8 +74,8 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'users',
|
icon: 'users',
|
||||||
tint: 'mint',
|
tint: 'mint',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ to: '/contacts/people/new', title: 'شخص جدید', icon: 'user-plus' },
|
{ to: '/contacts/people/new', title: 'شخص جدید', icon: 'user-plus', module: 'customers' },
|
||||||
{ to: '/contacts/companies/new', title: 'شرکت جدید', icon: 'building' },
|
{ to: '/contacts/companies/new', title: 'شرکت جدید', icon: 'building', module: 'customers' },
|
||||||
],
|
],
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
@@ -80,6 +84,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'مخاطبان حقیقی و پروندههای مشتری',
|
desc: 'مخاطبان حقیقی و پروندههای مشتری',
|
||||||
icon: 'user',
|
icon: 'user',
|
||||||
tint: 'mint',
|
tint: 'mint',
|
||||||
|
module: 'customers',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/contacts?tab=companies',
|
to: '/contacts?tab=companies',
|
||||||
@@ -87,6 +92,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'سازمانها و مشتریان حقوقی',
|
desc: 'سازمانها و مشتریان حقوقی',
|
||||||
icon: 'building',
|
icon: 'building',
|
||||||
tint: 'violet',
|
tint: 'violet',
|
||||||
|
module: 'customers',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -97,8 +103,8 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'briefcase',
|
icon: 'briefcase',
|
||||||
tint: 'mint',
|
tint: 'mint',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ to: '/deals/new', title: 'معامله جدید', icon: 'plus' },
|
{ to: '/deals/new', title: 'معامله جدید', icon: 'plus', module: 'pipeline' },
|
||||||
{ to: '/quotes/new', title: 'پیشفاکتور جدید', icon: 'document' },
|
{ to: '/quotes/new', title: 'پیشفاکتور جدید', icon: 'document', module: 'quotes' },
|
||||||
],
|
],
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
@@ -107,6 +113,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'سرنخها، معاملات و مراحل فروش',
|
desc: 'سرنخها، معاملات و مراحل فروش',
|
||||||
icon: 'filter',
|
icon: 'filter',
|
||||||
tint: 'mint',
|
tint: 'mint',
|
||||||
|
module: 'pipeline',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/products',
|
to: '/settings/products',
|
||||||
@@ -115,6 +122,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'box',
|
icon: 'box',
|
||||||
tint: 'peach',
|
tint: 'peach',
|
||||||
admin: true,
|
admin: true,
|
||||||
|
module: 'products',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/invoices',
|
to: '/invoices',
|
||||||
@@ -122,6 +130,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'صدور فاکتور و پیگیری پرداختها',
|
desc: 'صدور فاکتور و پیگیری پرداختها',
|
||||||
icon: 'mail',
|
icon: 'mail',
|
||||||
tint: 'sky',
|
tint: 'sky',
|
||||||
|
module: 'invoices',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/quotes',
|
to: '/quotes',
|
||||||
@@ -129,6 +138,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'پیشنهاد قیمت و پیگیری تایید',
|
desc: 'پیشنهاد قیمت و پیگیری تایید',
|
||||||
icon: 'document',
|
icon: 'document',
|
||||||
tint: 'amber',
|
tint: 'amber',
|
||||||
|
module: 'quotes',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/commissions',
|
to: '/commissions',
|
||||||
@@ -136,6 +146,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'سهم فروشندگان از فاکتورهای صادرشده',
|
desc: 'سهم فروشندگان از فاکتورهای صادرشده',
|
||||||
icon: 'dollar',
|
icon: 'dollar',
|
||||||
tint: 'rose',
|
tint: 'rose',
|
||||||
|
module: 'commissions',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/sales/forecast',
|
to: '/sales/forecast',
|
||||||
@@ -143,6 +154,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'تعیین اهداف و تحلیل عملکرد فروش',
|
desc: 'تعیین اهداف و تحلیل عملکرد فروش',
|
||||||
icon: 'target',
|
icon: 'target',
|
||||||
tint: 'mint',
|
tint: 'mint',
|
||||||
|
module: 'sales_forecast',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -153,8 +165,8 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'check',
|
icon: 'check',
|
||||||
tint: 'sky',
|
tint: 'sky',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ to: '/activities/new', title: 'فعالیت جدید', icon: 'plus' },
|
{ to: '/activities/new', title: 'فعالیت جدید', icon: 'plus', module: 'activities' },
|
||||||
{ to: '/activities/calendar', title: 'تقویم', icon: 'calendar' },
|
{ to: '/activities/calendar', title: 'تقویم', icon: 'calendar', module: 'activities' },
|
||||||
],
|
],
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
@@ -163,6 +175,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'کارها، تماسها و پیگیریهای جاری',
|
desc: 'کارها، تماسها و پیگیریهای جاری',
|
||||||
icon: 'check',
|
icon: 'check',
|
||||||
tint: 'mint',
|
tint: 'mint',
|
||||||
|
module: 'activities',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/activities/calendar',
|
to: '/activities/calendar',
|
||||||
@@ -170,6 +183,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'نمای روز، هفته و زمانبندی کارها',
|
desc: 'نمای روز، هفته و زمانبندی کارها',
|
||||||
icon: 'calendar',
|
icon: 'calendar',
|
||||||
tint: 'violet',
|
tint: 'violet',
|
||||||
|
module: 'activities',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -180,8 +194,8 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'chat',
|
icon: 'chat',
|
||||||
tint: 'peach',
|
tint: 'peach',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ to: '/tickets/new', title: 'تیکت جدید', icon: 'plus' },
|
{ to: '/tickets/new', title: 'تیکت جدید', icon: 'plus', module: 'tickets' },
|
||||||
{ to: '/tickets', title: 'تیکتها', icon: 'chat' },
|
{ to: '/tickets', title: 'تیکتها', icon: 'chat', module: 'tickets' },
|
||||||
],
|
],
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
@@ -190,6 +204,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'درخواستها و پیامهای پشتیبانی',
|
desc: 'درخواستها و پیامهای پشتیبانی',
|
||||||
icon: 'chat',
|
icon: 'chat',
|
||||||
tint: 'peach',
|
tint: 'peach',
|
||||||
|
module: 'tickets',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -200,8 +215,18 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'sparkle',
|
icon: 'sparkle',
|
||||||
tint: 'violet',
|
tint: 'violet',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ to: '/settings/automations', title: 'قانون گردش کار', icon: 'layers' },
|
{
|
||||||
{ to: '/settings/message-automations', title: 'قانون پیامک', icon: 'send' },
|
to: '/settings/automations',
|
||||||
|
title: 'قانون گردش کار',
|
||||||
|
icon: 'layers',
|
||||||
|
module: 'workflow_automations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/settings/message-automations',
|
||||||
|
title: 'قانون پیامک',
|
||||||
|
icon: 'send',
|
||||||
|
module: 'message_automations',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
@@ -211,6 +236,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'layers',
|
icon: 'layers',
|
||||||
tint: 'violet',
|
tint: 'violet',
|
||||||
admin: true,
|
admin: true,
|
||||||
|
module: 'workflow_automations',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/message-automations',
|
to: '/settings/message-automations',
|
||||||
@@ -219,6 +245,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'send',
|
icon: 'send',
|
||||||
tint: 'sky',
|
tint: 'sky',
|
||||||
admin: true,
|
admin: true,
|
||||||
|
module: 'message_automations',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -229,8 +256,8 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'apps-plus',
|
icon: 'apps-plus',
|
||||||
tint: 'violet',
|
tint: 'violet',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ to: '/project-cards/new', title: 'کارت پروژه جدید', icon: 'plus' },
|
{ to: '/project-cards/new', title: 'کارت پروژه جدید', icon: 'plus', module: 'project_boards' },
|
||||||
{ to: '/cheques', title: 'ثبت چک', icon: 'document' },
|
{ to: '/cheques', title: 'ثبت چک', icon: 'document', module: 'cheques' },
|
||||||
],
|
],
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
@@ -240,6 +267,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'layers',
|
icon: 'layers',
|
||||||
tint: 'mint',
|
tint: 'mint',
|
||||||
admin: true,
|
admin: true,
|
||||||
|
module: 'lead_forms',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/projects',
|
to: '/projects',
|
||||||
@@ -247,6 +275,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'کارتها و مراحل اجرای کار',
|
desc: 'کارتها و مراحل اجرای کار',
|
||||||
icon: 'flag',
|
icon: 'flag',
|
||||||
tint: 'violet',
|
tint: 'violet',
|
||||||
|
module: 'project_boards',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/inventory',
|
to: '/inventory',
|
||||||
@@ -254,6 +283,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'موجودی کالا و گردش داخلی',
|
desc: 'موجودی کالا و گردش داخلی',
|
||||||
icon: 'box',
|
icon: 'box',
|
||||||
tint: 'peach',
|
tint: 'peach',
|
||||||
|
module: 'inventory',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/cheques',
|
to: '/cheques',
|
||||||
@@ -261,6 +291,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'چکهای دریافتی و پرداختی',
|
desc: 'چکهای دریافتی و پرداختی',
|
||||||
icon: 'document',
|
icon: 'document',
|
||||||
tint: 'sky',
|
tint: 'sky',
|
||||||
|
module: 'cheques',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/attachments',
|
to: '/attachments',
|
||||||
@@ -268,6 +299,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'فایلهای متصل به یادداشتها',
|
desc: 'فایلهای متصل به یادداشتها',
|
||||||
icon: 'document',
|
icon: 'document',
|
||||||
tint: 'amber',
|
tint: 'amber',
|
||||||
|
module: 'attachments',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -278,8 +310,13 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'settings',
|
icon: 'settings',
|
||||||
tint: 'sky',
|
tint: 'sky',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ to: '/settings/team#settings-team', title: 'تیم و نقشها', icon: 'users' },
|
{
|
||||||
{ to: '/settings/tahesab', title: 'تهحساب', icon: 'chart' },
|
to: '/settings/team#settings-team',
|
||||||
|
title: 'تیم و نقشها',
|
||||||
|
icon: 'users',
|
||||||
|
module: 'team_settings',
|
||||||
|
},
|
||||||
|
{ to: '/settings/tahesab', title: 'تهحساب', icon: 'chart', module: 'tahesab' },
|
||||||
],
|
],
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
@@ -288,6 +325,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'نام سازمان و تنظیمات پایه',
|
desc: 'نام سازمان و تنظیمات پایه',
|
||||||
icon: 'building',
|
icon: 'building',
|
||||||
tint: 'mint',
|
tint: 'mint',
|
||||||
|
module: 'team_settings',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/team#settings-team',
|
to: '/settings/team#settings-team',
|
||||||
@@ -295,6 +333,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'اعضا، دسترسی و نقشها',
|
desc: 'اعضا، دسترسی و نقشها',
|
||||||
icon: 'users',
|
icon: 'users',
|
||||||
tint: 'violet',
|
tint: 'violet',
|
||||||
|
module: 'team_settings',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/custom-fields',
|
to: '/settings/custom-fields',
|
||||||
@@ -302,6 +341,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'فیلدهای اضافه روی پروندهها',
|
desc: 'فیلدهای اضافه روی پروندهها',
|
||||||
icon: 'pencil',
|
icon: 'pencil',
|
||||||
tint: 'peach',
|
tint: 'peach',
|
||||||
|
module: 'custom_fields',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/currencies',
|
to: '/settings/currencies',
|
||||||
@@ -309,6 +349,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'ارز پایه و نرخ تبدیل',
|
desc: 'ارز پایه و نرخ تبدیل',
|
||||||
icon: 'dollar',
|
icon: 'dollar',
|
||||||
tint: 'sky',
|
tint: 'sky',
|
||||||
|
module: 'currencies',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/tahesab',
|
to: '/settings/tahesab',
|
||||||
@@ -316,6 +357,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'اتصال مالی و تنظیمات حسابداری',
|
desc: 'اتصال مالی و تنظیمات حسابداری',
|
||||||
icon: 'chart',
|
icon: 'chart',
|
||||||
tint: 'amber',
|
tint: 'amber',
|
||||||
|
module: 'tahesab',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/faq',
|
to: '/settings/faq',
|
||||||
@@ -323,6 +365,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'پرسشهای پرتکرار سازمان',
|
desc: 'پرسشهای پرتکرار سازمان',
|
||||||
icon: 'lightbulb',
|
icon: 'lightbulb',
|
||||||
tint: 'rose',
|
tint: 'rose',
|
||||||
|
module: 'faq',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -65,6 +65,18 @@ onMounted(() => {
|
|||||||
<strong>{{ toFaDigits(stats.membershipCount) }}</strong>
|
<strong>{{ toFaDigits(stats.membershipCount) }}</strong>
|
||||||
<small>نقش در محیطها</small>
|
<small>نقش در محیطها</small>
|
||||||
</article>
|
</article>
|
||||||
|
<article class="kpi warn">
|
||||||
|
<span class="ico"><UiIcon name="clock" :size="20" /></span>
|
||||||
|
<p>در آستانه انقضا</p>
|
||||||
|
<strong>{{ toFaDigits(stats.expiringSoonCount ?? 0) }}</strong>
|
||||||
|
<small>کمتر از ۳ روز</small>
|
||||||
|
</article>
|
||||||
|
<article class="kpi danger">
|
||||||
|
<span class="ico"><UiIcon name="warning" :size="20" /></span>
|
||||||
|
<p>منقضی</p>
|
||||||
|
<strong>{{ toFaDigits(stats.expiredBillingCount ?? 0) }}</strong>
|
||||||
|
<small>نیاز به تمدید</small>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="shortcuts">
|
<div class="shortcuts">
|
||||||
@@ -125,10 +137,20 @@ onMounted(() => {
|
|||||||
|
|
||||||
.kpis {
|
.kpis {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.kpi.warn .ico {
|
||||||
|
background: color-mix(in srgb, #f59e0b 18%, white);
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi.danger .ico {
|
||||||
|
background: color-mix(in srgb, var(--danger) 14%, white);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
.kpi {
|
.kpi {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #eef2f7;
|
border: 1px solid #eef2f7;
|
||||||
|
|||||||
@@ -3,11 +3,18 @@ import { onMounted, reactive, ref } from 'vue';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type {
|
import type {
|
||||||
CreatePlatformOrganizationRequest,
|
CreatePlatformOrganizationRequest,
|
||||||
|
OrgModuleKey,
|
||||||
PlatformOrganizationDto,
|
PlatformOrganizationDto,
|
||||||
UpdatePlatformOrganizationRequest,
|
UpdatePlatformOrganizationRequest,
|
||||||
UserRole,
|
UserRole,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { USER_ROLE_LABELS, USER_ROLES, isBusinessField } from '@mento/shared';
|
import {
|
||||||
|
ORG_MODULE_GROUPS,
|
||||||
|
ORG_MODULE_LABELS,
|
||||||
|
USER_ROLE_LABELS,
|
||||||
|
USER_ROLES,
|
||||||
|
isBusinessField,
|
||||||
|
} from '@mento/shared';
|
||||||
import { platformApi } from '@/lib/platform-api';
|
import { 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';
|
||||||
@@ -40,6 +47,9 @@ const editForm = reactive({
|
|||||||
businessField: '' as string,
|
businessField: '' as string,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const moduleDraft = ref<OrgModuleKey[]>([]);
|
||||||
|
const savingModules = ref(false);
|
||||||
|
|
||||||
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;
|
||||||
@@ -92,6 +102,7 @@ async function toggleOpen(org: PlatformOrganizationDto) {
|
|||||||
`/platform/organizations/${org.id}`,
|
`/platform/organizations/${org.id}`,
|
||||||
);
|
);
|
||||||
detail.value = data;
|
detail.value = data;
|
||||||
|
moduleDraft.value = [...(data.enabledModules ?? [])];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = apiError(e, 'بارگذاری اعضا ناموفق بود');
|
error.value = apiError(e, 'بارگذاری اعضا ناموفق بود');
|
||||||
}
|
}
|
||||||
@@ -222,6 +233,7 @@ async function setMembershipRole(membershipId: string, role: UserRole) {
|
|||||||
`/platform/organizations/${openId.value}`,
|
`/platform/organizations/${openId.value}`,
|
||||||
);
|
);
|
||||||
detail.value = data;
|
detail.value = data;
|
||||||
|
moduleDraft.value = [...(data.enabledModules ?? [])];
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = apiError(e, 'تغییر نقش ناموفق بود');
|
error.value = apiError(e, 'تغییر نقش ناموفق بود');
|
||||||
@@ -230,6 +242,41 @@ async function setMembershipRole(membershipId: string, role: UserRole) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleModule(key: OrgModuleKey) {
|
||||||
|
if (moduleDraft.value.includes(key)) {
|
||||||
|
moduleDraft.value = moduleDraft.value.filter((k) => k !== key);
|
||||||
|
} else {
|
||||||
|
moduleDraft.value = [...moduleDraft.value, key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectGroup(keys: OrgModuleKey[], on: boolean) {
|
||||||
|
if (on) {
|
||||||
|
moduleDraft.value = [...new Set([...moduleDraft.value, ...keys])];
|
||||||
|
} else {
|
||||||
|
moduleDraft.value = moduleDraft.value.filter((k) => !keys.includes(k));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveModules(orgId: string) {
|
||||||
|
savingModules.value = true;
|
||||||
|
error.value = '';
|
||||||
|
success.value = '';
|
||||||
|
try {
|
||||||
|
const { data } = await platformApi.patch<PlatformOrganizationDto>(
|
||||||
|
`/platform/organizations/${orgId}/modules`,
|
||||||
|
{ enabledModules: moduleDraft.value },
|
||||||
|
);
|
||||||
|
detail.value = data;
|
||||||
|
moduleDraft.value = [...(data.enabledModules ?? [])];
|
||||||
|
success.value = 'ماژولهای محیط ذخیره شد';
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'ذخیره ماژولها ناموفق بود');
|
||||||
|
} finally {
|
||||||
|
savingModules.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void load();
|
void load();
|
||||||
});
|
});
|
||||||
@@ -355,6 +402,47 @@ onMounted(() => {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div v-if="openId === org.id" class="detail">
|
<div v-if="openId === org.id" class="detail">
|
||||||
|
<div class="modules">
|
||||||
|
<div class="modules-head">
|
||||||
|
<strong>ماژولهای فعال</strong>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="savingModules"
|
||||||
|
@click="saveModules(org.id)"
|
||||||
|
>
|
||||||
|
{{ savingModules ? 'ذخیره…' : 'ذخیره ماژولها' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-for="group in ORG_MODULE_GROUPS" :key="group.title" class="mod-group">
|
||||||
|
<div class="mod-group-head">
|
||||||
|
<span>{{ group.title }}</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost"
|
||||||
|
type="button"
|
||||||
|
@click="selectGroup(group.keys, true)"
|
||||||
|
>
|
||||||
|
همه
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost"
|
||||||
|
type="button"
|
||||||
|
@click="selectGroup(group.keys, false)"
|
||||||
|
>
|
||||||
|
هیچکدام
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label v-for="key in group.keys" :key="key" class="mod-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="moduleDraft.includes(key)"
|
||||||
|
@change="toggleModule(key)"
|
||||||
|
/>
|
||||||
|
{{ ORG_MODULE_LABELS[key] }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p v-if="!detail?.members?.length" class="muted">عضوی ثبت نشده.</p>
|
<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">
|
||||||
@@ -572,6 +660,37 @@ label span {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
padding-top: 0.75rem;
|
padding-top: 0.75rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modules {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modules-head,
|
||||||
|
.mod-group-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail ul {
|
.detail ul {
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
<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 { PlatformUserDto, UserRole } from '@mento/shared';
|
import type {
|
||||||
import { USER_ROLE_LABELS, USER_ROLES } from '@mento/shared';
|
ApplyBillingPlanRequest,
|
||||||
|
BillingPlanCode,
|
||||||
|
PlatformUserDto,
|
||||||
|
UserRole,
|
||||||
|
} from '@mento/shared';
|
||||||
|
import {
|
||||||
|
BILLING_PLAN_CODES,
|
||||||
|
BILLING_PLAN_LABELS,
|
||||||
|
USER_ROLE_LABELS,
|
||||||
|
USER_ROLES,
|
||||||
|
} from '@mento/shared';
|
||||||
import { platformApi } from '@/lib/platform-api';
|
import { platformApi } from '@/lib/platform-api';
|
||||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||||
|
|
||||||
@@ -121,6 +131,25 @@ async function setMembershipRole(userId: string, membershipId: string, role: Use
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function applyPlan(userId: string, planCode: BillingPlanCode) {
|
||||||
|
busyId.value = `plan-${userId}-${planCode}`;
|
||||||
|
error.value = '';
|
||||||
|
success.value = '';
|
||||||
|
try {
|
||||||
|
const body: ApplyBillingPlanRequest = { planCode };
|
||||||
|
const { data } = await platformApi.post<PlatformUserDto>(
|
||||||
|
`/platform/users/${userId}/billing/apply-plan`,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
items.value = items.value.map((row) => (row.id === userId ? data : row));
|
||||||
|
success.value = `پلن «${BILLING_PLAN_LABELS[planCode]}» اعمال شد`;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'اعمال پلن ناموفق بود');
|
||||||
|
} finally {
|
||||||
|
busyId.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void load();
|
void load();
|
||||||
});
|
});
|
||||||
@@ -201,6 +230,29 @@ onMounted(() => {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div v-if="openId === user.id" class="detail">
|
<div v-if="openId === user.id" class="detail">
|
||||||
|
<div class="billing">
|
||||||
|
<strong>اشتراک</strong>
|
||||||
|
<p v-if="user.billing" class="muted">
|
||||||
|
{{ BILLING_PLAN_LABELS[user.billing.planCode] }}
|
||||||
|
· {{ user.billing.status }}
|
||||||
|
· {{ toFaDigits(user.billing.daysRemaining) }} روز
|
||||||
|
· سقف محیط {{ toFaDigits(user.billing.maxOrganizations) }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="muted">هنوز اشتراک ثبت نشده — با اعمال پلن ساخته میشود.</p>
|
||||||
|
<div class="plan-row">
|
||||||
|
<button
|
||||||
|
v-for="code in BILLING_PLAN_CODES.filter((c) => c !== 'custom')"
|
||||||
|
:key="code"
|
||||||
|
class="btn btn-ghost"
|
||||||
|
type="button"
|
||||||
|
:disabled="busyId === `plan-${user.id}-${code}`"
|
||||||
|
@click="applyPlan(user.id, code)"
|
||||||
|
>
|
||||||
|
{{ BILLING_PLAN_LABELS[code] }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p v-if="!user.memberships.length" class="muted">عضو هیچ محیطی نیست.</p>
|
<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">
|
||||||
@@ -412,6 +464,22 @@ onMounted(() => {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.billing {
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.member-actions {
|
.member-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
|
|||||||
+5
-1
@@ -210,7 +210,11 @@ cd /var/www/crm/apps/api
|
|||||||
npx prisma db seed
|
npx prisma db seed
|
||||||
```
|
```
|
||||||
|
|
||||||
اگر حساب با همان موبایل از قبل باشد، seed آن را عوض نمیکند. ادمینهای بعدی را از داخل پنل (`/adm/admins`) اضافه کنید.
|
اگر حساب با همان موبایل از قبل باشد، seed رمز را هم از `.env` بهروز میکند. ادمینهای بعدی را از داخل پنل (`/adm/admins`) اضافه کنید.
|
||||||
|
|
||||||
|
### اشتراک و ماژولها
|
||||||
|
|
||||||
|
بعد از این نسخه، `prisma migrate deploy` جدول `BillingAccount` و ستون `enabledModules` را میسازد و برای مالکان فعلی trial **۱۵ روزه** میسازد. از `/adm/users` پلن را تمدید کنید و از `/adm/organizations` ماژول هر محیط را تنظیم کنید.
|
||||||
|
|
||||||
3. ورود: `https://crm.montzar.ir/adm` → داشبورد `/adm/dashboard`
|
3. ورود: `https://crm.montzar.ir/adm` → داشبورد `/adm/dashboard`
|
||||||
توکن سوپرادمین جدا از JWT کاربران سازمانی است.
|
توکن سوپرادمین جدا از JWT کاربران سازمانی است.
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# BillingAccount + Modules Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** Execute task-by-task. Checkboxes track progress.
|
||||||
|
|
||||||
|
**Goal:** Owner-level `BillingAccount` (plans/expiry/max orgs), per-org `enabledModules`, CRM enforcement + remaining-days UI, `/adm` controls and polish.
|
||||||
|
|
||||||
|
**Architecture:** Shared module catalog; Prisma `BillingAccount` + `Organization.enabledModules`; billing checks on login/org-create; module guards on API + filtered nav; platform admin APIs/UI for plans and modules.
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS, Prisma, Vue 3, `@mento/shared`, existing CRM CSS tokens.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Manual activation only (no payment gateway).
|
||||||
|
- Existing owners: trial 15 days; `maxOrganizations >=` current owned org count.
|
||||||
|
- Expiry: block CRM login; header days remaining; warn ≤3 days.
|
||||||
|
- Vue + CRM CSS only for `/adm`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Shared catalog + DTOs
|
||||||
|
|
||||||
|
- [ ] Add `ORG_MODULE_KEYS`, labels, plan presets, billing DTOs to `packages/shared/src/index.ts`
|
||||||
|
- [ ] Extend org/login DTOs with `enabledModules` + `subscription` summary
|
||||||
|
|
||||||
|
## Task 2: Prisma
|
||||||
|
|
||||||
|
- [ ] `BillingAccount` model; `Organization.enabledModules String[]`
|
||||||
|
- [ ] Migration SQL + data backfill (trial 15d, full modules)
|
||||||
|
|
||||||
|
## Task 3: Billing service + auth/org enforcement
|
||||||
|
|
||||||
|
- [ ] `BillingService` resolve/require active for org; apply plan; ensure account
|
||||||
|
- [ ] Hook login / switch org / create org
|
||||||
|
- [ ] Module require helper for key controllers (or lightweight guard)
|
||||||
|
|
||||||
|
## Task 4: Platform API + /adm UI
|
||||||
|
|
||||||
|
- [ ] Endpoints: get/apply billing, patch org modules
|
||||||
|
- [ ] Adm users: billing card; adm orgs: module checklist; light shell polish
|
||||||
|
|
||||||
|
## Task 5: Web CRM
|
||||||
|
|
||||||
|
- [ ] Filter nav hubs by modules
|
||||||
|
- [ ] Header subscription chip + warn
|
||||||
|
- [ ] Router guard for disabled modules
|
||||||
@@ -37,6 +37,220 @@ export interface AuthUserDto {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
organizationName?: string;
|
organizationName?: string;
|
||||||
hasAvatar?: boolean;
|
hasAvatar?: boolean;
|
||||||
|
enabledModules?: OrgModuleKey[];
|
||||||
|
subscription?: SubscriptionSummaryDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BillingPlanCode =
|
||||||
|
| "trial"
|
||||||
|
| "monthly"
|
||||||
|
| "quarterly"
|
||||||
|
| "semiannual"
|
||||||
|
| "yearly"
|
||||||
|
| "custom";
|
||||||
|
|
||||||
|
export type BillingAccountStatus = "active" | "expired" | "suspended";
|
||||||
|
|
||||||
|
export const BILLING_PLAN_CODES: BillingPlanCode[] = [
|
||||||
|
"trial",
|
||||||
|
"monthly",
|
||||||
|
"quarterly",
|
||||||
|
"semiannual",
|
||||||
|
"yearly",
|
||||||
|
"custom",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const BILLING_PLAN_LABELS: Record<BillingPlanCode, string> = {
|
||||||
|
trial: "دمو ۱۵ روزه",
|
||||||
|
monthly: "ماهانه",
|
||||||
|
quarterly: "سهماهه",
|
||||||
|
semiannual: "ششماهه",
|
||||||
|
yearly: "یکساله",
|
||||||
|
custom: "سفارشی",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** مدت پیشفرض پلنها (روز) — custom ندارد */
|
||||||
|
export const BILLING_PLAN_DAYS: Record<Exclude<BillingPlanCode, "custom">, number> = {
|
||||||
|
trial: 15,
|
||||||
|
monthly: 30,
|
||||||
|
quarterly: 90,
|
||||||
|
semiannual: 180,
|
||||||
|
yearly: 365,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BILLING_PLAN_DEFAULT_MAX_ORGS: Record<
|
||||||
|
Exclude<BillingPlanCode, "custom">,
|
||||||
|
number
|
||||||
|
> = {
|
||||||
|
trial: 1,
|
||||||
|
monthly: 3,
|
||||||
|
quarterly: 5,
|
||||||
|
semiannual: 10,
|
||||||
|
yearly: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrgModuleKey =
|
||||||
|
| "customers"
|
||||||
|
| "pipeline"
|
||||||
|
| "products"
|
||||||
|
| "invoices"
|
||||||
|
| "quotes"
|
||||||
|
| "commissions"
|
||||||
|
| "sales_forecast"
|
||||||
|
| "activities"
|
||||||
|
| "tickets"
|
||||||
|
| "workflow_automations"
|
||||||
|
| "message_automations"
|
||||||
|
| "lead_forms"
|
||||||
|
| "project_boards"
|
||||||
|
| "inventory"
|
||||||
|
| "cheques"
|
||||||
|
| "attachments"
|
||||||
|
| "loyalty"
|
||||||
|
| "reports"
|
||||||
|
| "notes"
|
||||||
|
| "tahesab"
|
||||||
|
| "custom_fields"
|
||||||
|
| "currencies"
|
||||||
|
| "team_settings"
|
||||||
|
| "faq";
|
||||||
|
|
||||||
|
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
||||||
|
"customers",
|
||||||
|
"pipeline",
|
||||||
|
"products",
|
||||||
|
"invoices",
|
||||||
|
"quotes",
|
||||||
|
"commissions",
|
||||||
|
"sales_forecast",
|
||||||
|
"activities",
|
||||||
|
"tickets",
|
||||||
|
"workflow_automations",
|
||||||
|
"message_automations",
|
||||||
|
"lead_forms",
|
||||||
|
"project_boards",
|
||||||
|
"inventory",
|
||||||
|
"cheques",
|
||||||
|
"attachments",
|
||||||
|
"loyalty",
|
||||||
|
"reports",
|
||||||
|
"notes",
|
||||||
|
"tahesab",
|
||||||
|
"custom_fields",
|
||||||
|
"currencies",
|
||||||
|
"team_settings",
|
||||||
|
"faq",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
||||||
|
customers: "مشتریان",
|
||||||
|
pipeline: "کاریز فروش",
|
||||||
|
products: "محصولات",
|
||||||
|
invoices: "فاکتورها",
|
||||||
|
quotes: "پیشفاکتورها",
|
||||||
|
commissions: "پورسانت",
|
||||||
|
sales_forecast: "هدف و پیشبینی",
|
||||||
|
activities: "فعالیتها",
|
||||||
|
tickets: "تیکتها",
|
||||||
|
workflow_automations: "اتوماسیون گردش کار",
|
||||||
|
message_automations: "اتوماسیون پیامک",
|
||||||
|
lead_forms: "فرمساز سرنخ",
|
||||||
|
project_boards: "کاریز پروژه",
|
||||||
|
inventory: "انبار",
|
||||||
|
cheques: "چکها",
|
||||||
|
attachments: "پیوستها",
|
||||||
|
loyalty: "باشگاه مشتریان",
|
||||||
|
reports: "گزارشها",
|
||||||
|
notes: "یادداشتها",
|
||||||
|
tahesab: "تهحساب",
|
||||||
|
custom_fields: "فیلدهای سفارشی",
|
||||||
|
currencies: "ارزها",
|
||||||
|
team_settings: "تیم و تنظیمات سازمان",
|
||||||
|
faq: "FAQ",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ORG_MODULE_GROUPS: Array<{
|
||||||
|
title: string;
|
||||||
|
keys: OrgModuleKey[];
|
||||||
|
}> = [
|
||||||
|
{ title: "مشتریان", keys: ["customers"] },
|
||||||
|
{
|
||||||
|
title: "فروش",
|
||||||
|
keys: [
|
||||||
|
"pipeline",
|
||||||
|
"products",
|
||||||
|
"invoices",
|
||||||
|
"quotes",
|
||||||
|
"commissions",
|
||||||
|
"sales_forecast",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ title: "فعالیتها", keys: ["activities", "notes"] },
|
||||||
|
{ title: "پشتیبانی", keys: ["tickets", "faq"] },
|
||||||
|
{
|
||||||
|
title: "اتوماسیون",
|
||||||
|
keys: ["workflow_automations", "message_automations"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "ابزارها",
|
||||||
|
keys: [
|
||||||
|
"lead_forms",
|
||||||
|
"project_boards",
|
||||||
|
"inventory",
|
||||||
|
"cheques",
|
||||||
|
"attachments",
|
||||||
|
"loyalty",
|
||||||
|
"reports",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "تنظیمات",
|
||||||
|
keys: ["team_settings", "custom_fields", "currencies", "tahesab"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isOrgModuleKey(value: string): value is OrgModuleKey {
|
||||||
|
return (ORG_MODULE_KEYS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultEnabledModules(): OrgModuleKey[] {
|
||||||
|
return [...ORG_MODULE_KEYS];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionSummaryDto {
|
||||||
|
planCode: BillingPlanCode;
|
||||||
|
status: BillingAccountStatus;
|
||||||
|
startsAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
maxOrganizations: number;
|
||||||
|
daysRemaining: number;
|
||||||
|
warn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BillingAccountDto {
|
||||||
|
id: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
planCode: BillingPlanCode;
|
||||||
|
status: BillingAccountStatus;
|
||||||
|
startsAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
maxOrganizations: number;
|
||||||
|
externalPaymentRef?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
daysRemaining: number;
|
||||||
|
warn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyBillingPlanRequest {
|
||||||
|
planCode: BillingPlanCode;
|
||||||
|
maxOrganizations?: number;
|
||||||
|
expiresAt?: string;
|
||||||
|
notes?: string | null;
|
||||||
|
status?: BillingAccountStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateOrgModulesRequest {
|
||||||
|
enabledModules: OrgModuleKey[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrgUserDto {
|
export interface OrgUserDto {
|
||||||
@@ -245,6 +459,7 @@ export interface OrganizationDto {
|
|||||||
baseCurrency: string;
|
baseCurrency: string;
|
||||||
enabledCurrencies: string[];
|
enabledCurrencies: string[];
|
||||||
exchangeRates: ExchangeRateDto[];
|
exchangeRates: ExchangeRateDto[];
|
||||||
|
enabledModules: OrgModuleKey[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateOrganizationRequest {
|
export interface UpdateOrganizationRequest {
|
||||||
@@ -2552,6 +2767,8 @@ export interface PlatformStatsDto {
|
|||||||
userCount: number;
|
userCount: number;
|
||||||
activeUserCount: number;
|
activeUserCount: number;
|
||||||
membershipCount: number;
|
membershipCount: number;
|
||||||
|
expiringSoonCount?: number;
|
||||||
|
expiredBillingCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlatformOrgMemberDto {
|
export interface PlatformOrgMemberDto {
|
||||||
@@ -2575,6 +2792,7 @@ export interface PlatformOrganizationDto {
|
|||||||
memberCount: number;
|
memberCount: number;
|
||||||
personCount: number;
|
personCount: number;
|
||||||
dealCount: number;
|
dealCount: number;
|
||||||
|
enabledModules?: OrgModuleKey[];
|
||||||
members?: PlatformOrgMemberDto[];
|
members?: PlatformOrgMemberDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2596,6 +2814,7 @@ export interface PlatformUserDto {
|
|||||||
lastLoginAt?: string | null;
|
lastLoginAt?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
memberships: PlatformUserMembershipDto[];
|
memberships: PlatformUserMembershipDto[];
|
||||||
|
billing?: BillingAccountDto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdatePlatformStatusRequest {
|
export interface UpdatePlatformStatusRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user