Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebf13cfde5 | ||
|
|
0e580b19c3 | ||
|
|
4a9fe0b07c | ||
|
|
04d37a9d12 | ||
|
|
605e1d3340 | ||
|
|
b016b4754f | ||
|
|
ff5ad21eb9 | ||
|
|
4776d1a3c3 | ||
|
|
9c7dad3677 | ||
|
|
a428c6f135 | ||
|
|
3b643d7bb9 |
@@ -24,4 +24,12 @@ SMS_SENDER=
|
|||||||
# مثال ساخت: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
# مثال ساخت: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
INTEGRATIONS_ENCRYPTION_KEY=
|
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
|
# 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;
|
||||||
@@ -147,6 +147,9 @@ model Organization {
|
|||||||
businessField String?
|
businessField String?
|
||||||
baseCurrency String @default("IRR")
|
baseCurrency String @default("IRR")
|
||||||
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
|
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
|
||||||
|
/// ماژولهای فعال منو برای این محیط
|
||||||
|
enabledModules String[] @default([])
|
||||||
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
users User[]
|
users User[]
|
||||||
@@ -229,6 +232,7 @@ model User {
|
|||||||
ticketMessages TicketMessage[] @relation("TicketMessageAuthor")
|
ticketMessages TicketMessage[] @relation("TicketMessageAuthor")
|
||||||
confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy")
|
confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy")
|
||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
|
billingAccount BillingAccount?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -278,6 +282,42 @@ model Membership {
|
|||||||
@@index([orgRoleId])
|
@@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 {
|
model Company {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
organizationId String
|
organizationId String
|
||||||
|
|||||||
@@ -13,7 +13,103 @@ const DEFAULT_STAGES = [
|
|||||||
{ name: 'از دسترفته', isWon: false, isLost: true },
|
{ name: 'از دسترفته', isWon: false, isLost: true },
|
||||||
] as const;
|
] 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() {
|
async function main() {
|
||||||
|
await seedPlatformAdmin();
|
||||||
|
|
||||||
const email = 'owner@demo.local';
|
const email = 'owner@demo.local';
|
||||||
let org = await prisma.organization.findUnique({ where: { slug: 'demo-mento' } });
|
let org = await prisma.organization.findUnique({ where: { slug: 'demo-mento' } });
|
||||||
if (!org) {
|
if (!org) {
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import type { Actor } from '../common/record-scope';
|
|||||||
import { assigneeWhere } from '../common/record-scope';
|
import { assigneeWhere } from '../common/record-scope';
|
||||||
import { normalizePhone } from '../contacts/normalize';
|
import { normalizePhone } from '../contacts/normalize';
|
||||||
import {
|
import {
|
||||||
|
excelFieldsFromColumns,
|
||||||
formatClock,
|
formatClock,
|
||||||
formatJalaliDate,
|
formatJalaliDate,
|
||||||
|
parseExcelMapping,
|
||||||
parseJalaliDateTime,
|
parseJalaliDateTime,
|
||||||
|
previewExcel,
|
||||||
readMappedRows,
|
readMappedRows,
|
||||||
stripHtml,
|
stripHtml,
|
||||||
writeXlsx,
|
writeXlsx,
|
||||||
|
type ExcelPreviewResult,
|
||||||
} from '../common/excel';
|
} from '../common/excel';
|
||||||
|
|
||||||
const ACTIVITY_COLUMNS = [
|
const ACTIVITY_COLUMNS = [
|
||||||
@@ -143,8 +147,16 @@ export class ActivitiesExcelService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
|
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||||
const rows = await readMappedRows(file, ACTIVITY_ALIASES);
|
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: [] };
|
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||||
|
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
|||||||
@@ -79,6 +79,17 @@ export class ActivitiesController {
|
|||||||
return new StreamableFile(buffer);
|
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')
|
@Post('import')
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor('file', {
|
FileInterceptor('file', {
|
||||||
@@ -89,8 +100,9 @@ export class ActivitiesController {
|
|||||||
import(
|
import(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@Body('mapping') mapping?: string,
|
||||||
) {
|
) {
|
||||||
return this.excelService.import(user, file);
|
return this.excelService.import(user, file, mapping);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import { MemoryModule } from './memory/memory.module';
|
|||||||
import { TahesabModule } from './integrations/tahesab/tahesab.module';
|
import { TahesabModule } from './integrations/tahesab/tahesab.module';
|
||||||
import { InventoryModule } from './inventory/inventory.module';
|
import { InventoryModule } from './inventory/inventory.module';
|
||||||
import { FinanceModule } from './finance/finance.module';
|
import { FinanceModule } from './finance/finance.module';
|
||||||
|
import { PlatformModule } from './platform/platform.module';
|
||||||
|
import { BillingModule } from './billing/billing.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -44,7 +46,9 @@ import { FinanceModule } from './finance/finance.module';
|
|||||||
PrismaModule,
|
PrismaModule,
|
||||||
StorageModule,
|
StorageModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
BillingModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
PlatformModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
OrgRolesModule,
|
OrgRolesModule,
|
||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { JwtModule } from '@nestjs/jwt';
|
|||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { MembershipsModule } from '../memberships/memberships.module';
|
import { MembershipsModule } from '../memberships/memberships.module';
|
||||||
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
@@ -13,6 +14,7 @@ import { SmsService } from '../common/sms.service';
|
|||||||
imports: [
|
imports: [
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
MembershipsModule,
|
MembershipsModule,
|
||||||
|
BillingModule,
|
||||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
|
|||||||
@@ -10,12 +10,19 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { AuthUserDto, LoginResponse, LoginResult } from '@mento/shared';
|
import type {
|
||||||
|
AuthUserDto,
|
||||||
|
LoginResponse,
|
||||||
|
LoginResult,
|
||||||
|
OrgModuleKey,
|
||||||
|
SubscriptionSummaryDto,
|
||||||
|
} from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SmsService } from '../common/sms.service';
|
||||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||||
import { MembershipsService } from '../memberships/memberships.service';
|
import { MembershipsService } from '../memberships/memberships.service';
|
||||||
|
import { BillingService } from '../billing/billing.service';
|
||||||
import {
|
import {
|
||||||
LoginDto,
|
LoginDto,
|
||||||
LoginOtpRequestDto,
|
LoginOtpRequestDto,
|
||||||
@@ -97,6 +104,7 @@ export class AuthService {
|
|||||||
private readonly sms: SmsService,
|
private readonly sms: SmsService,
|
||||||
private readonly files: LocalFileStorage,
|
private readonly files: LocalFileStorage,
|
||||||
private readonly memberships: MembershipsService,
|
private readonly memberships: MembershipsService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -260,6 +268,11 @@ export class AuthService {
|
|||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
role: 'owner',
|
role: 'owner',
|
||||||
});
|
});
|
||||||
|
await this.billing.ensureTrialForOwner(owner.id, { maxOrganizations: 1 });
|
||||||
|
await this.prisma.organization.update({
|
||||||
|
where: { id: organization.id },
|
||||||
|
data: { enabledModules: this.billing.defaultModulesForNewOrg() },
|
||||||
|
});
|
||||||
|
|
||||||
await this.prisma.authChallenge.update({
|
await this.prisma.authChallenge.update({
|
||||||
where: { id: challenge.id },
|
where: { id: challenge.id },
|
||||||
@@ -578,6 +591,9 @@ export class AuthService {
|
|||||||
if (!user || user.isActive === false) {
|
if (!user || user.isActive === false) {
|
||||||
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
||||||
}
|
}
|
||||||
|
const subscription = await this.billing.requireActiveForOrganization(
|
||||||
|
organizationId,
|
||||||
|
);
|
||||||
await this.memberships.syncUserLastOrg(
|
await this.memberships.syncUserLastOrg(
|
||||||
userId,
|
userId,
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -597,6 +613,7 @@ export class AuthService {
|
|||||||
user: await this.toAuthUserDto(
|
user: await this.toAuthUserDto(
|
||||||
{ ...user, role: membership.role, organizationId },
|
{ ...user, role: membership.role, organizationId },
|
||||||
organizationId,
|
organizationId,
|
||||||
|
subscription,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -611,19 +628,30 @@ export class AuthService {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
},
|
},
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
|
subscription?: SubscriptionSummaryDto | null,
|
||||||
): Promise<AuthUserDto> {
|
): Promise<AuthUserDto> {
|
||||||
const membership = await this.prisma.membership.findFirst({
|
const membership = await this.prisma.membership.findFirst({
|
||||||
where: { userId: user.id, organizationId, isActive: true },
|
where: { userId: user.id, organizationId, isActive: true },
|
||||||
include: { organization: { select: { name: true } } },
|
include: {
|
||||||
|
organization: { select: { name: true, enabledModules: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const role = membership?.role ?? user.role;
|
const role = membership?.role ?? user.role;
|
||||||
const orgName = membership?.organization.name;
|
const orgName = membership?.organization.name;
|
||||||
|
const sub =
|
||||||
|
subscription ??
|
||||||
|
(await this.billing.requireActiveForOrganization(organizationId));
|
||||||
return this.toAuthUser({
|
return this.toAuthUser({
|
||||||
...user,
|
...user,
|
||||||
role,
|
role,
|
||||||
organizationId,
|
organizationId,
|
||||||
organizationName: orgName,
|
organizationName: orgName,
|
||||||
avatarStorageKey: await this.readAvatarKey(user.id),
|
avatarStorageKey: await this.readAvatarKey(user.id),
|
||||||
|
enabledModules: this.billing.normalizeModules(
|
||||||
|
membership?.organization.enabledModules,
|
||||||
|
),
|
||||||
|
subscription: sub,
|
||||||
|
isOrgOwner: role === 'owner',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,6 +736,9 @@ export class AuthService {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
organizationName?: string;
|
organizationName?: string;
|
||||||
avatarStorageKey?: string | null;
|
avatarStorageKey?: string | null;
|
||||||
|
enabledModules?: OrgModuleKey[];
|
||||||
|
subscription?: SubscriptionSummaryDto | null;
|
||||||
|
isOrgOwner?: boolean;
|
||||||
}): AuthUserDto {
|
}): AuthUserDto {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -718,6 +749,9 @@ export class AuthService {
|
|||||||
organizationName: user.organizationName,
|
organizationName: user.organizationName,
|
||||||
hasAvatar: Boolean(user.avatarStorageKey),
|
hasAvatar: Boolean(user.avatarStorageKey),
|
||||||
...(user.mobile ? { mobile: user.mobile } : {}),
|
...(user.mobile ? { mobile: user.mobile } : {}),
|
||||||
|
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
|
||||||
|
...(user.subscription ? { subscription: user.subscription } : {}),
|
||||||
|
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { BillingService } from '../billing/billing.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
export type JwtPayload = {
|
export type JwtPayload = {
|
||||||
@@ -15,6 +16,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
constructor(
|
constructor(
|
||||||
config: ConfigService,
|
config: ConfigService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
@@ -24,7 +26,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async validate(payload: JwtPayload & { typ?: string; mobile?: string }) {
|
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();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
if (!payload.sub || !payload.organizationId) {
|
if (!payload.sub || !payload.organizationId) {
|
||||||
@@ -42,12 +48,14 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
organizationId: payload.organizationId,
|
organizationId: payload.organizationId,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
organization: { isActive: true },
|
||||||
},
|
},
|
||||||
select: { organizationId: true, role: true },
|
select: { organizationId: true, role: true },
|
||||||
});
|
});
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
await this.billing.requireActiveForOrganization(membership.organizationId);
|
||||||
return {
|
return {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
organizationId: membership.organizationId,
|
organizationId: membership.organizationId,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { BillingService } from './billing.service';
|
||||||
|
import { OrgModuleGuard } from './org-module.guard';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [BillingService, OrgModuleGuard],
|
||||||
|
exports: [BillingService, OrgModuleGuard],
|
||||||
|
})
|
||||||
|
export class BillingModule {}
|
||||||
@@ -0,0 +1,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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -188,9 +188,121 @@ export async function workbookFromUpload(file: Express.Multer.File | undefined)
|
|||||||
return sheet;
|
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(
|
export async function readMappedRows(
|
||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
aliases: Record<string, string>,
|
aliases: Record<string, string>,
|
||||||
|
mapping?: Record<string, string>,
|
||||||
): Promise<Record<string, string>[]> {
|
): Promise<Record<string, string>[]> {
|
||||||
const sheet = await workbookFromUpload(file);
|
const sheet = await workbookFromUpload(file);
|
||||||
const headerRow = sheet.getRow(1);
|
const headerRow = sheet.getRow(1);
|
||||||
@@ -198,13 +310,12 @@ export async function readMappedRows(
|
|||||||
headerRow.eachCell((cell, col) => {
|
headerRow.eachCell((cell, col) => {
|
||||||
const raw = cellStr(cell.value);
|
const raw = cellStr(cell.value);
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
const compact = normalizeHeader(raw);
|
const key = resolveFieldKey(raw, aliases, mapping);
|
||||||
const key = aliases[compact] ?? aliases[raw.trim()] ?? aliases[normalizeHeader(raw)];
|
|
||||||
if (key) map.set(col, key);
|
if (key) map.set(col, key);
|
||||||
});
|
});
|
||||||
if (!map.size) {
|
if (!map.size) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'ستونهای فایل شناسایی نشد. از قالب خروجی همین سیستم یا خروجی دیدار استفاده کنید.',
|
'ستونهای فایل شناسایی نشد. ستونهای فایل را با فیلدهای سیستم تطبیق دهید.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,13 +56,20 @@ export class CompaniesController {
|
|||||||
return new StreamableFile(buffer);
|
return new StreamableFile(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('import/preview')
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
previewImport(@UploadedFile() file: Express.Multer.File) {
|
||||||
|
return this.excelService.previewCompanies(file);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
import(
|
import(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@Body('mapping') mapping?: string,
|
||||||
) {
|
) {
|
||||||
return this.excelService.importCompanies(user, file);
|
return this.excelService.importCompanies(user, file, mapping);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import type { Actor } from '../common/record-scope';
|
|||||||
import { ownerWhere } from '../common/record-scope';
|
import { ownerWhere } from '../common/record-scope';
|
||||||
import {
|
import {
|
||||||
formatJalaliDate,
|
formatJalaliDate,
|
||||||
|
parseExcelMapping,
|
||||||
|
previewExcel,
|
||||||
readMappedRows,
|
readMappedRows,
|
||||||
writeXlsx,
|
writeXlsx,
|
||||||
|
type ExcelPreviewResult,
|
||||||
} from '../common/excel';
|
} from '../common/excel';
|
||||||
import { CompaniesService } from './companies.service';
|
import { CompaniesService } from './companies.service';
|
||||||
import { PeopleService } from './people.service';
|
import { PeopleService } from './people.service';
|
||||||
@@ -107,6 +110,19 @@ const PERSON_ALIASES: Record<string, string> = {
|
|||||||
ستاره: 'star',
|
ستاره: '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> = {
|
const COMPANY_ALIASES: Record<string, string> = {
|
||||||
name: 'name',
|
name: 'name',
|
||||||
نام: 'name',
|
نام: 'name',
|
||||||
@@ -126,6 +142,16 @@ const COMPANY_ALIASES: Record<string, string> = {
|
|||||||
یادداشت: 'notes',
|
یادداشت: '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 = [
|
const CONTACT_COLUMNS = [
|
||||||
{ header: '_type', key: '_type', width: 12 },
|
{ header: '_type', key: '_type', width: 12 },
|
||||||
{ header: 'کد دیدار مشتری', key: 'didarId', width: 16 },
|
{ 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(
|
async importPeople(
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
|
mappingRaw?: string,
|
||||||
): Promise<ImportContactsResult> {
|
): 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: [] };
|
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||||
|
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
@@ -307,9 +342,18 @@ export class ContactsExcelService {
|
|||||||
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'contact') {
|
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'contact') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const names = splitDidarName(row.firstName ?? '', row.lastName ?? '', row.title);
|
const names = splitDidarName(row.firstName ?? '', row.lastName ?? '', row.title) ?? {
|
||||||
if (!names) {
|
firstName: (row.firstName ?? '').trim().slice(0, 80),
|
||||||
result.errors.push({ row: rowNum, message: 'نام مشتری الزامی است' });
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,8 +401,9 @@ export class ContactsExcelService {
|
|||||||
async importCompanies(
|
async importCompanies(
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
|
mappingRaw?: string,
|
||||||
): Promise<ImportContactsResult> {
|
): 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: [] };
|
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||||
|
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
|||||||
@@ -135,15 +135,15 @@ export class UpdateCompanyDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CreatePersonDto {
|
export class CreatePersonDto {
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
firstName!: string;
|
firstName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
lastName!: string;
|
lastName?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@@ -192,13 +192,11 @@ export class CreatePersonDto {
|
|||||||
export class UpdatePersonDto {
|
export class UpdatePersonDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -18,13 +18,16 @@ import type { Response } from 'express';
|
|||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
|
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||||
|
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||||
import { CreatePersonDto, ListQueryDto, UpdatePersonDto } from './dto';
|
import { CreatePersonDto, ListQueryDto, UpdatePersonDto } from './dto';
|
||||||
import { PeopleService } from './people.service';
|
import { PeopleService } from './people.service';
|
||||||
import { ContactsExcelService } from './contacts-excel.service';
|
import { ContactsExcelService } from './contacts-excel.service';
|
||||||
import { brand } from '@mento/shared';
|
import { brand } from '@mento/shared';
|
||||||
|
|
||||||
@Controller('people')
|
@Controller('people')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||||
|
@RequireOrgModule('customers')
|
||||||
export class PeopleController {
|
export class PeopleController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly peopleService: PeopleService,
|
private readonly peopleService: PeopleService,
|
||||||
@@ -57,13 +60,22 @@ export class PeopleController {
|
|||||||
return new StreamableFile(buffer);
|
return new StreamableFile(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('import/preview')
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
previewImport(
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.excelService.previewPeople(file);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
import(
|
import(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@Body('mapping') mapping?: string,
|
||||||
) {
|
) {
|
||||||
return this.excelService.importPeople(user, file);
|
return this.excelService.importPeople(user, file, mapping);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@@ -73,7 +85,11 @@ export class PeopleController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
create(@CurrentUser() user: Actor, @Body() dto: CreatePersonDto) {
|
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')
|
@Patch(':id')
|
||||||
|
|||||||
@@ -134,8 +134,8 @@ export class PeopleService {
|
|||||||
|
|
||||||
const data: Prisma.PersonUncheckedCreateInput = {
|
const data: Prisma.PersonUncheckedCreateInput = {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
firstName: dto.firstName.trim(),
|
firstName: (dto.firstName ?? '').trim(),
|
||||||
lastName: dto.lastName.trim(),
|
lastName: (dto.lastName ?? '').trim(),
|
||||||
email: normalizeEmail(dto.email),
|
email: normalizeEmail(dto.email),
|
||||||
phone: normalizePhone(dto.phone),
|
phone: normalizePhone(dto.phone),
|
||||||
mobile: normalizePhone(dto.mobile),
|
mobile: normalizePhone(dto.mobile),
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export class MembershipsService {
|
|||||||
|
|
||||||
async listActive(userId: string) {
|
async listActive(userId: string) {
|
||||||
return this.prisma.membership.findMany({
|
return this.prisma.membership.findMany({
|
||||||
where: { userId, isActive: true },
|
where: { userId, isActive: true, organization: { isActive: true } },
|
||||||
include: {
|
include: {
|
||||||
organization: { select: { id: true, name: true } },
|
organization: { select: { id: true, name: true } },
|
||||||
orgRole: { select: { name: true } },
|
orgRole: { select: { name: true } },
|
||||||
@@ -35,12 +35,24 @@ export class MembershipsService {
|
|||||||
): Promise<MembershipActor> {
|
): Promise<MembershipActor> {
|
||||||
const row = await this.prisma.membership.findFirst({
|
const row = await this.prisma.membership.findFirst({
|
||||||
where: { userId, organizationId, isActive: true },
|
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) {
|
if (!row) {
|
||||||
throw new ForbiddenException('به این محیط دسترسی ندارید');
|
throw new ForbiddenException('به این محیط دسترسی ندارید');
|
||||||
}
|
}
|
||||||
return row;
|
if (row.organization.isActive === false) {
|
||||||
|
throw new UnauthorizedException('این محیط غیرفعال است');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userId: row.userId,
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
role: row.role,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
toChoices(
|
toChoices(
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import { Roles } from '../auth/roles.decorator';
|
import { Roles } from '../auth/roles.decorator';
|
||||||
import { RolesGuard } from '../auth/roles.guard';
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
|
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||||
|
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||||
import {
|
import {
|
||||||
CreateMessageAutomationRuleDto,
|
CreateMessageAutomationRuleDto,
|
||||||
UpdateMessageAutomationRuleDto,
|
UpdateMessageAutomationRuleDto,
|
||||||
@@ -19,7 +21,8 @@ import {
|
|||||||
import { MessageAutomationsService } from './message-automations.service';
|
import { MessageAutomationsService } from './message-automations.service';
|
||||||
|
|
||||||
@Controller('message-automations')
|
@Controller('message-automations')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||||
|
@RequireOrgModule('message_automations')
|
||||||
@Roles('owner', 'admin')
|
@Roles('owner', 'admin')
|
||||||
export class MessageAutomationsController {
|
export class MessageAutomationsController {
|
||||||
constructor(private readonly messages: MessageAutomationsService) {}
|
constructor(private readonly messages: MessageAutomationsService) {}
|
||||||
|
|||||||
@@ -3,11 +3,18 @@ import { AuthModule } from '../auth/auth.module';
|
|||||||
import { MembershipsModule } from '../memberships/memberships.module';
|
import { MembershipsModule } from '../memberships/memberships.module';
|
||||||
import { OrgRolesModule } from '../org-roles/org-roles.module';
|
import { OrgRolesModule } from '../org-roles/org-roles.module';
|
||||||
import { PipelinesModule } from '../pipelines/pipelines.module';
|
import { PipelinesModule } from '../pipelines/pipelines.module';
|
||||||
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { OrganizationsController } from './organizations.controller';
|
import { OrganizationsController } from './organizations.controller';
|
||||||
import { OrganizationsService } from './organizations.service';
|
import { OrganizationsService } from './organizations.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, MembershipsModule, OrgRolesModule, PipelinesModule],
|
imports: [
|
||||||
|
AuthModule,
|
||||||
|
MembershipsModule,
|
||||||
|
OrgRolesModule,
|
||||||
|
PipelinesModule,
|
||||||
|
BillingModule,
|
||||||
|
],
|
||||||
controllers: [OrganizationsController],
|
controllers: [OrganizationsController],
|
||||||
providers: [OrganizationsService],
|
providers: [OrganizationsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { isKnownCurrency } from '../common/currency';
|
|||||||
import { MembershipsService } from '../memberships/memberships.service';
|
import { MembershipsService } from '../memberships/memberships.service';
|
||||||
import { OrgRolesService } from '../org-roles/org-roles.service';
|
import { OrgRolesService } from '../org-roles/org-roles.service';
|
||||||
import { PipelinesService } from '../pipelines/pipelines.service';
|
import { PipelinesService } from '../pipelines/pipelines.service';
|
||||||
|
import { BillingService } from '../billing/billing.service';
|
||||||
|
|
||||||
function slugify(input: string): string {
|
function slugify(input: string): string {
|
||||||
return (
|
return (
|
||||||
@@ -34,6 +35,7 @@ export class OrganizationsService {
|
|||||||
private readonly memberships: MembershipsService,
|
private readonly memberships: MembershipsService,
|
||||||
private readonly orgRoles: OrgRolesService,
|
private readonly orgRoles: OrgRolesService,
|
||||||
private readonly pipelines: PipelinesService,
|
private readonly pipelines: PipelinesService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createForUser(
|
async createForUser(
|
||||||
@@ -57,6 +59,8 @@ export class OrganizationsService {
|
|||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.billing.assertCanCreateOrganization(userId);
|
||||||
|
|
||||||
const baseSlug = slugify(name);
|
const baseSlug = slugify(name);
|
||||||
let slug = baseSlug;
|
let slug = baseSlug;
|
||||||
let i = 1;
|
let i = 1;
|
||||||
@@ -69,6 +73,7 @@ export class OrganizationsService {
|
|||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
businessField,
|
businessField,
|
||||||
|
enabledModules: this.billing.defaultModulesForNewOrg(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.memberships.createMembership({
|
await this.memberships.createMembership({
|
||||||
@@ -76,6 +81,7 @@ export class OrganizationsService {
|
|||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
role: 'owner',
|
role: 'owner',
|
||||||
});
|
});
|
||||||
|
await this.billing.ensureTrialForOwner(userId);
|
||||||
await this.orgRoles.ensureDefaultRoles(organization.id);
|
await this.orgRoles.ensureDefaultRoles(organization.id);
|
||||||
await this.pipelines.ensureDefaultPipeline(organization.id);
|
await this.pipelines.ensureDefaultPipeline(organization.id);
|
||||||
return { id: organization.id };
|
return { id: organization.id };
|
||||||
@@ -219,6 +225,7 @@ export class OrganizationsService {
|
|||||||
businessField: string | null;
|
businessField: string | null;
|
||||||
baseCurrency: string;
|
baseCurrency: string;
|
||||||
enabledCurrencies: string[];
|
enabledCurrencies: string[];
|
||||||
|
enabledModules?: string[];
|
||||||
exchangeRates: Array<{
|
exchangeRates: Array<{
|
||||||
currency: string;
|
currency: string;
|
||||||
rateToBase: Prisma.Decimal;
|
rateToBase: Prisma.Decimal;
|
||||||
@@ -235,6 +242,7 @@ export class OrganizationsService {
|
|||||||
: null,
|
: null,
|
||||||
baseCurrency: org.baseCurrency,
|
baseCurrency: org.baseCurrency,
|
||||||
enabledCurrencies: org.enabledCurrencies,
|
enabledCurrencies: org.enabledCurrencies,
|
||||||
|
enabledModules: this.billing.normalizeModules(org.enabledModules),
|
||||||
exchangeRates: org.exchangeRates.map((r) => ({
|
exchangeRates: org.exchangeRates.map((r) => ({
|
||||||
currency: r.currency,
|
currency: r.currency,
|
||||||
rateToBase: Number(r.rateToBase),
|
rateToBase: Number(r.rateToBase),
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ import { ownerWhere, resolveOwnerId } from '../common/record-scope';
|
|||||||
import { toJsonCustomFields } from '../common/custom-fields';
|
import { toJsonCustomFields } from '../common/custom-fields';
|
||||||
import { normalizePhone } from '../contacts/normalize';
|
import { normalizePhone } from '../contacts/normalize';
|
||||||
import {
|
import {
|
||||||
|
excelFieldsFromColumns,
|
||||||
formatJalaliDate,
|
formatJalaliDate,
|
||||||
parseAmount,
|
parseAmount,
|
||||||
|
parseExcelMapping,
|
||||||
parseJalaliDateTime,
|
parseJalaliDateTime,
|
||||||
|
previewExcel,
|
||||||
readMappedRows,
|
readMappedRows,
|
||||||
writeXlsx,
|
writeXlsx,
|
||||||
|
type ExcelPreviewResult,
|
||||||
} from '../common/excel';
|
} from '../common/excel';
|
||||||
import { PipelinesService } from './pipelines.service';
|
import { PipelinesService } from './pipelines.service';
|
||||||
|
|
||||||
@@ -144,8 +148,16 @@ export class DealsExcelService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
|
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||||
const rows = await readMappedRows(file, DEAL_ALIASES);
|
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 result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||||
const affected = new Map<string, string>();
|
const affected = new Map<string, string>();
|
||||||
|
|
||||||
|
|||||||
@@ -18,13 +18,16 @@ import type { Response } from 'express';
|
|||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
|
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||||
|
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||||
import { CreateDealDto, MoveDealDto, UpdateDealDto } from './dto';
|
import { CreateDealDto, MoveDealDto, UpdateDealDto } from './dto';
|
||||||
import { DealsService } from './deals.service';
|
import { DealsService } from './deals.service';
|
||||||
import { DealsExcelService } from './deals-excel.service';
|
import { DealsExcelService } from './deals-excel.service';
|
||||||
import { brand } from '@mento/shared';
|
import { brand } from '@mento/shared';
|
||||||
|
|
||||||
@Controller('deals')
|
@Controller('deals')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||||
|
@RequireOrgModule('pipeline')
|
||||||
export class DealsController {
|
export class DealsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly dealsService: DealsService,
|
private readonly dealsService: DealsService,
|
||||||
@@ -45,6 +48,19 @@ export class DealsController {
|
|||||||
return new StreamableFile(buffer);
|
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')
|
@Post('import')
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor('file', {
|
FileInterceptor('file', {
|
||||||
@@ -55,8 +71,9 @@ export class DealsController {
|
|||||||
import(
|
import(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@Body('mapping') mapping?: string,
|
||||||
) {
|
) {
|
||||||
return this.excelService.import(user, file);
|
return this.excelService.import(user, file, mapping);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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 type { ImportContactsResult } from '@mento/shared';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
|
excelFieldsFromColumns,
|
||||||
parseAmount,
|
parseAmount,
|
||||||
|
parseExcelMapping,
|
||||||
|
previewExcel,
|
||||||
readMappedRows,
|
readMappedRows,
|
||||||
writeXlsx,
|
writeXlsx,
|
||||||
|
type ExcelPreviewResult,
|
||||||
} from '../common/excel';
|
} from '../common/excel';
|
||||||
import { ProductsService } from './products.service';
|
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(
|
async import(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
|
mappingRaw?: string,
|
||||||
): Promise<ImportContactsResult> {
|
): 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: [] };
|
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||||
|
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
|||||||
@@ -62,6 +62,18 @@ export class ProductsController {
|
|||||||
return new StreamableFile(buffer);
|
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')
|
@Post('import')
|
||||||
@Roles('owner', 'admin')
|
@Roles('owner', 'admin')
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
@@ -73,8 +85,9 @@ export class ProductsController {
|
|||||||
import(
|
import(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@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')
|
@Get(':id')
|
||||||
|
|||||||
@@ -128,6 +128,17 @@ export class ProjectBoardsController {
|
|||||||
return new StreamableFile(buffer);
|
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')
|
@Post('project-cards/import')
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor('file', {
|
FileInterceptor('file', {
|
||||||
@@ -138,8 +149,9 @@ export class ProjectBoardsController {
|
|||||||
importCards(
|
importCards(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@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')
|
@Get('project-cards/:id')
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import type { Actor } from '../common/record-scope';
|
|||||||
import { ownerWhere } from '../common/record-scope';
|
import { ownerWhere } from '../common/record-scope';
|
||||||
import { normalizePhone } from '../contacts/normalize';
|
import { normalizePhone } from '../contacts/normalize';
|
||||||
import {
|
import {
|
||||||
|
excelFieldsFromColumns,
|
||||||
formatJalaliDate,
|
formatJalaliDate,
|
||||||
|
parseExcelMapping,
|
||||||
parseJalaliDateTime,
|
parseJalaliDateTime,
|
||||||
|
previewExcel,
|
||||||
readMappedRows,
|
readMappedRows,
|
||||||
stripHtml,
|
stripHtml,
|
||||||
writeXlsx,
|
writeXlsx,
|
||||||
|
type ExcelPreviewResult,
|
||||||
} from '../common/excel';
|
} from '../common/excel';
|
||||||
import { ProjectBoardsService } from './project-boards.service';
|
import { ProjectBoardsService } from './project-boards.service';
|
||||||
|
|
||||||
@@ -105,8 +109,16 @@ export class ProjectCardsExcelService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
|
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||||
const rows = await readMappedRows(file, CASE_ALIASES);
|
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 result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||||
const affected = new Map<string, string>();
|
const affected = new Map<string, string>();
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 596 KiB |
@@ -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>
|
||||||
@@ -78,8 +78,18 @@ let dragMoved = false;
|
|||||||
let dragOffsetX = 0;
|
let dragOffsetX = 0;
|
||||||
let dragOffsetY = 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 {
|
function defaultPos(): FabPos {
|
||||||
const extra = viewport.value.w <= 900 ? 64 : 0;
|
const extra = mobileTabbarPx();
|
||||||
return {
|
return {
|
||||||
left: FAB_MARGIN_LEFT,
|
left: FAB_MARGIN_LEFT,
|
||||||
top: Math.max(8, viewport.value.h - FAB_MARGIN_BOTTOM - extra - FAB_SIZE),
|
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 {
|
function clampPos(pos: FabPos): FabPos {
|
||||||
const margin = 8;
|
const margin = 8;
|
||||||
const bottomGap = viewport.value.w <= 900 ? 64 : 0;
|
const bottomGap = mobileTabbarPx();
|
||||||
return {
|
return {
|
||||||
left: Math.min(Math.max(margin, pos.left), viewport.value.w - FAB_SIZE - margin),
|
left: Math.min(Math.max(margin, pos.left), viewport.value.w - FAB_SIZE - margin),
|
||||||
top: Math.min(
|
top: Math.min(
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ defineProps<{
|
|||||||
| 'grid'
|
| 'grid'
|
||||||
| 'list'
|
| 'list'
|
||||||
| 'eye'
|
| 'eye'
|
||||||
|
| 'eye-off'
|
||||||
|
| 'lock'
|
||||||
| 'truck'
|
| 'truck'
|
||||||
| 'grip'
|
| 'grip'
|
||||||
| 'apps-plus'
|
| '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" />
|
<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" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
</template>
|
</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'">
|
<template v-else-if="name === 'truck'">
|
||||||
<path d="M3 7.5h11V16H3z" />
|
<path d="M3 7.5h11V16H3z" />
|
||||||
<path d="M14 10h4.2l2.3 3.2V16H14z" />
|
<path d="M14 10h4.2l2.3 3.2V16H14z" />
|
||||||
|
|||||||
@@ -10,44 +10,37 @@ withDefaults(
|
|||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
headline: 'همه ارتباطات، یکجا مدیریت میشوند',
|
headline: 'همه ارتباطات، یکجا مدیریت میشوند',
|
||||||
description: `با ${brand.nameFa} فروش، پشتیبانی و ارتباط با مشتری را در یک پنل یکپارچه مدیریت کنید و رشد پایدار بسازید.`,
|
description: `${brand.nameFa} به شما کمک می کند فروش بیشتر داشته باشید،\nمشتریان را بهتر بشناسید و کسب و کارتان را رشد دهید.`,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class="brand-panel" aria-hidden="false">
|
<aside class="brand-panel" aria-hidden="false">
|
||||||
<div class="brand-head">
|
<div class="brand-main">
|
||||||
<MentoLogo :size="44" :show-text="false" />
|
<div class="brand-head">
|
||||||
<div class="brand-copy">
|
<MentoLogo :size="70" :show-text="false" />
|
||||||
<BrandName />
|
<div class="brand-copy">
|
||||||
<span>{{ brand.taglineFa }}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="brand-body">
|
<nav class="brand-foot">
|
||||||
<h2>{{ headline }}</h2>
|
<a href="#">شرایط و قوانین</a>
|
||||||
<p>{{ description }}</p>
|
<span>|</span>
|
||||||
</div>
|
<a href="#">تماس با ما</a>
|
||||||
|
</nav>
|
||||||
<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>
|
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -56,184 +49,110 @@ withDefaults(
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: none;
|
display: none;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
align-items: center;
|
||||||
padding: clamp(1.4rem, 3vw, 2rem);
|
justify-content: center;
|
||||||
background:
|
text-align: center;
|
||||||
radial-gradient(circle at 20% 10%, rgba(45, 212, 191, 0.18), transparent 42%),
|
gap: 0;
|
||||||
linear-gradient(165deg, #f0fdfa 0%, #e6fffa 48%, #f8fafc 100%);
|
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;
|
overflow: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-head {
|
.brand-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-copy {
|
.brand-copy {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.1rem;
|
gap: 0.1rem;
|
||||||
|
text-align: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-copy strong {
|
.brand-copy :deep(.brand-name) {
|
||||||
font-size: 1.2rem;
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-weight: 600;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-copy span {
|
.brand-copy .tagline {
|
||||||
font-size: 0.78rem;
|
font-size: 0.88rem;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-body {
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.brand-body h2 {
|
.brand-body h2 {
|
||||||
margin: 0 0 0.55rem;
|
margin: 0 0 0.6rem;
|
||||||
font-size: clamp(1.35rem, 2.2vw, 1.75rem);
|
font-size: clamp(1.22rem, 1.95vw, 1.52rem);
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-body p {
|
.brand-body p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
line-height: 1.75;
|
line-height: 1.7;
|
||||||
font-size: 0.95rem;
|
font-size: 1.08rem;
|
||||||
max-width: 28ch;
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
.illustration {
|
.illustration {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: auto;
|
width: 100%;
|
||||||
min-height: 220px;
|
display: grid;
|
||||||
flex: 1;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash {
|
.illustration img {
|
||||||
|
width: min(100%, 460px);
|
||||||
|
height: auto;
|
||||||
|
max-height: 360px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-foot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset-inline-start: 12%;
|
inset-inline: 0;
|
||||||
bottom: 18%;
|
bottom: 1.15rem;
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
justify-content: center;
|
||||||
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;
|
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
height: 48px;
|
color: #94a3b8;
|
||||||
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-bars i {
|
.brand-foot a {
|
||||||
flex: 1;
|
color: #94a3b8;
|
||||||
border-radius: 6px 6px 2px 2px;
|
text-decoration: none;
|
||||||
background: linear-gradient(180deg, #5eead4, #0f766e);
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-bars i:nth-child(1) { height: 45%; }
|
.brand-foot a:hover {
|
||||||
.chart-bars i:nth-child(2) { height: 70%; }
|
color: #0f766e;
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ const model = defineModel<string | number>({ default: '' });
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ui-input::placeholder {
|
.ui-input::placeholder {
|
||||||
color: var(--muted-light);
|
color: #c0c8d4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-input-wrapper:focus-within {
|
.ui-input-wrapper:focus-within {
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
|
|
||||||
.dialog {
|
.dialog {
|
||||||
width: min(880px, 100%);
|
width: min(880px, 100%);
|
||||||
max-height: min(88vh, 880px);
|
max-height: min(88dvh, 88vh, 880px);
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
@@ -119,9 +120,10 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
.body {
|
.body {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
overscroll-behavior: contain;
|
||||||
flex-direction: column;
|
-webkit-overflow-scrolling: touch;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -130,10 +132,8 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
.body :deep(.page.embedded),
|
.body :deep(.page.embedded),
|
||||||
.body :deep(.product-form-page.embedded),
|
.body :deep(.product-form-page.embedded),
|
||||||
.body :deep(.company-form-page.embedded) {
|
.body :deep(.company-form-page.embedded) {
|
||||||
display: flex;
|
min-height: 0;
|
||||||
flex-direction: column;
|
max-width: 100%;
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.body :deep(.page.embedded > .form),
|
.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 > form),
|
.body :deep(.crm-page.embedded > form),
|
||||||
.body :deep(.crm-page.embedded > .crm-form) {
|
.body :deep(.crm-page.embedded > .crm-form) {
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +208,7 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
|
flex: 1 1 0%;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const model = defineModel<string>({ default: '' });
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ui-textarea::placeholder {
|
.ui-textarea::placeholder {
|
||||||
color: var(--muted-light);
|
color: #c0c8d4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-textarea:focus {
|
.ui-textarea:focus {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -79,17 +79,43 @@ const moreSectionActive = computed(
|
|||||||
settingsActive.value,
|
settingsActive.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const enabledModules = computed(() => auth.user?.enabledModules ?? null);
|
||||||
|
const subscription = computed(() => auth.user?.subscription ?? null);
|
||||||
|
|
||||||
|
function moduleAllowed(module?: string) {
|
||||||
|
if (!module) return true;
|
||||||
|
if (!enabledModules.value) return true;
|
||||||
|
return enabledModules.value.includes(module as never);
|
||||||
|
}
|
||||||
|
|
||||||
const openHub = computed<NavHubDef | null>(() => {
|
const openHub = computed<NavHubDef | null>(() => {
|
||||||
if (!openHubKey.value) return null;
|
if (!openHubKey.value) return null;
|
||||||
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
|
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
|
||||||
if (!hub) return null;
|
if (!hub) return null;
|
||||||
return {
|
return {
|
||||||
...hub,
|
...hub,
|
||||||
cards: hub.cards.filter((card) => !card.admin || canSettings.value),
|
cards: hub.cards.filter(
|
||||||
shortcuts: hub.shortcuts.filter((item) => !item.admin || canSettings.value),
|
(card) =>
|
||||||
|
(!card.admin || canSettings.value) && moduleAllowed(card.module),
|
||||||
|
),
|
||||||
|
shortcuts: hub.shortcuts.filter(
|
||||||
|
(item) =>
|
||||||
|
(!item.admin || canSettings.value) && moduleAllowed(item.module),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hubVisible = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
for (const hub of NAV_HUBS) {
|
||||||
|
map[hub.key] = hub.cards.some(
|
||||||
|
(card) =>
|
||||||
|
(!card.admin || canSettings.value) && moduleAllowed(card.module),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
function givenName(fullName: string): string {
|
function givenName(fullName: string): string {
|
||||||
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
||||||
if (parts.length <= 1) return fullName.trim();
|
if (parts.length <= 1) return fullName.trim();
|
||||||
@@ -368,7 +394,7 @@ onUnmounted(() => {
|
|||||||
<span>داشبورد</span>
|
<span>داشبورد</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<div class="nav-fold" :class="{ active: customersActive || openHubKey === 'customers' }">
|
<div class="nav-fold" v-if="hubVisible.customers" :class="{ active: customersActive || openHubKey === 'customers' }">
|
||||||
<button
|
<button
|
||||||
class="nav-item nav-fold-btn"
|
class="nav-item nav-fold-btn"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -383,7 +409,7 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-fold" :class="{ active: salesActive || openHubKey === 'sales' }">
|
<div class="nav-fold" v-if="hubVisible.sales" :class="{ active: salesActive || openHubKey === 'sales' }">
|
||||||
<button
|
<button
|
||||||
class="nav-item nav-fold-btn"
|
class="nav-item nav-fold-btn"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -398,7 +424,7 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-fold" :class="{ active: activitiesActive || openHubKey === 'activities' }">
|
<div class="nav-fold" v-if="hubVisible.activities" :class="{ active: activitiesActive || openHubKey === 'activities' }">
|
||||||
<button
|
<button
|
||||||
class="nav-item nav-fold-btn"
|
class="nav-item nav-fold-btn"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -600,6 +626,17 @@ onUnmounted(() => {
|
|||||||
<RouterLink class="round-btn" :to="{ name: 'tickets' }" title="پیامها">
|
<RouterLink class="round-btn" :to="{ name: 'tickets' }" title="پیامها">
|
||||||
<UiIcon name="mail" :size="18" />
|
<UiIcon name="mail" :size="18" />
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<div
|
||||||
|
v-if="subscription"
|
||||||
|
class="sub-chip"
|
||||||
|
:class="{ warn: subscription.warn }"
|
||||||
|
:title="`انقضا: ${subscription.expiresAt}`"
|
||||||
|
>
|
||||||
|
<UiIcon name="clock" :size="15" />
|
||||||
|
<span>
|
||||||
|
{{ toFaDigits(subscription.daysRemaining) }} روز باقیمانده
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="date-chip">
|
<div class="date-chip">
|
||||||
<UiIcon name="calendar" :size="16" />
|
<UiIcon name="calendar" :size="16" />
|
||||||
<span>{{ todayLabel }}</span>
|
<span>{{ todayLabel }}</span>
|
||||||
@@ -1601,6 +1638,24 @@ onUnmounted(() => {
|
|||||||
height: 34px;
|
height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sub-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent-subtle);
|
||||||
|
color: var(--accent-deep);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-chip.warn {
|
||||||
|
background: color-mix(in srgb, var(--danger) 12%, white);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
.date-chip {
|
.date-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ api.interceptors.response.use(
|
|||||||
(error) => {
|
(error) => {
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
if (path.startsWith('/p/')) {
|
if (path.startsWith('/p/') || path.startsWith('/adm')) {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
localStorage.removeItem('mento_token');
|
localStorage.removeItem('mento_token');
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { OrgModuleKey } from '@mento/shared';
|
||||||
|
|
||||||
export type NavHubKey =
|
export type NavHubKey =
|
||||||
| 'customers'
|
| 'customers'
|
||||||
| 'sales'
|
| 'sales'
|
||||||
@@ -43,6 +45,7 @@ export type NavHubCard = {
|
|||||||
icon: NavIconName;
|
icon: NavIconName;
|
||||||
tint: NavHubTint;
|
tint: NavHubTint;
|
||||||
admin?: boolean;
|
admin?: boolean;
|
||||||
|
module?: OrgModuleKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NavHubShortcut = {
|
export type NavHubShortcut = {
|
||||||
@@ -50,6 +53,7 @@ export type NavHubShortcut = {
|
|||||||
title: string;
|
title: string;
|
||||||
icon: NavIconName;
|
icon: NavIconName;
|
||||||
admin?: boolean;
|
admin?: boolean;
|
||||||
|
module?: OrgModuleKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NavHubDef = {
|
export type NavHubDef = {
|
||||||
@@ -70,8 +74,8 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'users',
|
icon: 'users',
|
||||||
tint: 'mint',
|
tint: 'mint',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ to: '/contacts/people/new', title: 'شخص جدید', icon: 'user-plus' },
|
{ to: '/contacts/people/new', title: 'شخص جدید', icon: 'user-plus', module: 'customers' },
|
||||||
{ to: '/contacts/companies/new', title: 'شرکت جدید', icon: 'building' },
|
{ to: '/contacts/companies/new', title: 'شرکت جدید', icon: 'building', module: 'customers' },
|
||||||
],
|
],
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
@@ -80,6 +84,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'مخاطبان حقیقی و پروندههای مشتری',
|
desc: 'مخاطبان حقیقی و پروندههای مشتری',
|
||||||
icon: 'user',
|
icon: 'user',
|
||||||
tint: 'mint',
|
tint: 'mint',
|
||||||
|
module: 'customers',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/contacts?tab=companies',
|
to: '/contacts?tab=companies',
|
||||||
@@ -87,6 +92,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'سازمانها و مشتریان حقوقی',
|
desc: 'سازمانها و مشتریان حقوقی',
|
||||||
icon: 'building',
|
icon: 'building',
|
||||||
tint: 'violet',
|
tint: 'violet',
|
||||||
|
module: 'customers',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -97,8 +103,8 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'briefcase',
|
icon: 'briefcase',
|
||||||
tint: 'mint',
|
tint: 'mint',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ to: '/deals/new', title: 'معامله جدید', icon: 'plus' },
|
{ to: '/deals/new', title: 'معامله جدید', icon: 'plus', module: 'pipeline' },
|
||||||
{ to: '/quotes/new', title: 'پیشفاکتور جدید', icon: 'document' },
|
{ to: '/quotes/new', title: 'پیشفاکتور جدید', icon: 'document', module: 'quotes' },
|
||||||
],
|
],
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
@@ -107,6 +113,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'سرنخها، معاملات و مراحل فروش',
|
desc: 'سرنخها، معاملات و مراحل فروش',
|
||||||
icon: 'filter',
|
icon: 'filter',
|
||||||
tint: 'mint',
|
tint: 'mint',
|
||||||
|
module: 'pipeline',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/products',
|
to: '/settings/products',
|
||||||
@@ -115,6 +122,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'box',
|
icon: 'box',
|
||||||
tint: 'peach',
|
tint: 'peach',
|
||||||
admin: true,
|
admin: true,
|
||||||
|
module: 'products',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/invoices',
|
to: '/invoices',
|
||||||
@@ -122,6 +130,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'صدور فاکتور و پیگیری پرداختها',
|
desc: 'صدور فاکتور و پیگیری پرداختها',
|
||||||
icon: 'mail',
|
icon: 'mail',
|
||||||
tint: 'sky',
|
tint: 'sky',
|
||||||
|
module: 'invoices',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/quotes',
|
to: '/quotes',
|
||||||
@@ -129,6 +138,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'پیشنهاد قیمت و پیگیری تایید',
|
desc: 'پیشنهاد قیمت و پیگیری تایید',
|
||||||
icon: 'document',
|
icon: 'document',
|
||||||
tint: 'amber',
|
tint: 'amber',
|
||||||
|
module: 'quotes',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/commissions',
|
to: '/commissions',
|
||||||
@@ -136,6 +146,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'سهم فروشندگان از فاکتورهای صادرشده',
|
desc: 'سهم فروشندگان از فاکتورهای صادرشده',
|
||||||
icon: 'dollar',
|
icon: 'dollar',
|
||||||
tint: 'rose',
|
tint: 'rose',
|
||||||
|
module: 'commissions',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/sales/forecast',
|
to: '/sales/forecast',
|
||||||
@@ -143,6 +154,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'تعیین اهداف و تحلیل عملکرد فروش',
|
desc: 'تعیین اهداف و تحلیل عملکرد فروش',
|
||||||
icon: 'target',
|
icon: 'target',
|
||||||
tint: 'mint',
|
tint: 'mint',
|
||||||
|
module: 'sales_forecast',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -153,8 +165,8 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'check',
|
icon: 'check',
|
||||||
tint: 'sky',
|
tint: 'sky',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ to: '/activities/new', title: 'فعالیت جدید', icon: 'plus' },
|
{ to: '/activities/new', title: 'فعالیت جدید', icon: 'plus', module: 'activities' },
|
||||||
{ to: '/activities/calendar', title: 'تقویم', icon: 'calendar' },
|
{ to: '/activities/calendar', title: 'تقویم', icon: 'calendar', module: 'activities' },
|
||||||
],
|
],
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
@@ -163,6 +175,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'کارها، تماسها و پیگیریهای جاری',
|
desc: 'کارها، تماسها و پیگیریهای جاری',
|
||||||
icon: 'check',
|
icon: 'check',
|
||||||
tint: 'mint',
|
tint: 'mint',
|
||||||
|
module: 'activities',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/activities/calendar',
|
to: '/activities/calendar',
|
||||||
@@ -170,6 +183,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'نمای روز، هفته و زمانبندی کارها',
|
desc: 'نمای روز، هفته و زمانبندی کارها',
|
||||||
icon: 'calendar',
|
icon: 'calendar',
|
||||||
tint: 'violet',
|
tint: 'violet',
|
||||||
|
module: 'activities',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -180,8 +194,8 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'chat',
|
icon: 'chat',
|
||||||
tint: 'peach',
|
tint: 'peach',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ to: '/tickets/new', title: 'تیکت جدید', icon: 'plus' },
|
{ to: '/tickets/new', title: 'تیکت جدید', icon: 'plus', module: 'tickets' },
|
||||||
{ to: '/tickets', title: 'تیکتها', icon: 'chat' },
|
{ to: '/tickets', title: 'تیکتها', icon: 'chat', module: 'tickets' },
|
||||||
],
|
],
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
@@ -190,6 +204,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'درخواستها و پیامهای پشتیبانی',
|
desc: 'درخواستها و پیامهای پشتیبانی',
|
||||||
icon: 'chat',
|
icon: 'chat',
|
||||||
tint: 'peach',
|
tint: 'peach',
|
||||||
|
module: 'tickets',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -200,8 +215,18 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'sparkle',
|
icon: 'sparkle',
|
||||||
tint: 'violet',
|
tint: 'violet',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ to: '/settings/automations', title: 'قانون گردش کار', icon: 'layers' },
|
{
|
||||||
{ to: '/settings/message-automations', title: 'قانون پیامک', icon: 'send' },
|
to: '/settings/automations',
|
||||||
|
title: 'قانون گردش کار',
|
||||||
|
icon: 'layers',
|
||||||
|
module: 'workflow_automations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/settings/message-automations',
|
||||||
|
title: 'قانون پیامک',
|
||||||
|
icon: 'send',
|
||||||
|
module: 'message_automations',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
@@ -211,6 +236,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'layers',
|
icon: 'layers',
|
||||||
tint: 'violet',
|
tint: 'violet',
|
||||||
admin: true,
|
admin: true,
|
||||||
|
module: 'workflow_automations',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/message-automations',
|
to: '/settings/message-automations',
|
||||||
@@ -219,6 +245,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'send',
|
icon: 'send',
|
||||||
tint: 'sky',
|
tint: 'sky',
|
||||||
admin: true,
|
admin: true,
|
||||||
|
module: 'message_automations',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -229,8 +256,8 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'apps-plus',
|
icon: 'apps-plus',
|
||||||
tint: 'violet',
|
tint: 'violet',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ to: '/project-cards/new', title: 'کارت پروژه جدید', icon: 'plus' },
|
{ to: '/project-cards/new', title: 'کارت پروژه جدید', icon: 'plus', module: 'project_boards' },
|
||||||
{ to: '/cheques', title: 'ثبت چک', icon: 'document' },
|
{ to: '/cheques', title: 'ثبت چک', icon: 'document', module: 'cheques' },
|
||||||
],
|
],
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
@@ -240,6 +267,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'layers',
|
icon: 'layers',
|
||||||
tint: 'mint',
|
tint: 'mint',
|
||||||
admin: true,
|
admin: true,
|
||||||
|
module: 'lead_forms',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/projects',
|
to: '/projects',
|
||||||
@@ -247,6 +275,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'کارتها و مراحل اجرای کار',
|
desc: 'کارتها و مراحل اجرای کار',
|
||||||
icon: 'flag',
|
icon: 'flag',
|
||||||
tint: 'violet',
|
tint: 'violet',
|
||||||
|
module: 'project_boards',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/inventory',
|
to: '/inventory',
|
||||||
@@ -254,6 +283,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'موجودی کالا و گردش داخلی',
|
desc: 'موجودی کالا و گردش داخلی',
|
||||||
icon: 'box',
|
icon: 'box',
|
||||||
tint: 'peach',
|
tint: 'peach',
|
||||||
|
module: 'inventory',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/cheques',
|
to: '/cheques',
|
||||||
@@ -261,6 +291,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'چکهای دریافتی و پرداختی',
|
desc: 'چکهای دریافتی و پرداختی',
|
||||||
icon: 'document',
|
icon: 'document',
|
||||||
tint: 'sky',
|
tint: 'sky',
|
||||||
|
module: 'cheques',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/attachments',
|
to: '/attachments',
|
||||||
@@ -268,6 +299,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'فایلهای متصل به یادداشتها',
|
desc: 'فایلهای متصل به یادداشتها',
|
||||||
icon: 'document',
|
icon: 'document',
|
||||||
tint: 'amber',
|
tint: 'amber',
|
||||||
|
module: 'attachments',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -278,8 +310,13 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'settings',
|
icon: 'settings',
|
||||||
tint: 'sky',
|
tint: 'sky',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ to: '/settings/team#settings-team', title: 'تیم و نقشها', icon: 'users' },
|
{
|
||||||
{ to: '/settings/tahesab', title: 'تهحساب', icon: 'chart' },
|
to: '/settings/team#settings-team',
|
||||||
|
title: 'تیم و نقشها',
|
||||||
|
icon: 'users',
|
||||||
|
module: 'team_settings',
|
||||||
|
},
|
||||||
|
{ to: '/settings/tahesab', title: 'تهحساب', icon: 'chart', module: 'tahesab' },
|
||||||
],
|
],
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
@@ -288,6 +325,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'نام سازمان و تنظیمات پایه',
|
desc: 'نام سازمان و تنظیمات پایه',
|
||||||
icon: 'building',
|
icon: 'building',
|
||||||
tint: 'mint',
|
tint: 'mint',
|
||||||
|
module: 'team_settings',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/team#settings-team',
|
to: '/settings/team#settings-team',
|
||||||
@@ -295,6 +333,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'اعضا، دسترسی و نقشها',
|
desc: 'اعضا، دسترسی و نقشها',
|
||||||
icon: 'users',
|
icon: 'users',
|
||||||
tint: 'violet',
|
tint: 'violet',
|
||||||
|
module: 'team_settings',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/custom-fields',
|
to: '/settings/custom-fields',
|
||||||
@@ -302,6 +341,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'فیلدهای اضافه روی پروندهها',
|
desc: 'فیلدهای اضافه روی پروندهها',
|
||||||
icon: 'pencil',
|
icon: 'pencil',
|
||||||
tint: 'peach',
|
tint: 'peach',
|
||||||
|
module: 'custom_fields',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/currencies',
|
to: '/settings/currencies',
|
||||||
@@ -309,6 +349,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'ارز پایه و نرخ تبدیل',
|
desc: 'ارز پایه و نرخ تبدیل',
|
||||||
icon: 'dollar',
|
icon: 'dollar',
|
||||||
tint: 'sky',
|
tint: 'sky',
|
||||||
|
module: 'currencies',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/tahesab',
|
to: '/settings/tahesab',
|
||||||
@@ -316,6 +357,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'اتصال مالی و تنظیمات حسابداری',
|
desc: 'اتصال مالی و تنظیمات حسابداری',
|
||||||
icon: 'chart',
|
icon: 'chart',
|
||||||
tint: 'amber',
|
tint: 'amber',
|
||||||
|
module: 'tahesab',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/faq',
|
to: '/settings/faq',
|
||||||
@@ -323,6 +365,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
desc: 'پرسشهای پرتکرار سازمان',
|
desc: 'پرسشهای پرتکرار سازمان',
|
||||||
icon: 'lightbulb',
|
icon: 'lightbulb',
|
||||||
tint: 'rose',
|
tint: 'rose',
|
||||||
|
module: 'faq',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import type { UserRole } from '@mento/shared';
|
import type { UserRole } from '@mento/shared';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { usePlatformAuthStore } from '@/stores/platform-auth';
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -29,6 +30,39 @@ export const router = createRouter({
|
|||||||
component: () => import('@/views/PortalView.vue'),
|
component: () => import('@/views/PortalView.vue'),
|
||||||
meta: { public: true },
|
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: '/',
|
path: '/',
|
||||||
component: () => import('@/layouts/AppLayout.vue'),
|
component: () => import('@/layouts/AppLayout.vue'),
|
||||||
@@ -291,6 +325,16 @@ export const router = createRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
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();
|
const auth = useAuthStore();
|
||||||
if (!to.meta.public && !auth.isAuthenticated) {
|
if (!to.meta.public && !auth.isAuthenticated) {
|
||||||
return { name: 'login', query: { redirect: to.fullPath } };
|
return { name: 'login', query: { redirect: to.fullPath } };
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
@import "./tokens.css";
|
@import "./tokens.css";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--mobile-tabbar-h: 0px;
|
||||||
|
|
||||||
/* Surface & Background */
|
/* Surface & Background */
|
||||||
--bg: #f8fafc;
|
--bg: #f8fafc;
|
||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
@@ -245,12 +247,21 @@ textarea {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
color: #c0c8d4;
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
:root {
|
||||||
|
--mobile-tabbar-h: calc(3.65rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: clamp(1.35rem, 5vw, 1.75rem);
|
font-size: clamp(1.35rem, 5vw, 1.75rem);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
UiPagination,
|
UiPagination,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import UiIcon from '@/components/UiIcon.vue';
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
|
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||||
import ActivityFormView from '@/views/ActivityFormView.vue';
|
import ActivityFormView from '@/views/ActivityFormView.vue';
|
||||||
import type {
|
import type {
|
||||||
ActivityDto,
|
ActivityDto,
|
||||||
@@ -78,7 +79,6 @@ const activityModal = ref<{
|
|||||||
const menuId = ref<string | null>(null);
|
const menuId = ref<string | null>(null);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const importMsg = ref('');
|
const importMsg = ref('');
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
|
||||||
const counts = ref<Record<ActivityFilter, number>>({
|
const counts = ref<Record<ActivityFilter, number>>({
|
||||||
today: 0,
|
today: 0,
|
||||||
overdue: 0,
|
overdue: 0,
|
||||||
@@ -286,44 +286,31 @@ async function exportExcel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickImportFile() {
|
function onImportStart() {
|
||||||
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;
|
busy.value = true;
|
||||||
importMsg.value = '';
|
importMsg.value = '';
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
}
|
||||||
const form = new FormData();
|
|
||||||
form.append('file', file);
|
async function onImportDone(data: ImportContactsResult) {
|
||||||
const { data } = await api.post<ImportContactsResult>('/activities/import', form, {
|
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||||
headers: { 'Content-Type': undefined },
|
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||||
});
|
}`;
|
||||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
if (data.errors.length) {
|
||||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
error.value = data.errors
|
||||||
}`;
|
.slice(0, 3)
|
||||||
if (data.errors.length) {
|
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||||
error.value = data.errors
|
.join(' | ');
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
await Promise.all([load(), loadCounts()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImportFail(message: string) {
|
||||||
|
error.value = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImportSettled() {
|
||||||
|
busy.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -357,17 +344,20 @@ onMounted(() => {
|
|||||||
<span class="btn-full">خروجی اکسل</span>
|
<span class="btn-full">خروجی اکسل</span>
|
||||||
<span class="btn-short">خروجی</span>
|
<span class="btn-short">خروجی</span>
|
||||||
</UiButton>
|
</UiButton>
|
||||||
<UiButton variant="ghost" type="button" :disabled="busy" @click.stop="pickImportFile">
|
<ExcelImportFlow
|
||||||
<span class="btn-full">ورود از اکسل</span>
|
preview-path="/activities/import/preview"
|
||||||
<span class="btn-short">ورود</span>
|
import-path="/activities/import"
|
||||||
</UiButton>
|
:disabled="busy"
|
||||||
<input
|
@start="onImportStart"
|
||||||
ref="fileInput"
|
@done="onImportDone"
|
||||||
type="file"
|
@fail="onImportFail"
|
||||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
@settled="onImportSettled"
|
||||||
hidden
|
>
|
||||||
@change="onImportFile"
|
<UiButton variant="ghost" type="button" :disabled="busy">
|
||||||
/>
|
<span class="btn-full">ورود از اکسل</span>
|
||||||
|
<span class="btn-short">ورود</span>
|
||||||
|
</UiButton>
|
||||||
|
</ExcelImportFlow>
|
||||||
<UiButton variant="primary" @click="activityModal = {}">
|
<UiButton variant="primary" @click="activityModal = {}">
|
||||||
<UiIcon name="plus" :size="16" />
|
<UiIcon name="plus" :size="16" />
|
||||||
فعالیت جدید
|
فعالیت جدید
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ onMounted(load);
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
|
padding-bottom: 5.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
@@ -572,7 +573,7 @@ dd {
|
|||||||
.dock {
|
.dock {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset-inline: 0.85rem;
|
inset-inline: 0.85rem;
|
||||||
bottom: calc(0.55rem + env(safe-area-inset-bottom));
|
bottom: calc(0.55rem + var(--mobile-tabbar-h));
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.15fr 1fr 1fr;
|
grid-template-columns: 1.15fr 1fr 1fr;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -607,6 +608,10 @@ dd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
|
.page {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.dock {
|
.dock {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
inset-inline: auto;
|
inset-inline: auto;
|
||||||
|
|||||||
@@ -661,6 +661,10 @@ watch([id, isNew], () => void load(), { immediate: true });
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.crm-page:not(.embedded) {
|
||||||
|
padding-bottom: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.crm-page.embedded {
|
.crm-page.embedded {
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
@@ -1142,7 +1146,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
|||||||
.mobile-save {
|
.mobile-save {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset-inline: 0.85rem;
|
inset-inline: 0.85rem;
|
||||||
bottom: calc(0.55rem + env(safe-area-inset-bottom));
|
bottom: calc(0.55rem + var(--mobile-tabbar-h));
|
||||||
z-index: 8;
|
z-index: 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1154,6 +1158,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 960px) {
|
@media (min-width: 960px) {
|
||||||
|
.crm-page:not(.embedded),
|
||||||
.page {
|
.page {
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import axios from 'axios';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||||
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
|
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
|
||||||
|
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||||
import {
|
import {
|
||||||
UiButton,
|
UiButton,
|
||||||
UiModal,
|
UiModal,
|
||||||
@@ -52,7 +53,6 @@ const error = ref('');
|
|||||||
const importMsg = ref('');
|
const importMsg = ref('');
|
||||||
const people = ref<PaginatedResponse<PersonDto> | null>(null);
|
const people = ref<PaginatedResponse<PersonDto> | null>(null);
|
||||||
const companies = ref<PaginatedResponse<CompanyDto> | null>(null);
|
const companies = ref<PaginatedResponse<CompanyDto> | null>(null);
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
|
||||||
const menuId = ref<string | null>(null);
|
const menuId = ref<string | null>(null);
|
||||||
const sortOpen = ref(false);
|
const sortOpen = ref(false);
|
||||||
const filterOpen = ref(false);
|
const filterOpen = ref(false);
|
||||||
@@ -187,46 +187,38 @@ async function exportExcel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickImportFile() {
|
const importPreviewPath = computed(() =>
|
||||||
fileInput.value?.click();
|
tab.value === 'people' ? '/people/import/preview' : '/companies/import/preview',
|
||||||
}
|
);
|
||||||
|
const importPath = computed(() =>
|
||||||
|
tab.value === 'people' ? '/people/import' : '/companies/import',
|
||||||
|
);
|
||||||
|
|
||||||
async function onImportFile(ev: Event) {
|
function onImportStart() {
|
||||||
const input = ev.target as HTMLInputElement;
|
|
||||||
const file = input.files?.[0];
|
|
||||||
input.value = '';
|
|
||||||
if (!file) return;
|
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
importMsg.value = '';
|
importMsg.value = '';
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
}
|
||||||
const form = new FormData();
|
|
||||||
form.append('file', file);
|
async function onImportDone(data: ImportContactsResult) {
|
||||||
const path = tab.value === 'people' ? '/people/import' : '/companies/import';
|
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||||
const { data } = await api.post<ImportContactsResult>(path, form, {
|
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||||
headers: { 'Content-Type': undefined },
|
}`;
|
||||||
});
|
if (data.errors.length) {
|
||||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
error.value = data.errors
|
||||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
.slice(0, 3)
|
||||||
}`;
|
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||||
if (data.errors.length) {
|
.join(' | ');
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImportFail(message: string) {
|
||||||
|
error.value = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImportSettled() {
|
||||||
|
busy.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -251,17 +243,20 @@ onMounted(load);
|
|||||||
<span class="btn-full">خروجی اکسل</span>
|
<span class="btn-full">خروجی اکسل</span>
|
||||||
<span class="btn-short">خروجی</span>
|
<span class="btn-short">خروجی</span>
|
||||||
</UiButton>
|
</UiButton>
|
||||||
<UiButton variant="ghost" size="sm" :disabled="busy" @click.stop="pickImportFile">
|
<ExcelImportFlow
|
||||||
<span class="btn-full">ورود از اکسل</span>
|
:preview-path="importPreviewPath"
|
||||||
<span class="btn-short">ورود</span>
|
:import-path="importPath"
|
||||||
</UiButton>
|
:disabled="busy"
|
||||||
<input
|
@start="onImportStart"
|
||||||
ref="fileInput"
|
@done="onImportDone"
|
||||||
type="file"
|
@fail="onImportFail"
|
||||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
@settled="onImportSettled"
|
||||||
hidden
|
>
|
||||||
@change="onImportFile"
|
<UiButton variant="ghost" size="sm" :disabled="busy">
|
||||||
/>
|
<span class="btn-full">ورود از اکسل</span>
|
||||||
|
<span class="btn-short">ورود</span>
|
||||||
|
</UiButton>
|
||||||
|
</ExcelImportFlow>
|
||||||
<UiButton
|
<UiButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@click="contactModal = tab === 'companies' ? 'company' : 'person'"
|
@click="contactModal = tab === 'companies' ? 'company' : 'person'"
|
||||||
|
|||||||
@@ -791,6 +791,7 @@ onMounted(load);
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
padding-bottom: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page.embedded {
|
.page.embedded {
|
||||||
@@ -1391,7 +1392,7 @@ h1 {
|
|||||||
.mobile-save {
|
.mobile-save {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset-inline: 0.85rem;
|
inset-inline: 0.85rem;
|
||||||
bottom: calc(0.55rem + env(safe-area-inset-bottom));
|
bottom: calc(0.55rem + var(--mobile-tabbar-h));
|
||||||
z-index: 8;
|
z-index: 8;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1.2fr;
|
grid-template-columns: 1fr 1.2fr;
|
||||||
|
|||||||
@@ -2362,7 +2362,7 @@ h1 {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0.5rem;
|
bottom: calc(0.5rem + var(--mobile-tabbar-h));
|
||||||
padding: 0.35rem 0;
|
padding: 0.35rem 0;
|
||||||
background: linear-gradient(to top, #f8fafc 70%, transparent);
|
background: linear-gradient(to top, #f8fafc 70%, transparent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -857,7 +857,7 @@ tbody tr:nth-child(even) { background: #fcfdff; }
|
|||||||
.page-btn.on { background: var(--teal); color: #fff; border-color: transparent; }
|
.page-btn.on { background: var(--teal); color: #fff; border-color: transparent; }
|
||||||
|
|
||||||
.fab {
|
.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;
|
background: var(--teal); color: #fff; text-align: center; text-decoration: none;
|
||||||
border-radius: var(--radius); padding: 0.9rem; font-weight: 600;
|
border-radius: var(--radius); padding: 0.9rem; font-weight: 600;
|
||||||
box-shadow: 0 12px 28px rgba(13,148,136,.35);
|
box-shadow: 0 12px 28px rgba(13,148,136,.35);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { LoginChallengeResponse, LoginOrganizationChoiceResponse } from '@m
|
|||||||
import { USER_ROLE_LABELS } from '@/lib/roles';
|
import { USER_ROLE_LABELS } from '@/lib/roles';
|
||||||
import { brand } from '@mento/shared';
|
import { brand } from '@mento/shared';
|
||||||
import BrandName from '@/components/brand/BrandName.vue';
|
import BrandName from '@/components/brand/BrandName.vue';
|
||||||
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -18,8 +19,8 @@ const route = useRoute();
|
|||||||
const mode = ref<'password' | 'otp'>('password');
|
const mode = ref<'password' | 'otp'>('password');
|
||||||
const otpStep = ref<'mobile' | 'code'>('mobile');
|
const otpStep = ref<'mobile' | 'code'>('mobile');
|
||||||
|
|
||||||
const identifier = ref('owner@demo.local');
|
const identifier = ref('');
|
||||||
const password = ref('demo1234');
|
const password = ref('');
|
||||||
const mobile = ref('');
|
const mobile = ref('');
|
||||||
const showPassword = ref(false);
|
const showPassword = ref(false);
|
||||||
const remember = ref(true);
|
const remember = ref(true);
|
||||||
@@ -209,15 +210,17 @@ function cancelOrgChoice() {
|
|||||||
required
|
required
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
placeholder="ایمیل یا شماره موبایل خود را وارد کنید"
|
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>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="password">رمز عبور</label>
|
<label for="password">رمز عبور</label>
|
||||||
<div class="input-wrap">
|
<div class="input-wrap has-eye">
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
v-model="password"
|
v-model="password"
|
||||||
@@ -225,16 +228,18 @@ function cancelOrgChoice() {
|
|||||||
required
|
required
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
placeholder="رمز عبور خود را وارد کنید"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="eye"
|
class="eye"
|
||||||
:aria-label="showPassword ? 'مخفی کردن رمز' : 'نمایش رمز'"
|
:aria-label="showPassword ? 'مخفی کردن رمز' : 'نمایش رمز'"
|
||||||
@click="showPassword = !showPassword"
|
@click="showPassword = !showPassword"
|
||||||
>
|
>
|
||||||
{{ showPassword ? 'مخفی' : 'نمایش' }}
|
<UiIcon :name="showPassword ? 'eye-off' : 'eye'" :size="18" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,9 +271,11 @@ function cancelOrgChoice() {
|
|||||||
required
|
required
|
||||||
autocomplete="tel"
|
autocomplete="tel"
|
||||||
placeholder="۰۹۱۲۱۲۳۴۵۶۷"
|
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>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
@@ -297,7 +304,7 @@ function cancelOrgChoice() {
|
|||||||
required
|
required
|
||||||
autocomplete="one-time-code"
|
autocomplete="one-time-code"
|
||||||
placeholder="کد را وارد کنید"
|
placeholder="کد را وارد کنید"
|
||||||
dir="ltr"
|
class="ltr-value"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -339,21 +346,22 @@ function cancelOrgChoice() {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
padding-bottom: max(1rem, env(safe-area-inset-bottom));
|
padding-bottom: max(1rem, env(safe-area-inset-bottom));
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 90% 0%, rgba(13, 148, 136, 0.08), transparent 36%),
|
radial-gradient(ellipse 70% 45% at 50% 0%, rgba(45, 212, 191, 0.12), transparent 55%),
|
||||||
var(--bg);
|
radial-gradient(rgba(13, 148, 136, 0.12) 1px, transparent 1.2px),
|
||||||
|
#f5fbfb;
|
||||||
|
background-size: auto, 20px 20px, auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-shell {
|
.auth-shell {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: min(1040px, 100%);
|
width: min(1100px, 100%);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
background: var(--surface);
|
background: transparent;
|
||||||
border: 1px solid var(--line);
|
border: 0;
|
||||||
border-radius: var(--radius);
|
box-shadow: none;
|
||||||
box-shadow: var(--shadow);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: min(640px, calc(100dvh - 5.5rem));
|
min-height: min(680px, calc(100dvh - 5.5rem));
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-panel {
|
.form-panel {
|
||||||
@@ -361,6 +369,9 @@ function cancelOrgChoice() {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: clamp(1.2rem, 3vw, 2.4rem);
|
padding: clamp(1.2rem, 3vw, 2.4rem);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 28px;
|
||||||
|
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-brand {
|
.mobile-brand {
|
||||||
@@ -434,6 +445,22 @@ h1 {
|
|||||||
gap: 0.95rem;
|
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 {
|
.input-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -446,48 +473,42 @@ h1 {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-wrap.has-eye input {
|
||||||
|
padding-left: 2.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
.input-wrap input:focus {
|
.input-wrap input:focus {
|
||||||
outline: 2px solid var(--accent-soft);
|
outline: 2px solid var(--accent-soft);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ico {
|
.field-ico {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
inset-inline-end: 0.85rem;
|
right: 0.75rem;
|
||||||
|
left: auto;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 18px;
|
color: #94a3b8;
|
||||||
height: 18px;
|
display: grid;
|
||||||
opacity: 0.45;
|
place-items: center;
|
||||||
pointer-events: none;
|
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 {
|
.eye {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
inset-inline-start: 0.55rem;
|
left: 0.45rem;
|
||||||
|
right: auto;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted);
|
color: #64748b;
|
||||||
font-size: 0.75rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.25rem 0.4rem;
|
padding: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-actions {
|
.row-actions {
|
||||||
@@ -612,8 +633,34 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
|
.auth-page {
|
||||||
|
padding: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-shell {
|
.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 {
|
.mobile-brand {
|
||||||
@@ -621,8 +668,12 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-inner {
|
.form-inner {
|
||||||
margin: 0;
|
margin: 0 auto;
|
||||||
width: min(440px, 100%);
|
width: min(440px, 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-footer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2482,7 +2482,7 @@ textarea {
|
|||||||
|
|
||||||
.m-wiz-foot {
|
.m-wiz-foot {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: var(--mobile-tabbar-h);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.25fr 1fr;
|
grid-template-columns: 1.25fr 1fr;
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
|
|||||||
@@ -1710,7 +1710,7 @@ onBeforeUnmount(() => {
|
|||||||
.fab {
|
.fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset-inline-end: 1.1rem;
|
inset-inline-end: 1.1rem;
|
||||||
bottom: 1.25rem;
|
bottom: calc(1.25rem + var(--mobile-tabbar-h));
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
width: 54px;
|
width: 54px;
|
||||||
height: 54px;
|
height: 54px;
|
||||||
@@ -2147,6 +2147,10 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
|
.notes-page {
|
||||||
|
padding-bottom: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.desktop-only {
|
.desktop-only {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,12 +196,12 @@ onMounted(load);
|
|||||||
<form v-else class="crm-form" @submit.prevent="submit">
|
<form v-else class="crm-form" @submit.prevent="submit">
|
||||||
<UiCard>
|
<UiCard>
|
||||||
<div class="crm-form-grid-2">
|
<div class="crm-form-grid-2">
|
||||||
<UiField label="نام" required for-id="firstName">
|
<UiField label="نام" for-id="firstName">
|
||||||
<UiInput id="firstName" v-model="firstName" required />
|
<UiInput id="firstName" v-model="firstName" />
|
||||||
</UiField>
|
</UiField>
|
||||||
|
|
||||||
<UiField label="نام خانوادگی" required for-id="lastName">
|
<UiField label="نام خانوادگی" for-id="lastName">
|
||||||
<UiInput id="lastName" v-model="lastName" required />
|
<UiInput id="lastName" v-model="lastName" />
|
||||||
</UiField>
|
</UiField>
|
||||||
|
|
||||||
<UiField label="موبایل" for-id="mobile">
|
<UiField label="موبایل" for-id="mobile">
|
||||||
|
|||||||
@@ -1658,7 +1658,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
|||||||
.dock {
|
.dock {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset-inline: 1.1rem;
|
inset-inline: 1.1rem;
|
||||||
bottom: calc(0.7rem + env(safe-area-inset-bottom));
|
bottom: calc(0.7rem + var(--mobile-tabbar-h));
|
||||||
z-index: 12;
|
z-index: 12;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -1709,6 +1709,12 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 959px) {
|
||||||
|
.page {
|
||||||
|
padding-bottom: 6.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 960px) {
|
@media (min-width: 960px) {
|
||||||
.page {
|
.page {
|
||||||
padding-bottom: 1.2rem;
|
padding-bottom: 1.2rem;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
moveStageToIndex,
|
moveStageToIndex,
|
||||||
updateDealStage,
|
updateDealStage,
|
||||||
} from '@/lib/pipeline-board';
|
} from '@/lib/pipeline-board';
|
||||||
|
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||||
import UiButton from '@/components/ui/UiButton.vue';
|
import UiButton from '@/components/ui/UiButton.vue';
|
||||||
import UiInput from '@/components/ui/UiInput.vue';
|
import UiInput from '@/components/ui/UiInput.vue';
|
||||||
import UiModal from '@/components/ui/UiModal.vue';
|
import UiModal from '@/components/ui/UiModal.vue';
|
||||||
@@ -47,7 +48,6 @@ const deleteStageId = ref<string | null>(null);
|
|||||||
const stageBusy = ref(false);
|
const stageBusy = ref(false);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const importMsg = ref('');
|
const importMsg = ref('');
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
const draggingId = ref<string | null>(null);
|
const draggingId = ref<string | null>(null);
|
||||||
const draggingColumnId = ref<string | null>(null);
|
const draggingColumnId = ref<string | null>(null);
|
||||||
@@ -390,43 +390,35 @@ async function exportExcel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickImportFile() {
|
function onImportStart() {
|
||||||
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;
|
busy.value = true;
|
||||||
importMsg.value = '';
|
importMsg.value = '';
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
}
|
||||||
const form = new FormData();
|
|
||||||
form.append('file', file);
|
async function onImportDone(data: ImportContactsResult) {
|
||||||
const { data } = await api.post<ImportContactsResult>('/deals/import', form, {
|
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||||
headers: { 'Content-Type': undefined },
|
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||||
});
|
}${data.boards?.length ? ` · کاریز: ${data.boards.join('، ')}` : ''}`;
|
||||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
if (data.errors.length) {
|
||||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
error.value = data.errors
|
||||||
}${data.boards?.length ? ` · کاریز: ${data.boards.join('، ')}` : ''}`;
|
.slice(0, 3)
|
||||||
if (data.errors.length) {
|
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||||
error.value = data.errors
|
.join(' | ');
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
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) {
|
function onMenuDeal(dealId: string) {
|
||||||
@@ -472,16 +464,19 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
|
|||||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
|
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
|
||||||
خروجی اکسل
|
خروجی اکسل
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="pickImportFile">
|
<ExcelImportFlow
|
||||||
ورود از اکسل
|
preview-path="/deals/import/preview"
|
||||||
</button>
|
import-path="/deals/import"
|
||||||
<input
|
:disabled="loading || busy"
|
||||||
ref="fileInput"
|
@start="onImportStart"
|
||||||
type="file"
|
@done="onImportDone"
|
||||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
@fail="onImportFail"
|
||||||
hidden
|
@settled="onImportSettled"
|
||||||
@change="onImportFile"
|
>
|
||||||
/>
|
<button class="btn-ghost-sm" type="button" :disabled="loading || busy">
|
||||||
|
ورود از اکسل
|
||||||
|
</button>
|
||||||
|
</ExcelImportFlow>
|
||||||
</template>
|
</template>
|
||||||
</PipelineToolbar>
|
</PipelineToolbar>
|
||||||
|
|
||||||
@@ -658,7 +653,7 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
|
|||||||
.menu-pop {
|
.menu-pop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset-inline-start: 1rem;
|
inset-inline-start: 1rem;
|
||||||
bottom: 5.5rem;
|
bottom: calc(var(--mobile-tabbar-h) + 1.75rem);
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
UiPagination,
|
UiPagination,
|
||||||
UiSelect,
|
UiSelect,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
|
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||||
import UiIcon from '@/components/UiIcon.vue';
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
import ProductFormView from '@/views/ProductFormView.vue';
|
import ProductFormView from '@/views/ProductFormView.vue';
|
||||||
import type { ImportContactsResult, ProductDto } from '@mento/shared';
|
import type { ImportContactsResult, ProductDto } from '@mento/shared';
|
||||||
@@ -41,7 +42,6 @@ const moreOpen = ref(false);
|
|||||||
const productModal = ref(false);
|
const productModal = ref(false);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const importMsg = ref('');
|
const importMsg = ref('');
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
const categories = computed(() => {
|
const categories = computed(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
@@ -187,45 +187,32 @@ async function exportExcel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickImportFile() {
|
function onImportStart() {
|
||||||
closeMenus();
|
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;
|
busy.value = true;
|
||||||
importMsg.value = '';
|
importMsg.value = '';
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
}
|
||||||
const form = new FormData();
|
|
||||||
form.append('file', file);
|
async function onImportDone(data: ImportContactsResult) {
|
||||||
const { data } = await api.post<ImportContactsResult>('/products/import', form, {
|
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||||
headers: { 'Content-Type': undefined },
|
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||||
});
|
}`;
|
||||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
if (data.errors.length) {
|
||||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
error.value = data.errors
|
||||||
}`;
|
.slice(0, 3)
|
||||||
if (data.errors.length) {
|
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||||
error.value = data.errors
|
.join(' | ');
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImportFail(message: string) {
|
||||||
|
error.value = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImportSettled() {
|
||||||
|
busy.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleActive(product: ProductDto) {
|
async function toggleActive(product: ProductDto) {
|
||||||
@@ -270,16 +257,17 @@ onMounted(load);
|
|||||||
<UiButton variant="ghost" type="button" :disabled="busy" @click="exportExcel">
|
<UiButton variant="ghost" type="button" :disabled="busy" @click="exportExcel">
|
||||||
خروجی اکسل
|
خروجی اکسل
|
||||||
</UiButton>
|
</UiButton>
|
||||||
<UiButton variant="ghost" type="button" :disabled="busy" @click="pickImportFile">
|
<ExcelImportFlow
|
||||||
ورود از اکسل
|
preview-path="/products/import/preview"
|
||||||
</UiButton>
|
import-path="/products/import"
|
||||||
<input
|
:disabled="busy"
|
||||||
ref="fileInput"
|
@start="onImportStart"
|
||||||
type="file"
|
@done="onImportDone"
|
||||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
@fail="onImportFail"
|
||||||
hidden
|
@settled="onImportSettled"
|
||||||
@change="onImportFile"
|
>
|
||||||
/>
|
<UiButton variant="ghost" type="button" :disabled="busy">ورود از اکسل</UiButton>
|
||||||
|
</ExcelImportFlow>
|
||||||
<UiButton variant="primary" @click="productModal = true">
|
<UiButton variant="primary" @click="productModal = true">
|
||||||
<UiIcon name="plus" :size="16" />
|
<UiIcon name="plus" :size="16" />
|
||||||
محصول جدید
|
محصول جدید
|
||||||
@@ -743,7 +731,7 @@ tr:last-child td {
|
|||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 1.1rem;
|
left: 1.1rem;
|
||||||
bottom: 1.1rem;
|
bottom: calc(1.1rem + var(--mobile-tabbar-h));
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
|||||||
@@ -1420,7 +1420,7 @@ h1 {
|
|||||||
.dock {
|
.dock {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset-inline: 0.85rem;
|
inset-inline: 0.85rem;
|
||||||
bottom: calc(0.55rem + env(safe-area-inset-bottom));
|
bottom: calc(0.55rem + var(--mobile-tabbar-h));
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
moveStageToIndex,
|
moveStageToIndex,
|
||||||
updateDealStage,
|
updateDealStage,
|
||||||
} from '@/lib/pipeline-board';
|
} from '@/lib/pipeline-board';
|
||||||
|
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||||
import UiButton from '@/components/ui/UiButton.vue';
|
import UiButton from '@/components/ui/UiButton.vue';
|
||||||
import UiInput from '@/components/ui/UiInput.vue';
|
import UiInput from '@/components/ui/UiInput.vue';
|
||||||
import UiModal from '@/components/ui/UiModal.vue';
|
import UiModal from '@/components/ui/UiModal.vue';
|
||||||
@@ -50,7 +51,6 @@ const deleteStageId = ref<string | null>(null);
|
|||||||
const stageBusy = ref(false);
|
const stageBusy = ref(false);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const importMsg = ref('');
|
const importMsg = ref('');
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
const draggingId = ref<string | null>(null);
|
const draggingId = ref<string | null>(null);
|
||||||
const draggingColumnId = ref<string | null>(null);
|
const draggingColumnId = ref<string | null>(null);
|
||||||
@@ -387,45 +387,35 @@ async function exportExcel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickImportFile() {
|
function onImportStart() {
|
||||||
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;
|
busy.value = true;
|
||||||
importMsg.value = '';
|
importMsg.value = '';
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
}
|
||||||
const form = new FormData();
|
|
||||||
form.append('file', file);
|
async function onImportDone(data: ImportContactsResult) {
|
||||||
const { data } = await api.post<ImportContactsResult>(
|
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||||
'/project-cards/import',
|
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||||
form,
|
}${data.boards?.length ? ` · برد: ${data.boards.join('، ')}` : ''}`;
|
||||||
{ headers: { 'Content-Type': undefined } },
|
if (data.errors.length) {
|
||||||
);
|
error.value = data.errors
|
||||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
.slice(0, 3)
|
||||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||||
}${data.boards?.length ? ` · برد: ${data.boards.join('، ')}` : ''}`;
|
.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;
|
|
||||||
}
|
}
|
||||||
|
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) {
|
function onMenuCard(cardId: string) {
|
||||||
@@ -470,16 +460,19 @@ onBeforeUnmount(() => {
|
|||||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
|
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
|
||||||
خروجی اکسل
|
خروجی اکسل
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="pickImportFile">
|
<ExcelImportFlow
|
||||||
ورود از اکسل
|
preview-path="/project-cards/import/preview"
|
||||||
</button>
|
import-path="/project-cards/import"
|
||||||
<input
|
:disabled="loading || busy"
|
||||||
ref="fileInput"
|
@start="onImportStart"
|
||||||
type="file"
|
@done="onImportDone"
|
||||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
@fail="onImportFail"
|
||||||
hidden
|
@settled="onImportSettled"
|
||||||
@change="onImportFile"
|
>
|
||||||
/>
|
<button class="btn-ghost-sm" type="button" :disabled="loading || busy">
|
||||||
|
ورود از اکسل
|
||||||
|
</button>
|
||||||
|
</ExcelImportFlow>
|
||||||
</template>
|
</template>
|
||||||
</PipelineToolbar>
|
</PipelineToolbar>
|
||||||
|
|
||||||
@@ -671,7 +664,7 @@ onBeforeUnmount(() => {
|
|||||||
.menu-pop {
|
.menu-pop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset-inline-start: 1rem;
|
inset-inline-start: 1rem;
|
||||||
bottom: 5.5rem;
|
bottom: calc(var(--mobile-tabbar-h) + 1.75rem);
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import MentoLogo from '@/components/brand/MentoLogo.vue';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { brand, type RegisterChallengeResponse } from '@mento/shared';
|
import { brand, type RegisterChallengeResponse } from '@mento/shared';
|
||||||
import BrandName from '@/components/brand/BrandName.vue';
|
import BrandName from '@/components/brand/BrandName.vue';
|
||||||
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -105,10 +106,7 @@ function backToForm() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="auth-page">
|
<div class="auth-page">
|
||||||
<div class="auth-shell">
|
<div class="auth-shell">
|
||||||
<AuthBrandPanel
|
<AuthBrandPanel />
|
||||||
headline="همه ارتباطات، یکجا مدیریت میشوند"
|
|
||||||
:description="`با ساخت حساب ${brand.nameFa}، سازمان خود را راهاندازی کنید و فروش و پشتیبانی را از یکجا پیش ببرید.`"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section class="form-panel">
|
<section class="form-panel">
|
||||||
<header class="mobile-brand">
|
<header class="mobile-brand">
|
||||||
@@ -134,6 +132,9 @@ function backToForm() {
|
|||||||
<label for="org">نام سازمان <em>*</em></label>
|
<label for="org">نام سازمان <em>*</em></label>
|
||||||
<div class="input-wrap">
|
<div class="input-wrap">
|
||||||
<input id="org" v-model="organizationName" required placeholder="نام سازمان" />
|
<input id="org" v-model="organizationName" required placeholder="نام سازمان" />
|
||||||
|
<span class="field-ico" aria-hidden="true">
|
||||||
|
<UiIcon name="building" :size="18" />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -157,7 +158,9 @@ function backToForm() {
|
|||||||
required
|
required
|
||||||
placeholder="نام و نام خانوادگی خود را وارد کنید"
|
placeholder="نام و نام خانوادگی خود را وارد کنید"
|
||||||
/>
|
/>
|
||||||
<span class="ico user" aria-hidden="true" />
|
<span class="field-ico" aria-hidden="true">
|
||||||
|
<UiIcon name="user" :size="18" />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -169,9 +172,11 @@ function backToForm() {
|
|||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
placeholder="ایمیل خود را وارد کنید"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,14 +192,16 @@ function backToForm() {
|
|||||||
type="tel"
|
type="tel"
|
||||||
required
|
required
|
||||||
placeholder="۹۱۲۱۲۳۴۵۶۷"
|
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>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="password">رمز عبور <em>*</em></label>
|
<label for="password">رمز عبور <em>*</em></label>
|
||||||
<div class="input-wrap">
|
<div class="input-wrap has-eye">
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
v-model="password"
|
v-model="password"
|
||||||
@@ -202,15 +209,18 @@ function backToForm() {
|
|||||||
minlength="6"
|
minlength="6"
|
||||||
required
|
required
|
||||||
placeholder="رمز عبور خود را وارد کنید"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="eye"
|
class="eye"
|
||||||
|
:aria-label="showPassword ? 'مخفی کردن رمز' : 'نمایش رمز'"
|
||||||
@click="showPassword = !showPassword"
|
@click="showPassword = !showPassword"
|
||||||
>
|
>
|
||||||
{{ showPassword ? 'مخفی' : 'نمایش' }}
|
<UiIcon :name="showPassword ? 'eye-off' : 'eye'" :size="18" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,7 +229,7 @@ function backToForm() {
|
|||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="passwordConfirm">تکرار رمز عبور <em>*</em></label>
|
<label for="passwordConfirm">تکرار رمز عبور <em>*</em></label>
|
||||||
<div class="input-wrap">
|
<div class="input-wrap has-eye">
|
||||||
<input
|
<input
|
||||||
id="passwordConfirm"
|
id="passwordConfirm"
|
||||||
v-model="passwordConfirm"
|
v-model="passwordConfirm"
|
||||||
@@ -227,15 +237,18 @@ function backToForm() {
|
|||||||
minlength="6"
|
minlength="6"
|
||||||
required
|
required
|
||||||
placeholder="رمز عبور خود را تکرار کنید"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="eye"
|
class="eye"
|
||||||
|
:aria-label="showPasswordConfirm ? 'مخفی کردن رمز' : 'نمایش رمز'"
|
||||||
@click="showPasswordConfirm = !showPasswordConfirm"
|
@click="showPasswordConfirm = !showPasswordConfirm"
|
||||||
>
|
>
|
||||||
{{ showPasswordConfirm ? 'مخفی' : 'نمایش' }}
|
<UiIcon :name="showPasswordConfirm ? 'eye-off' : 'eye'" :size="18" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,7 +260,9 @@ function backToForm() {
|
|||||||
v-model="referralCode"
|
v-model="referralCode"
|
||||||
placeholder="اگر کد معرف دارید وارد کنید"
|
placeholder="اگر کد معرف دارید وارد کنید"
|
||||||
/>
|
/>
|
||||||
<span class="ico gift" aria-hidden="true" />
|
<span class="field-ico" aria-hidden="true">
|
||||||
|
<UiIcon name="gift" :size="18" />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -297,7 +312,7 @@ function backToForm() {
|
|||||||
type="text"
|
type="text"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
required
|
required
|
||||||
dir="ltr"
|
class="ltr-value"
|
||||||
placeholder="کد ایمیل"
|
placeholder="کد ایمیل"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,7 +324,7 @@ function backToForm() {
|
|||||||
type="text"
|
type="text"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
required
|
required
|
||||||
dir="ltr"
|
class="ltr-value"
|
||||||
placeholder="کد موبایل"
|
placeholder="کد موبایل"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -349,8 +364,10 @@ function backToForm() {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
padding-bottom: max(1rem, env(safe-area-inset-bottom));
|
padding-bottom: max(1rem, env(safe-area-inset-bottom));
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 8% 0%, rgba(13, 148, 136, 0.07), transparent 34%),
|
radial-gradient(ellipse 70% 45% at 50% 0%, rgba(45, 212, 191, 0.12), transparent 55%),
|
||||||
var(--bg);
|
radial-gradient(rgba(13, 148, 136, 0.12) 1px, transparent 1.2px),
|
||||||
|
#f5fbfb;
|
||||||
|
background-size: auto, 20px 20px, auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-shell {
|
.auth-shell {
|
||||||
@@ -358,16 +375,18 @@ function backToForm() {
|
|||||||
width: min(1100px, 100%);
|
width: min(1100px, 100%);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
background: var(--surface);
|
background: transparent;
|
||||||
border: 1px solid var(--line);
|
border: 0;
|
||||||
border-radius: var(--radius);
|
box-shadow: none;
|
||||||
box-shadow: var(--shadow);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-panel {
|
.form-panel {
|
||||||
padding: clamp(1rem, 2.5vw, 2rem);
|
padding: clamp(1rem, 2.5vw, 2rem);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 28px;
|
||||||
|
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-brand {
|
.mobile-brand {
|
||||||
@@ -466,6 +485,23 @@ h1 {
|
|||||||
padding: 0.8rem 0.95rem;
|
padding: 0.8rem 0.95rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #fff;
|
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 {
|
.input-wrap {
|
||||||
@@ -473,16 +509,21 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.input-wrap input {
|
.input-wrap input {
|
||||||
padding-inline-end: 2.5rem;
|
padding-right: 2.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap.has-eye input {
|
||||||
|
padding-left: 2.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-wrap input {
|
.mobile-wrap input {
|
||||||
padding-inline-start: 3.4rem;
|
padding-left: 3.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cc {
|
.cc {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset-inline-start: 0.7rem;
|
left: 0.7rem;
|
||||||
|
right: auto;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -490,49 +531,33 @@ h1 {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ico {
|
.field-ico {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
inset-inline-end: 0.8rem;
|
right: 0.7rem;
|
||||||
|
left: auto;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 16px;
|
color: #94a3b8;
|
||||||
height: 16px;
|
display: grid;
|
||||||
opacity: 0.45;
|
place-items: center;
|
||||||
pointer-events: none;
|
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 {
|
.eye {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
inset-inline-start: 0.45rem;
|
left: 0.35rem;
|
||||||
|
right: auto;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted);
|
color: #64748b;
|
||||||
font-size: 0.72rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terms {
|
.terms {
|
||||||
@@ -618,12 +643,48 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
|
.auth-page {
|
||||||
|
padding: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-shell {
|
.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 {
|
.mobile-brand {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-inner {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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; }
|
.sum-row.payable { color: var(--teal); font-weight: 600; border-bottom: 0; }
|
||||||
.forecast-summary { padding: 1rem; }
|
.forecast-summary { padding: 1rem; }
|
||||||
.mob-bottom {
|
.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;
|
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);
|
background: #fff; border-top: 1px solid #eef2f7; box-shadow: 0 -8px 24px rgba(15,23,42,.06);
|
||||||
}
|
}
|
||||||
.fab {
|
.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)); }
|
.kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
}
|
}
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
.page { padding-bottom: 5.5rem; }
|
||||||
.desk-only { display: none !important; }
|
.desk-only { display: none !important; }
|
||||||
.mob-only, .mob-tabs, .mob-goals, .mob-forecast, .mob-bottom { display: flex; }
|
.mob-only, .mob-tabs, .mob-goals, .mob-forecast, .mob-bottom { display: flex; }
|
||||||
.mob-goals, .mob-forecast, .mob-form-screen { display: grid; }
|
.mob-goals, .mob-forecast, .mob-form-screen { display: grid; }
|
||||||
|
|||||||
@@ -1745,7 +1745,7 @@ textarea {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
max-height: min(78dvh, 640px);
|
max-height: min(78dvh, 640px);
|
||||||
border-radius: 22px 22px 0 0;
|
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;
|
animation: sheet-up 0.22s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -39,3 +39,9 @@ STT_INTERNAL_TOKEN=change-me-stt-internal-token
|
|||||||
STT_TIMEOUT_MS=60000
|
STT_TIMEOUT_MS=60000
|
||||||
|
|
||||||
INTEGRATIONS_ENCRYPTION_KEY=change-me-64-hex-chars-use-openssl-rand-hex-32
|
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=
|
||||||
|
|||||||
@@ -44,3 +44,10 @@ STT_TIMEOUT_MS=60000
|
|||||||
|
|
||||||
# رمزنگاری توکنهای اتصال حسابداری (تهحساب) — ۳۲ بایت hex (۶۴ کاراکتر)
|
# رمزنگاری توکنهای اتصال حسابداری (تهحساب) — ۳۲ بایت hex (۶۴ کاراکتر)
|
||||||
INTEGRATIONS_ENCRYPTION_KEY=change-me-64-hex-chars-use-openssl-rand-hex-32
|
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=
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ nano apps/api/.env
|
|||||||
| `SMS_*` | همان ترِز مونتایم |
|
| `SMS_*` | همان ترِز مونتایم |
|
||||||
| `PORTAL_SHOW_OTP` | در production حتماً `false` |
|
| `PORTAL_SHOW_OTP` | در production حتماً `false` |
|
||||||
| `INTEGRATIONS_ENCRYPTION_KEY` | ۶۴ کاراکتر hex برای رمز توکن تهحساب (`openssl rand -hex 32`) |
|
| `INTEGRATIONS_ENCRYPTION_KEY` | ۶۴ کاراکتر hex برای رمز توکن تهحساب (`openssl rand -hex 32`) |
|
||||||
|
| `PLATFORM_ADMIN_MOBILE` / `PASSWORD` / `NAME` | حساب اولیه سوپرادمین برای `/adm` (با `npx prisma db seed`) |
|
||||||
|
|
||||||
نمونهٔ اتصال:
|
نمونهٔ اتصال:
|
||||||
|
|
||||||
@@ -186,6 +187,37 @@ sudo nginx -t && sudo systemctl reload nginx
|
|||||||
2. دمو: `owner@demo.local` / `demo1234` (اگر seed زده باشید)
|
2. دمو: `owner@demo.local` / `demo1234` (اگر seed زده باشید)
|
||||||
3. پرتال: `https://crm.montzar.ir/p/demo-mento` → OTP باید پیامک شود
|
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.
|
||||||
@@ -37,6 +37,223 @@ export interface AuthUserDto {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
organizationName?: string;
|
organizationName?: string;
|
||||||
hasAvatar?: boolean;
|
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 {
|
export interface OrgUserDto {
|
||||||
@@ -245,6 +462,7 @@ export interface OrganizationDto {
|
|||||||
baseCurrency: string;
|
baseCurrency: string;
|
||||||
enabledCurrencies: string[];
|
enabledCurrencies: string[];
|
||||||
exchangeRates: ExchangeRateDto[];
|
exchangeRates: ExchangeRateDto[];
|
||||||
|
enabledModules: OrgModuleKey[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateOrganizationRequest {
|
export interface UpdateOrganizationRequest {
|
||||||
@@ -423,8 +641,8 @@ export interface PersonDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePersonRequest {
|
export interface CreatePersonRequest {
|
||||||
firstName: string;
|
firstName?: string;
|
||||||
lastName: string;
|
lastName?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
mobile?: string;
|
mobile?: string;
|
||||||
@@ -850,6 +1068,25 @@ export interface ImportContactsResult {
|
|||||||
boards?: string[];
|
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 {
|
export interface ProductDto {
|
||||||
id: string;
|
id: string;
|
||||||
kind: "product" | "service";
|
kind: "product" | "service";
|
||||||
@@ -2496,6 +2733,123 @@ export interface CreateMessageAutomationRuleRequest {
|
|||||||
export type UpdateMessageAutomationRuleRequest =
|
export type UpdateMessageAutomationRuleRequest =
|
||||||
Partial<CreateMessageAutomationRuleRequest>;
|
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 { brand, APP_NAME, APP_NAME_FA, brandSuggestionFa } from "./brand";
|
||||||
export type { Brand } from "./brand";
|
export type { Brand } from "./brand";
|
||||||
export const API_PREFIX = "api";
|
export const API_PREFIX = "api";
|
||||||
|
|||||||
Reference in New Issue
Block a user