Author SHA1 Message Date
MahdiandCursor ebf13cfde5 Fix web build by removing unused axios import.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 15:59:31 +03:30
Mahdi 0e580b19c3 Editing Super Admin permissions and visual changes 2026-09-20 12:07:05 +03:30
Mahdi 4a9fe0b07c Configuring access rights in the CRM Super Admin 2026-09-20 11:24:14 +03:30
MahdiandCursor 04d37a9d12 Add design spec for BillingAccount, per-org modules, and /adm polish.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 10:54:29 +03:30
Mahdi 605e1d3340 Edit login suoerAdmin 2026-09-19 22:42:51 +03:30
Mahdi b016b4754f Creating a CRM Super Admin 2026-09-19 21:54:21 +03:30
Mahdi ff5ad21eb9 Edit form costumer 2026-09-19 16:23:11 +03:30
Mahdi 4776d1a3c3 Edit import excel 2026-09-19 15:30:31 +03:30
Mahdi 9c7dad3677 Edit login and registration details 2026-09-19 13:57:15 +03:30
MahdiandCursor a428c6f135 Fix modal body scroll so tall forms like new-person stay usable.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 11:10:16 +03:30
MahdiandCursor 3b643d7bb9 Lift mobile docks and FABs above the bottom tab bar.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 17:36:21 +03:30
86 changed files with 6587 additions and 592 deletions
+8
View File
@@ -24,4 +24,12 @@ SMS_SENDER=
# مثال ساخت: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
INTEGRATIONS_ENCRYPTION_KEY=
# سوپرادمین پلتفرم — ورود از /adm با موبایل + رمز
# در لوکال اگر خالی بماند: 09120000000 / demo1234
PLATFORM_ADMIN_MOBILE=
PLATFORM_ADMIN_PASSWORD=
PLATFORM_ADMIN_NAME=سوپرادمین
# اختیاری
# PLATFORM_ADMIN_EMAIL=
# Production: see deploy/.env.production.example and deploy/DEPLOY.md
@@ -0,0 +1,18 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN IF NOT EXISTS "isActive" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE IF NOT EXISTS "PlatformAdmin" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"fullName" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PlatformAdmin_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "PlatformAdmin_email_key" ON "PlatformAdmin"("email");
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "PlatformAdmin" ADD COLUMN IF NOT EXISTS "mobile" TEXT;
-- Make email optional (existing rows keep their email)
ALTER TABLE "PlatformAdmin" ALTER COLUMN "email" DROP NOT NULL;
-- Unique mobile
CREATE UNIQUE INDEX IF NOT EXISTS "PlatformAdmin_mobile_key" ON "PlatformAdmin"("mobile");
@@ -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;
+40
View File
@@ -147,6 +147,9 @@ model Organization {
businessField String?
baseCurrency String @default("IRR")
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
/// ماژول‌های فعال منو برای این محیط
enabledModules String[] @default([])
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
@@ -229,6 +232,7 @@ model User {
ticketMessages TicketMessage[] @relation("TicketMessageAuthor")
confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy")
memberships Membership[]
billingAccount BillingAccount?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -278,6 +282,42 @@ model Membership {
@@index([orgRoleId])
}
/// حساب سوپرادمین پلتفرم — جدا از User سازمانی
model PlatformAdmin {
id String @id @default(cuid())
/// ورود اصلی با موبایل نرمال‌شده (مثلاً 0912…)
mobile String? @unique
email String? @unique
passwordHash String
fullName String
isActive Boolean @default(true)
lastLoginAt DateTime?
createdAt DateTime @default(now())
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 {
id String @id @default(cuid())
organizationId String
+96
View File
@@ -13,7 +13,103 @@ const DEFAULT_STAGES = [
{ name: 'از دست‌رفته', isWon: false, isLost: true },
] as const;
async function seedPlatformAdmin() {
const envMobile = (process.env.PLATFORM_ADMIN_MOBILE ?? '').trim();
const envEmail = (process.env.PLATFORM_ADMIN_EMAIL ?? '').trim().toLowerCase();
const envPassword = process.env.PLATFORM_ADMIN_PASSWORD ?? '';
const envName = (process.env.PLATFORM_ADMIN_NAME ?? 'سوپرادمین').trim() || 'سوپرادمین';
const isProd = process.env.NODE_ENV === 'production';
const mobileRaw = envMobile || (isProd ? '' : '09120000000');
const password = envPassword || (isProd ? '' : 'demo1234');
const email = envEmail || null;
if (!mobileRaw || !password) {
console.log(
'Platform admin seed skipped (set PLATFORM_ADMIN_MOBILE and PLATFORM_ADMIN_PASSWORD)',
);
return;
}
if (password.length < 8) {
console.log('Platform admin seed skipped (password must be at least 8 chars)');
return;
}
// normalize: 09xxxxxxxxx
let mobile = mobileRaw.replace(/[\s\-()]/g, '');
if (mobile.startsWith('+98')) mobile = `0${mobile.slice(3)}`;
if (mobile.startsWith('98') && mobile.length === 12) mobile = `0${mobile.slice(2)}`;
if (/^9\d{9}$/.test(mobile)) mobile = `0${mobile}`;
if (!/^09\d{9}$/.test(mobile)) {
console.log('Platform admin seed skipped (PLATFORM_ADMIN_MOBILE invalid)');
return;
}
const passwordHash = await bcrypt.hash(password, 10);
const existingByMobile = await prisma.platformAdmin.findUnique({ where: { mobile } });
if (existingByMobile) {
await prisma.platformAdmin.update({
where: { id: existingByMobile.id },
data: {
passwordHash,
fullName: envName,
isActive: true,
...(email ? { email } : {}),
},
});
console.log('Platform admin password refreshed:', mobile);
return;
}
if (email) {
const existingByEmail = await prisma.platformAdmin.findUnique({ where: { email } });
if (existingByEmail) {
await prisma.platformAdmin.update({
where: { id: existingByEmail.id },
data: {
mobile,
passwordHash,
fullName: envName,
isActive: true,
},
});
console.log('Platform admin linked mobile + password refreshed:', mobile, '→', email);
return;
}
}
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({
data: {
mobile,
email,
passwordHash,
fullName: envName,
},
});
console.log('Seeded platform admin:', mobile);
}
async function main() {
await seedPlatformAdmin();
const email = 'owner@demo.local';
let org = await prisma.organization.findUnique({ where: { slug: 'demo-mento' } });
if (!org) {
@@ -5,12 +5,16 @@ import type { Actor } from '../common/record-scope';
import { assigneeWhere } from '../common/record-scope';
import { normalizePhone } from '../contacts/normalize';
import {
excelFieldsFromColumns,
formatClock,
formatJalaliDate,
parseExcelMapping,
parseJalaliDateTime,
previewExcel,
readMappedRows,
stripHtml,
writeXlsx,
type ExcelPreviewResult,
} from '../common/excel';
const ACTIVITY_COLUMNS = [
@@ -143,8 +147,16 @@ export class ActivitiesExcelService {
);
}
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, ACTIVITY_ALIASES);
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, ACTIVITY_ALIASES, excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title']));
}
async import(
actor: Actor,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, ACTIVITY_ALIASES, parseExcelMapping(mappingRaw));
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
for (let i = 0; i < rows.length; i++) {
@@ -79,6 +79,17 @@ export class ActivitiesController {
return new StreamableFile(buffer);
}
@Post('import/preview')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15_000_000 },
}),
)
importPreview(@UploadedFile() file: Express.Multer.File) {
return this.excelService.preview(file);
}
@Post('import')
@UseInterceptors(
FileInterceptor('file', {
@@ -89,8 +100,9 @@ export class ActivitiesController {
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@Body('mapping') mapping?: string,
) {
return this.excelService.import(user, file);
return this.excelService.import(user, file, mapping);
}
@Get(':id')
+4
View File
@@ -32,6 +32,8 @@ import { MemoryModule } from './memory/memory.module';
import { TahesabModule } from './integrations/tahesab/tahesab.module';
import { InventoryModule } from './inventory/inventory.module';
import { FinanceModule } from './finance/finance.module';
import { PlatformModule } from './platform/platform.module';
import { BillingModule } from './billing/billing.module';
@Module({
imports: [
@@ -44,7 +46,9 @@ import { FinanceModule } from './finance/finance.module';
PrismaModule,
StorageModule,
HealthModule,
BillingModule,
AuthModule,
PlatformModule,
UsersModule,
OrgRolesModule,
OrganizationsModule,
+2
View File
@@ -3,6 +3,7 @@ import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MembershipsModule } from '../memberships/memberships.module';
import { BillingModule } from '../billing/billing.module';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
@@ -13,6 +14,7 @@ import { SmsService } from '../common/sms.service';
imports: [
ConfigModule,
MembershipsModule,
BillingModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
+36 -2
View File
@@ -10,12 +10,19 @@ import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
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 { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { MembershipsService } from '../memberships/memberships.service';
import { BillingService } from '../billing/billing.service';
import {
LoginDto,
LoginOtpRequestDto,
@@ -97,6 +104,7 @@ export class AuthService {
private readonly sms: SmsService,
private readonly files: LocalFileStorage,
private readonly memberships: MembershipsService,
private readonly billing: BillingService,
) {}
/**
@@ -260,6 +268,11 @@ export class AuthService {
organizationId: organization.id,
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({
where: { id: challenge.id },
@@ -578,6 +591,9 @@ export class AuthService {
if (!user || user.isActive === false) {
throw new UnauthorizedException('حساب کاربری غیرفعال است');
}
const subscription = await this.billing.requireActiveForOrganization(
organizationId,
);
await this.memberships.syncUserLastOrg(
userId,
organizationId,
@@ -597,6 +613,7 @@ export class AuthService {
user: await this.toAuthUserDto(
{ ...user, role: membership.role, organizationId },
organizationId,
subscription,
),
};
}
@@ -611,19 +628,30 @@ export class AuthService {
organizationId: string;
},
organizationId: string,
subscription?: SubscriptionSummaryDto | null,
): Promise<AuthUserDto> {
const membership = await this.prisma.membership.findFirst({
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 orgName = membership?.organization.name;
const sub =
subscription ??
(await this.billing.requireActiveForOrganization(organizationId));
return this.toAuthUser({
...user,
role,
organizationId,
organizationName: orgName,
avatarStorageKey: await this.readAvatarKey(user.id),
enabledModules: this.billing.normalizeModules(
membership?.organization.enabledModules,
),
subscription: sub,
isOrgOwner: role === 'owner',
});
}
@@ -708,6 +736,9 @@ export class AuthService {
organizationId: string;
organizationName?: string;
avatarStorageKey?: string | null;
enabledModules?: OrgModuleKey[];
subscription?: SubscriptionSummaryDto | null;
isOrgOwner?: boolean;
}): AuthUserDto {
return {
id: user.id,
@@ -718,6 +749,9 @@ export class AuthService {
organizationName: user.organizationName,
hasAvatar: Boolean(user.avatarStorageKey),
...(user.mobile ? { mobile: user.mobile } : {}),
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
...(user.subscription ? { subscription: user.subscription } : {}),
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
};
}
}
+9 -1
View File
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { BillingService } from '../billing/billing.service';
import { PrismaService } from '../prisma/prisma.service';
export type JwtPayload = {
@@ -15,6 +16,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
config: ConfigService,
private readonly prisma: PrismaService,
private readonly billing: BillingService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
@@ -24,7 +26,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
async validate(payload: JwtPayload & { typ?: string; mobile?: string }) {
if (payload.typ === 'portal' || payload.typ === 'org-select') {
if (
payload.typ === 'portal' ||
payload.typ === 'org-select' ||
payload.typ === 'platform-admin'
) {
throw new UnauthorizedException();
}
if (!payload.sub || !payload.organizationId) {
@@ -42,12 +48,14 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
userId: user.id,
organizationId: payload.organizationId,
isActive: true,
organization: { isActive: true },
},
select: { organizationId: true, role: true },
});
if (!membership) {
throw new UnauthorizedException();
}
await this.billing.requireActiveForOrganization(membership.organizationId);
return {
userId: user.id,
organizationId: membership.organizationId,
+10
View File
@@ -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 {}
+302
View File
@@ -0,0 +1,302 @@
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;
return this.getByOwnerUserId(owner.userId);
}
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> {
const account = await this.getByOwnerUserId(ownerUserId);
if (!account) {
throw new UnauthorizedException(
'اشتراک یافت نشد؛ ابتدا پلن را از سوپرادمین اعمال کنید',
);
}
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) {
let account = await this.getByOwnerUserId(ownerUserId);
if (!account) {
// اولین محیط مالک: trial خودکار
account = await this.ensureTrialForOwner(ownerUserId, { maxOrganizations: 1 });
}
const summary = await this.assertActive(account);
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 ownerMembership = await this.prisma.membership.findFirst({
where: { userId: ownerUserId, role: 'owner', isActive: true },
select: { id: true },
});
if (!ownerMembership) {
throw new BadRequestException(
'اشتراک فقط برای مالک محیط تعریف می‌شود؛ این کاربر مالک هیچ محیطی نیست',
);
}
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);
+32
View File
@@ -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;
}
}
+114 -3
View File
@@ -188,9 +188,121 @@ export async function workbookFromUpload(file: Express.Multer.File | undefined)
return sheet;
}
export type ExcelFieldOption = {
key: string;
label: string;
required?: boolean;
};
export type ExcelColumnPreview = {
header: string;
index: number;
samples: string[];
};
export type ExcelPreviewResult = {
columns: ExcelColumnPreview[];
fields: ExcelFieldOption[];
suggested: Record<string, string>;
needsMapping: boolean;
};
export function excelFieldsFromColumns(
columns: Array<{ header: string; key: string }>,
requiredKeys: string[] = [],
): ExcelFieldOption[] {
const seen = new Set<string>();
const fields: ExcelFieldOption[] = [];
for (const col of columns) {
if (!col.key || col.key === '_type' || seen.has(col.key)) continue;
seen.add(col.key);
fields.push({
key: col.key,
label: col.header,
required: requiredKeys.includes(col.key),
});
}
return fields;
}
export function parseExcelMapping(raw?: string): Record<string, string> | undefined {
if (raw == null || !String(raw).trim()) return undefined;
try {
const parsed = JSON.parse(String(raw)) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('invalid');
}
const mapping: Record<string, string> = {};
for (const [header, field] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof field === 'string' && field.trim()) mapping[header] = field.trim();
}
return mapping;
} catch {
throw new BadRequestException('نگاشت ستون‌ها نامعتبر است');
}
}
function resolveFieldKey(
raw: string,
aliases: Record<string, string>,
mapping?: Record<string, string>,
) {
if (mapping) {
return mapping[raw] || mapping[normalizeHeader(raw)] || '';
}
const compact = normalizeHeader(raw);
return aliases[compact] ?? aliases[raw.trim()] ?? aliases[normalizeHeader(raw)] ?? '';
}
export async function previewExcel(
file: Express.Multer.File,
aliases: Record<string, string>,
fields: ExcelFieldOption[],
): Promise<ExcelPreviewResult> {
const sheet = await workbookFromUpload(file);
const headerRow = sheet.getRow(1);
const columns: ExcelColumnPreview[] = [];
const suggested: Record<string, string> = {};
const used = new Set<string>();
headerRow.eachCell((cell, col) => {
const header = cellStr(cell.value);
if (!header) return;
const samples: string[] = [];
const last = Math.min(sheet.rowCount, 6);
for (let r = 2; r <= last; r += 1) {
const value = cellStr(sheet.getRow(r).getCell(col).value);
if (value) samples.push(value);
if (samples.length >= 3) break;
}
columns.push({ header, index: col, samples });
const key = resolveFieldKey(header, aliases);
if (key && !used.has(key)) {
suggested[header] = key;
used.add(key);
}
});
if (!columns.length) {
throw new BadRequestException('ستون عنوان در ردیف اول فایل پیدا نشد');
}
const required = fields.filter((f) => f.required).map((f) => f.key);
const mapped = new Set(Object.values(suggested));
const missingRequired = required.some((key) => !mapped.has(key));
const unrecognized = columns.some((col) => !suggested[col.header]);
return {
columns,
fields,
suggested,
needsMapping: unrecognized || missingRequired || !mapped.size,
};
}
export async function readMappedRows(
file: Express.Multer.File,
aliases: Record<string, string>,
mapping?: Record<string, string>,
): Promise<Record<string, string>[]> {
const sheet = await workbookFromUpload(file);
const headerRow = sheet.getRow(1);
@@ -198,13 +310,12 @@ export async function readMappedRows(
headerRow.eachCell((cell, col) => {
const raw = cellStr(cell.value);
if (!raw) return;
const compact = normalizeHeader(raw);
const key = aliases[compact] ?? aliases[raw.trim()] ?? aliases[normalizeHeader(raw)];
const key = resolveFieldKey(raw, aliases, mapping);
if (key) map.set(col, key);
});
if (!map.size) {
throw new BadRequestException(
'ستون‌های فایل شناسایی نشد. از قالب خروجی همین سیستم یا خروجی دیدار استفاده کنید.',
'ستون‌های فایل شناسایی نشد. ستون‌های فایل را با فیلدهای سیستم تطبیق دهید.',
);
}
@@ -56,13 +56,20 @@ export class CompaniesController {
return new StreamableFile(buffer);
}
@Post('import/preview')
@UseInterceptors(FileInterceptor('file'))
previewImport(@UploadedFile() file: Express.Multer.File) {
return this.excelService.previewCompanies(file);
}
@Post('import')
@UseInterceptors(FileInterceptor('file'))
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@Body('mapping') mapping?: string,
) {
return this.excelService.importCompanies(user, file);
return this.excelService.importCompanies(user, file, mapping);
}
@Get(':id')
@@ -6,8 +6,11 @@ import type { Actor } from '../common/record-scope';
import { ownerWhere } from '../common/record-scope';
import {
formatJalaliDate,
parseExcelMapping,
previewExcel,
readMappedRows,
writeXlsx,
type ExcelPreviewResult,
} from '../common/excel';
import { CompaniesService } from './companies.service';
import { PeopleService } from './people.service';
@@ -107,6 +110,19 @@ const PERSON_ALIASES: Record<string, string> = {
ستاره: 'star',
};
const PERSON_FIELDS = [
{ key: 'firstName', label: 'نام' },
{ key: 'lastName', label: 'نام خانوادگی' },
{ key: 'title', label: 'نام کامل / عنوان' },
{ key: 'mobile', label: 'موبایل' },
{ key: 'phone', label: 'تلفن' },
{ key: 'email', label: 'ایمیل' },
{ key: 'position', label: 'سمت' },
{ key: 'companyName', label: 'شرکت' },
{ key: 'notes', label: 'یادداشت' },
{ key: 'mood', label: 'وضعیت مشتری' },
];
const COMPANY_ALIASES: Record<string, string> = {
name: 'name',
نام: 'name',
@@ -126,6 +142,16 @@ const COMPANY_ALIASES: Record<string, string> = {
یادداشت: 'notes',
};
const COMPANY_FIELDS = [
{ key: 'name', label: 'نام شرکت', required: true },
{ key: 'nationalId', label: 'شناسه ملی' },
{ key: 'phone', label: 'تلفن' },
{ key: 'email', label: 'ایمیل' },
{ key: 'website', label: 'وب‌سایت' },
{ key: 'address', label: 'آدرس' },
{ key: 'notes', label: 'یادداشت' },
];
const CONTACT_COLUMNS = [
{ header: '_type', key: '_type', width: 12 },
{ header: 'کد دیدار مشتری', key: 'didarId', width: 16 },
@@ -294,11 +320,20 @@ export class ContactsExcelService {
);
}
previewPeople(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, PERSON_ALIASES, PERSON_FIELDS);
}
previewCompanies(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, COMPANY_ALIASES, COMPANY_FIELDS);
}
async importPeople(
actor: Actor,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, PERSON_ALIASES);
const rows = await readMappedRows(file, PERSON_ALIASES, parseExcelMapping(mappingRaw));
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
for (let i = 0; i < rows.length; i++) {
@@ -307,9 +342,18 @@ export class ContactsExcelService {
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'contact') {
continue;
}
const names = splitDidarName(row.firstName ?? '', row.lastName ?? '', row.title);
if (!names) {
result.errors.push({ row: rowNum, message: 'نام مشتری الزامی است' });
const names = splitDidarName(row.firstName ?? '', row.lastName ?? '', row.title) ?? {
firstName: (row.firstName ?? '').trim().slice(0, 80),
lastName: (row.lastName ?? '').trim().slice(0, 80),
};
if (
!names.firstName &&
!names.lastName &&
!row.mobile?.trim() &&
!row.email?.trim() &&
!row.phone?.trim()
) {
result.errors.push({ row: rowNum, message: 'حداقل نام، موبایل، تلفن یا ایمیل لازم است' });
continue;
}
@@ -357,8 +401,9 @@ export class ContactsExcelService {
async importCompanies(
actor: Actor,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, COMPANY_ALIASES);
const rows = await readMappedRows(file, COMPANY_ALIASES, parseExcelMapping(mappingRaw));
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
for (let i = 0; i < rows.length; i++) {
+4 -6
View File
@@ -135,15 +135,15 @@ export class UpdateCompanyDto {
}
export class CreatePersonDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
firstName!: string;
firstName?: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
lastName!: string;
lastName?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@@ -192,13 +192,11 @@ export class CreatePersonDto {
export class UpdatePersonDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
firstName?: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
lastName?: string;
+19 -3
View File
@@ -18,13 +18,16 @@ import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
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 { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service';
import { brand } from '@mento/shared';
@Controller('people')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('customers')
export class PeopleController {
constructor(
private readonly peopleService: PeopleService,
@@ -57,13 +60,22 @@ export class PeopleController {
return new StreamableFile(buffer);
}
@Post('import/preview')
@UseInterceptors(FileInterceptor('file'))
previewImport(
@UploadedFile() file: Express.Multer.File,
) {
return this.excelService.previewPeople(file);
}
@Post('import')
@UseInterceptors(FileInterceptor('file'))
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@Body('mapping') mapping?: string,
) {
return this.excelService.importPeople(user, file);
return this.excelService.importPeople(user, file, mapping);
}
@Get(':id')
@@ -73,7 +85,11 @@ export class PeopleController {
@Post()
create(@CurrentUser() user: Actor, @Body() dto: CreatePersonDto) {
return this.peopleService.create(user, dto);
return this.peopleService.create(user, {
...dto,
firstName: dto.firstName ?? '',
lastName: dto.lastName ?? '',
});
}
@Patch(':id')
+2 -2
View File
@@ -134,8 +134,8 @@ export class PeopleService {
const data: Prisma.PersonUncheckedCreateInput = {
organizationId: actor.organizationId,
firstName: dto.firstName.trim(),
lastName: dto.lastName.trim(),
firstName: (dto.firstName ?? '').trim(),
lastName: (dto.lastName ?? '').trim(),
email: normalizeEmail(dto.email),
phone: normalizePhone(dto.phone),
mobile: normalizePhone(dto.mobile),
@@ -20,7 +20,7 @@ export class MembershipsService {
async listActive(userId: string) {
return this.prisma.membership.findMany({
where: { userId, isActive: true },
where: { userId, isActive: true, organization: { isActive: true } },
include: {
organization: { select: { id: true, name: true } },
orgRole: { select: { name: true } },
@@ -35,12 +35,24 @@ export class MembershipsService {
): Promise<MembershipActor> {
const row = await this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true },
select: { userId: true, organizationId: true, role: true },
select: {
userId: true,
organizationId: true,
role: true,
organization: { select: { isActive: true } },
},
});
if (!row) {
throw new ForbiddenException('به این محیط دسترسی ندارید');
}
return row;
if (row.organization.isActive === false) {
throw new UnauthorizedException('این محیط غیرفعال است');
}
return {
userId: row.userId,
organizationId: row.organizationId,
role: row.role,
};
}
toChoices(
@@ -12,6 +12,8 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import {
CreateMessageAutomationRuleDto,
UpdateMessageAutomationRuleDto,
@@ -19,7 +21,8 @@ import {
import { MessageAutomationsService } from './message-automations.service';
@Controller('message-automations')
@UseGuards(JwtAuthGuard, RolesGuard)
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@RequireOrgModule('message_automations')
@Roles('owner', 'admin')
export class MessageAutomationsController {
constructor(private readonly messages: MessageAutomationsService) {}
@@ -3,11 +3,18 @@ import { AuthModule } from '../auth/auth.module';
import { MembershipsModule } from '../memberships/memberships.module';
import { OrgRolesModule } from '../org-roles/org-roles.module';
import { PipelinesModule } from '../pipelines/pipelines.module';
import { BillingModule } from '../billing/billing.module';
import { OrganizationsController } from './organizations.controller';
import { OrganizationsService } from './organizations.service';
@Module({
imports: [AuthModule, MembershipsModule, OrgRolesModule, PipelinesModule],
imports: [
AuthModule,
MembershipsModule,
OrgRolesModule,
PipelinesModule,
BillingModule,
],
controllers: [OrganizationsController],
providers: [OrganizationsService],
})
@@ -15,6 +15,7 @@ import { isKnownCurrency } from '../common/currency';
import { MembershipsService } from '../memberships/memberships.service';
import { OrgRolesService } from '../org-roles/org-roles.service';
import { PipelinesService } from '../pipelines/pipelines.service';
import { BillingService } from '../billing/billing.service';
function slugify(input: string): string {
return (
@@ -34,6 +35,7 @@ export class OrganizationsService {
private readonly memberships: MembershipsService,
private readonly orgRoles: OrgRolesService,
private readonly pipelines: PipelinesService,
private readonly billing: BillingService,
) {}
async createForUser(
@@ -57,6 +59,8 @@ export class OrganizationsService {
throw new UnauthorizedException();
}
await this.billing.assertCanCreateOrganization(userId);
const baseSlug = slugify(name);
let slug = baseSlug;
let i = 1;
@@ -69,6 +73,7 @@ export class OrganizationsService {
name,
slug,
businessField,
enabledModules: this.billing.defaultModulesForNewOrg(),
},
});
await this.memberships.createMembership({
@@ -76,6 +81,7 @@ export class OrganizationsService {
organizationId: organization.id,
role: 'owner',
});
await this.billing.ensureTrialForOwner(userId);
await this.orgRoles.ensureDefaultRoles(organization.id);
await this.pipelines.ensureDefaultPipeline(organization.id);
return { id: organization.id };
@@ -219,6 +225,7 @@ export class OrganizationsService {
businessField: string | null;
baseCurrency: string;
enabledCurrencies: string[];
enabledModules?: string[];
exchangeRates: Array<{
currency: string;
rateToBase: Prisma.Decimal;
@@ -235,6 +242,7 @@ export class OrganizationsService {
: null,
baseCurrency: org.baseCurrency,
enabledCurrencies: org.enabledCurrencies,
enabledModules: this.billing.normalizeModules(org.enabledModules),
exchangeRates: org.exchangeRates.map((r) => ({
currency: r.currency,
rateToBase: Number(r.rateToBase),
+14 -2
View File
@@ -7,11 +7,15 @@ import { ownerWhere, resolveOwnerId } from '../common/record-scope';
import { toJsonCustomFields } from '../common/custom-fields';
import { normalizePhone } from '../contacts/normalize';
import {
excelFieldsFromColumns,
formatJalaliDate,
parseAmount,
parseExcelMapping,
parseJalaliDateTime,
previewExcel,
readMappedRows,
writeXlsx,
type ExcelPreviewResult,
} from '../common/excel';
import { PipelinesService } from './pipelines.service';
@@ -144,8 +148,16 @@ export class DealsExcelService {
);
}
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, DEAL_ALIASES);
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, DEAL_ALIASES, excelFieldsFromColumns(DEAL_COLUMNS, ['title']));
}
async import(
actor: Actor,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, DEAL_ALIASES, parseExcelMapping(mappingRaw));
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
const affected = new Map<string, string>();
+19 -2
View File
@@ -18,13 +18,16 @@ import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
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 { DealsService } from './deals.service';
import { DealsExcelService } from './deals-excel.service';
import { brand } from '@mento/shared';
@Controller('deals')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('pipeline')
export class DealsController {
constructor(
private readonly dealsService: DealsService,
@@ -45,6 +48,19 @@ export class DealsController {
return new StreamableFile(buffer);
}
@Post('import/preview')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15_000_000 },
}),
)
importPreview(
@UploadedFile() file: Express.Multer.File,
) {
return this.excelService.preview(file);
}
@Post('import')
@UseInterceptors(
FileInterceptor('file', {
@@ -55,8 +71,9 @@ export class DealsController {
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@Body('mapping') mapping?: string,
) {
return this.excelService.import(user, file);
return this.excelService.import(user, file, mapping);
}
@Get(':id')
+153
View File
@@ -0,0 +1,153 @@
import {
IsArray,
IsBoolean,
IsEmail,
IsIn,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
import { USER_ROLES, type UserRole } from '@mento/shared';
export class PlatformLoginDto {
@IsString()
@MinLength(8)
@MaxLength(40)
mobile!: string;
@IsString()
@MinLength(6)
password!: string;
}
export class CreatePlatformAdminDto {
@IsString()
@MinLength(2)
@MaxLength(120)
fullName!: string;
@IsString()
@MinLength(8)
@MaxLength(40)
mobile!: string;
@IsString()
@MinLength(8)
@MaxLength(120)
password!: string;
@IsOptional()
@IsEmail()
email?: string;
}
export class ResetPlatformAdminPasswordDto {
@IsString()
@MinLength(8)
@MaxLength(120)
password!: string;
}
export class UpdatePlatformStatusDto {
@IsBoolean()
isActive!: boolean;
}
export class CreatePlatformOrganizationDto {
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(64)
businessField?: string;
@IsString()
@MinLength(2)
@MaxLength(120)
ownerFullName!: string;
@IsEmail()
ownerEmail!: string;
@IsString()
@MinLength(8)
@MaxLength(120)
ownerPassword!: string;
@IsOptional()
@IsString()
@MaxLength(40)
ownerMobile?: string;
}
export class UpdatePlatformOrganizationDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(120)
name?: string;
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(64)
slug?: string;
@IsOptional()
@ValidateIf((_, v) => v !== null && v !== undefined)
@IsString()
@MaxLength(64)
businessField?: string | null;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdatePlatformMembershipRoleDto {
@IsIn(USER_ROLES)
role!: UserRole;
}
export class ResetPlatformUserPasswordDto {
@IsString()
@MinLength(8)
@MaxLength(120)
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[];
}
@@ -0,0 +1,22 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import { PlatformLoginDto } from './dto';
import { PlatformAuthGuard } from './platform-auth.guard';
import { PlatformAuthService } from './platform-auth.service';
import type { PlatformActor } from './platform-jwt.strategy';
@Controller('platform/auth')
export class PlatformAuthController {
constructor(private readonly auth: PlatformAuthService) {}
@Post('login')
login(@Body() dto: PlatformLoginDto) {
return this.auth.login(dto);
}
@Get('me')
@UseGuards(PlatformAuthGuard)
me(@CurrentUser() actor: PlatformActor) {
return this.auth.me(actor.platformAdminId);
}
}
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class PlatformAuthGuard extends AuthGuard('platform-jwt') {}
@@ -0,0 +1,80 @@
import {
BadRequestException,
ConflictException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import type { PlatformAdminDto, PlatformLoginResponse } from '@mento/shared';
import { normalizeMobile } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { PlatformLoginDto } from './dto';
@Injectable()
export class PlatformAuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) {}
async login(dto: PlatformLoginDto): Promise<PlatformLoginResponse> {
const mobile = normalizeMobile(dto.mobile);
if (!mobile) {
throw new UnauthorizedException('اطلاعات ورود نادرست است');
}
const admin = await this.prisma.platformAdmin.findUnique({
where: { mobile },
});
if (!admin || admin.isActive === false) {
throw new UnauthorizedException('اطلاعات ورود نادرست است');
}
const ok = await bcrypt.compare(dto.password, admin.passwordHash);
if (!ok) {
throw new UnauthorizedException('اطلاعات ورود نادرست است');
}
await this.prisma.platformAdmin.update({
where: { id: admin.id },
data: { lastLoginAt: new Date() },
});
return {
accessToken: this.jwt.sign(
{ sub: admin.id, typ: 'platform-admin' },
{ expiresIn: '12h' },
),
admin: this.toDto(admin),
};
}
async me(platformAdminId: string): Promise<PlatformAdminDto> {
const admin = await this.prisma.platformAdmin.findUnique({
where: { id: platformAdminId },
});
if (!admin || admin.isActive === false) {
throw new UnauthorizedException();
}
return this.toDto(admin);
}
toDto(admin: {
id: string;
mobile: string | null;
email: string | null;
fullName: string;
isActive?: boolean;
lastLoginAt?: Date | null;
createdAt?: Date;
}): PlatformAdminDto {
return {
id: admin.id,
mobile: admin.mobile,
email: admin.email,
fullName: admin.fullName,
...(admin.isActive !== undefined ? { isActive: admin.isActive } : {}),
...(admin.lastLoginAt !== undefined
? { lastLoginAt: admin.lastLoginAt?.toISOString() ?? null }
: {}),
...(admin.createdAt ? { createdAt: admin.createdAt.toISOString() } : {}),
};
}
}
@@ -0,0 +1,59 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from '../prisma/prisma.service';
export type PlatformJwtPayload = {
sub: string;
typ?: string;
};
export type PlatformActor = {
platformAdminId: string;
mobile: string | null;
email: string | null;
fullName: string;
};
@Injectable()
export class PlatformJwtStrategy extends PassportStrategy(
Strategy,
'platform-jwt',
) {
constructor(
config: ConfigService,
private readonly prisma: PrismaService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get<string>('JWT_SECRET') ?? 'dev-secret',
});
}
async validate(payload: PlatformJwtPayload): Promise<PlatformActor> {
if (payload.typ !== 'platform-admin' || !payload.sub) {
throw new UnauthorizedException();
}
const admin = await this.prisma.platformAdmin.findUnique({
where: { id: payload.sub },
select: {
id: true,
mobile: true,
email: true,
fullName: true,
isActive: true,
},
});
if (!admin || admin.isActive === false) {
throw new UnauthorizedException();
}
return {
platformAdminId: admin.id,
mobile: admin.mobile,
email: admin.email,
fullName: admin.fullName,
};
}
}
@@ -0,0 +1,138 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import {
ApplyBillingPlanDto,
CreatePlatformAdminDto,
CreatePlatformOrganizationDto,
ResetPlatformAdminPasswordDto,
ResetPlatformUserPasswordDto,
UpdateOrgModulesDto,
UpdatePlatformMembershipRoleDto,
UpdatePlatformOrganizationDto,
UpdatePlatformStatusDto,
} from './dto';
import { PlatformAuthGuard } from './platform-auth.guard';
import type { PlatformActor } from './platform-jwt.strategy';
import { PlatformService } from './platform.service';
@Controller('platform')
@UseGuards(PlatformAuthGuard)
export class PlatformController {
constructor(private readonly platform: PlatformService) {}
@Get('stats')
stats() {
return this.platform.stats();
}
@Get('admins')
listAdmins() {
return this.platform.listAdmins();
}
@Post('admins')
createAdmin(@Body() dto: CreatePlatformAdminDto) {
return this.platform.createAdmin(dto);
}
@Patch('admins/:id')
setAdminActive(
@Param('id') id: string,
@Body() dto: UpdatePlatformStatusDto,
@CurrentUser() actor: PlatformActor,
) {
return this.platform.setAdminActive(id, dto.isActive, actor.platformAdminId);
}
@Patch('admins/:id/password')
resetAdminPassword(
@Param('id') id: string,
@Body() dto: ResetPlatformAdminPasswordDto,
) {
return this.platform.resetAdminPassword(id, dto);
}
@Get('organizations')
listOrganizations(@Query('q') q?: string) {
return this.platform.listOrganizations(q);
}
@Post('organizations')
createOrganization(@Body() dto: CreatePlatformOrganizationDto) {
return this.platform.createOrganization(dto);
}
@Get('organizations/:id')
getOrganization(@Param('id') id: string) {
return this.platform.getOrganization(id);
}
@Patch('organizations/:id')
updateOrganization(
@Param('id') id: string,
@Body() dto: UpdatePlatformOrganizationDto,
) {
return this.platform.updateOrganization(id, dto);
}
@Get('users')
listUsers(@Query('q') q?: string) {
return this.platform.listUsers(q);
}
@Get('users/:id')
getUser(@Param('id') id: string) {
return this.platform.getUser(id);
}
@Patch('users/:id')
setUserActive(@Param('id') id: string, @Body() dto: UpdatePlatformStatusDto) {
return this.platform.setUserActive(id, dto.isActive);
}
@Patch('users/:id/password')
resetUserPassword(
@Param('id') id: string,
@Body() dto: ResetPlatformUserPasswordDto,
) {
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')
setMembershipActive(
@Param('id') id: string,
@Body() dto: UpdatePlatformStatusDto,
) {
return this.platform.setMembershipActive(id, dto.isActive);
}
@Patch('memberships/:id/role')
updateMembershipRole(
@Param('id') id: string,
@Body() dto: UpdatePlatformMembershipRoleDto,
) {
return this.platform.updateMembershipRole(id, dto.role);
}
}
+32
View File
@@ -0,0 +1,32 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { AuthModule } from '../auth/auth.module';
import { MembershipsModule } from '../memberships/memberships.module';
import { OrgRolesModule } from '../org-roles/org-roles.module';
import { PipelinesModule } from '../pipelines/pipelines.module';
import { BillingModule } from '../billing/billing.module';
import { PlatformAuthController } from './platform-auth.controller';
import { PlatformAuthGuard } from './platform-auth.guard';
import { PlatformAuthService } from './platform-auth.service';
import { PlatformJwtStrategy } from './platform-jwt.strategy';
import { PlatformController } from './platform.controller';
import { PlatformService } from './platform.service';
@Module({
imports: [
AuthModule,
MembershipsModule,
OrgRolesModule,
PipelinesModule,
BillingModule,
PassportModule,
],
controllers: [PlatformAuthController, PlatformController],
providers: [
PlatformJwtStrategy,
PlatformAuthGuard,
PlatformAuthService,
PlatformService,
],
})
export class PlatformModule {}
+717
View File
@@ -0,0 +1,717 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
PlatformAdminDto,
PlatformOrganizationDto,
PlatformStatsDto,
PlatformUserDto,
UserRole,
} from '@mento/shared';
import { isBusinessField } from '@mento/shared';
import * as bcrypt from 'bcryptjs';
import { Prisma } from '@prisma/client';
import { normalizeMobile } from '../auth/auth.service';
import { MembershipsService } from '../memberships/memberships.service';
import { OrgRolesService } from '../org-roles/org-roles.service';
import { PipelinesService } from '../pipelines/pipelines.service';
import { BillingService } from '../billing/billing.service';
import { PrismaService } from '../prisma/prisma.service';
import type {
ApplyBillingPlanDto,
CreatePlatformAdminDto,
CreatePlatformOrganizationDto,
ResetPlatformAdminPasswordDto,
ResetPlatformUserPasswordDto,
UpdateOrgModulesDto,
UpdatePlatformOrganizationDto,
} from './dto';
function slugify(input: string): string {
return (
input
.trim()
.toLowerCase()
.replace(/[^\w\u0600-\u06FF]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48) || `org-${Date.now()}`
);
}
@Injectable()
export class PlatformService {
constructor(
private readonly prisma: PrismaService,
private readonly memberships: MembershipsService,
private readonly orgRoles: OrgRolesService,
private readonly pipelines: PipelinesService,
private readonly billing: BillingService,
) {}
async stats(): Promise<PlatformStatsDto> {
const [
organizationCount,
activeOrganizationCount,
userCount,
activeUserCount,
membershipCount,
] = await this.prisma.$transaction([
this.prisma.organization.count(),
this.prisma.organization.count({ where: { isActive: true } }),
this.prisma.user.count(),
this.prisma.user.count({ where: { isActive: true } }),
this.prisma.membership.count(),
]);
const [expiringSoonCount, expiredBillingCount] = await Promise.all([
this.billing.countExpiringSoon(),
this.billing.countExpired(),
]);
return {
organizationCount,
activeOrganizationCount,
userCount,
activeUserCount,
membershipCount,
expiringSoonCount,
expiredBillingCount,
};
}
async listOrganizations(query?: string): Promise<PlatformOrganizationDto[]> {
const q = query?.trim();
const rows = await this.prisma.organization.findMany({
where: q
? {
OR: [
{ name: { contains: q, mode: 'insensitive' } },
{ slug: { contains: q, mode: 'insensitive' } },
],
}
: undefined,
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
slug: true,
businessField: true,
isActive: true,
createdAt: true,
enabledModules: true,
_count: {
select: {
memberships: true,
people: true,
deals: true,
},
},
},
});
return rows.map((row) => this.orgToDto(row));
}
async getOrganization(id: string): Promise<PlatformOrganizationDto> {
const row = await this.prisma.organization.findUnique({
where: { id },
select: {
id: true,
name: true,
slug: true,
businessField: true,
isActive: true,
createdAt: true,
enabledModules: true,
_count: {
select: {
memberships: true,
people: true,
deals: true,
},
},
memberships: {
include: {
user: {
select: {
id: true,
fullName: true,
email: true,
mobile: true,
isActive: true,
},
},
},
orderBy: { createdAt: 'asc' },
},
},
});
if (!row) throw new NotFoundException('محیط پیدا نشد');
return {
...this.orgToDto(row),
members: row.memberships.map((m) => ({
membershipId: m.id,
userId: m.user.id,
fullName: m.user.fullName,
email: m.user.email,
mobile: m.user.mobile,
role: m.role,
isActive: m.isActive,
userIsActive: m.user.isActive,
})),
};
}
async createOrganization(
dto: CreatePlatformOrganizationDto,
): Promise<PlatformOrganizationDto> {
const name = dto.name.trim();
if (name.length < 2) {
throw new BadRequestException('نام محیط معتبر نیست');
}
const ownerFullName = dto.ownerFullName.trim();
if (ownerFullName.length < 2) {
throw new BadRequestException('نام مالک معتبر نیست');
}
const ownerEmail = dto.ownerEmail.trim().toLowerCase();
const ownerPassword = dto.ownerPassword.trim();
if (ownerPassword.length < 8) {
throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است');
}
const businessField =
dto.businessField && isBusinessField(dto.businessField)
? dto.businessField
: 'personal';
let ownerMobile: string | null = null;
if (dto.ownerMobile?.trim()) {
ownerMobile = normalizeMobile(dto.ownerMobile);
if (!ownerMobile) {
throw new BadRequestException('شماره موبایل مالک معتبر نیست');
}
}
const existingEmail = await this.prisma.user.findFirst({
where: { email: ownerEmail },
select: { id: true },
});
if (existingEmail) {
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
}
if (ownerMobile) {
const existingMobile = await this.prisma.user.findFirst({
where: { mobile: ownerMobile },
select: { id: true },
});
if (existingMobile) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
}
const baseSlug = slugify(name);
let slug = baseSlug;
let i = 1;
while (await this.prisma.organization.findUnique({ where: { slug } })) {
slug = `${baseSlug}-${i++}`;
}
const passwordHash = await bcrypt.hash(ownerPassword, 10);
const now = new Date();
try {
const organization = await this.prisma.organization.create({
data: {
name,
slug,
businessField,
enabledModules: this.billing.defaultModulesForNewOrg(),
users: {
create: {
email: ownerEmail,
mobile: ownerMobile,
fullName: ownerFullName,
passwordHash,
role: 'owner',
emailVerifiedAt: now,
mobileVerifiedAt: ownerMobile ? now : null,
},
},
},
include: { users: true },
});
const owner = organization.users[0];
if (!owner) {
throw new BadRequestException('ساخت حساب مالک ناموفق بود');
}
await this.memberships.createMembership({
userId: owner.id,
organizationId: organization.id,
role: 'owner',
});
await this.billing.ensureTrialForOwner(owner.id, { maxOrganizations: 1 });
await this.orgRoles.ensureDefaultRoles(organization.id);
await this.pipelines.ensureDefaultPipeline(organization.id);
return this.getOrganization(organization.id);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('ایمیل یا موبایل تکراری است');
}
throw e;
}
}
async updateOrganization(
id: string,
dto: UpdatePlatformOrganizationDto,
): Promise<PlatformOrganizationDto> {
const existing = await this.prisma.organization.findUnique({
where: { id },
select: { id: true, slug: true },
});
if (!existing) throw new NotFoundException('محیط پیدا نشد');
const data: Prisma.OrganizationUpdateInput = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (name.length < 2) {
throw new BadRequestException('نام محیط معتبر نیست');
}
data.name = name;
}
if (dto.slug !== undefined) {
const slug = slugify(dto.slug);
if (slug.length < 2) {
throw new BadRequestException('اسلاگ معتبر نیست');
}
if (slug !== existing.slug) {
const clash = await this.prisma.organization.findUnique({
where: { slug },
select: { id: true },
});
if (clash) {
throw new ConflictException('این اسلاگ قبلاً استفاده شده است');
}
data.slug = slug;
}
}
if (dto.businessField !== undefined) {
if (dto.businessField === null || dto.businessField === '') {
data.businessField = null;
} else if (!isBusinessField(dto.businessField)) {
throw new BadRequestException('زمینه فعالیت معتبر نیست');
} else {
data.businessField = dto.businessField;
}
}
if (dto.isActive !== undefined) {
data.isActive = dto.isActive;
}
if (Object.keys(data).length === 0) {
throw new BadRequestException('هیچ فیلدی برای به‌روزرسانی ارسال نشده');
}
await this.prisma.organization.update({ where: { id }, data });
return this.getOrganization(id);
}
async setOrganizationActive(
id: string,
isActive: boolean,
): Promise<PlatformOrganizationDto> {
return this.updateOrganization(id, { isActive });
}
async listUsers(query?: string): Promise<PlatformUserDto[]> {
const q = query?.trim();
const rows = await this.prisma.user.findMany({
where: q
? {
OR: [
{ fullName: { contains: q, mode: 'insensitive' } },
{ email: { contains: q, mode: 'insensitive' } },
{ mobile: { contains: q } },
],
}
: undefined,
orderBy: { createdAt: 'desc' },
take: 300,
include: {
billingAccount: true,
memberships: {
include: {
organization: {
select: { id: true, name: true, isActive: true },
},
},
orderBy: { createdAt: 'asc' },
},
},
});
return rows.map((row) => this.userToDto(row));
}
async getUser(id: string): Promise<PlatformUserDto> {
const row = await this.prisma.user.findUnique({
where: { id },
include: {
memberships: {
include: {
organization: {
select: { id: true, name: true, isActive: true },
},
},
orderBy: { createdAt: 'asc' },
},
billingAccount: true,
},
});
if (!row) throw new NotFoundException('کاربر پیدا نشد');
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> {
const existing = await this.prisma.user.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('کاربر پیدا نشد');
await this.prisma.user.update({
where: { id },
data: { isActive },
});
return this.getUser(id);
}
async resetUserPassword(
id: string,
dto: ResetPlatformUserPasswordDto,
): Promise<{ ok: true }> {
const existing = await this.prisma.user.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('کاربر پیدا نشد');
const password = dto.password.trim();
if (password.length < 8) {
throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است');
}
const passwordHash = await bcrypt.hash(password, 10);
await this.prisma.user.update({
where: { id },
data: { passwordHash },
});
return { ok: true };
}
async setMembershipActive(
id: string,
isActive: boolean,
): Promise<PlatformUserDto> {
const row = await this.prisma.membership.findUnique({
where: { id },
select: {
id: true,
userId: true,
organizationId: true,
role: true,
},
});
if (!row) throw new NotFoundException('عضویت پیدا نشد');
if (!isActive && row.role === 'owner') {
const activeOwners = await this.memberships.countActiveOwners(
row.organizationId,
row.userId,
);
if (activeOwners < 1) {
throw new BadRequestException('حداقل یک مالک فعال برای این محیط لازم است');
}
}
await this.prisma.membership.update({
where: { id },
data: { isActive },
});
return this.getUser(row.userId);
}
async updateMembershipRole(
id: string,
role: UserRole,
): Promise<PlatformUserDto> {
const row = await this.prisma.membership.findUnique({
where: { id },
include: {
user: { select: { id: true, organizationId: true } },
},
});
if (!row) throw new NotFoundException('عضویت پیدا نشد');
if (row.role === 'owner' && role !== 'owner') {
const owners = await this.memberships.countActiveOwners(
row.organizationId,
row.userId,
);
if (owners < 1) {
throw new BadRequestException(
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
);
}
}
await this.orgRoles.ensureDefaultRoles(row.organizationId);
const orgRole = await this.prisma.orgRole.findFirst({
where: { organizationId: row.organizationId, systemKey: role },
});
await this.prisma.membership.update({
where: { id },
data: {
role,
orgRoleId: orgRole?.id ?? row.orgRoleId,
},
});
if (row.user.organizationId === row.organizationId) {
await this.memberships.syncUserLastOrg(
row.userId,
row.organizationId,
role,
orgRole?.id ?? row.orgRoleId,
);
}
return this.getUser(row.userId);
}
async listAdmins(): Promise<PlatformAdminDto[]> {
const rows = await this.prisma.platformAdmin.findMany({
orderBy: { createdAt: 'asc' },
});
return rows.map((row) => this.adminToDto(row));
}
async createAdmin(dto: CreatePlatformAdminDto): Promise<PlatformAdminDto> {
const fullName = dto.fullName.trim();
if (fullName.length < 2) {
throw new BadRequestException('نام معتبر نیست');
}
const mobile = normalizeMobile(dto.mobile);
if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
const password = dto.password.trim();
if (password.length < 8) {
throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است');
}
const email = dto.email?.trim().toLowerCase() || null;
const existingMobile = await this.prisma.platformAdmin.findUnique({
where: { mobile },
select: { id: true },
});
if (existingMobile) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
if (email) {
const existingEmail = await this.prisma.platformAdmin.findUnique({
where: { email },
select: { id: true },
});
if (existingEmail) {
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
}
}
const passwordHash = await bcrypt.hash(password, 10);
try {
const created = await this.prisma.platformAdmin.create({
data: {
fullName,
mobile,
email,
passwordHash,
},
});
return this.adminToDto(created);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('موبایل یا ایمیل تکراری است');
}
throw e;
}
}
async setAdminActive(
id: string,
isActive: boolean,
actorId: string,
): Promise<PlatformAdminDto> {
const existing = await this.prisma.platformAdmin.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
if (!isActive && id === actorId) {
throw new BadRequestException('نمی‌توانید حساب خودتان را غیرفعال کنید');
}
if (!isActive) {
const activeCount = await this.prisma.platformAdmin.count({
where: { isActive: true, id: { not: id } },
});
if (activeCount < 1) {
throw new BadRequestException('حداقل یک سوپرادمین فعال لازم است');
}
}
const updated = await this.prisma.platformAdmin.update({
where: { id },
data: { isActive },
});
return this.adminToDto(updated);
}
async resetAdminPassword(
id: string,
dto: ResetPlatformAdminPasswordDto,
): Promise<{ ok: true }> {
const existing = await this.prisma.platformAdmin.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
const password = dto.password.trim();
if (password.length < 8) {
throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است');
}
const passwordHash = await bcrypt.hash(password, 10);
await this.prisma.platformAdmin.update({
where: { id },
data: { passwordHash },
});
return { ok: true };
}
private adminToDto(row: {
id: string;
mobile: string | null;
email: string | null;
fullName: string;
isActive: boolean;
lastLoginAt: Date | null;
createdAt: Date;
}): PlatformAdminDto {
return {
id: row.id,
mobile: row.mobile,
email: row.email,
fullName: row.fullName,
isActive: row.isActive,
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
private orgToDto(row: {
id: string;
name: string;
slug: string;
businessField: string | null;
isActive: boolean;
createdAt: Date;
enabledModules?: string[];
_count: { memberships: number; people: number; deals: number };
}): PlatformOrganizationDto {
return {
id: row.id,
name: row.name,
slug: row.slug,
businessField: row.businessField,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
memberCount: row._count.memberships,
personCount: row._count.people,
dealCount: row._count.deals,
enabledModules: this.billing.normalizeModules(row.enabledModules),
};
}
private userToDto(row: {
id: string;
email: string;
mobile: string | null;
fullName: string;
isActive: boolean;
lastLoginAt: Date | null;
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<{
id: string;
role: PlatformUserDto['memberships'][number]['role'];
isActive: boolean;
organization: { id: string; name: string; isActive: boolean };
}>;
}): PlatformUserDto {
const isBillingOwner = row.memberships.some(
(m) => m.role === 'owner' && m.isActive,
);
return {
id: row.id,
email: row.email,
mobile: row.mobile,
fullName: row.fullName,
isActive: row.isActive,
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
isBillingOwner,
billing:
isBillingOwner && row.billingAccount
? this.billing.toDto(row.billingAccount)
: null,
memberships: row.memberships.map((m) => ({
membershipId: m.id,
organizationId: m.organization.id,
organizationName: m.organization.name,
organizationIsActive: m.organization.isActive,
role: m.role,
isActive: m.isActive,
})),
};
}
}
@@ -2,9 +2,13 @@ import { Injectable } from '@nestjs/common';
import type { ImportContactsResult } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import {
excelFieldsFromColumns,
parseAmount,
parseExcelMapping,
previewExcel,
readMappedRows,
writeXlsx,
type ExcelPreviewResult,
} from '../common/excel';
import { ProductsService } from './products.service';
@@ -106,11 +110,16 @@ export class ProductsExcelService {
);
}
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, PRODUCT_ALIASES, excelFieldsFromColumns(PRODUCT_COLUMNS, ['name']));
}
async import(
organizationId: string,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, PRODUCT_ALIASES);
const rows = await readMappedRows(file, PRODUCT_ALIASES, parseExcelMapping(mappingRaw));
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
for (let i = 0; i < rows.length; i++) {
+14 -1
View File
@@ -62,6 +62,18 @@ export class ProductsController {
return new StreamableFile(buffer);
}
@Post('import/preview')
@Roles('owner', 'admin')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15_000_000 },
}),
)
importPreview(@UploadedFile() file: Express.Multer.File) {
return this.excelService.preview(file);
}
@Post('import')
@Roles('owner', 'admin')
@UseInterceptors(
@@ -73,8 +85,9 @@ export class ProductsController {
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@Body('mapping') mapping?: string,
) {
return this.excelService.import(user.organizationId, file);
return this.excelService.import(user.organizationId, file, mapping);
}
@Get(':id')
@@ -128,6 +128,17 @@ export class ProjectBoardsController {
return new StreamableFile(buffer);
}
@Post('project-cards/import/preview')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15_000_000 },
}),
)
previewCards(@UploadedFile() file: Express.Multer.File) {
return this.excelService.preview(file);
}
@Post('project-cards/import')
@UseInterceptors(
FileInterceptor('file', {
@@ -138,8 +149,9 @@ export class ProjectBoardsController {
importCards(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@Body('mapping') mapping?: string,
) {
return this.excelService.import(user, file);
return this.excelService.import(user, file, mapping);
}
@Get('project-cards/:id')
@@ -5,11 +5,15 @@ import type { Actor } from '../common/record-scope';
import { ownerWhere } from '../common/record-scope';
import { normalizePhone } from '../contacts/normalize';
import {
excelFieldsFromColumns,
formatJalaliDate,
parseExcelMapping,
parseJalaliDateTime,
previewExcel,
readMappedRows,
stripHtml,
writeXlsx,
type ExcelPreviewResult,
} from '../common/excel';
import { ProjectBoardsService } from './project-boards.service';
@@ -105,8 +109,16 @@ export class ProjectCardsExcelService {
);
}
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, CASE_ALIASES);
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, CASE_ALIASES, excelFieldsFromColumns(CASE_COLUMNS, ['title']));
}
async import(
actor: Actor,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, CASE_ALIASES, parseExcelMapping(mappingRaw));
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
const affected = new Map<string, string>();
Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

+224
View File
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type { ExcelPreviewResult, ImportContactsResult } from '@mento/shared';
import { UiButton, UiModal, UiSelect } from '@/components/ui';
const props = defineProps<{
previewPath: string;
importPath: string;
disabled?: boolean;
}>();
const emit = defineEmits<{
start: [];
done: [ImportContactsResult];
fail: [string];
settled: [];
}>();
const input = ref<HTMLInputElement | null>(null);
const pending = ref<File | null>(null);
const preview = ref<ExcelPreviewResult | null>(null);
const mapping = reactive<Record<string, string>>({});
const submitting = ref(false);
const requiredKeys = computed(
() => preview.value?.fields.filter((f) => f.required).map((f) => f.key) ?? [],
);
const mappedFields = computed(() => new Set(Object.values(mapping).filter(Boolean)));
const canImport = computed(() => requiredKeys.value.every((key) => mappedFields.value.has(key)));
function pick() {
if (props.disabled || submitting.value) return;
input.value?.click();
}
function reset() {
pending.value = null;
preview.value = null;
submitting.value = false;
for (const key of Object.keys(mapping)) delete mapping[key];
}
function closeMapper() {
reset();
emit('settled');
}
function setColumnField(header: string, field: string) {
if (field) {
for (const [col, current] of Object.entries(mapping)) {
if (col !== header && current === field) mapping[col] = '';
}
}
mapping[header] = field;
}
async function postImport(file: File, map?: Record<string, string>) {
const form = new FormData();
form.append('file', file);
if (map && Object.values(map).some(Boolean)) {
form.append('mapping', JSON.stringify(map));
}
const { data } = await api.post<ImportContactsResult>(props.importPath, form, {
headers: { 'Content-Type': undefined },
});
return data;
}
function apiMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
async function onFile(ev: Event) {
const el = ev.target as HTMLInputElement;
const file = el.files?.[0];
el.value = '';
if (!file) return;
emit('start');
submitting.value = true;
try {
const form = new FormData();
form.append('file', file);
const { data } = await api.post<ExcelPreviewResult>(props.previewPath, form, {
headers: { 'Content-Type': undefined },
});
if (!data.needsMapping) {
emit('done', await postImport(file));
reset();
emit('settled');
return;
}
pending.value = file;
preview.value = data;
for (const col of data.columns) {
mapping[col.header] = data.suggested[col.header] ?? '';
}
} catch (e) {
emit('fail', apiMessage(e, 'خواندن فایل اکسل ناموفق بود'));
reset();
emit('settled');
} finally {
submitting.value = false;
}
}
async function confirmMapping() {
if (!pending.value || !canImport.value) return;
submitting.value = true;
try {
emit('done', await postImport(pending.value, { ...mapping }));
reset();
emit('settled');
} catch (e) {
emit('fail', apiMessage(e, 'ایمپورت ناموفق بود'));
submitting.value = false;
}
}
</script>
<template>
<span class="excel-import" @click.stop="pick">
<slot />
<input
ref="input"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
@change="onFile"
@click.stop
/>
</span>
<UiModal :open="!!preview" title="تطبیق ستون‌های اکسل" @close="closeMapper">
<p class="lead">
ستون‌های این فایل با قالب سیستم یکی نیست. مشخص کنید هر ستون فایل در کدام فیلد ریخته شود.
</p>
<div class="rows">
<div v-for="col in preview?.columns ?? []" :key="col.header" class="row">
<div class="source">
<strong>{{ col.header }}</strong>
<small v-if="col.samples.length">نمونه: {{ col.samples.join('، ') }}</small>
</div>
<UiSelect
:model-value="mapping[col.header] ?? ''"
@update:model-value="setColumnField(col.header, $event)"
>
<option value="">نادیده بگیر</option>
<option v-for="field in preview?.fields ?? []" :key="field.key" :value="field.key">
{{ field.label }}{{ field.required ? ' *' : '' }}
</option>
</UiSelect>
</div>
</div>
<p v-if="!canImport" class="hint">فیلدهای ستاره‌دار را حتماً به یک ستون وصل کنید.</p>
<template #footer>
<UiButton variant="ghost" :disabled="submitting" @click="closeMapper">انصراف</UiButton>
<UiButton :disabled="submitting || !canImport" @click="confirmMapping">
{{ submitting ? 'در حال ورود…' : 'ورود فایل' }}
</UiButton>
</template>
</UiModal>
</template>
<style scoped>
.excel-import {
display: inline-flex;
}
.lead {
margin: 0 0 1rem;
color: var(--muted);
line-height: 1.7;
}
.rows {
display: grid;
gap: 0.75rem;
}
.row {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: 0.75rem;
align-items: center;
}
.source {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.source strong {
font-size: 0.95rem;
}
.source small {
color: var(--muted);
font-size: 0.78rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hint {
margin: 0.85rem 0 0;
color: var(--danger);
font-size: 0.85rem;
}
@media (max-width: 700px) {
.row {
grid-template-columns: 1fr;
}
}
</style>
+12 -2
View File
@@ -78,8 +78,18 @@ let dragMoved = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
function mobileTabbarPx(): number {
if (typeof window === 'undefined' || viewport.value.w > 900) return 0;
const bar = document.querySelector('.mobile-tabbar');
if (bar instanceof HTMLElement) {
const h = bar.getBoundingClientRect().height;
if (h > 0) return h;
}
return 64;
}
function defaultPos(): FabPos {
const extra = viewport.value.w <= 900 ? 64 : 0;
const extra = mobileTabbarPx();
return {
left: FAB_MARGIN_LEFT,
top: Math.max(8, viewport.value.h - FAB_MARGIN_BOTTOM - extra - FAB_SIZE),
@@ -88,7 +98,7 @@ function defaultPos(): FabPos {
function clampPos(pos: FabPos): FabPos {
const margin = 8;
const bottomGap = viewport.value.w <= 900 ? 64 : 0;
const bottomGap = mobileTabbarPx();
return {
left: Math.min(Math.max(margin, pos.left), viewport.value.w - FAB_SIZE - margin),
top: Math.min(
+11
View File
@@ -55,6 +55,8 @@ defineProps<{
| 'grid'
| 'list'
| 'eye'
| 'eye-off'
| 'lock'
| 'truck'
| 'grip'
| 'apps-plus'
@@ -339,6 +341,15 @@ defineProps<{
<path d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="3" />
</template>
<template v-else-if="name === 'eye-off'">
<path d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="3" />
<path d="M4 20 20 4" />
</template>
<template v-else-if="name === 'lock'">
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
</template>
<template v-else-if="name === 'truck'">
<path d="M3 7.5h11V16H3z" />
<path d="M14 10h4.2l2.3 3.2V16H14z" />
+84 -165
View File
@@ -10,44 +10,37 @@ withDefaults(
}>(),
{
headline: 'همه ارتباطات، یکجا مدیریت می‌شوند',
description: `با ${brand.nameFa} فروش، پشتیبانی و ارتباط با مشتری را در یک پنل یکپارچه مدیریت کنید و رشد پایدار بسازید.`,
description: `${brand.nameFa} به شما کمک می کند فروش بیشتر داشته باشید،\nمشتریان را بهتر بشناسید و کسب و کارتان را رشد دهید.`,
},
);
</script>
<template>
<aside class="brand-panel" aria-hidden="false">
<div class="brand-head">
<MentoLogo :size="44" :show-text="false" />
<div class="brand-copy">
<BrandName />
<span>{{ brand.taglineFa }}</span>
<div class="brand-main">
<div class="brand-head">
<MentoLogo :size="70" :show-text="false" />
<div class="brand-copy">
<BrandName />
<span class="tagline">{{ brand.taglineFa }}</span>
</div>
</div>
<div class="brand-body">
<h2>{{ headline }}</h2>
<p>{{ description }}</p>
</div>
<div class="illustration" aria-hidden="true">
<img src="/brand/lagin_pic.png" alt="" />
</div>
</div>
<div class="brand-body">
<h2>{{ headline }}</h2>
<p>{{ description }}</p>
</div>
<div class="illustration" aria-hidden="true">
<div class="dash">
<div class="dash-top">
<span class="avatar" />
<span class="bar short" />
<span class="bar" />
</div>
<div class="chart-line" />
<div class="chart-bars">
<i /><i /><i /><i /><i />
</div>
</div>
<span class="float chat" />
<span class="float people" />
<span class="float check" />
<span class="float ring" />
<span class="plant" />
</div>
<nav class="brand-foot">
<a href="#">شرایط و قوانین</a>
<span>|</span>
<a href="#">تماس با ما</a>
</nav>
</aside>
</template>
@@ -56,184 +49,110 @@ withDefaults(
position: relative;
display: none;
flex-direction: column;
gap: 1.5rem;
padding: clamp(1.4rem, 3vw, 2rem);
background:
radial-gradient(circle at 20% 10%, rgba(45, 212, 191, 0.18), transparent 42%),
linear-gradient(165deg, #f0fdfa 0%, #e6fffa 48%, #f8fafc 100%);
align-items: center;
justify-content: center;
text-align: center;
gap: 0;
padding: 2.6rem clamp(1.4rem, 3vw, 2.4rem);
background-color: #f5fbfb;
background-image:
radial-gradient(ellipse 72% 48% at 50% 58%, rgba(45, 212, 191, 0.14), transparent 70%),
radial-gradient(circle at 12% 10%, rgba(20, 184, 166, 0.08), transparent 28%),
radial-gradient(rgba(13, 148, 136, 0.16) 1.05px, transparent 1.2px);
background-size: auto, auto, 20px 20px;
overflow: hidden;
min-width: 0;
min-height: 100%;
}
.brand-main {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.35rem;
}
.brand-head {
display: flex;
align-items: center;
gap: 0.75rem;
gap: 0.85rem;
}
.brand-copy {
display: grid;
gap: 0.1rem;
text-align: start;
}
.brand-copy strong {
font-size: 1.2rem;
.brand-copy :deep(.brand-name) {
font-size: 2rem;
font-weight: 800;
color: #0f172a;
font-weight: 600;
line-height: 1.1;
}
.brand-copy span {
font-size: 0.78rem;
.brand-copy .tagline {
font-size: 0.88rem;
color: #64748b;
}
.brand-body {
width: fit-content;
max-width: 100%;
margin-inline: auto;
}
.brand-body h2 {
margin: 0 0 0.55rem;
font-size: clamp(1.35rem, 2.2vw, 1.75rem);
margin: 0 0 0.6rem;
font-size: clamp(1.22rem, 1.95vw, 1.52rem);
line-height: 1.45;
color: #0f172a;
font-weight: 800;
white-space: nowrap;
}
.brand-body p {
margin: 0;
width: 100%;
color: #64748b;
line-height: 1.75;
font-size: 0.95rem;
max-width: 28ch;
line-height: 1.7;
font-size: 1.08rem;
white-space: pre-line;
}
.illustration {
position: relative;
margin-top: auto;
min-height: 220px;
flex: 1;
width: 100%;
display: grid;
place-items: center;
}
.dash {
.illustration img {
width: min(100%, 460px);
height: auto;
max-height: 360px;
object-fit: contain;
}
.brand-foot {
position: absolute;
inset-inline-start: 12%;
bottom: 18%;
width: min(78%, 280px);
aspect-ratio: 1.35;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.1);
padding: 1rem;
backdrop-filter: blur(6px);
}
.dash-top {
inset-inline: 0;
bottom: 1.15rem;
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.avatar {
width: 28px;
height: 28px;
border-radius: 999px;
background: linear-gradient(145deg, #5eead4, #0d9488);
}
.bar {
height: 8px;
border-radius: 999px;
background: #e2e8f0;
flex: 1;
}
.bar.short {
flex: 0.55;
}
.chart-line {
height: 54px;
border-radius: var(--radius);
background:
linear-gradient(180deg, transparent 55%, rgba(13, 148, 136, 0.12) 55%),
radial-gradient(circle at 20% 60%, #14b8a6 3px, transparent 4px),
radial-gradient(circle at 45% 35%, #14b8a6 3px, transparent 4px),
radial-gradient(circle at 70% 50%, #14b8a6 3px, transparent 4px),
radial-gradient(circle at 88% 28%, #14b8a6 3px, transparent 4px);
margin-bottom: 0.85rem;
}
.chart-bars {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 0.45rem;
height: 48px;
color: #94a3b8;
font-size: 0.8rem;
}
.chart-bars i {
flex: 1;
border-radius: 6px 6px 2px 2px;
background: linear-gradient(180deg, #5eead4, #0f766e);
opacity: 0.85;
.brand-foot a {
color: #94a3b8;
text-decoration: none;
}
.chart-bars i:nth-child(1) { height: 45%; }
.chart-bars i:nth-child(2) { height: 70%; }
.chart-bars i:nth-child(3) { height: 55%; }
.chart-bars i:nth-child(4) { height: 88%; }
.chart-bars i:nth-child(5) { height: 62%; }
.float {
position: absolute;
width: 42px;
height: 42px;
border-radius: var(--radius);
background: #fff;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
}
.float.chat {
top: 18%;
inset-inline-end: 8%;
background:
radial-gradient(circle at 35% 40%, #0d9488 0 28%, transparent 29%),
linear-gradient(145deg, #ecfdf5, #fff);
}
.float.people {
top: 42%;
inset-inline-start: 4%;
background:
radial-gradient(circle at 50% 38%, #14b8a6 0 22%, transparent 23%),
linear-gradient(145deg, #f0fdfa, #fff);
}
.float.check {
bottom: 28%;
inset-inline-end: 6%;
background:
linear-gradient(135deg, transparent 42%, #10b981 42% 58%, transparent 58%),
linear-gradient(45deg, transparent 48%, #10b981 48% 62%, transparent 62%),
#ecfdf5;
}
.float.ring {
top: 8%;
inset-inline-start: 18%;
border-radius: 999px;
background:
conic-gradient(#0d9488 0 70%, #e2e8f0 70% 100%);
mask: radial-gradient(farthest-side, transparent calc(100% - 7px), #000 calc(100% - 6px));
}
.plant {
position: absolute;
bottom: 8%;
inset-inline-start: 8%;
width: 28px;
height: 36px;
background:
radial-gradient(ellipse at 50% 20%, #34d399 0 35%, transparent 36%),
linear-gradient(180deg, #0d9488 0 55%, #134e4a 55%);
border-radius: 40% 40% 8px 8px;
opacity: 0.9;
.brand-foot a:hover {
color: #0f766e;
}
@media (min-width: 900px) {
+1 -1
View File
@@ -96,7 +96,7 @@ const model = defineModel<string | number>({ default: '' });
}
.ui-input::placeholder {
color: var(--muted-light);
color: #c0c8d4;
}
.ui-input-wrapper:focus-within {
+8 -8
View File
@@ -67,7 +67,8 @@ function onBackdrop(event: MouseEvent) {
.dialog {
width: min(880px, 100%);
max-height: min(88vh, 880px);
max-height: min(88dvh, 88vh, 880px);
min-height: 0;
display: flex;
flex-direction: column;
background: #fff;
@@ -119,9 +120,10 @@ function onBackdrop(event: MouseEvent) {
.body {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding: 16px;
box-sizing: border-box;
}
@@ -130,10 +132,8 @@ function onBackdrop(event: MouseEvent) {
.body :deep(.page.embedded),
.body :deep(.product-form-page.embedded),
.body :deep(.company-form-page.embedded) {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 100%;
min-height: 0;
max-width: 100%;
}
.body :deep(.page.embedded > .form),
@@ -141,7 +141,6 @@ function onBackdrop(event: MouseEvent) {
.body :deep(.crm-page.embedded > .form),
.body :deep(.crm-page.embedded > form),
.body :deep(.crm-page.embedded > .crm-form) {
flex: 1 1 auto;
min-height: 0;
}
@@ -209,6 +208,7 @@ function onBackdrop(event: MouseEvent) {
}
.body {
flex: 1 1 0%;
padding: 16px;
}
+1 -1
View File
@@ -56,7 +56,7 @@ const model = defineModel<string>({ default: '' });
}
.ui-textarea::placeholder {
color: var(--muted-light);
color: #c0c8d4;
}
.ui-textarea:focus {
+424
View File
@@ -0,0 +1,424 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router';
import { usePlatformAuthStore } from '@/stores/platform-auth';
import UiIcon from '@/components/UiIcon.vue';
import MentoLogo from '@/components/brand/MentoLogo.vue';
import BrandName from '@/components/brand/BrandName.vue';
const platform = usePlatformAuthStore();
const router = useRouter();
const route = useRoute();
const displayName = computed(() => platform.admin?.fullName || 'سوپرادمین');
const pageTitle = computed(() => {
switch (route.name) {
case 'adm-dashboard':
return 'داشبورد';
case 'adm-organizations':
return 'محیط‌ها';
case 'adm-users':
return 'کاربران';
case 'adm-admins':
return 'ادمین‌ها';
default:
return 'سوپرادمین';
}
});
const tabs = [
{ name: 'adm-dashboard', label: 'خانه', icon: 'home' as const },
{ name: 'adm-organizations', label: 'محیط‌ها', icon: 'building' as const },
{ name: 'adm-users', label: 'کاربران', icon: 'users' as const },
{ name: 'adm-admins', label: 'ادمین‌ها', icon: 'lock' as const },
];
function onLogout() {
platform.logout();
void router.push({ name: 'adm-login' });
}
</script>
<template>
<div class="adm-shell">
<aside class="sidebar desktop-only">
<div class="brand">
<div class="brand-mark">
<MentoLogo :size="36" :show-text="false" />
</div>
<div class="brand-copy">
<BrandName />
<span>کنترل پلتفرم</span>
</div>
</div>
<nav class="nav" aria-label="منوی سوپرادمین">
<RouterLink
v-for="tab in tabs"
:key="tab.name"
:to="{ name: tab.name }"
class="nav-item"
>
<span class="nav-ico" aria-hidden="true">
<UiIcon :name="tab.icon" :size="18" />
</span>
<span>{{ tab.label }}</span>
</RouterLink>
</nav>
<div class="side-foot">
<div class="who-card">
<span class="who-avatar" aria-hidden="true">
{{ displayName.slice(0, 1) }}
</span>
<div class="who-meta">
<strong>{{ displayName }}</strong>
<small>سوپرادمین</small>
</div>
</div>
<button type="button" class="logout" @click="onLogout">
<UiIcon name="logout" :size="16" />
<span>خروج</span>
</button>
</div>
</aside>
<div class="stage">
<header class="topbar">
<div class="top-brand mobile-only">
<MentoLogo :size="28" :show-text="false" />
<div>
<strong>{{ pageTitle }}</strong>
<small>سوپرادمین</small>
</div>
</div>
<div class="top-desktop desktop-only">
<h1>{{ pageTitle }}</h1>
<p>مدیریت حساب‌ها، اشتراک و ماژول‌ها</p>
</div>
<button type="button" class="logout-top mobile-only" @click="onLogout">
<UiIcon name="logout" :size="18" />
</button>
</header>
<main class="main">
<RouterView />
</main>
</div>
<nav class="tabbar" aria-label="ناوبری پایین">
<RouterLink
v-for="tab in tabs"
:key="tab.name"
:to="{ name: tab.name }"
class="tab"
>
<UiIcon :name="tab.icon" :size="20" />
<span>{{ tab.label }}</span>
</RouterLink>
</nav>
</div>
</template>
<style scoped>
.adm-shell {
min-height: 100vh;
min-height: 100dvh;
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
background:
radial-gradient(ellipse 70% 45% at 100% -10%, rgba(20, 184, 166, 0.14), transparent 55%),
radial-gradient(ellipse 50% 35% at 0% 100%, rgba(14, 165, 233, 0.08), transparent 50%),
linear-gradient(180deg, #f4fbfa 0%, var(--bg) 40%);
}
.desktop-only {
display: flex;
}
.mobile-only {
display: none;
}
.sidebar {
flex-direction: column;
gap: 1.35rem;
padding: 1.25rem 1rem;
background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(12px);
border-inline-end: 1px solid color-mix(in srgb, var(--line) 80%, transparent);
box-shadow: 10px 0 40px rgba(15, 23, 42, 0.04);
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.35rem 0.4rem;
}
.brand-mark {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 16px;
background: linear-gradient(145deg, var(--accent-subtle), #fff);
border: 1px solid color-mix(in srgb, var(--accent) 18%, white);
box-shadow: 0 8px 20px rgba(13, 148, 136, 0.08);
}
.brand-copy {
display: grid;
min-width: 0;
gap: 0.1rem;
}
.brand-copy span {
color: var(--muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: -0.01em;
}
.nav {
display: grid;
gap: 0.35rem;
flex: 1;
align-content: start;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.72rem 0.8rem;
border-radius: 14px;
color: var(--ink-secondary);
text-decoration: none;
font-weight: 750;
font-size: 0.92rem;
transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.9);
color: var(--ink);
}
.nav-ico {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 11px;
background: var(--surface-subtle);
color: var(--muted);
}
.nav-item.router-link-active {
background: linear-gradient(135deg, var(--accent-subtle), #fff);
color: var(--accent-deep);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, white);
}
.nav-item.router-link-active .nav-ico {
background: var(--accent);
color: #fff;
}
.side-foot {
display: grid;
gap: 0.65rem;
padding-top: 0.35rem;
border-top: 1px solid var(--line-light);
}
.who-card {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem;
border-radius: 14px;
background: #fff;
border: 1px solid var(--line-light);
}
.who-avatar {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--accent-soft);
color: var(--accent-deep);
font-weight: 900;
}
.who-meta {
display: grid;
min-width: 0;
}
.who-meta strong {
font-size: 0.86rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.who-meta small {
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
}
.logout {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
border: 1px solid var(--line);
background: #fff;
color: var(--muted);
font: inherit;
font-size: 0.82rem;
font-weight: 750;
cursor: pointer;
padding: 0.55rem 0.75rem;
border-radius: 12px;
}
.logout:hover {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 30%, white);
}
.stage {
min-width: 0;
display: flex;
flex-direction: column;
min-height: 100dvh;
}
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.5rem 0.85rem;
background: color-mix(in srgb, #f4fbfa 78%, transparent);
backdrop-filter: blur(14px);
border-bottom: 1px solid transparent;
}
.top-desktop h1 {
margin: 0;
font-size: 1.35rem;
letter-spacing: -0.02em;
}
.top-desktop p {
margin: 0.2rem 0 0;
color: var(--muted);
font-size: 0.84rem;
}
.top-brand {
display: flex;
align-items: center;
gap: 0.65rem;
}
.top-brand strong {
display: block;
font-size: 1rem;
}
.top-brand small {
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
}
.logout-top {
border: 0;
background: #fff;
width: 40px;
height: 40px;
border-radius: 12px;
display: grid;
place-items: center;
color: var(--muted);
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
cursor: pointer;
}
.main {
flex: 1;
min-width: 0;
padding: 0.4rem 1.5rem 2rem;
}
.tabbar {
display: none;
position: sticky;
bottom: 0;
z-index: 30;
grid-template-columns: repeat(4, 1fr);
gap: 0.15rem;
padding: 0.45rem 0.55rem calc(0.45rem + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(16px);
border-top: 1px solid var(--line-light);
box-shadow: 0 -10px 30px rgba(15, 23, 42, 0.06);
}
.tab {
display: grid;
justify-items: center;
gap: 0.15rem;
padding: 0.45rem 0.2rem;
border-radius: 12px;
text-decoration: none;
color: var(--muted);
font-size: 0.68rem;
font-weight: 800;
}
.tab.router-link-active {
color: var(--accent-deep);
background: var(--accent-subtle);
}
@media (max-width: 860px) {
.adm-shell {
grid-template-columns: 1fr;
}
.desktop-only {
display: none !important;
}
.mobile-only {
display: flex;
}
.topbar {
padding: 0.75rem 0.9rem;
border-bottom: 1px solid var(--line-light);
}
.main {
padding: 0.75rem 0.9rem calc(1.2rem + env(safe-area-inset-bottom));
}
.tabbar {
display: grid;
}
}
</style>
+60 -5
View File
@@ -79,17 +79,43 @@ const moreSectionActive = computed(
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>(() => {
if (!openHubKey.value) return null;
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
if (!hub) return null;
return {
...hub,
cards: hub.cards.filter((card) => !card.admin || canSettings.value),
shortcuts: hub.shortcuts.filter((item) => !item.admin || canSettings.value),
cards: hub.cards.filter(
(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 {
const parts = fullName.trim().split(/\s+/).filter(Boolean);
if (parts.length <= 1) return fullName.trim();
@@ -368,7 +394,7 @@ onUnmounted(() => {
<span>داشبورد</span>
</RouterLink>
<div class="nav-fold" :class="{ active: customersActive || openHubKey === 'customers' }">
<div class="nav-fold" v-if="hubVisible.customers" :class="{ active: customersActive || openHubKey === 'customers' }">
<button
class="nav-item nav-fold-btn"
type="button"
@@ -383,7 +409,7 @@ onUnmounted(() => {
</button>
</div>
<div class="nav-fold" :class="{ active: salesActive || openHubKey === 'sales' }">
<div class="nav-fold" v-if="hubVisible.sales" :class="{ active: salesActive || openHubKey === 'sales' }">
<button
class="nav-item nav-fold-btn"
type="button"
@@ -398,7 +424,7 @@ onUnmounted(() => {
</button>
</div>
<div class="nav-fold" :class="{ active: activitiesActive || openHubKey === 'activities' }">
<div class="nav-fold" v-if="hubVisible.activities" :class="{ active: activitiesActive || openHubKey === 'activities' }">
<button
class="nav-item nav-fold-btn"
type="button"
@@ -600,6 +626,17 @@ onUnmounted(() => {
<RouterLink class="round-btn" :to="{ name: 'tickets' }" title="پیام‌ها">
<UiIcon name="mail" :size="18" />
</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">
<UiIcon name="calendar" :size="16" />
<span>{{ todayLabel }}</span>
@@ -1601,6 +1638,24 @@ onUnmounted(() => {
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 {
display: inline-flex;
align-items: center;
+1 -1
View File
@@ -26,7 +26,7 @@ api.interceptors.response.use(
(error) => {
if (axios.isAxiosError(error) && error.response?.status === 401) {
const path = window.location.pathname;
if (path.startsWith('/p/')) {
if (path.startsWith('/p/') || path.startsWith('/adm')) {
return Promise.reject(error);
}
localStorage.removeItem('mento_token');
+57 -14
View File
@@ -1,3 +1,5 @@
import type { OrgModuleKey } from '@mento/shared';
export type NavHubKey =
| 'customers'
| 'sales'
@@ -43,6 +45,7 @@ export type NavHubCard = {
icon: NavIconName;
tint: NavHubTint;
admin?: boolean;
module?: OrgModuleKey;
};
export type NavHubShortcut = {
@@ -50,6 +53,7 @@ export type NavHubShortcut = {
title: string;
icon: NavIconName;
admin?: boolean;
module?: OrgModuleKey;
};
export type NavHubDef = {
@@ -70,8 +74,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'users',
tint: 'mint',
shortcuts: [
{ to: '/contacts/people/new', title: 'شخص جدید', icon: 'user-plus' },
{ to: '/contacts/companies/new', title: 'شرکت جدید', icon: 'building' },
{ to: '/contacts/people/new', title: 'شخص جدید', icon: 'user-plus', module: 'customers' },
{ to: '/contacts/companies/new', title: 'شرکت جدید', icon: 'building', module: 'customers' },
],
cards: [
{
@@ -80,6 +84,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'مخاطبان حقیقی و پرونده‌های مشتری',
icon: 'user',
tint: 'mint',
module: 'customers',
},
{
to: '/contacts?tab=companies',
@@ -87,6 +92,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'سازمان‌ها و مشتریان حقوقی',
icon: 'building',
tint: 'violet',
module: 'customers',
},
],
},
@@ -97,8 +103,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'briefcase',
tint: 'mint',
shortcuts: [
{ to: '/deals/new', title: 'معامله جدید', icon: 'plus' },
{ to: '/quotes/new', title: 'پیش‌فاکتور جدید', icon: 'document' },
{ to: '/deals/new', title: 'معامله جدید', icon: 'plus', module: 'pipeline' },
{ to: '/quotes/new', title: 'پیش‌فاکتور جدید', icon: 'document', module: 'quotes' },
],
cards: [
{
@@ -107,6 +113,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'سرنخ‌ها، معاملات و مراحل فروش',
icon: 'filter',
tint: 'mint',
module: 'pipeline',
},
{
to: '/settings/products',
@@ -115,6 +122,7 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'box',
tint: 'peach',
admin: true,
module: 'products',
},
{
to: '/invoices',
@@ -122,6 +130,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'صدور فاکتور و پیگیری پرداخت‌ها',
icon: 'mail',
tint: 'sky',
module: 'invoices',
},
{
to: '/quotes',
@@ -129,6 +138,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'پیشنهاد قیمت و پیگیری تایید',
icon: 'document',
tint: 'amber',
module: 'quotes',
},
{
to: '/commissions',
@@ -136,6 +146,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'سهم فروشندگان از فاکتورهای صادرشده',
icon: 'dollar',
tint: 'rose',
module: 'commissions',
},
{
to: '/sales/forecast',
@@ -143,6 +154,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'تعیین اهداف و تحلیل عملکرد فروش',
icon: 'target',
tint: 'mint',
module: 'sales_forecast',
},
],
},
@@ -153,8 +165,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'check',
tint: 'sky',
shortcuts: [
{ to: '/activities/new', title: 'فعالیت جدید', icon: 'plus' },
{ to: '/activities/calendar', title: 'تقویم', icon: 'calendar' },
{ to: '/activities/new', title: 'فعالیت جدید', icon: 'plus', module: 'activities' },
{ to: '/activities/calendar', title: 'تقویم', icon: 'calendar', module: 'activities' },
],
cards: [
{
@@ -163,6 +175,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'کارها، تماس‌ها و پیگیری‌های جاری',
icon: 'check',
tint: 'mint',
module: 'activities',
},
{
to: '/activities/calendar',
@@ -170,6 +183,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'نمای روز، هفته و زمان‌بندی کارها',
icon: 'calendar',
tint: 'violet',
module: 'activities',
},
],
},
@@ -180,8 +194,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'chat',
tint: 'peach',
shortcuts: [
{ to: '/tickets/new', title: 'تیکت جدید', icon: 'plus' },
{ to: '/tickets', title: 'تیکت‌ها', icon: 'chat' },
{ to: '/tickets/new', title: 'تیکت جدید', icon: 'plus', module: 'tickets' },
{ to: '/tickets', title: 'تیکت‌ها', icon: 'chat', module: 'tickets' },
],
cards: [
{
@@ -190,6 +204,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'درخواست‌ها و پیام‌های پشتیبانی',
icon: 'chat',
tint: 'peach',
module: 'tickets',
},
],
},
@@ -200,8 +215,18 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'sparkle',
tint: 'violet',
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: [
{
@@ -211,6 +236,7 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'layers',
tint: 'violet',
admin: true,
module: 'workflow_automations',
},
{
to: '/settings/message-automations',
@@ -219,6 +245,7 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'send',
tint: 'sky',
admin: true,
module: 'message_automations',
},
],
},
@@ -229,8 +256,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'apps-plus',
tint: 'violet',
shortcuts: [
{ to: '/project-cards/new', title: 'کارت پروژه جدید', icon: 'plus' },
{ to: '/cheques', title: 'ثبت چک', icon: 'document' },
{ to: '/project-cards/new', title: 'کارت پروژه جدید', icon: 'plus', module: 'project_boards' },
{ to: '/cheques', title: 'ثبت چک', icon: 'document', module: 'cheques' },
],
cards: [
{
@@ -240,6 +267,7 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'layers',
tint: 'mint',
admin: true,
module: 'lead_forms',
},
{
to: '/projects',
@@ -247,6 +275,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'کارت‌ها و مراحل اجرای کار',
icon: 'flag',
tint: 'violet',
module: 'project_boards',
},
{
to: '/inventory',
@@ -254,6 +283,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'موجودی کالا و گردش داخلی',
icon: 'box',
tint: 'peach',
module: 'inventory',
},
{
to: '/cheques',
@@ -261,6 +291,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'چک‌های دریافتی و پرداختی',
icon: 'document',
tint: 'sky',
module: 'cheques',
},
{
to: '/attachments',
@@ -268,6 +299,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'فایل‌های متصل به یادداشت‌ها',
icon: 'document',
tint: 'amber',
module: 'attachments',
},
],
},
@@ -278,8 +310,13 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'settings',
tint: 'sky',
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: [
{
@@ -288,6 +325,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'نام سازمان و تنظیمات پایه',
icon: 'building',
tint: 'mint',
module: 'team_settings',
},
{
to: '/settings/team#settings-team',
@@ -295,6 +333,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'اعضا، دسترسی و نقش‌ها',
icon: 'users',
tint: 'violet',
module: 'team_settings',
},
{
to: '/settings/custom-fields',
@@ -302,6 +341,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'فیلدهای اضافه روی پرونده‌ها',
icon: 'pencil',
tint: 'peach',
module: 'custom_fields',
},
{
to: '/settings/currencies',
@@ -309,6 +349,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'ارز پایه و نرخ تبدیل',
icon: 'dollar',
tint: 'sky',
module: 'currencies',
},
{
to: '/settings/tahesab',
@@ -316,6 +357,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'اتصال مالی و تنظیمات حسابداری',
icon: 'chart',
tint: 'amber',
module: 'tahesab',
},
{
to: '/settings/faq',
@@ -323,6 +365,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'پرسش‌های پرتکرار سازمان',
icon: 'lightbulb',
tint: 'rose',
module: 'faq',
},
],
},
+31
View File
@@ -0,0 +1,31 @@
import axios from 'axios';
export const platformApi = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});
platformApi.interceptors.request.use((config) => {
const token = localStorage.getItem('mento_platform_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
platformApi.interceptors.response.use(
(response) => response,
(error) => {
if (axios.isAxiosError(error) && error.response?.status === 401) {
const path = window.location.pathname;
localStorage.removeItem('mento_platform_token');
localStorage.removeItem('mento_platform_admin');
if (path.startsWith('/adm') && path !== '/adm') {
window.location.assign('/adm');
}
}
return Promise.reject(error);
},
);
+44
View File
@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router';
import type { UserRole } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { usePlatformAuthStore } from '@/stores/platform-auth';
export const router = createRouter({
history: createWebHistory(),
@@ -29,6 +30,39 @@ export const router = createRouter({
component: () => import('@/views/PortalView.vue'),
meta: { public: true },
},
{
path: '/adm',
name: 'adm-login',
component: () => import('@/views/adm/AdmLoginView.vue'),
meta: { public: true, platformPublic: true },
},
{
path: '/adm',
component: () => import('@/layouts/AdmLayout.vue'),
meta: { public: true, platform: true },
children: [
{
path: 'dashboard',
name: 'adm-dashboard',
component: () => import('@/views/adm/AdmDashboardView.vue'),
},
{
path: 'organizations',
name: 'adm-organizations',
component: () => import('@/views/adm/AdmOrganizationsView.vue'),
},
{
path: 'users',
name: 'adm-users',
component: () => import('@/views/adm/AdmUsersView.vue'),
},
{
path: 'admins',
name: 'adm-admins',
component: () => import('@/views/adm/AdmAdminsView.vue'),
},
],
},
{
path: '/',
component: () => import('@/layouts/AppLayout.vue'),
@@ -291,6 +325,16 @@ export const router = createRouter({
});
router.beforeEach((to) => {
if (to.path === '/adm' || to.path.startsWith('/adm/')) {
const platform = usePlatformAuthStore();
if (to.meta.platformPublic) {
if (platform.isAuthenticated) return { name: 'adm-dashboard' };
return true;
}
if (!platform.isAuthenticated) return { name: 'adm-login' };
return true;
}
const auth = useAuthStore();
if (!to.meta.public && !auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } };
+67
View File
@@ -0,0 +1,67 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import type { PlatformAdminDto, PlatformLoginResponse } from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
const TOKEN_KEY = 'mento_platform_token';
const ADMIN_KEY = 'mento_platform_admin';
function readStoredAdmin(): PlatformAdminDto | null {
const raw = localStorage.getItem(ADMIN_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as PlatformAdminDto;
} catch {
return null;
}
}
export const usePlatformAuthStore = defineStore('platform-auth', () => {
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY));
const admin = ref<PlatformAdminDto | null>(readStoredAdmin());
const isAuthenticated = computed(() => Boolean(token.value));
function persist(payload: PlatformLoginResponse) {
token.value = payload.accessToken;
admin.value = payload.admin;
localStorage.setItem(TOKEN_KEY, payload.accessToken);
localStorage.setItem(ADMIN_KEY, JSON.stringify(payload.admin));
}
async function login(mobile: string, password: string) {
const { data } = await platformApi.post<PlatformLoginResponse>(
'/platform/auth/login',
{ mobile, password },
);
persist(data);
}
function logout() {
token.value = null;
admin.value = null;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(ADMIN_KEY);
}
async function hydrate() {
if (!token.value) return;
try {
const { data } = await platformApi.get<PlatformAdminDto>('/platform/auth/me');
admin.value = data;
localStorage.setItem(ADMIN_KEY, JSON.stringify(data));
} catch {
logout();
}
}
if (token.value) void hydrate();
return {
token,
admin,
isAuthenticated,
login,
logout,
hydrate,
};
});
+11
View File
@@ -2,6 +2,8 @@
@import "./tokens.css";
:root {
--mobile-tabbar-h: 0px;
/* Surface & Background */
--bg: #f8fafc;
--surface: #ffffff;
@@ -245,12 +247,21 @@ textarea {
border-color: var(--accent);
}
input::placeholder,
textarea::placeholder {
color: #c0c8d4;
}
.error {
color: var(--danger);
font-size: 0.9rem;
}
@media (max-width: 900px) {
:root {
--mobile-tabbar-h: calc(3.65rem + env(safe-area-inset-bottom, 0px));
}
h1 {
font-size: clamp(1.35rem, 5vw, 1.75rem);
}
+36 -46
View File
@@ -17,6 +17,7 @@ import {
UiPagination,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import ActivityFormView from '@/views/ActivityFormView.vue';
import type {
ActivityDto,
@@ -78,7 +79,6 @@ const activityModal = ref<{
const menuId = ref<string | null>(null);
const busy = ref(false);
const importMsg = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const counts = ref<Record<ActivityFilter, number>>({
today: 0,
overdue: 0,
@@ -286,44 +286,31 @@ async function exportExcel() {
}
}
function pickImportFile() {
fileInput.value?.click();
}
async function onImportFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
function onImportStart() {
busy.value = true;
importMsg.value = '';
error.value = '';
try {
const form = new FormData();
form.append('file', file);
const { data } = await api.post<ImportContactsResult>('/activities/import', form, {
headers: { 'Content-Type': undefined },
});
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
}`;
if (data.errors.length) {
error.value = data.errors
.slice(0, 3)
.map((e) => `ردیف ${e.row}: ${e.message}`)
.join(' | ');
}
await Promise.all([load(), loadCounts()]);
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ایمپورت ناموفق بود');
} else {
error.value = 'ایمپورت ناموفق بود';
}
} finally {
busy.value = false;
}
async function onImportDone(data: ImportContactsResult) {
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
}`;
if (data.errors.length) {
error.value = data.errors
.slice(0, 3)
.map((e) => `ردیف ${e.row}: ${e.message}`)
.join(' | ');
}
await Promise.all([load(), loadCounts()]);
}
function onImportFail(message: string) {
error.value = message;
}
function onImportSettled() {
busy.value = false;
}
watch(
@@ -357,17 +344,20 @@ onMounted(() => {
<span class="btn-full">خروجی اکسل</span>
<span class="btn-short">خروجی</span>
</UiButton>
<UiButton variant="ghost" type="button" :disabled="busy" @click.stop="pickImportFile">
<span class="btn-full">ورود از اکسل</span>
<span class="btn-short">ورود</span>
</UiButton>
<input
ref="fileInput"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
@change="onImportFile"
/>
<ExcelImportFlow
preview-path="/activities/import/preview"
import-path="/activities/import"
:disabled="busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<UiButton variant="ghost" type="button" :disabled="busy">
<span class="btn-full">ورود از اکسل</span>
<span class="btn-short">ورود</span>
</UiButton>
</ExcelImportFlow>
<UiButton variant="primary" @click="activityModal = {}">
<UiIcon name="plus" :size="16" />
فعالیت جدید
+6 -1
View File
@@ -319,6 +319,7 @@ onMounted(load);
margin: 0;
display: grid;
gap: 0.9rem;
padding-bottom: 5.5rem;
}
.nav {
@@ -572,7 +573,7 @@ dd {
.dock {
position: fixed;
inset-inline: 0.85rem;
bottom: calc(0.55rem + env(safe-area-inset-bottom));
bottom: calc(0.55rem + var(--mobile-tabbar-h));
display: grid;
grid-template-columns: 1.15fr 1fr 1fr;
gap: 0.5rem;
@@ -607,6 +608,10 @@ dd {
}
@media (min-width: 900px) {
.page {
padding-bottom: 0;
}
.dock {
position: sticky;
inset-inline: auto;
+6 -1
View File
@@ -661,6 +661,10 @@ watch([id, isNew], () => void load(), { immediate: true });
</template>
<style scoped>
.crm-page:not(.embedded) {
padding-bottom: 5rem;
}
.crm-page.embedded {
padding-bottom: 0;
}
@@ -1142,7 +1146,7 @@ watch([id, isNew], () => void load(), { immediate: true });
.mobile-save {
position: fixed;
inset-inline: 0.85rem;
bottom: calc(0.55rem + env(safe-area-inset-bottom));
bottom: calc(0.55rem + var(--mobile-tabbar-h));
z-index: 8;
}
@@ -1154,6 +1158,7 @@ watch([id, isNew], () => void load(), { immediate: true });
}
@media (min-width: 960px) {
.crm-page:not(.embedded),
.page {
padding-bottom: 1rem;
gap: 1rem;
+42 -47
View File
@@ -5,6 +5,7 @@ import axios from 'axios';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import {
UiButton,
UiModal,
@@ -52,7 +53,6 @@ const error = ref('');
const importMsg = ref('');
const people = ref<PaginatedResponse<PersonDto> | null>(null);
const companies = ref<PaginatedResponse<CompanyDto> | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const menuId = ref<string | null>(null);
const sortOpen = ref(false);
const filterOpen = ref(false);
@@ -187,46 +187,38 @@ async function exportExcel() {
}
}
function pickImportFile() {
fileInput.value?.click();
}
const importPreviewPath = computed(() =>
tab.value === 'people' ? '/people/import/preview' : '/companies/import/preview',
);
const importPath = computed(() =>
tab.value === 'people' ? '/people/import' : '/companies/import',
);
async function onImportFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
function onImportStart() {
busy.value = true;
importMsg.value = '';
error.value = '';
try {
const form = new FormData();
form.append('file', file);
const path = tab.value === 'people' ? '/people/import' : '/companies/import';
const { data } = await api.post<ImportContactsResult>(path, form, {
headers: { 'Content-Type': undefined },
});
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
}`;
if (data.errors.length) {
const sample = data.errors
.slice(0, 3)
.map((e) => `ردیف ${e.row}: ${e.message}`)
.join(' | ');
error.value = sample;
}
await load();
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ایمپورت ناموفق بود');
} else {
error.value = 'ایمپورت ناموفق بود';
}
} finally {
busy.value = false;
}
async function onImportDone(data: ImportContactsResult) {
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
}`;
if (data.errors.length) {
error.value = data.errors
.slice(0, 3)
.map((e) => `ردیف ${e.row}: ${e.message}`)
.join(' | ');
}
await load();
}
function onImportFail(message: string) {
error.value = message;
}
function onImportSettled() {
busy.value = false;
}
watch(
@@ -251,17 +243,20 @@ onMounted(load);
<span class="btn-full">خروجی اکسل</span>
<span class="btn-short">خروجی</span>
</UiButton>
<UiButton variant="ghost" size="sm" :disabled="busy" @click.stop="pickImportFile">
<span class="btn-full">ورود از اکسل</span>
<span class="btn-short">ورود</span>
</UiButton>
<input
ref="fileInput"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
@change="onImportFile"
/>
<ExcelImportFlow
:preview-path="importPreviewPath"
:import-path="importPath"
:disabled="busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<UiButton variant="ghost" size="sm" :disabled="busy">
<span class="btn-full">ورود از اکسل</span>
<span class="btn-short">ورود</span>
</UiButton>
</ExcelImportFlow>
<UiButton
variant="primary"
@click="contactModal = tab === 'companies' ? 'company' : 'person'"
+2 -1
View File
@@ -791,6 +791,7 @@ onMounted(load);
width: 100%;
max-width: 100%;
min-width: 0;
padding-bottom: 5rem;
}
.page.embedded {
@@ -1391,7 +1392,7 @@ h1 {
.mobile-save {
position: fixed;
inset-inline: 0.85rem;
bottom: calc(0.55rem + env(safe-area-inset-bottom));
bottom: calc(0.55rem + var(--mobile-tabbar-h));
z-index: 8;
display: grid;
grid-template-columns: 1fr 1.2fr;
+1 -1
View File
@@ -2362,7 +2362,7 @@ h1 {
align-items: center;
gap: 0.65rem;
position: sticky;
bottom: 0.5rem;
bottom: calc(0.5rem + var(--mobile-tabbar-h));
padding: 0.35rem 0;
background: linear-gradient(to top, #f8fafc 70%, transparent);
}
+1 -1
View File
@@ -857,7 +857,7 @@ tbody tr:nth-child(even) { background: #fcfdff; }
.page-btn.on { background: var(--teal); color: #fff; border-color: transparent; }
.fab {
display: none; position: fixed; left: 1rem; right: 1rem; bottom: 1rem; z-index: 40;
display: none; position: fixed; left: 1rem; right: 1rem; bottom: calc(1rem + var(--mobile-tabbar-h)); z-index: 40;
background: var(--teal); color: #fff; text-align: center; text-decoration: none;
border-radius: var(--radius); padding: 0.9rem; font-weight: 600;
box-shadow: 0 12px 28px rgba(13,148,136,.35);
+96 -45
View File
@@ -9,6 +9,7 @@ import type { LoginChallengeResponse, LoginOrganizationChoiceResponse } from '@m
import { USER_ROLE_LABELS } from '@/lib/roles';
import { brand } from '@mento/shared';
import BrandName from '@/components/brand/BrandName.vue';
import UiIcon from '@/components/UiIcon.vue';
const auth = useAuthStore();
const router = useRouter();
@@ -18,8 +19,8 @@ const route = useRoute();
const mode = ref<'password' | 'otp'>('password');
const otpStep = ref<'mobile' | 'code'>('mobile');
const identifier = ref('owner@demo.local');
const password = ref('demo1234');
const identifier = ref('');
const password = ref('');
const mobile = ref('');
const showPassword = ref(false);
const remember = ref(true);
@@ -209,15 +210,17 @@ function cancelOrgChoice() {
required
autocomplete="username"
placeholder="ایمیل یا شماره موبایل خود را وارد کنید"
dir="ltr"
class="ltr-value"
/>
<span class="ico user" aria-hidden="true" />
<span class="field-ico" aria-hidden="true">
<UiIcon name="user" :size="18" />
</span>
</div>
</div>
<div class="field">
<label for="password">رمز عبور</label>
<div class="input-wrap">
<div class="input-wrap has-eye">
<input
id="password"
v-model="password"
@@ -225,16 +228,18 @@ function cancelOrgChoice() {
required
autocomplete="current-password"
placeholder="رمز عبور خود را وارد کنید"
dir="ltr"
class="ltr-value"
/>
<span class="ico lock" aria-hidden="true" />
<span class="field-ico" aria-hidden="true">
<UiIcon name="lock" :size="18" />
</span>
<button
type="button"
class="eye"
:aria-label="showPassword ? 'مخفی کردن رمز' : 'نمایش رمز'"
@click="showPassword = !showPassword"
>
{{ showPassword ? 'مخفی' : 'نمایش' }}
<UiIcon :name="showPassword ? 'eye-off' : 'eye'" :size="18" />
</button>
</div>
</div>
@@ -266,9 +271,11 @@ function cancelOrgChoice() {
required
autocomplete="tel"
placeholder="۰۹۱۲۱۲۳۴۵۶۷"
dir="ltr"
class="ltr-value"
/>
<span class="ico user" aria-hidden="true" />
<span class="field-ico" aria-hidden="true">
<UiIcon name="phone" :size="18" />
</span>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
@@ -297,7 +304,7 @@ function cancelOrgChoice() {
required
autocomplete="one-time-code"
placeholder="کد را وارد کنید"
dir="ltr"
class="ltr-value"
/>
</div>
</div>
@@ -339,21 +346,22 @@ function cancelOrgChoice() {
padding: 1rem;
padding-bottom: max(1rem, env(safe-area-inset-bottom));
background:
radial-gradient(circle at 90% 0%, rgba(13, 148, 136, 0.08), transparent 36%),
var(--bg);
radial-gradient(ellipse 70% 45% at 50% 0%, rgba(45, 212, 191, 0.12), transparent 55%),
radial-gradient(rgba(13, 148, 136, 0.12) 1px, transparent 1.2px),
#f5fbfb;
background-size: auto, 20px 20px, auto;
}
.auth-shell {
flex: 1;
width: min(1040px, 100%);
width: min(1100px, 100%);
margin: 0 auto;
display: grid;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
background: transparent;
border: 0;
box-shadow: none;
overflow: hidden;
min-height: min(640px, calc(100dvh - 5.5rem));
min-height: min(680px, calc(100dvh - 5.5rem));
}
.form-panel {
@@ -361,6 +369,9 @@ function cancelOrgChoice() {
flex-direction: column;
padding: clamp(1.2rem, 3vw, 2.4rem);
min-width: 0;
background: #fff;
border-radius: 28px;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.06);
}
.mobile-brand {
@@ -434,6 +445,22 @@ h1 {
gap: 0.95rem;
}
.form input {
direction: rtl;
text-align: right;
}
.form input::placeholder {
direction: rtl;
text-align: right;
color: #cdd4de;
}
.form input.ltr-value:not(:placeholder-shown) {
direction: ltr;
text-align: left;
}
.input-wrap {
position: relative;
}
@@ -446,48 +473,42 @@ h1 {
background: #fff;
}
.input-wrap.has-eye input {
padding-left: 2.7rem;
}
.input-wrap input:focus {
outline: 2px solid var(--accent-soft);
border-color: var(--accent);
}
.ico {
.field-ico {
position: absolute;
top: 50%;
inset-inline-end: 0.85rem;
right: 0.75rem;
left: auto;
transform: translateY(-50%);
width: 18px;
height: 18px;
opacity: 0.45;
color: #94a3b8;
display: grid;
place-items: center;
pointer-events: none;
}
.ico.user {
background:
radial-gradient(circle at 50% 35%, currentColor 0 28%, transparent 29%),
radial-gradient(circle at 50% 85%, currentColor 0 38%, transparent 39%);
color: #64748b;
border-radius: 999px;
}
.ico.lock {
background:
linear-gradient(currentColor, currentColor) center 62%/10px 8px no-repeat,
radial-gradient(circle at 50% 38%, transparent 34%, currentColor 35% 55%, transparent 56%);
color: #64748b;
}
.eye {
position: absolute;
top: 50%;
inset-inline-start: 0.55rem;
left: 0.45rem;
right: auto;
transform: translateY(-50%);
border: 0;
background: transparent;
color: var(--muted);
font-size: 0.75rem;
color: #64748b;
cursor: pointer;
padding: 0.25rem 0.4rem;
padding: 0;
width: 36px;
height: 36px;
display: grid;
place-items: center;
}
.row-actions {
@@ -612,8 +633,34 @@ h1 {
}
@media (min-width: 900px) {
.auth-page {
padding: 0;
background: #fff;
}
.auth-shell {
grid-template-columns: minmax(280px, 0.9fr) minmax(340px, 1.1fr);
width: 100%;
min-height: 100dvh;
grid-template-columns: minmax(420px, 1fr) minmax(440px, 1.05fr);
grid-template-areas: "form brand";
background: #fff;
border-radius: 0;
box-shadow: none;
}
.form-panel {
grid-area: form;
border-radius: 0;
box-shadow: none;
background: #fff;
justify-content: center;
}
.auth-shell :deep(.brand-panel) {
grid-area: brand;
height: 100dvh;
position: sticky;
top: 0;
}
.mobile-brand {
@@ -621,8 +668,12 @@ h1 {
}
.form-inner {
margin: 0;
margin: 0 auto;
width: min(440px, 100%);
}
.auth-footer {
display: none;
}
}
</style>
@@ -2482,7 +2482,7 @@ textarea {
.m-wiz-foot {
position: sticky;
bottom: 0;
bottom: var(--mobile-tabbar-h);
display: grid;
grid-template-columns: 1.25fr 1fr;
gap: 0.65rem;
+5 -1
View File
@@ -1710,7 +1710,7 @@ onBeforeUnmount(() => {
.fab {
position: fixed;
inset-inline-end: 1.1rem;
bottom: 1.25rem;
bottom: calc(1.25rem + var(--mobile-tabbar-h));
z-index: 40;
width: 54px;
height: 54px;
@@ -2147,6 +2147,10 @@ onBeforeUnmount(() => {
}
@media (max-width: 860px) {
.notes-page {
padding-bottom: 5rem;
}
.desktop-only {
display: none !important;
}
+4 -4
View File
@@ -196,12 +196,12 @@ onMounted(load);
<form v-else class="crm-form" @submit.prevent="submit">
<UiCard>
<div class="crm-form-grid-2">
<UiField label="نام" required for-id="firstName">
<UiInput id="firstName" v-model="firstName" required />
<UiField label="نام" for-id="firstName">
<UiInput id="firstName" v-model="firstName" />
</UiField>
<UiField label="نام خانوادگی" required for-id="lastName">
<UiInput id="lastName" v-model="lastName" required />
<UiField label="نام خانوادگی" for-id="lastName">
<UiInput id="lastName" v-model="lastName" />
</UiField>
<UiField label="موبایل" for-id="mobile">
+7 -1
View File
@@ -1658,7 +1658,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
.dock {
position: fixed;
inset-inline: 1.1rem;
bottom: calc(0.7rem + env(safe-area-inset-bottom));
bottom: calc(0.7rem + var(--mobile-tabbar-h));
z-index: 12;
display: flex;
justify-content: space-between;
@@ -1709,6 +1709,12 @@ watch(() => route.query.tab, readTab, { immediate: true });
cursor: not-allowed;
}
@media (max-width: 959px) {
.page {
padding-bottom: 6.5rem;
}
}
@media (min-width: 960px) {
.page {
padding-bottom: 1.2rem;
+40 -45
View File
@@ -16,6 +16,7 @@ import {
moveStageToIndex,
updateDealStage,
} from '@/lib/pipeline-board';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import UiButton from '@/components/ui/UiButton.vue';
import UiInput from '@/components/ui/UiInput.vue';
import UiModal from '@/components/ui/UiModal.vue';
@@ -47,7 +48,6 @@ const deleteStageId = ref<string | null>(null);
const stageBusy = ref(false);
const busy = ref(false);
const importMsg = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const draggingId = ref<string | null>(null);
const draggingColumnId = ref<string | null>(null);
@@ -390,43 +390,35 @@ async function exportExcel() {
}
}
function pickImportFile() {
fileInput.value?.click();
}
async function onImportFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
function onImportStart() {
busy.value = true;
importMsg.value = '';
error.value = '';
try {
const form = new FormData();
form.append('file', file);
const { data } = await api.post<ImportContactsResult>('/deals/import', form, {
headers: { 'Content-Type': undefined },
});
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
}${data.boards?.length ? ` · کاریز: ${data.boards.join('، ')}` : ''}`;
if (data.errors.length) {
error.value = data.errors
.slice(0, 3)
.map((e) => `ردیف ${e.row}: ${e.message}`)
.join(' | ');
}
if (data.boardId && data.boardId !== selectedPipelineId.value) {
await router.replace({ name: 'pipeline', query: { pipelineId: data.boardId } });
} else {
await load();
}
} catch (e) {
error.value = apiMessage(e, 'ایمپورت ناموفق بود');
} finally {
busy.value = false;
}
async function onImportDone(data: ImportContactsResult) {
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
}${data.boards?.length ? ` · کاریز: ${data.boards.join('، ')}` : ''}`;
if (data.errors.length) {
error.value = data.errors
.slice(0, 3)
.map((e) => `ردیف ${e.row}: ${e.message}`)
.join(' | ');
}
if (data.boardId && data.boardId !== selectedPipelineId.value) {
await router.replace({ name: 'pipeline', query: { pipelineId: data.boardId } });
} else {
await load();
}
}
function onImportFail(message: string) {
error.value = message;
}
function onImportSettled() {
busy.value = false;
}
function onMenuDeal(dealId: string) {
@@ -472,16 +464,19 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
خروجی اکسل
</button>
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="pickImportFile">
ورود از اکسل
</button>
<input
ref="fileInput"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
@change="onImportFile"
/>
<ExcelImportFlow
preview-path="/deals/import/preview"
import-path="/deals/import"
:disabled="loading || busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<button class="btn-ghost-sm" type="button" :disabled="loading || busy">
ورود از اکسل
</button>
</ExcelImportFlow>
</template>
</PipelineToolbar>
@@ -658,7 +653,7 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
.menu-pop {
position: fixed;
inset-inline-start: 1rem;
bottom: 5.5rem;
bottom: calc(var(--mobile-tabbar-h) + 1.75rem);
z-index: 40;
background: #fff;
border: 1px solid var(--line);
+34 -46
View File
@@ -15,6 +15,7 @@ import {
UiPagination,
UiSelect,
} from '@/components/ui';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import UiIcon from '@/components/UiIcon.vue';
import ProductFormView from '@/views/ProductFormView.vue';
import type { ImportContactsResult, ProductDto } from '@mento/shared';
@@ -41,7 +42,6 @@ const moreOpen = ref(false);
const productModal = ref(false);
const busy = ref(false);
const importMsg = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const categories = computed(() => {
const set = new Set<string>();
@@ -187,45 +187,32 @@ async function exportExcel() {
}
}
function pickImportFile() {
function onImportStart() {
closeMenus();
fileInput.value?.click();
}
async function onImportFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
busy.value = true;
importMsg.value = '';
error.value = '';
try {
const form = new FormData();
form.append('file', file);
const { data } = await api.post<ImportContactsResult>('/products/import', form, {
headers: { 'Content-Type': undefined },
});
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
}`;
if (data.errors.length) {
error.value = data.errors
.slice(0, 3)
.map((e) => `ردیف ${e.row}: ${e.message}`)
.join(' | ');
}
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string | string[] })?.message ??
'ایمپورت ناموفق بود',
)
: 'ایمپورت ناموفق بود';
} finally {
busy.value = false;
}
async function onImportDone(data: ImportContactsResult) {
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
}`;
if (data.errors.length) {
error.value = data.errors
.slice(0, 3)
.map((e) => `ردیف ${e.row}: ${e.message}`)
.join(' | ');
}
await load();
}
function onImportFail(message: string) {
error.value = message;
}
function onImportSettled() {
busy.value = false;
}
async function toggleActive(product: ProductDto) {
@@ -270,16 +257,17 @@ onMounted(load);
<UiButton variant="ghost" type="button" :disabled="busy" @click="exportExcel">
خروجی اکسل
</UiButton>
<UiButton variant="ghost" type="button" :disabled="busy" @click="pickImportFile">
ورود از اکسل
</UiButton>
<input
ref="fileInput"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
@change="onImportFile"
/>
<ExcelImportFlow
preview-path="/products/import/preview"
import-path="/products/import"
:disabled="busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<UiButton variant="ghost" type="button" :disabled="busy">ورود از اکسل</UiButton>
</ExcelImportFlow>
<UiButton variant="primary" @click="productModal = true">
<UiIcon name="plus" :size="16" />
محصول جدید
@@ -743,7 +731,7 @@ tr:last-child td {
display: none;
position: fixed;
left: 1.1rem;
bottom: 1.1rem;
bottom: calc(1.1rem + var(--mobile-tabbar-h));
width: 56px;
height: 56px;
border-radius: 999px;
+1 -1
View File
@@ -1420,7 +1420,7 @@ h1 {
.dock {
position: fixed;
inset-inline: 0.85rem;
bottom: calc(0.55rem + env(safe-area-inset-bottom));
bottom: calc(0.55rem + var(--mobile-tabbar-h));
width: auto;
}
+40 -47
View File
@@ -17,6 +17,7 @@ import {
moveStageToIndex,
updateDealStage,
} from '@/lib/pipeline-board';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import UiButton from '@/components/ui/UiButton.vue';
import UiInput from '@/components/ui/UiInput.vue';
import UiModal from '@/components/ui/UiModal.vue';
@@ -50,7 +51,6 @@ const deleteStageId = ref<string | null>(null);
const stageBusy = ref(false);
const busy = ref(false);
const importMsg = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const draggingId = ref<string | null>(null);
const draggingColumnId = ref<string | null>(null);
@@ -387,45 +387,35 @@ async function exportExcel() {
}
}
function pickImportFile() {
fileInput.value?.click();
}
async function onImportFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
function onImportStart() {
busy.value = true;
importMsg.value = '';
error.value = '';
try {
const form = new FormData();
form.append('file', file);
const { data } = await api.post<ImportContactsResult>(
'/project-cards/import',
form,
{ headers: { 'Content-Type': undefined } },
);
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
}${data.boards?.length ? ` · برد: ${data.boards.join('، ')}` : ''}`;
if (data.errors.length) {
error.value = data.errors
.slice(0, 3)
.map((e) => `ردیف ${e.row}: ${e.message}`)
.join(' | ');
}
if (data.boardId && data.boardId !== selectedBoardId.value) {
await router.replace({ name: 'projects', query: { boardId: data.boardId } });
} else {
await load();
}
} catch (e) {
error.value = apiMessage(e, 'ایمپورت ناموفق بود');
} finally {
busy.value = false;
}
async function onImportDone(data: ImportContactsResult) {
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
}${data.boards?.length ? ` · برد: ${data.boards.join('، ')}` : ''}`;
if (data.errors.length) {
error.value = data.errors
.slice(0, 3)
.map((e) => `ردیف ${e.row}: ${e.message}`)
.join(' | ');
}
if (data.boardId && data.boardId !== selectedBoardId.value) {
await router.replace({ name: 'projects', query: { boardId: data.boardId } });
} else {
await load();
}
}
function onImportFail(message: string) {
error.value = message;
}
function onImportSettled() {
busy.value = false;
}
function onMenuCard(cardId: string) {
@@ -470,16 +460,19 @@ onBeforeUnmount(() => {
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
خروجی اکسل
</button>
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="pickImportFile">
ورود از اکسل
</button>
<input
ref="fileInput"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
@change="onImportFile"
/>
<ExcelImportFlow
preview-path="/project-cards/import/preview"
import-path="/project-cards/import"
:disabled="loading || busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<button class="btn-ghost-sm" type="button" :disabled="loading || busy">
ورود از اکسل
</button>
</ExcelImportFlow>
</template>
</PipelineToolbar>
@@ -671,7 +664,7 @@ onBeforeUnmount(() => {
.menu-pop {
position: fixed;
inset-inline-start: 1rem;
bottom: 5.5rem;
bottom: calc(var(--mobile-tabbar-h) + 1.75rem);
z-index: 40;
background: #fff;
border: 1px solid var(--line);
+121 -60
View File
@@ -8,6 +8,7 @@ import MentoLogo from '@/components/brand/MentoLogo.vue';
import axios from 'axios';
import { brand, type RegisterChallengeResponse } from '@mento/shared';
import BrandName from '@/components/brand/BrandName.vue';
import UiIcon from '@/components/UiIcon.vue';
const auth = useAuthStore();
const router = useRouter();
@@ -105,10 +106,7 @@ function backToForm() {
<template>
<div class="auth-page">
<div class="auth-shell">
<AuthBrandPanel
headline="همه ارتباطات، یکجا مدیریت می‌شوند"
:description="`با ساخت حساب ${brand.nameFa}، سازمان خود را راه‌اندازی کنید و فروش و پشتیبانی را از یکجا پیش ببرید.`"
/>
<AuthBrandPanel />
<section class="form-panel">
<header class="mobile-brand">
@@ -134,6 +132,9 @@ function backToForm() {
<label for="org">نام سازمان <em>*</em></label>
<div class="input-wrap">
<input id="org" v-model="organizationName" required placeholder="نام سازمان" />
<span class="field-ico" aria-hidden="true">
<UiIcon name="building" :size="18" />
</span>
</div>
</div>
<div class="field">
@@ -157,7 +158,9 @@ function backToForm() {
required
placeholder="نام و نام خانوادگی خود را وارد کنید"
/>
<span class="ico user" aria-hidden="true" />
<span class="field-ico" aria-hidden="true">
<UiIcon name="user" :size="18" />
</span>
</div>
</div>
<div class="field">
@@ -169,9 +172,11 @@ function backToForm() {
type="email"
required
placeholder="ایمیل خود را وارد کنید"
dir="ltr"
class="ltr-value"
/>
<span class="ico mail" aria-hidden="true" />
<span class="field-ico" aria-hidden="true">
<UiIcon name="mail" :size="18" />
</span>
</div>
</div>
</div>
@@ -187,14 +192,16 @@ function backToForm() {
type="tel"
required
placeholder="۹۱۲۱۲۳۴۵۶۷"
dir="ltr"
class="ltr-value"
/>
<span class="ico phone" aria-hidden="true" />
<span class="field-ico" aria-hidden="true">
<UiIcon name="phone" :size="18" />
</span>
</div>
</div>
<div class="field">
<label for="password">رمز عبور <em>*</em></label>
<div class="input-wrap">
<div class="input-wrap has-eye">
<input
id="password"
v-model="password"
@@ -202,15 +209,18 @@ function backToForm() {
minlength="6"
required
placeholder="رمز عبور خود را وارد کنید"
dir="ltr"
class="ltr-value"
/>
<span class="ico lock" aria-hidden="true" />
<span class="field-ico" aria-hidden="true">
<UiIcon name="lock" :size="18" />
</span>
<button
type="button"
class="eye"
:aria-label="showPassword ? 'مخفی کردن رمز' : 'نمایش رمز'"
@click="showPassword = !showPassword"
>
{{ showPassword ? 'مخفی' : 'نمایش' }}
<UiIcon :name="showPassword ? 'eye-off' : 'eye'" :size="18" />
</button>
</div>
</div>
@@ -219,7 +229,7 @@ function backToForm() {
<div class="grid-2">
<div class="field">
<label for="passwordConfirm">تکرار رمز عبور <em>*</em></label>
<div class="input-wrap">
<div class="input-wrap has-eye">
<input
id="passwordConfirm"
v-model="passwordConfirm"
@@ -227,15 +237,18 @@ function backToForm() {
minlength="6"
required
placeholder="رمز عبور خود را تکرار کنید"
dir="ltr"
class="ltr-value"
/>
<span class="ico lock" aria-hidden="true" />
<span class="field-ico" aria-hidden="true">
<UiIcon name="lock" :size="18" />
</span>
<button
type="button"
class="eye"
:aria-label="showPasswordConfirm ? 'مخفی کردن رمز' : 'نمایش رمز'"
@click="showPasswordConfirm = !showPasswordConfirm"
>
{{ showPasswordConfirm ? 'مخفی' : 'نمایش' }}
<UiIcon :name="showPasswordConfirm ? 'eye-off' : 'eye'" :size="18" />
</button>
</div>
</div>
@@ -247,7 +260,9 @@ function backToForm() {
v-model="referralCode"
placeholder="اگر کد معرف دارید وارد کنید"
/>
<span class="ico gift" aria-hidden="true" />
<span class="field-ico" aria-hidden="true">
<UiIcon name="gift" :size="18" />
</span>
</div>
</div>
</div>
@@ -297,7 +312,7 @@ function backToForm() {
type="text"
inputmode="numeric"
required
dir="ltr"
class="ltr-value"
placeholder="کد ایمیل"
/>
</div>
@@ -309,7 +324,7 @@ function backToForm() {
type="text"
inputmode="numeric"
required
dir="ltr"
class="ltr-value"
placeholder="کد موبایل"
/>
</div>
@@ -349,8 +364,10 @@ function backToForm() {
padding: 1rem;
padding-bottom: max(1rem, env(safe-area-inset-bottom));
background:
radial-gradient(circle at 8% 0%, rgba(13, 148, 136, 0.07), transparent 34%),
var(--bg);
radial-gradient(ellipse 70% 45% at 50% 0%, rgba(45, 212, 191, 0.12), transparent 55%),
radial-gradient(rgba(13, 148, 136, 0.12) 1px, transparent 1.2px),
#f5fbfb;
background-size: auto, 20px 20px, auto;
}
.auth-shell {
@@ -358,16 +375,18 @@ function backToForm() {
width: min(1100px, 100%);
margin: 0 auto;
display: grid;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
background: transparent;
border: 0;
box-shadow: none;
overflow: hidden;
}
.form-panel {
padding: clamp(1rem, 2.5vw, 2rem);
min-width: 0;
background: #fff;
border-radius: 28px;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.06);
}
.mobile-brand {
@@ -466,6 +485,23 @@ h1 {
padding: 0.8rem 0.95rem;
width: 100%;
background: #fff;
direction: rtl;
text-align: right;
}
.field select {
color: var(--ink);
}
.form input::placeholder {
direction: rtl;
text-align: right;
color: #cdd4de;
}
.form input.ltr-value:not(:placeholder-shown) {
direction: ltr;
text-align: left;
}
.input-wrap {
@@ -473,16 +509,21 @@ h1 {
}
.input-wrap input {
padding-inline-end: 2.5rem;
padding-right: 2.6rem;
}
.input-wrap.has-eye input {
padding-left: 2.7rem;
}
.mobile-wrap input {
padding-inline-start: 3.4rem;
padding-left: 3.4rem;
}
.cc {
position: absolute;
inset-inline-start: 0.7rem;
left: 0.7rem;
right: auto;
top: 50%;
transform: translateY(-50%);
color: var(--muted);
@@ -490,49 +531,33 @@ h1 {
pointer-events: none;
}
.ico {
.field-ico {
position: absolute;
top: 50%;
inset-inline-end: 0.8rem;
right: 0.7rem;
left: auto;
transform: translateY(-50%);
width: 16px;
height: 16px;
opacity: 0.45;
color: #94a3b8;
display: grid;
place-items: center;
pointer-events: none;
background: #64748b;
border-radius: var(--radius);
}
.ico.user {
border-radius: 999px;
background:
radial-gradient(circle at 50% 35%, #64748b 0 28%, transparent 29%),
radial-gradient(circle at 50% 85%, #64748b 0 38%, transparent 39%);
}
.ico.mail {
background: linear-gradient(#64748b, #64748b);
mask: linear-gradient(#000 0 0);
opacity: 0.4;
border-radius: var(--radius);
}
.ico.lock,
.ico.phone,
.ico.gift {
opacity: 0.35;
}
.eye {
position: absolute;
top: 50%;
inset-inline-start: 0.45rem;
left: 0.35rem;
right: auto;
transform: translateY(-50%);
border: 0;
background: transparent;
color: var(--muted);
font-size: 0.72rem;
color: #64748b;
cursor: pointer;
padding: 0;
width: 36px;
height: 36px;
display: grid;
place-items: center;
}
.terms {
@@ -618,12 +643,48 @@ h1 {
}
@media (min-width: 900px) {
.auth-page {
padding: 0;
background: #fff;
}
.auth-shell {
grid-template-columns: minmax(260px, 0.85fr) minmax(380px, 1.15fr);
width: 100%;
min-height: 100dvh;
grid-template-columns: minmax(420px, 1fr) minmax(440px, 1.05fr);
grid-template-areas: "form brand";
background: #fff;
border-radius: 0;
box-shadow: none;
}
.form-panel {
grid-area: form;
border-radius: 0;
box-shadow: none;
background: #fff;
display: flex;
flex-direction: column;
justify-content: center;
}
.auth-shell :deep(.brand-panel) {
grid-area: brand;
height: 100dvh;
position: sticky;
top: 0;
}
.mobile-brand {
display: none;
}
.form-inner {
margin: 0 auto;
}
.auth-footer {
display: none;
}
}
</style>
+3 -2
View File
@@ -921,9 +921,9 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap;
.sum-row.payable { color: var(--teal); font-weight: 600; border-bottom: 0; }
.forecast-summary { padding: 1rem; }
.mob-bottom {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
position: fixed; left: 0; right: 0; bottom: var(--mobile-tabbar-h); z-index: 40;
display: none; grid-template-columns: auto 1fr auto; gap: 0.5rem; align-items: center;
padding: 0.65rem 0.85rem calc(0.65rem + env(safe-area-inset-bottom));
padding: 0.65rem 0.85rem;
background: #fff; border-top: 1px solid #eef2f7; box-shadow: 0 -8px 24px rgba(15,23,42,.06);
}
.fab {
@@ -948,6 +948,7 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap;
.kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 900px) {
.page { padding-bottom: 5.5rem; }
.desk-only { display: none !important; }
.mob-only, .mob-tabs, .mob-goals, .mob-forecast, .mob-bottom { display: flex; }
.mob-goals, .mob-forecast, .mob-form-screen { display: grid; }
+1 -1
View File
@@ -1745,7 +1745,7 @@ textarea {
min-height: 0;
max-height: min(78dvh, 640px);
border-radius: 22px 22px 0 0;
padding-bottom: calc(0.85rem + env(safe-area-inset-bottom));
padding-bottom: 0.85rem;
animation: sheet-up 0.22s ease;
}
+383
View File
@@ -0,0 +1,383 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import type { CreatePlatformAdminRequest, PlatformAdminDto } from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { formatJalaliNumeric } from '@/lib/jalali';
import { usePlatformAuthStore } from '@/stores/platform-auth';
const platform = usePlatformAuthStore();
const loading = ref(false);
const error = ref('');
const success = ref('');
const items = ref<PlatformAdminDto[]>([]);
const busyId = ref('');
const showCreate = ref(false);
const creating = ref(false);
const resetId = ref<string | null>(null);
const resetPassword = ref('');
const resetting = ref(false);
const createForm = reactive<CreatePlatformAdminRequest>({
fullName: '',
mobile: '',
password: '',
email: '',
});
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await platformApi.get<PlatformAdminDto[]>('/platform/admins');
items.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری ادمین‌ها ناموفق بود');
} finally {
loading.value = false;
}
}
function resetCreateForm() {
createForm.fullName = '';
createForm.mobile = '';
createForm.password = '';
createForm.email = '';
}
async function createAdmin() {
creating.value = true;
error.value = '';
success.value = '';
try {
const body: CreatePlatformAdminRequest = {
fullName: createForm.fullName.trim(),
mobile: createForm.mobile.trim(),
password: createForm.password,
email: createForm.email?.trim() || undefined,
};
const { data } = await platformApi.post<PlatformAdminDto>('/platform/admins', body);
items.value = [...items.value, data];
showCreate.value = false;
resetCreateForm();
success.value = `ادمین «${data.fullName}» اضافه شد`;
} catch (e) {
error.value = apiError(e, 'افزودن ادمین ناموفق بود');
} finally {
creating.value = false;
}
}
async function setActive(admin: PlatformAdminDto, isActive: boolean) {
busyId.value = admin.id;
error.value = '';
try {
const { data } = await platformApi.patch<PlatformAdminDto>(
`/platform/admins/${admin.id}`,
{ isActive },
);
items.value = items.value.map((row) => (row.id === admin.id ? data : row));
} catch (e) {
error.value = apiError(e, 'تغییر وضعیت ناموفق بود');
} finally {
busyId.value = '';
}
}
function startReset(id: string) {
resetId.value = resetId.value === id ? null : id;
resetPassword.value = '';
error.value = '';
success.value = '';
}
async function submitReset(id: string) {
resetting.value = true;
error.value = '';
success.value = '';
try {
await platformApi.patch(`/platform/admins/${id}/password`, {
password: resetPassword.value,
});
success.value = 'رمز عبور به‌روز شد';
resetId.value = null;
resetPassword.value = '';
} catch (e) {
error.value = apiError(e, 'ریست رمز ناموفق بود');
} finally {
resetting.value = false;
}
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<header class="head">
<p class="lead">افزودن سوپرادمین با موبایل، فعال/غیرفعال و ریست رمز.</p>
<button class="btn" type="button" @click="showCreate = !showCreate">
{{ showCreate ? 'بستن فرم' : 'ادمین جدید' }}
</button>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="success" class="ok">{{ success }}</p>
<form v-if="showCreate" class="panel" @submit.prevent="createAdmin">
<h2>افزودن سوپرادمین</h2>
<div class="grid">
<label>
<span>نام</span>
<input v-model="createForm.fullName" required minlength="2" />
</label>
<label>
<span>موبایل</span>
<input v-model="createForm.mobile" type="tel" required dir="ltr" placeholder="09xxxxxxxxx" />
</label>
<label>
<span>رمز عبور</span>
<input v-model="createForm.password" type="password" required minlength="8" />
</label>
<label>
<span>ایمیل (اختیاری)</span>
<input v-model="createForm.email" type="email" dir="ltr" />
</label>
</div>
<div class="panel-actions">
<button class="btn" type="submit" :disabled="creating">
{{ creating ? 'در حال ذخیره…' : 'افزودن' }}
</button>
</div>
</form>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="!items.length" class="muted">ادمینی ثبت نشده.</p>
<div v-else class="list">
<article v-for="admin in items" :key="admin.id" class="row">
<div class="row-main">
<div>
<strong>
{{ admin.fullName }}
<em v-if="admin.id === platform.admin?.id" class="you">شما</em>
</strong>
<span>
{{ admin.mobile || '—' }}
<template v-if="admin.email"> · {{ admin.email }}</template>
</span>
</div>
<div class="meta">
<em :class="admin.isActive ? 'on' : 'off'">
{{ admin.isActive ? 'فعال' : 'غیرفعال' }}
</em>
<small v-if="admin.lastLoginAt">
آخرین ورود {{ formatJalaliNumeric(new Date(admin.lastLoginAt)) }}
</small>
<small v-else>هنوز وارد نشده</small>
</div>
</div>
<div class="actions">
<button class="btn btn-ghost" type="button" @click="startReset(admin.id)">
ریست رمز
</button>
<button
class="btn"
type="button"
:disabled="busyId === admin.id || admin.id === platform.admin?.id"
@click="setActive(admin, !admin.isActive)"
>
{{ admin.isActive ? 'غیرفعال' : 'فعال' }}
</button>
</div>
<form
v-if="resetId === admin.id"
class="reset-panel"
@submit.prevent="submitReset(admin.id)"
>
<label>
<span>رمز جدید (حداقل ۸ کاراکتر)</span>
<input
v-model="resetPassword"
type="password"
required
minlength="8"
autocomplete="new-password"
/>
</label>
<div class="panel-actions">
<button class="btn" type="submit" :disabled="resetting">ذخیره رمز</button>
<button class="btn btn-ghost" type="button" @click="startReset(admin.id)">
انصراف
</button>
</div>
</form>
</article>
</div>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 0.95rem;
max-width: 1080px;
}
.head {
display: flex;
justify-content: space-between;
gap: 0.85rem;
flex-wrap: wrap;
align-items: center;
}
.lead,
.head p,
.muted {
margin: 0;
color: var(--muted);
font-size: 0.88rem;
}
.error {
margin: 0;
color: var(--danger);
}
.ok {
margin: 0;
color: var(--success-deep);
}
.panel,
.reset-panel {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 18px;
padding: 1rem;
display: grid;
gap: 0.85rem;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04);
}
.panel h2 {
margin: 0;
font-size: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
label {
display: grid;
gap: 0.35rem;
}
label span {
font-size: 0.82rem;
color: var(--muted);
font-weight: 700;
}
input {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.65rem 0.8rem;
font: inherit;
background: #fff;
}
.panel-actions {
display: flex;
gap: 0.5rem;
}
.list {
display: grid;
gap: 0.7rem;
}
.row {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 18px;
padding: 0.95rem 1rem;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.7rem 1rem;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.035);
}
.row-main {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.row-main span,
.row-main small {
display: block;
color: var(--muted);
font-size: 0.8rem;
margin-top: 0.2rem;
}
.you {
margin-inline-start: 0.4rem;
font-style: normal;
font-size: 0.75rem;
color: var(--accent-deep);
font-weight: 800;
}
.meta {
text-align: end;
}
.actions {
display: flex;
gap: 0.45rem;
align-items: flex-start;
}
.on,
.off {
font-style: normal;
font-size: 0.78rem;
font-weight: 800;
}
.on {
color: var(--success-deep);
}
.off {
color: var(--danger);
}
.reset-panel {
grid-column: 1 / -1;
background: #f8fafc;
}
@media (max-width: 720px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
+315
View File
@@ -0,0 +1,315 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import type { PlatformStatsDto } from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { toFaDigits } from '@/lib/jalali';
import { usePlatformAuthStore } from '@/stores/platform-auth';
import UiIcon from '@/components/UiIcon.vue';
const platform = usePlatformAuthStore();
const loading = ref(true);
const error = ref('');
const stats = ref<PlatformStatsDto | null>(null);
async function load() {
error.value = '';
loading.value = true;
try {
const { data } = await platformApi.get<PlatformStatsDto>('/platform/stats');
stats.value = data;
} catch {
error.value = 'بارگذاری آمار ناموفق بود';
} finally {
loading.value = false;
}
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<section class="hero">
<div class="hero-copy">
<p class="eyebrow">سوپرادمین</p>
<h2>سلام {{ platform.admin?.fullName || '' }}</h2>
<p class="lead">امروز وضعیت حساب‌ها، اشتراک‌ها و ماژول‌ها را از اینجا کنترل کنید.</p>
</div>
<div class="hero-orb" aria-hidden="true" />
</section>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else-if="stats" class="kpis">
<article class="kpi">
<span class="ico"><UiIcon name="building" :size="18" /></span>
<div>
<p>محیط‌ها</p>
<strong>{{ toFaDigits(stats.organizationCount) }}</strong>
<small>{{ toFaDigits(stats.activeOrganizationCount) }} فعال</small>
</div>
</article>
<article class="kpi">
<span class="ico"><UiIcon name="users" :size="18" /></span>
<div>
<p>کاربران</p>
<strong>{{ toFaDigits(stats.userCount) }}</strong>
<small>{{ toFaDigits(stats.activeUserCount) }} فعال</small>
</div>
</article>
<article class="kpi">
<span class="ico"><UiIcon name="layers" :size="18" /></span>
<div>
<p>عضویت‌ها</p>
<strong>{{ toFaDigits(stats.membershipCount) }}</strong>
<small>نقش در محیط‌ها</small>
</div>
</article>
<article class="kpi warn">
<span class="ico"><UiIcon name="clock" :size="18" /></span>
<div>
<p>نزدیک انقضا</p>
<strong>{{ toFaDigits(stats.expiringSoonCount ?? 0) }}</strong>
<small>کمتر از ۳ روز</small>
</div>
</article>
<article class="kpi danger">
<span class="ico"><UiIcon name="warning" :size="18" /></span>
<div>
<p>منقضی</p>
<strong>{{ toFaDigits(stats.expiredBillingCount ?? 0) }}</strong>
<small>نیاز به تمدید</small>
</div>
</article>
</div>
<section class="actions">
<h3>میانبرها</h3>
<div class="shortcuts">
<RouterLink :to="{ name: 'adm-organizations' }" class="card">
<span class="card-ico mint"><UiIcon name="building" :size="18" /></span>
<div>
<strong>محیط‌ها</strong>
<span>ساخت، ماژول و فعال/غیرفعال</span>
</div>
<UiIcon name="chevron" :size="16" class="go" />
</RouterLink>
<RouterLink :to="{ name: 'adm-users' }" class="card">
<span class="card-ico violet"><UiIcon name="users" :size="18" /></span>
<div>
<strong>کاربران</strong>
<span>اشتراک مالک، نقش و ریست رمز</span>
</div>
<UiIcon name="chevron" :size="16" class="go" />
</RouterLink>
<RouterLink :to="{ name: 'adm-admins' }" class="card">
<span class="card-ico peach"><UiIcon name="lock" :size="18" /></span>
<div>
<strong>ادمین‌ها</strong>
<span>افزودن سوپرادمین با موبایل</span>
</div>
<UiIcon name="chevron" :size="16" class="go" />
</RouterLink>
</div>
</section>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 1.15rem;
max-width: 1080px;
}
.hero {
position: relative;
overflow: hidden;
border-radius: 22px;
padding: 1.25rem 1.3rem;
background:
linear-gradient(135deg, rgba(20, 184, 166, 0.16), rgba(255, 255, 255, 0.92) 55%),
#fff;
border: 1px solid color-mix(in srgb, var(--accent) 16%, white);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.05);
}
.hero-copy {
position: relative;
z-index: 1;
max-width: 34rem;
}
.eyebrow {
margin: 0;
color: var(--accent-deep);
font-size: 0.75rem;
font-weight: 900;
}
.hero h2 {
margin: 0.25rem 0 0.35rem;
font-size: clamp(1.35rem, 2.4vw, 1.7rem);
letter-spacing: -0.02em;
}
.lead,
.muted {
margin: 0;
color: var(--muted);
line-height: 1.6;
}
.hero-orb {
position: absolute;
inset-inline-end: -40px;
top: -50px;
width: 180px;
height: 180px;
border-radius: 50%;
background: radial-gradient(circle, rgba(45, 212, 191, 0.35), transparent 70%);
}
.error {
margin: 0;
color: var(--danger);
}
.kpis {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.75rem;
}
.kpi {
display: flex;
gap: 0.75rem;
align-items: flex-start;
background: #fff;
border: 1px solid #eef2f7;
border-radius: 18px;
padding: 0.95rem;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04);
}
.kpi p,
.kpi small {
margin: 0;
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
}
.kpi strong {
display: block;
margin: 0.15rem 0;
font-size: 1.4rem;
letter-spacing: -0.02em;
}
.ico {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 38px;
height: 38px;
border-radius: 12px;
background: var(--accent-subtle);
color: var(--accent-deep);
}
.kpi.warn .ico {
background: var(--warning-soft);
color: var(--warning-deep);
}
.kpi.danger .ico {
background: var(--danger-soft);
color: var(--danger);
}
.actions h3 {
margin: 0 0 0.65rem;
font-size: 0.95rem;
}
.shortcuts {
display: grid;
gap: 0.65rem;
}
.card {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.85rem;
padding: 0.95rem 1rem;
background: #fff;
border: 1px solid var(--line-light);
border-radius: 18px;
text-decoration: none;
color: inherit;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.card:hover {
transform: translateY(-1px);
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.07);
}
.card span {
display: block;
color: var(--muted);
font-size: 0.8rem;
margin-top: 0.15rem;
}
.card-ico {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 14px;
}
.card-ico.mint {
background: var(--accent-subtle);
color: var(--accent-deep);
}
.card-ico.violet {
background: #f3e8ff;
color: #7c3aed;
}
.card-ico.peach {
background: #ffedd5;
color: #c2410c;
}
.go {
color: var(--muted-light);
}
@media (max-width: 980px) {
.kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.kpis {
grid-template-columns: 1fr;
}
.hero {
border-radius: 18px;
padding: 1.1rem;
}
}
</style>
+325
View File
@@ -0,0 +1,325 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { usePlatformAuthStore } from '@/stores/platform-auth';
import AuthBrandPanel from '@/components/auth/AuthBrandPanel.vue';
import MentoLogo from '@/components/brand/MentoLogo.vue';
import BrandName from '@/components/brand/BrandName.vue';
import UiIcon from '@/components/UiIcon.vue';
import { brand } from '@mento/shared';
const platform = usePlatformAuthStore();
const router = useRouter();
const mobile = ref('');
const password = ref('');
const showPassword = ref(false);
const error = ref('');
const loading = ref(false);
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
if (!e.response) {
return 'اتصال به سرور برقرار نشد.';
}
const msg = (e.response.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
async function submit() {
error.value = '';
loading.value = true;
try {
await platform.login(mobile.value.trim(), password.value);
await router.push({ name: 'adm-dashboard' });
} catch (e) {
error.value = apiError(e, 'ورود ناموفق بود');
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="auth-page">
<div class="auth-shell">
<AuthBrandPanel
headline="کنترل حساب‌ها و دسترسی‌ها"
description="پنل سوپرادمین برای مدیریت محیط‌ها، کاربران و وضعیت دسترسی‌ها."
/>
<section class="form-panel">
<header class="mobile-brand">
<MentoLogo :size="52" :show-text="false" />
<BrandName />
<span>پنل سوپرادمین</span>
</header>
<div class="form-inner">
<h1>ورود سوپرادمین</h1>
<p class="lead">فقط حساب پلتفرم می‌تواند وارد این بخش شود.</p>
<form class="form" @submit.prevent="submit">
<div class="field">
<label for="adm-mobile">شماره موبایل</label>
<div class="input-wrap">
<input
id="adm-mobile"
v-model="mobile"
type="tel"
required
autocomplete="username"
placeholder="09xxxxxxxxx"
class="ltr-value"
dir="ltr"
/>
<span class="field-ico" aria-hidden="true">
<UiIcon name="phone" :size="18" />
</span>
</div>
</div>
<div class="field">
<label for="adm-password">رمز عبور</label>
<div class="input-wrap has-eye">
<input
id="adm-password"
v-model="password"
:type="showPassword ? 'text' : 'password'"
required
autocomplete="current-password"
placeholder="رمز عبور را وارد کنید"
class="ltr-value"
/>
<span class="field-ico" aria-hidden="true">
<UiIcon name="lock" :size="18" />
</span>
<button
type="button"
class="eye"
:aria-label="showPassword ? 'مخفی کردن رمز' : 'نمایش رمز'"
@click="showPassword = !showPassword"
>
<UiIcon :name="showPassword ? 'eye-off' : 'eye'" :size="18" />
</button>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<button class="btn submit" type="submit" :disabled="loading">
{{ loading ? 'در حال ورود…' : 'ورود به پنل' }}
</button>
</form>
</div>
</section>
</div>
<footer class="auth-footer">
<p>۱۴۰۵ {{ brand.nameFa }}. تمامی حقوق محفوظ است.</p>
</footer>
</div>
</template>
<style scoped>
.auth-page {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
padding: 1rem;
padding-bottom: max(1rem, env(safe-area-inset-bottom));
background:
radial-gradient(ellipse 70% 45% at 50% 0%, rgba(45, 212, 191, 0.12), transparent 55%),
radial-gradient(rgba(13, 148, 136, 0.12) 1px, transparent 1.2px),
#f5fbfb;
background-size: auto, 20px 20px, auto;
}
.auth-shell {
flex: 1;
width: min(1100px, 100%);
margin: 0 auto;
display: grid;
overflow: hidden;
min-height: min(680px, calc(100dvh - 5.5rem));
}
.form-panel {
display: flex;
flex-direction: column;
padding: clamp(1.2rem, 3vw, 2.4rem);
min-width: 0;
background: #fff;
border-radius: 28px;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.06);
}
.mobile-brand {
display: grid;
justify-items: center;
text-align: center;
gap: 0.35rem;
margin-bottom: 1.4rem;
padding-top: 0.4rem;
}
.mobile-brand span {
color: var(--muted);
font-size: 0.82rem;
}
.form-inner {
width: min(420px, 100%);
margin: 0 auto;
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
}
h1 {
margin: 0 0 0.4rem;
font-size: clamp(1.45rem, 3vw, 1.85rem);
}
.lead {
margin: 0 0 1.2rem;
color: var(--muted);
line-height: 1.7;
}
.form {
display: grid;
gap: 0.95rem;
}
.form input {
direction: rtl;
text-align: right;
}
.form input::placeholder {
direction: rtl;
text-align: right;
color: #cdd4de;
}
.form input.ltr-value:not(:placeholder-shown) {
direction: ltr;
text-align: left;
}
.input-wrap {
position: relative;
}
.input-wrap input {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.85rem 2.6rem 0.85rem 0.95rem;
width: 100%;
background: #fff;
}
.input-wrap.has-eye input {
padding-left: 2.7rem;
}
.input-wrap input:focus {
outline: 2px solid var(--accent-soft);
border-color: var(--accent);
}
.field-ico {
position: absolute;
top: 50%;
right: 0.75rem;
left: auto;
transform: translateY(-50%);
color: #94a3b8;
display: grid;
place-items: center;
pointer-events: none;
}
.eye {
position: absolute;
top: 50%;
left: 0.45rem;
right: auto;
transform: translateY(-50%);
border: 0;
background: transparent;
color: #64748b;
cursor: pointer;
padding: 0;
width: 36px;
height: 36px;
display: grid;
place-items: center;
}
.submit {
width: 100%;
padding: 0.9rem 1.1rem;
border-radius: var(--radius);
font-size: 1rem;
}
.error {
margin: 0;
color: var(--danger);
font-size: 0.88rem;
}
.auth-footer {
width: min(1040px, 100%);
margin: 0.85rem auto 0;
color: var(--muted);
font-size: 0.82rem;
padding-inline: 0.35rem;
}
@media (min-width: 900px) {
.auth-page {
padding: 0;
background: #fff;
}
.auth-shell {
width: 100%;
min-height: 100dvh;
grid-template-columns: minmax(420px, 1fr) minmax(440px, 1.05fr);
grid-template-areas: "form brand";
}
.form-panel {
grid-area: form;
border-radius: 0;
box-shadow: none;
justify-content: center;
}
.auth-shell :deep(.brand-panel) {
grid-area: brand;
height: 100dvh;
position: sticky;
top: 0;
}
.mobile-brand {
display: none;
}
.form-inner {
width: min(440px, 100%);
}
.auth-footer {
display: none;
}
}
</style>
@@ -0,0 +1,748 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import type {
CreatePlatformOrganizationRequest,
OrgModuleKey,
PlatformOrganizationDto,
UpdatePlatformOrganizationRequest,
UserRole,
} from '@mento/shared';
import {
ORG_MODULE_GROUPS,
ORG_MODULE_LABELS,
USER_ROLE_LABELS,
USER_ROLES,
isBusinessField,
} from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
const query = ref('');
const loading = ref(false);
const error = ref('');
const success = ref('');
const items = ref<PlatformOrganizationDto[]>([]);
const openId = ref<string | null>(null);
const detail = ref<PlatformOrganizationDto | null>(null);
const busyId = ref('');
const showCreate = ref(false);
const creating = ref(false);
const createForm = reactive<CreatePlatformOrganizationRequest>({
name: '',
businessField: 'personal',
ownerFullName: '',
ownerEmail: '',
ownerPassword: '',
ownerMobile: '',
});
const editingId = ref<string | null>(null);
const savingEdit = ref(false);
const editForm = reactive({
name: '',
slug: '',
businessField: '' as string,
});
const moduleDraft = ref<OrgModuleKey[]>([]);
const savingModules = ref(false);
function fieldLabel(value?: string | null) {
if (!value) return '—';
return isBusinessField(value) ? BUSINESS_FIELD_LABELS[value] : value;
}
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
function resetCreateForm() {
createForm.name = '';
createForm.businessField = 'personal';
createForm.ownerFullName = '';
createForm.ownerEmail = '';
createForm.ownerPassword = '';
createForm.ownerMobile = '';
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await platformApi.get<PlatformOrganizationDto[]>(
'/platform/organizations',
{ params: query.value.trim() ? { q: query.value.trim() } : undefined },
);
items.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری محیط‌ها ناموفق بود');
} finally {
loading.value = false;
}
}
async function toggleOpen(org: PlatformOrganizationDto) {
if (openId.value === org.id) {
openId.value = null;
detail.value = null;
editingId.value = null;
return;
}
openId.value = org.id;
editingId.value = null;
try {
const { data } = await platformApi.get<PlatformOrganizationDto>(
`/platform/organizations/${org.id}`,
);
detail.value = data;
moduleDraft.value = [...(data.enabledModules ?? [])];
} catch (e) {
error.value = apiError(e, 'بارگذاری اعضا ناموفق بود');
}
}
function startEdit(org: PlatformOrganizationDto) {
editingId.value = org.id;
editForm.name = org.name;
editForm.slug = org.slug;
editForm.businessField = org.businessField ?? '';
success.value = '';
error.value = '';
}
function cancelEdit() {
editingId.value = null;
}
async function saveEdit(orgId: string) {
savingEdit.value = true;
error.value = '';
success.value = '';
try {
const body: UpdatePlatformOrganizationRequest = {
name: editForm.name.trim(),
slug: editForm.slug.trim(),
businessField: editForm.businessField || null,
};
const { data } = await platformApi.patch<PlatformOrganizationDto>(
`/platform/organizations/${orgId}`,
body,
);
items.value = items.value.map((row) =>
row.id === orgId
? {
...row,
name: data.name,
slug: data.slug,
businessField: data.businessField,
isActive: data.isActive,
}
: row,
);
if (detail.value?.id === orgId) {
detail.value = { ...detail.value, ...data };
}
editingId.value = null;
success.value = 'اطلاعات محیط ذخیره شد';
} catch (e) {
error.value = apiError(e, 'ذخیره تغییرات ناموفق بود');
} finally {
savingEdit.value = false;
}
}
async function createOrganization() {
creating.value = true;
error.value = '';
success.value = '';
try {
const body: CreatePlatformOrganizationRequest = {
name: createForm.name.trim(),
businessField: createForm.businessField || undefined,
ownerFullName: createForm.ownerFullName.trim(),
ownerEmail: createForm.ownerEmail.trim(),
ownerPassword: createForm.ownerPassword,
ownerMobile: createForm.ownerMobile?.trim() || undefined,
};
const { data } = await platformApi.post<PlatformOrganizationDto>(
'/platform/organizations',
body,
);
items.value = [data, ...items.value];
showCreate.value = false;
resetCreateForm();
success.value = `محیط «${data.name}» ساخته شد`;
} catch (e) {
error.value = apiError(e, 'ساخت محیط ناموفق بود');
} finally {
creating.value = false;
}
}
async function setOrgActive(org: PlatformOrganizationDto, isActive: boolean) {
busyId.value = org.id;
error.value = '';
try {
const { data } = await platformApi.patch<PlatformOrganizationDto>(
`/platform/organizations/${org.id}`,
{ isActive },
);
items.value = items.value.map((row) => (row.id === org.id ? { ...row, isActive } : row));
if (detail.value?.id === org.id) {
detail.value = { ...detail.value, ...data };
}
} catch (e) {
error.value = apiError(e, 'تغییر وضعیت محیط ناموفق بود');
} finally {
busyId.value = '';
}
}
async function setMembershipActive(membershipId: string, isActive: boolean) {
busyId.value = membershipId;
error.value = '';
try {
await platformApi.patch(`/platform/memberships/${membershipId}`, { isActive });
if (openId.value) {
const { data } = await platformApi.get<PlatformOrganizationDto>(
`/platform/organizations/${openId.value}`,
);
detail.value = data;
}
} catch (e) {
error.value = apiError(e, 'تغییر عضویت ناموفق بود');
} finally {
busyId.value = '';
}
}
async function setMembershipRole(membershipId: string, role: UserRole) {
busyId.value = `role-${membershipId}`;
error.value = '';
try {
await platformApi.patch(`/platform/memberships/${membershipId}/role`, { role });
if (openId.value) {
const { data } = await platformApi.get<PlatformOrganizationDto>(
`/platform/organizations/${openId.value}`,
);
detail.value = data;
moduleDraft.value = [...(data.enabledModules ?? [])];
}
} catch (e) {
error.value = apiError(e, 'تغییر نقش ناموفق بود');
} finally {
busyId.value = '';
}
}
function toggleModule(key: OrgModuleKey) {
if (moduleDraft.value.includes(key)) {
moduleDraft.value = moduleDraft.value.filter((k) => k !== key);
} else {
moduleDraft.value = [...moduleDraft.value, key];
}
}
function selectGroup(keys: OrgModuleKey[], on: boolean) {
if (on) {
moduleDraft.value = [...new Set([...moduleDraft.value, ...keys])];
} else {
moduleDraft.value = moduleDraft.value.filter((k) => !keys.includes(k));
}
}
async function saveModules(orgId: string) {
savingModules.value = true;
error.value = '';
success.value = '';
try {
const { data } = await platformApi.patch<PlatformOrganizationDto>(
`/platform/organizations/${orgId}/modules`,
{ enabledModules: moduleDraft.value },
);
detail.value = data;
moduleDraft.value = [...(data.enabledModules ?? [])];
success.value = 'ماژول‌های محیط ذخیره شد';
} catch (e) {
error.value = apiError(e, 'ذخیره ماژول‌ها ناموفق بود');
} finally {
savingModules.value = false;
}
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<header class="head">
<p class="lead">ساخت، ویرایش، ماژول‌ها و نقش اعضا.</p>
<div class="head-actions">
<form class="search" @submit.prevent="load">
<input v-model="query" type="search" placeholder="جستجوی نام یا اسلاگ" />
<button class="btn" type="submit" :disabled="loading">جستجو</button>
</form>
<button class="btn" type="button" @click="showCreate = !showCreate">
{{ showCreate ? 'بستن فرم' : 'محیط جدید' }}
</button>
</div>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="success" class="ok">{{ success }}</p>
<form v-if="showCreate" class="panel" @submit.prevent="createOrganization">
<h2>ساخت محیط جدید</h2>
<div class="grid">
<label>
<span>نام محیط</span>
<input v-model="createForm.name" required minlength="2" />
</label>
<label>
<span>زمینه فعالیت</span>
<select v-model="createForm.businessField">
<option v-for="field in BUSINESS_FIELDS" :key="field" :value="field">
{{ BUSINESS_FIELD_LABELS[field] }}
</option>
</select>
</label>
<label>
<span>نام مالک</span>
<input v-model="createForm.ownerFullName" required minlength="2" />
</label>
<label>
<span>ایمیل مالک</span>
<input v-model="createForm.ownerEmail" type="email" required />
</label>
<label>
<span>رمز مالک</span>
<input v-model="createForm.ownerPassword" type="password" required minlength="8" />
</label>
<label>
<span>موبایل مالک (اختیاری)</span>
<input v-model="createForm.ownerMobile" type="tel" dir="ltr" />
</label>
</div>
<div class="panel-actions">
<button class="btn" type="submit" :disabled="creating">
{{ creating ? 'در حال ساخت…' : 'ساخت محیط' }}
</button>
</div>
</form>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="!items.length" class="muted">محیطی پیدا نشد.</p>
<div v-else class="list">
<article v-for="org in items" :key="org.id" class="row">
<button type="button" class="row-main" @click="toggleOpen(org)">
<div>
<strong>{{ org.name }}</strong>
<span>{{ org.slug }} · {{ fieldLabel(org.businessField) }}</span>
</div>
<div class="meta">
<em :class="org.isActive ? 'on' : 'off'">{{ org.isActive ? 'فعال' : 'غیرفعال' }}</em>
<small>
{{ toFaDigits(org.memberCount) }} عضو ·
{{ toFaDigits(org.personCount) }} شخص ·
{{ formatJalaliNumeric(new Date(org.createdAt)) }}
</small>
</div>
</button>
<div class="actions">
<button class="btn btn-ghost" type="button" @click="startEdit(org)">ویرایش</button>
<button
class="btn"
type="button"
:disabled="busyId === org.id"
@click="setOrgActive(org, !org.isActive)"
>
{{ org.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
</button>
</div>
<form
v-if="editingId === org.id"
class="edit-panel"
@submit.prevent="saveEdit(org.id)"
>
<label>
<span>نام</span>
<input v-model="editForm.name" required minlength="2" />
</label>
<label>
<span>اسلاگ</span>
<input v-model="editForm.slug" required minlength="2" dir="ltr" />
</label>
<label>
<span>زمینه فعالیت</span>
<select v-model="editForm.businessField">
<option value="">—</option>
<option v-for="field in BUSINESS_FIELDS" :key="field" :value="field">
{{ BUSINESS_FIELD_LABELS[field] }}
</option>
</select>
</label>
<div class="panel-actions">
<button class="btn" type="submit" :disabled="savingEdit">ذخیره</button>
<button class="btn btn-ghost" type="button" @click="cancelEdit">انصراف</button>
</div>
</form>
<div v-if="openId === org.id" class="detail">
<div class="modules">
<div class="modules-head">
<strong>ماژول‌های فعال</strong>
<button
class="btn"
type="button"
:disabled="savingModules"
@click="saveModules(org.id)"
>
{{ savingModules ? 'ذخیره…' : 'ذخیره ماژول‌ها' }}
</button>
</div>
<div v-for="group in ORG_MODULE_GROUPS" :key="group.title" class="mod-group">
<div class="mod-group-head">
<span>{{ group.title }}</span>
<button
class="btn btn-ghost"
type="button"
@click="selectGroup(group.keys, true)"
>
همه
</button>
<button
class="btn btn-ghost"
type="button"
@click="selectGroup(group.keys, false)"
>
هیچ‌کدام
</button>
</div>
<label v-for="key in group.keys" :key="key" class="mod-item">
<input
type="checkbox"
:checked="moduleDraft.includes(key)"
@change="toggleModule(key)"
/>
{{ ORG_MODULE_LABELS[key] }}
</label>
</div>
</div>
<p v-if="!detail?.members?.length" class="muted">عضوی ثبت نشده.</p>
<ul v-else>
<li v-for="member in detail.members" :key="member.membershipId">
<div>
<strong>{{ member.fullName }}</strong>
<span>
{{ member.email }}
· حساب {{ member.userIsActive ? 'فعال' : 'غیرفعال' }}
</span>
</div>
<div class="member-actions">
<select
:value="member.role"
:disabled="busyId === `role-${member.membershipId}`"
@change="
setMembershipRole(
member.membershipId,
($event.target as HTMLSelectElement).value as UserRole,
)
"
>
<option v-for="role in USER_ROLES" :key="role" :value="role">
{{ USER_ROLE_LABELS[role] }}
</option>
</select>
<button
class="btn btn-ghost"
type="button"
:disabled="busyId === member.membershipId"
@click="setMembershipActive(member.membershipId, !member.isActive)"
>
{{ member.isActive ? 'قطع دسترسی' : 'بازگشت دسترسی' }}
</button>
</div>
</li>
</ul>
</div>
</article>
</div>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 0.95rem;
max-width: 1080px;
}
.head {
display: flex;
justify-content: space-between;
gap: 0.85rem;
flex-wrap: wrap;
align-items: center;
}
.lead,
.head p,
.muted {
margin: 0;
color: var(--muted);
font-size: 0.88rem;
}
.head-actions {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
align-items: center;
}
.search {
display: flex;
gap: 0.45rem;
padding: 0.25rem;
border-radius: 14px;
background: #fff;
border: 1px solid var(--line-light);
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.04);
}
.search input,
.panel input,
.panel select,
.edit-panel input,
.edit-panel select,
.member-actions select {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.6rem 0.75rem;
font: inherit;
background: #fff;
}
.search input {
min-width: 180px;
border: 0;
box-shadow: none;
}
.error {
margin: 0;
color: var(--danger);
}
.ok {
margin: 0;
color: var(--success-deep);
}
.panel,
.edit-panel {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 18px;
padding: 1rem;
display: grid;
gap: 0.85rem;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04);
}
.panel h2 {
margin: 0;
font-size: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
label {
display: grid;
gap: 0.35rem;
}
label span {
font-size: 0.82rem;
color: var(--muted);
font-weight: 700;
}
.panel-actions {
display: flex;
gap: 0.5rem;
}
.list {
display: grid;
gap: 0.7rem;
}
.row {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 18px;
padding: 0.95rem 1rem;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.7rem 1rem;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.035);
}
.row-main {
display: flex;
justify-content: space-between;
gap: 1rem;
border: 0;
background: transparent;
text-align: start;
cursor: pointer;
padding: 0;
font: inherit;
color: inherit;
}
.row-main span,
.row-main small {
display: block;
color: var(--muted);
font-size: 0.8rem;
margin-top: 0.2rem;
}
.meta {
text-align: end;
}
.actions {
display: flex;
gap: 0.45rem;
align-items: flex-start;
}
.on,
.off {
font-style: normal;
font-size: 0.78rem;
font-weight: 800;
}
.on {
color: var(--success-deep);
}
.off {
color: var(--danger);
}
.edit-panel {
grid-column: 1 / -1;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: end;
}
.edit-panel .panel-actions {
grid-column: 1 / -1;
}
.detail {
grid-column: 1 / -1;
border-top: 1px solid var(--line);
padding-top: 0.75rem;
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 {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.55rem;
}
.detail li {
display: flex;
justify-content: space-between;
gap: 0.8rem;
align-items: center;
flex-wrap: wrap;
}
.detail span {
display: block;
color: var(--muted);
font-size: 0.8rem;
}
.member-actions {
display: flex;
gap: 0.45rem;
align-items: center;
}
@media (max-width: 720px) {
.grid,
.edit-panel {
grid-template-columns: 1fr;
}
.search {
width: 100%;
}
.search input {
flex: 1;
min-width: 0;
}
.row {
grid-template-columns: 1fr;
}
}
</style>
+499
View File
@@ -0,0 +1,499 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import type {
ApplyBillingPlanRequest,
BillingPlanCode,
PlatformUserDto,
UserRole,
} from '@mento/shared';
import {
BILLING_PLAN_CODES,
BILLING_PLAN_LABELS,
USER_ROLE_LABELS,
USER_ROLES,
} from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
const query = ref('');
const loading = ref(false);
const error = ref('');
const success = ref('');
const items = ref<PlatformUserDto[]>([]);
const openId = ref<string | null>(null);
const busyId = ref('');
const resetUserId = ref<string | null>(null);
const resetPassword = ref('');
const resetting = ref(false);
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await platformApi.get<PlatformUserDto[]>('/platform/users', {
params: query.value.trim() ? { q: query.value.trim() } : undefined,
});
items.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری کاربران ناموفق بود');
} finally {
loading.value = false;
}
}
function toggleOpen(id: string) {
openId.value = openId.value === id ? null : id;
if (resetUserId.value !== id) {
resetUserId.value = null;
resetPassword.value = '';
}
}
function startReset(userId: string) {
resetUserId.value = resetUserId.value === userId ? null : userId;
resetPassword.value = '';
error.value = '';
success.value = '';
}
async function submitReset(userId: string) {
resetting.value = true;
error.value = '';
success.value = '';
try {
await platformApi.patch(`/platform/users/${userId}/password`, {
password: resetPassword.value,
});
success.value = 'رمز عبور به‌روز شد';
resetUserId.value = null;
resetPassword.value = '';
} catch (e) {
error.value = apiError(e, 'ریست رمز ناموفق بود');
} finally {
resetting.value = false;
}
}
async function setUserActive(user: PlatformUserDto, isActive: boolean) {
busyId.value = user.id;
error.value = '';
try {
const { data } = await platformApi.patch<PlatformUserDto>(
`/platform/users/${user.id}`,
{ isActive },
);
items.value = items.value.map((row) => (row.id === user.id ? data : row));
} catch (e) {
error.value = apiError(e, 'تغییر وضعیت کاربر ناموفق بود');
} finally {
busyId.value = '';
}
}
async function setMembershipActive(userId: string, membershipId: string, isActive: boolean) {
busyId.value = membershipId;
error.value = '';
try {
const { data } = await platformApi.patch<PlatformUserDto>(
`/platform/memberships/${membershipId}`,
{ isActive },
);
items.value = items.value.map((row) => (row.id === userId ? data : row));
} catch (e) {
error.value = apiError(e, 'تغییر عضویت ناموفق بود');
} finally {
busyId.value = '';
}
}
async function setMembershipRole(userId: string, membershipId: string, role: UserRole) {
busyId.value = `role-${membershipId}`;
error.value = '';
try {
const { data } = await platformApi.patch<PlatformUserDto>(
`/platform/memberships/${membershipId}/role`,
{ role },
);
items.value = items.value.map((row) => (row.id === userId ? data : row));
} catch (e) {
error.value = apiError(e, 'تغییر نقش ناموفق بود');
} finally {
busyId.value = '';
}
}
async function applyPlan(userId: string, planCode: BillingPlanCode) {
busyId.value = `plan-${userId}-${planCode}`;
error.value = '';
success.value = '';
try {
const body: ApplyBillingPlanRequest = { planCode };
const { data } = await platformApi.post<PlatformUserDto>(
`/platform/users/${userId}/billing/apply-plan`,
body,
);
items.value = items.value.map((row) => (row.id === userId ? data : row));
success.value = `پلن «${BILLING_PLAN_LABELS[planCode]}» اعمال شد`;
} catch (e) {
error.value = apiError(e, 'اعمال پلن ناموفق بود');
} finally {
busyId.value = '';
}
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<header class="head">
<p class="lead">اشتراک مالک، نقش عضویت و ریست رمز.</p>
<form class="search" @submit.prevent="load">
<input v-model="query" type="search" placeholder="نام، ایمیل یا موبایل" />
<button class="btn" type="submit" :disabled="loading">جستجو</button>
</form>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="success" class="ok">{{ success }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="!items.length" class="muted">کاربری پیدا نشد.</p>
<div v-else class="list">
<article v-for="user in items" :key="user.id" class="row">
<button type="button" class="row-main" @click="toggleOpen(user.id)">
<div>
<strong>{{ user.fullName }}</strong>
<span>{{ user.email }}{{ user.mobile ? ` · ${user.mobile}` : '' }}</span>
</div>
<div class="meta">
<em :class="user.isActive ? 'on' : 'off'">{{ user.isActive ? 'فعال' : 'غیرفعال' }}</em>
<small>
{{ toFaDigits(user.memberships.length) }} محیط
<template v-if="user.lastLoginAt">
· آخرین ورود {{ formatJalaliNumeric(new Date(user.lastLoginAt)) }}
</template>
</small>
</div>
</button>
<div class="actions">
<button class="btn btn-ghost" type="button" @click="startReset(user.id)">
ریست رمز
</button>
<button
class="btn"
type="button"
:disabled="busyId === user.id"
@click="setUserActive(user, !user.isActive)"
>
{{ user.isActive ? 'غیرفعال کردن حساب' : 'فعال کردن حساب' }}
</button>
</div>
<form
v-if="resetUserId === user.id"
class="reset-panel"
@submit.prevent="submitReset(user.id)"
>
<label>
<span>رمز جدید (حداقل ۸ کاراکتر)</span>
<input
v-model="resetPassword"
type="password"
required
minlength="8"
autocomplete="new-password"
/>
</label>
<div class="panel-actions">
<button class="btn" type="submit" :disabled="resetting">
{{ resetting ? 'در حال ذخیره…' : 'ذخیره رمز' }}
</button>
<button class="btn btn-ghost" type="button" @click="startReset(user.id)">
انصراف
</button>
</div>
</form>
<div v-if="openId === user.id" class="detail">
<div v-if="user.isBillingOwner" 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-else class="muted billing-skip">
این کاربر عضو زیرمجموعه است و اشتراک جدا ندارد؛ اشتراک روی مالک محیط است.
</p>
<p v-if="!user.memberships.length" class="muted">عضو هیچ محیطی نیست.</p>
<ul v-else>
<li v-for="mem in user.memberships" :key="mem.membershipId">
<div>
<strong>{{ mem.organizationName }}</strong>
<span>
محیط {{ mem.organizationIsActive ? 'فعال' : 'غیرفعال' }}
</span>
</div>
<div class="member-actions">
<select
:value="mem.role"
:disabled="busyId === `role-${mem.membershipId}`"
@change="
setMembershipRole(
user.id,
mem.membershipId,
($event.target as HTMLSelectElement).value as UserRole,
)
"
>
<option v-for="role in USER_ROLES" :key="role" :value="role">
{{ USER_ROLE_LABELS[role] }}
</option>
</select>
<button
class="btn btn-ghost"
type="button"
:disabled="busyId === mem.membershipId"
@click="setMembershipActive(user.id, mem.membershipId, !mem.isActive)"
>
{{ mem.isActive ? 'قطع دسترسی' : 'بازگشت دسترسی' }}
</button>
</div>
</li>
</ul>
</div>
</article>
</div>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 0.95rem;
max-width: 1080px;
}
.head {
display: flex;
justify-content: space-between;
gap: 0.85rem;
flex-wrap: wrap;
align-items: center;
}
.lead,
.head p,
.muted {
margin: 0;
color: var(--muted);
font-size: 0.88rem;
}
.search {
display: flex;
gap: 0.45rem;
padding: 0.25rem;
border-radius: 14px;
background: #fff;
border: 1px solid var(--line-light);
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.04);
}
.search input,
.reset-panel input,
.member-actions select {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.6rem 0.75rem;
font: inherit;
background: #fff;
}
.search input {
min-width: 180px;
border: 0;
box-shadow: none;
}
.error {
margin: 0;
color: var(--danger);
}
.ok {
margin: 0;
color: var(--success-deep);
}
.list {
display: grid;
gap: 0.7rem;
}
.row {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 18px;
padding: 0.95rem 1rem;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.7rem 1rem;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.035);
}
.row-main {
display: flex;
justify-content: space-between;
gap: 1rem;
border: 0;
background: transparent;
text-align: start;
cursor: pointer;
padding: 0;
font: inherit;
color: inherit;
}
.row-main span,
.row-main small {
display: block;
color: var(--muted);
font-size: 0.8rem;
margin-top: 0.2rem;
}
.meta {
text-align: end;
}
.actions {
display: flex;
gap: 0.45rem;
align-items: flex-start;
flex-wrap: wrap;
}
.on,
.off {
font-style: normal;
font-size: 0.78rem;
font-weight: 800;
}
.on {
color: var(--success-deep);
}
.off {
color: var(--danger);
}
.reset-panel {
grid-column: 1 / -1;
background: #f8fafc;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.85rem;
display: grid;
gap: 0.7rem;
}
.reset-panel label {
display: grid;
gap: 0.35rem;
}
.reset-panel span {
font-size: 0.82rem;
color: var(--muted);
font-weight: 700;
}
.panel-actions {
display: flex;
gap: 0.5rem;
}
.detail {
grid-column: 1 / -1;
border-top: 1px solid var(--line);
padding-top: 0.75rem;
}
.detail ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.55rem;
}
.detail li {
display: flex;
justify-content: space-between;
gap: 0.8rem;
align-items: center;
flex-wrap: wrap;
}
.detail span {
display: block;
color: var(--muted);
font-size: 0.8rem;
}
.billing {
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;
}
.billing-skip {
margin: 0 0 0.75rem;
}
.member-actions {
display: flex;
gap: 0.45rem;
align-items: center;
}
</style>
+6
View File
@@ -39,3 +39,9 @@ STT_INTERNAL_TOKEN=change-me-stt-internal-token
STT_TIMEOUT_MS=60000
INTEGRATIONS_ENCRYPTION_KEY=change-me-64-hex-chars-use-openssl-rand-hex-32
# سوپرادمین پلتفرم — ورود با موبایل + رمز
PLATFORM_ADMIN_MOBILE=09121234567
PLATFORM_ADMIN_PASSWORD=change-me-strong-platform-password
PLATFORM_ADMIN_NAME=سوپرادمین
# PLATFORM_ADMIN_EMAIL=
+7
View File
@@ -44,3 +44,10 @@ STT_TIMEOUT_MS=60000
# رمزنگاری توکن‌های اتصال حسابداری (ته‌حساب) — ۳۲ بایت hex (۶۴ کاراکتر)
INTEGRATIONS_ENCRYPTION_KEY=change-me-64-hex-chars-use-openssl-rand-hex-32
# سوپرادمین پلتفرم — crm.montzar.ir/adm (ورود با موبایل + رمز)
# بعد از تنظیم، از apps/api: npx prisma db seed
PLATFORM_ADMIN_MOBILE=09121234567
PLATFORM_ADMIN_PASSWORD=change-me-strong-platform-password
PLATFORM_ADMIN_NAME=سوپرادمین
# PLATFORM_ADMIN_EMAIL=
+33 -1
View File
@@ -87,6 +87,7 @@ nano apps/api/.env
| `SMS_*` | همان ترِز مونتایم |
| `PORTAL_SHOW_OTP` | در production حتماً `false` |
| `INTEGRATIONS_ENCRYPTION_KEY` | ۶۴ کاراکتر hex برای رمز توکن ته‌حساب (`openssl rand -hex 32`) |
| `PLATFORM_ADMIN_MOBILE` / `PASSWORD` / `NAME` | حساب اولیه سوپرادمین برای `/adm` (با `npx prisma db seed`) |
نمونهٔ اتصال:
@@ -185,7 +186,38 @@ sudo nginx -t && sudo systemctl reload nginx
1. `https://crm.montzar.ir` → ورود پنل
2. دمو: `owner@demo.local` / `demo1234` (اگر seed زده باشید)
3. پرتال: `https://crm.montzar.ir/p/demo-mento` → OTP باید پیامک شود
4. منوها: گزارشات، کاریز پروژه، باشگاه مشتریان، اتوماسیون (مالک/مدیر)
4. منوها: گزارشات، کاریز پروژه، باشگاه مشتریان، اتوماسیون (مالک/مدیر)
5. سوپرادمین: `https://crm.montzar.ir/adm` (بعد از seed با `PLATFORM_ADMIN_*`)
---
## پنل سوپرادمین (`/adm`)
مسیر جدا روی nginx لازم نیست؛ همان SPA (`crm.montzar.ir`) روت `/adm` را سرو می‌کند. ورود با **موبایل + رمز** است.
1. در `apps/api/.env` این کلیدها را بگذارید (نمونه در `deploy/.env.production.example`):
```text
PLATFORM_ADMIN_MOBILE=09121234567
PLATFORM_ADMIN_PASSWORD=change-me-strong-platform-password
PLATFORM_ADMIN_NAME=سوپرادمین
```
2. بعد از `prisma migrate deploy` یک‌بار seed بزنید تا حساب اولیه ساخته شود:
```bash
cd /var/www/crm/apps/api
npx prisma db seed
```
اگر حساب با همان موبایل از قبل باشد، seed رمز را هم از `.env` به‌روز می‌کند. ادمین‌های بعدی را از داخل پنل (`/adm/admins`) اضافه کنید.
### اشتراک و ماژول‌ها
بعد از این نسخه، `prisma migrate deploy` جدول `BillingAccount` و ستون `enabledModules` را می‌سازد و برای مالکان فعلی trial **۱۵ روزه** می‌سازد. از `/adm/users` پلن را تمدید کنید و از `/adm/organizations` ماژول هر محیط را تنظیم کنید.
3. ورود: `https://crm.montzar.ir/adm` → داشبورد `/adm/dashboard`
توکن سوپرادمین جدا از 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
@@ -0,0 +1,202 @@
# Design: BillingAccount + per-org modules + /adm polish
**Date:** 2026-09-20
**Status:** Approved for planning
**Scope:** Superadmin (`/adm`) subscription control, per-organization module entitlements, CRM enforcement, and visual polish of the platform admin UI.
## Goals
1. **Subscription on account owner** via a dedicated `BillingAccount` (not raw fields on `Organization`).
2. **Modules per environment** — every CRM menu surface can be enabled/disabled per org from `/adm`.
3. **Manual plan activation now**; structure ready for a future landing-page payment gateway (no gateway in this phase).
4. **Expiry UX:** show remaining days in the CRM app header; warn from 3 days before; after expiry **block login** with a clear “اشتراک منقضی شده” message.
5. **Migrate existing accounts:** create a `BillingAccount` with plan `trial` and **15 days from migration time** so current customers keep working until extended.
6. **Polish `/adm` UI** to feel more app-like (same Vue + CRM CSS tokens; no separate Ant Design/Tailwind app).
## Non-goals (later)
- Online payment gateway / landing checkout
- Impersonate user login
- Complex multi-tier platform-admin roles
- Automatic invoice generation for Mentekt SaaS itself
## Decisions (locked)
| Topic | Choice |
|---|---|
| Account vs org | Both: subscription on owner (`BillingAccount`); modules on each `Organization` |
| Toggleable modules | Entire menu surface (not only add-ons) |
| Payment | Manual in `/adm` now; `planCode` + optional payment ref reserved for gateway |
| On expiry | Block CRM login; header shows remaining days; warn ≤3 days |
| Existing orgs/users | Backfill trial **15 days** from migrate/seed time |
| Data model style | Dedicated `BillingAccount` entity (approach 2) |
---
## Data model
### `BillingAccount`
One billing account per **owner user** (the SaaS customer).
| Field | Type | Notes |
|---|---|---|
| `id` | cuid | PK |
| `ownerUserId` | string unique | FK → `User` — the account holder |
| `planCode` | string | `trial` \| `monthly` \| `quarterly` \| `semiannual` \| `yearly` \| `custom` |
| `status` | string | `active` \| `expired` \| `suspended` |
| `startsAt` | DateTime | |
| `expiresAt` | DateTime | |
| `maxOrganizations` | int | e.g. trial demo = 1 |
| `externalPaymentRef` | string? | Reserved for future gateway / landing |
| `notes` | string? | Superadmin internal note |
| `createdAt` / `updatedAt` | DateTime | |
Indexes: `ownerUserId` unique; `expiresAt`; `status`.
**Ownership rule:** The billing owner is the user who first registered / is treated as account holder. Membership in other orgs does not create extra billing accounts. Creating a new org as owner still counts against `maxOrganizations` of that user’s `BillingAccount`.
### `Organization.enabledModules`
- `enabledModules String[]` on `Organization` (Prisma).
- Canonical keys live in `@mento/shared` (see Module catalog).
- Empty array after migration for existing orgs means **all modules enabled** (backward compatible), OR we backfill the full catalog — prefer **backfill full catalog** so “empty” can later mean “nothing enabled” if needed.
**Locked choice:** migration sets `enabledModules` to the **full default catalog** for existing orgs. New orgs get a default catalog from the plan template (trial = core set configurable in code; superadmin can change immediately).
### Plan presets (manual buttons in `/adm`)
| planCode | Default duration | Default maxOrgs |
|---|---|---|
| `trial` | 15 days | 1 |
| `monthly` | 30 days | configurable (default 3) |
| `quarterly` | 90 days | configurable (default 5) |
| `semiannual` | 180 days | configurable (default 10) |
| `yearly` | 365 days | configurable (default 20) |
| `custom` | superadmin picks `expiresAt` + `maxOrganizations` | as set |
Extending an active plan from `/adm` sets `startsAt` (if missing), `expiresAt` = now + duration (or custom date), `status = active`, updates `planCode`.
### Backfill migration
1. For every `User` who has at least one `Membership` with `role = owner` (or is the sole owner of an org), ensure one `BillingAccount`:
- Prefer: one account per distinct owner user who owns ≥1 org.
- `planCode = trial`, `status = active`, `startsAt = now`, `expiresAt = now + 15 days`, `maxOrganizations = max(1, current org count they own)` so nobody is suddenly blocked for having multiple orgs already.
2. Set `Organization.enabledModules` to full catalog for all existing orgs.
---
## Module catalog
Keys align with nav hubs/cards (and API surfaces). Shared constant e.g. `ORG_MODULE_KEYS` / labels in `@mento/shared`.
Suggested keys (final list in implementation may split cards 1:1 with nav):
- `customers` (people + companies)
- `pipeline` (sales pipeline / deals)
- `products`
- `invoices`
- `quotes`
- `commissions`
- `sales_forecast`
- `activities`
- `tickets`
- `workflow_automations`
- `message_automations`
- `lead_forms`
- `project_boards`
- `inventory`
- `cheques`
- `attachments`
- `loyalty` (if present in nav/settings)
- `reports` (if in nav)
- `notes` / smart notes if separately gated
- `tahesab`
- `custom_fields`
- `currencies`
- `team_settings` (keep always-on for owners? **Locked:** still toggleable, but warn in UI that disabling team settings locks self-service; login/org switcher remain available)
Settings hub items are individually toggleable. Core shell (home, profile, logout, org switcher) stays available when subscription is active.
---
## Enforcement
### Login / session
On CRM password login and org switch / token refresh:
1. Resolve `BillingAccount` for the user (by `ownerUserId`).
- If user is **not** a billing owner (invited member only): use the **org owner’s** billing account for the active organization (subscription is per owner account that owns the env).
- **Locked rule:** Entitlement for entering an org is the billing account of that org’s **active owner membership** (any active owner). If multiple owners, any one’s active billing is enough; prefer the earliest owner membership’s user.
2. If `status !== active` OR `expiresAt < now` → reject with message اشتراک منقضی / معلق؛ mark status `expired` lazily when detected.
3. Members of an expired owner cannot log into that org either.
### Create organization
- Only if caller has `BillingAccount` with `status = active` and `expiresAt >= now`.
- Count orgs where user has `role = owner` membership; must be `< maxOrganizations`.
### Modules
- **Web:** filter `NAV_HUBS` cards/shortcuts and router guards by `organization.enabledModules` (loaded with org/session payload).
- **API:** guard controllers/services for module-gated routes; return 403 with clear Persian message if module disabled.
- Role permissions (`RolePermissionKey`) remain orthogonal: module must be on **and** role must allow.
### Remaining days UI
- Include `subscription: { planCode, status, expiresAt, daysRemaining, warn }` in login/me/org bootstrap DTO.
- App header chip: «N روز باقی‌مانده».
- If `daysRemaining <= 3` and `> 0`: warning style banner/chip.
- If expired: user never reaches shell (login blocked).
---
## Platform admin (`/adm`) API
Authenticated with existing platform-admin JWT.
- `GET/PATCH` billing for a user: plan, dates, maxOrgs, status, notes.
- Shortcuts: `POST .../billing/apply-plan` with `{ planCode, maxOrganizations?, expiresAt? }`.
- `PATCH /platform/organizations/:id/modules` with `{ enabledModules: string[] }`.
- List users/orgs already exists; extend DTOs with billing summary + module flags.
---
## Platform admin UI polish
Keep Vue 3 + existing CSS variables (`--accent`, `--line`, cards). Improvements:
- Denserv app-like shell: clearer hierarchy, sticky subheaders, soft gradients consistent with CRM auth pages.
- User detail: billing card with plan chips + date picker (Jalali) + max orgs.
- Org detail: module checklist grouped by hub (مشتریان / فروش / …) with select-all per group.
- Dashboard KPIs: optional counts of expiring ≤3 days / expired (nice-to-have in same phase if cheap).
No Ant Design / Tailwind.
---
## Future gateway hook
Landing page later will:
1. Create/find `BillingAccount` by owner.
2. After payment success, set `planCode`, `expiresAt`, `externalPaymentRef`, `status = active`.
No payment code in this phase; do not invent webhook handlers yet.
---
## Risks & edge cases
- Users with multiple owned orgs today: backfill `maxOrganizations` ≥ current owned count.
- Invited users: gated by org owner’s subscription.
- Disabling a module mid-use: existing data kept; API/UI blocked until re-enabled.
- Superadmin `/adm` login unaffected by BillingAccount.
## Testing (manual)
1. Fresh seed/migrate → existing owner has 15-day trial; can log in; header shows days.
2. Set expiresAt to yesterday → login fails with expiry message.
3. Disable `message_automations` on an org → menu gone + API 403.
4. Trial maxOrgs=1 with one owned org → create second org rejected.
5. Apply `monthly` from `/adm` → expiresAt ~+30d, status active.
+356 -2
View File
@@ -37,6 +37,223 @@ export interface AuthUserDto {
organizationId: string;
organizationName?: string;
hasAvatar?: boolean;
/** مالک محیط فعلی — اشتراک متعلق به مالک است نه عضو زیرمجموعه */
isOrgOwner?: 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 {
@@ -245,6 +462,7 @@ export interface OrganizationDto {
baseCurrency: string;
enabledCurrencies: string[];
exchangeRates: ExchangeRateDto[];
enabledModules: OrgModuleKey[];
}
export interface UpdateOrganizationRequest {
@@ -423,8 +641,8 @@ export interface PersonDto {
}
export interface CreatePersonRequest {
firstName: string;
lastName: string;
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
mobile?: string;
@@ -850,6 +1068,25 @@ export interface ImportContactsResult {
boards?: string[];
}
export interface ExcelFieldOption {
key: string;
label: string;
required?: boolean;
}
export interface ExcelColumnPreview {
header: string;
index: number;
samples: string[];
}
export interface ExcelPreviewResult {
columns: ExcelColumnPreview[];
fields: ExcelFieldOption[];
suggested: Record<string, string>;
needsMapping: boolean;
}
export interface ProductDto {
id: string;
kind: "product" | "service";
@@ -2496,6 +2733,123 @@ export interface CreateMessageAutomationRuleRequest {
export type UpdateMessageAutomationRuleRequest =
Partial<CreateMessageAutomationRuleRequest>;
export interface PlatformAdminDto {
id: string;
mobile?: string | null;
email?: string | null;
fullName: string;
isActive?: boolean;
lastLoginAt?: string | null;
createdAt?: string;
}
export interface PlatformLoginRequest {
mobile: string;
password: string;
}
export interface PlatformLoginResponse {
accessToken: string;
admin: PlatformAdminDto;
}
export interface CreatePlatformAdminRequest {
fullName: string;
mobile: string;
password: string;
email?: string;
}
export interface ResetPlatformAdminPasswordRequest {
password: string;
}
export interface PlatformStatsDto {
organizationCount: number;
activeOrganizationCount: number;
userCount: number;
activeUserCount: number;
membershipCount: number;
expiringSoonCount?: number;
expiredBillingCount?: number;
}
export interface PlatformOrgMemberDto {
membershipId: string;
userId: string;
fullName: string;
email: string;
mobile?: string | null;
role: UserRole;
isActive: boolean;
userIsActive: boolean;
}
export interface PlatformOrganizationDto {
id: string;
name: string;
slug: string;
businessField?: string | null;
isActive: boolean;
createdAt: string;
memberCount: number;
personCount: number;
dealCount: number;
enabledModules?: OrgModuleKey[];
members?: PlatformOrgMemberDto[];
}
export interface PlatformUserMembershipDto {
membershipId: string;
organizationId: string;
organizationName: string;
organizationIsActive: boolean;
role: UserRole;
isActive: boolean;
}
export interface PlatformUserDto {
id: string;
email: string;
mobile?: string | null;
fullName: string;
isActive: boolean;
lastLoginAt?: string | null;
createdAt: string;
memberships: PlatformUserMembershipDto[];
/** فقط اگر حداقل یک عضویت مالک فعال دارد */
isBillingOwner?: boolean;
billing?: BillingAccountDto | null;
}
export interface UpdatePlatformStatusRequest {
isActive: boolean;
}
export interface CreatePlatformOrganizationRequest {
name: string;
businessField?: string;
ownerFullName: string;
ownerEmail: string;
ownerPassword: string;
ownerMobile?: string;
}
export interface UpdatePlatformOrganizationRequest {
name?: string;
slug?: string;
businessField?: string | null;
isActive?: boolean;
}
export interface UpdatePlatformMembershipRoleRequest {
role: UserRole;
}
export interface ResetPlatformUserPasswordRequest {
password: string;
}
export { brand, APP_NAME, APP_NAME_FA, brandSuggestionFa } from "./brand";
export type { Brand } from "./brand";
export const API_PREFIX = "api";