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'))"
|
||||
INTEGRATIONS_ENCRYPTION_KEY=
|
||||
|
||||
# سوپرادمین پلتفرم — ورود از /adm با موبایل + رمز
|
||||
# در لوکال اگر خالی بماند: 09120000000 / demo1234
|
||||
PLATFORM_ADMIN_MOBILE=
|
||||
PLATFORM_ADMIN_PASSWORD=
|
||||
PLATFORM_ADMIN_NAME=سوپرادمین
|
||||
# اختیاری
|
||||
# PLATFORM_ADMIN_EMAIL=
|
||||
|
||||
# Production: see deploy/.env.production.example and deploy/DEPLOY.md
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Organization" ADD COLUMN IF NOT EXISTS "isActive" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "PlatformAdmin" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"fullName" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"lastLoginAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PlatformAdmin_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "PlatformAdmin_email_key" ON "PlatformAdmin"("email");
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "PlatformAdmin" ADD COLUMN IF NOT EXISTS "mobile" TEXT;
|
||||
|
||||
-- Make email optional (existing rows keep their email)
|
||||
ALTER TABLE "PlatformAdmin" ALTER COLUMN "email" DROP NOT NULL;
|
||||
|
||||
-- Unique mobile
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "PlatformAdmin_mobile_key" ON "PlatformAdmin"("mobile");
|
||||
@@ -0,0 +1,64 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Organization" ADD COLUMN IF NOT EXISTS "enabledModules" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "BillingAccount" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ownerUserId" TEXT NOT NULL,
|
||||
"planCode" TEXT NOT NULL DEFAULT 'trial',
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"startsAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"maxOrganizations" INTEGER NOT NULL DEFAULT 1,
|
||||
"externalPaymentRef" TEXT,
|
||||
"notes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "BillingAccount_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "BillingAccount_ownerUserId_key" ON "BillingAccount"("ownerUserId");
|
||||
CREATE INDEX IF NOT EXISTS "BillingAccount_status_idx" ON "BillingAccount"("status");
|
||||
CREATE INDEX IF NOT EXISTS "BillingAccount_expiresAt_idx" ON "BillingAccount"("expiresAt");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'BillingAccount_ownerUserId_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "BillingAccount"
|
||||
ADD CONSTRAINT "BillingAccount_ownerUserId_fkey"
|
||||
FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Backfill: full module catalog for existing orgs (empty → all)
|
||||
UPDATE "Organization"
|
||||
SET "enabledModules" = ARRAY[
|
||||
'customers','pipeline','products','invoices','quotes','commissions','sales_forecast',
|
||||
'activities','tickets','workflow_automations','message_automations','lead_forms',
|
||||
'project_boards','inventory','cheques','attachments','loyalty','reports','notes',
|
||||
'tahesab','custom_fields','currencies','team_settings','faq'
|
||||
]::TEXT[]
|
||||
WHERE cardinality("enabledModules") = 0;
|
||||
|
||||
-- Backfill BillingAccount for every active owner membership (15-day trial)
|
||||
INSERT INTO "BillingAccount" (
|
||||
"id", "ownerUserId", "planCode", "status", "startsAt", "expiresAt",
|
||||
"maxOrganizations", "createdAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
md5(random()::text || clock_timestamp()::text || m."userId"),
|
||||
m."userId",
|
||||
'trial',
|
||||
'active',
|
||||
NOW(),
|
||||
NOW() + INTERVAL '15 days',
|
||||
GREATEST(1, COUNT(*)::int),
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM "Membership" m
|
||||
WHERE m."role" = 'owner' AND m."isActive" = true
|
||||
GROUP BY m."userId"
|
||||
ON CONFLICT ("ownerUserId") DO NOTHING;
|
||||
@@ -147,6 +147,9 @@ model Organization {
|
||||
businessField String?
|
||||
baseCurrency String @default("IRR")
|
||||
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
|
||||
/// ماژولهای فعال منو برای این محیط
|
||||
enabledModules String[] @default([])
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
users User[]
|
||||
@@ -229,6 +232,7 @@ model User {
|
||||
ticketMessages TicketMessage[] @relation("TicketMessageAuthor")
|
||||
confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy")
|
||||
memberships Membership[]
|
||||
billingAccount BillingAccount?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -278,6 +282,42 @@ model Membership {
|
||||
@@index([orgRoleId])
|
||||
}
|
||||
|
||||
/// حساب سوپرادمین پلتفرم — جدا از User سازمانی
|
||||
model PlatformAdmin {
|
||||
id String @id @default(cuid())
|
||||
/// ورود اصلی با موبایل نرمالشده (مثلاً 0912…)
|
||||
mobile String? @unique
|
||||
email String? @unique
|
||||
passwordHash String
|
||||
fullName String
|
||||
isActive Boolean @default(true)
|
||||
lastLoginAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// اشتراک SaaS روی مالک حساب (نه روی هر عضویت)
|
||||
model BillingAccount {
|
||||
id String @id @default(cuid())
|
||||
ownerUserId String @unique
|
||||
owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
|
||||
/// trial | monthly | quarterly | semiannual | yearly | custom
|
||||
planCode String @default("trial")
|
||||
/// active | expired | suspended
|
||||
status String @default("active")
|
||||
startsAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
maxOrganizations Int @default(1)
|
||||
/// رزرو برای درگاه/لندینگ بعدی
|
||||
externalPaymentRef String?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model Company {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
|
||||
@@ -13,7 +13,103 @@ const DEFAULT_STAGES = [
|
||||
{ name: 'از دسترفته', isWon: false, isLost: true },
|
||||
] as const;
|
||||
|
||||
async function seedPlatformAdmin() {
|
||||
const envMobile = (process.env.PLATFORM_ADMIN_MOBILE ?? '').trim();
|
||||
const envEmail = (process.env.PLATFORM_ADMIN_EMAIL ?? '').trim().toLowerCase();
|
||||
const envPassword = process.env.PLATFORM_ADMIN_PASSWORD ?? '';
|
||||
const envName = (process.env.PLATFORM_ADMIN_NAME ?? 'سوپرادمین').trim() || 'سوپرادمین';
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
const mobileRaw = envMobile || (isProd ? '' : '09120000000');
|
||||
const password = envPassword || (isProd ? '' : 'demo1234');
|
||||
const email = envEmail || null;
|
||||
|
||||
if (!mobileRaw || !password) {
|
||||
console.log(
|
||||
'Platform admin seed skipped (set PLATFORM_ADMIN_MOBILE and PLATFORM_ADMIN_PASSWORD)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
console.log('Platform admin seed skipped (password must be at least 8 chars)');
|
||||
return;
|
||||
}
|
||||
|
||||
// normalize: 09xxxxxxxxx
|
||||
let mobile = mobileRaw.replace(/[\s\-()]/g, '');
|
||||
if (mobile.startsWith('+98')) mobile = `0${mobile.slice(3)}`;
|
||||
if (mobile.startsWith('98') && mobile.length === 12) mobile = `0${mobile.slice(2)}`;
|
||||
if (/^9\d{9}$/.test(mobile)) mobile = `0${mobile}`;
|
||||
if (!/^09\d{9}$/.test(mobile)) {
|
||||
console.log('Platform admin seed skipped (PLATFORM_ADMIN_MOBILE invalid)');
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const existingByMobile = await prisma.platformAdmin.findUnique({ where: { mobile } });
|
||||
if (existingByMobile) {
|
||||
await prisma.platformAdmin.update({
|
||||
where: { id: existingByMobile.id },
|
||||
data: {
|
||||
passwordHash,
|
||||
fullName: envName,
|
||||
isActive: true,
|
||||
...(email ? { email } : {}),
|
||||
},
|
||||
});
|
||||
console.log('Platform admin password refreshed:', mobile);
|
||||
return;
|
||||
}
|
||||
if (email) {
|
||||
const existingByEmail = await prisma.platformAdmin.findUnique({ where: { email } });
|
||||
if (existingByEmail) {
|
||||
await prisma.platformAdmin.update({
|
||||
where: { id: existingByEmail.id },
|
||||
data: {
|
||||
mobile,
|
||||
passwordHash,
|
||||
fullName: envName,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
console.log('Platform admin linked mobile + password refreshed:', mobile, '→', email);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const orphans = await prisma.platformAdmin.findMany({
|
||||
where: { mobile: null },
|
||||
take: 2,
|
||||
});
|
||||
if (orphans.length === 1) {
|
||||
const orphan = orphans[0]!;
|
||||
await prisma.platformAdmin.update({
|
||||
where: { id: orphan.id },
|
||||
data: {
|
||||
mobile,
|
||||
passwordHash,
|
||||
fullName: envName,
|
||||
isActive: true,
|
||||
...(email ? { email } : {}),
|
||||
},
|
||||
});
|
||||
console.log('Platform admin orphan linked mobile:', mobile);
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.platformAdmin.create({
|
||||
data: {
|
||||
mobile,
|
||||
email,
|
||||
passwordHash,
|
||||
fullName: envName,
|
||||
},
|
||||
});
|
||||
console.log('Seeded platform admin:', mobile);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await seedPlatformAdmin();
|
||||
|
||||
const email = 'owner@demo.local';
|
||||
let org = await prisma.organization.findUnique({ where: { slug: 'demo-mento' } });
|
||||
if (!org) {
|
||||
|
||||
@@ -5,12 +5,16 @@ import type { Actor } from '../common/record-scope';
|
||||
import { assigneeWhere } from '../common/record-scope';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
formatClock,
|
||||
formatJalaliDate,
|
||||
parseExcelMapping,
|
||||
parseJalaliDateTime,
|
||||
previewExcel,
|
||||
readMappedRows,
|
||||
stripHtml,
|
||||
writeXlsx,
|
||||
type ExcelPreviewResult,
|
||||
} from '../common/excel';
|
||||
|
||||
const ACTIVITY_COLUMNS = [
|
||||
@@ -143,8 +147,16 @@ export class ActivitiesExcelService {
|
||||
);
|
||||
}
|
||||
|
||||
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, ACTIVITY_ALIASES);
|
||||
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||
return previewExcel(file, ACTIVITY_ALIASES, excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title']));
|
||||
}
|
||||
|
||||
async import(
|
||||
actor: Actor,
|
||||
file: Express.Multer.File,
|
||||
mappingRaw?: string,
|
||||
): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, ACTIVITY_ALIASES, parseExcelMapping(mappingRaw));
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
|
||||
@@ -79,6 +79,17 @@ export class ActivitiesController {
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post('import/preview')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 15_000_000 },
|
||||
}),
|
||||
)
|
||||
importPreview(@UploadedFile() file: Express.Multer.File) {
|
||||
return this.excelService.preview(file);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
@@ -89,8 +100,9 @@ export class ActivitiesController {
|
||||
import(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('mapping') mapping?: string,
|
||||
) {
|
||||
return this.excelService.import(user, file);
|
||||
return this.excelService.import(user, file, mapping);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -32,6 +32,8 @@ import { MemoryModule } from './memory/memory.module';
|
||||
import { TahesabModule } from './integrations/tahesab/tahesab.module';
|
||||
import { InventoryModule } from './inventory/inventory.module';
|
||||
import { FinanceModule } from './finance/finance.module';
|
||||
import { PlatformModule } from './platform/platform.module';
|
||||
import { BillingModule } from './billing/billing.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -44,7 +46,9 @@ import { FinanceModule } from './finance/finance.module';
|
||||
PrismaModule,
|
||||
StorageModule,
|
||||
HealthModule,
|
||||
BillingModule,
|
||||
AuthModule,
|
||||
PlatformModule,
|
||||
UsersModule,
|
||||
OrgRolesModule,
|
||||
OrganizationsModule,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { MembershipsModule } from '../memberships/memberships.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
@@ -13,6 +14,7 @@ import { SmsService } from '../common/sms.service';
|
||||
imports: [
|
||||
ConfigModule,
|
||||
MembershipsModule,
|
||||
BillingModule,
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
|
||||
@@ -10,12 +10,19 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import * as path from 'path';
|
||||
import type { AuthUserDto, LoginResponse, LoginResult } from '@mento/shared';
|
||||
import type {
|
||||
AuthUserDto,
|
||||
LoginResponse,
|
||||
LoginResult,
|
||||
OrgModuleKey,
|
||||
SubscriptionSummaryDto,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { MembershipsService } from '../memberships/memberships.service';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import {
|
||||
LoginDto,
|
||||
LoginOtpRequestDto,
|
||||
@@ -97,6 +104,7 @@ export class AuthService {
|
||||
private readonly sms: SmsService,
|
||||
private readonly files: LocalFileStorage,
|
||||
private readonly memberships: MembershipsService,
|
||||
private readonly billing: BillingService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -260,6 +268,11 @@ export class AuthService {
|
||||
organizationId: organization.id,
|
||||
role: 'owner',
|
||||
});
|
||||
await this.billing.ensureTrialForOwner(owner.id, { maxOrganizations: 1 });
|
||||
await this.prisma.organization.update({
|
||||
where: { id: organization.id },
|
||||
data: { enabledModules: this.billing.defaultModulesForNewOrg() },
|
||||
});
|
||||
|
||||
await this.prisma.authChallenge.update({
|
||||
where: { id: challenge.id },
|
||||
@@ -578,6 +591,9 @@ export class AuthService {
|
||||
if (!user || user.isActive === false) {
|
||||
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
||||
}
|
||||
const subscription = await this.billing.requireActiveForOrganization(
|
||||
organizationId,
|
||||
);
|
||||
await this.memberships.syncUserLastOrg(
|
||||
userId,
|
||||
organizationId,
|
||||
@@ -597,6 +613,7 @@ export class AuthService {
|
||||
user: await this.toAuthUserDto(
|
||||
{ ...user, role: membership.role, organizationId },
|
||||
organizationId,
|
||||
subscription,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -611,19 +628,30 @@ export class AuthService {
|
||||
organizationId: string;
|
||||
},
|
||||
organizationId: string,
|
||||
subscription?: SubscriptionSummaryDto | null,
|
||||
): Promise<AuthUserDto> {
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId: user.id, organizationId, isActive: true },
|
||||
include: { organization: { select: { name: true } } },
|
||||
include: {
|
||||
organization: { select: { name: true, enabledModules: true } },
|
||||
},
|
||||
});
|
||||
const role = membership?.role ?? user.role;
|
||||
const orgName = membership?.organization.name;
|
||||
const sub =
|
||||
subscription ??
|
||||
(await this.billing.requireActiveForOrganization(organizationId));
|
||||
return this.toAuthUser({
|
||||
...user,
|
||||
role,
|
||||
organizationId,
|
||||
organizationName: orgName,
|
||||
avatarStorageKey: await this.readAvatarKey(user.id),
|
||||
enabledModules: this.billing.normalizeModules(
|
||||
membership?.organization.enabledModules,
|
||||
),
|
||||
subscription: sub,
|
||||
isOrgOwner: role === 'owner',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -708,6 +736,9 @@ export class AuthService {
|
||||
organizationId: string;
|
||||
organizationName?: string;
|
||||
avatarStorageKey?: string | null;
|
||||
enabledModules?: OrgModuleKey[];
|
||||
subscription?: SubscriptionSummaryDto | null;
|
||||
isOrgOwner?: boolean;
|
||||
}): AuthUserDto {
|
||||
return {
|
||||
id: user.id,
|
||||
@@ -718,6 +749,9 @@ export class AuthService {
|
||||
organizationName: user.organizationName,
|
||||
hasAvatar: Boolean(user.avatarStorageKey),
|
||||
...(user.mobile ? { mobile: user.mobile } : {}),
|
||||
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
|
||||
...(user.subscription ? { subscription: user.subscription } : {}),
|
||||
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export type JwtPayload = {
|
||||
@@ -15,6 +16,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
@@ -24,7 +26,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload & { typ?: string; mobile?: string }) {
|
||||
if (payload.typ === 'portal' || payload.typ === 'org-select') {
|
||||
if (
|
||||
payload.typ === 'portal' ||
|
||||
payload.typ === 'org-select' ||
|
||||
payload.typ === 'platform-admin'
|
||||
) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
if (!payload.sub || !payload.organizationId) {
|
||||
@@ -42,12 +48,14 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
userId: user.id,
|
||||
organizationId: payload.organizationId,
|
||||
isActive: true,
|
||||
organization: { isActive: true },
|
||||
},
|
||||
select: { organizationId: true, role: true },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
await this.billing.requireActiveForOrganization(membership.organizationId);
|
||||
return {
|
||||
userId: user.id,
|
||||
organizationId: membership.organizationId,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
export type ExcelFieldOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export type ExcelColumnPreview = {
|
||||
header: string;
|
||||
index: number;
|
||||
samples: string[];
|
||||
};
|
||||
|
||||
export type ExcelPreviewResult = {
|
||||
columns: ExcelColumnPreview[];
|
||||
fields: ExcelFieldOption[];
|
||||
suggested: Record<string, string>;
|
||||
needsMapping: boolean;
|
||||
};
|
||||
|
||||
export function excelFieldsFromColumns(
|
||||
columns: Array<{ header: string; key: string }>,
|
||||
requiredKeys: string[] = [],
|
||||
): ExcelFieldOption[] {
|
||||
const seen = new Set<string>();
|
||||
const fields: ExcelFieldOption[] = [];
|
||||
for (const col of columns) {
|
||||
if (!col.key || col.key === '_type' || seen.has(col.key)) continue;
|
||||
seen.add(col.key);
|
||||
fields.push({
|
||||
key: col.key,
|
||||
label: col.header,
|
||||
required: requiredKeys.includes(col.key),
|
||||
});
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function parseExcelMapping(raw?: string): Record<string, string> | undefined {
|
||||
if (raw == null || !String(raw).trim()) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(String(raw)) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('invalid');
|
||||
}
|
||||
const mapping: Record<string, string> = {};
|
||||
for (const [header, field] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof field === 'string' && field.trim()) mapping[header] = field.trim();
|
||||
}
|
||||
return mapping;
|
||||
} catch {
|
||||
throw new BadRequestException('نگاشت ستونها نامعتبر است');
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFieldKey(
|
||||
raw: string,
|
||||
aliases: Record<string, string>,
|
||||
mapping?: Record<string, string>,
|
||||
) {
|
||||
if (mapping) {
|
||||
return mapping[raw] || mapping[normalizeHeader(raw)] || '';
|
||||
}
|
||||
const compact = normalizeHeader(raw);
|
||||
return aliases[compact] ?? aliases[raw.trim()] ?? aliases[normalizeHeader(raw)] ?? '';
|
||||
}
|
||||
|
||||
export async function previewExcel(
|
||||
file: Express.Multer.File,
|
||||
aliases: Record<string, string>,
|
||||
fields: ExcelFieldOption[],
|
||||
): Promise<ExcelPreviewResult> {
|
||||
const sheet = await workbookFromUpload(file);
|
||||
const headerRow = sheet.getRow(1);
|
||||
const columns: ExcelColumnPreview[] = [];
|
||||
const suggested: Record<string, string> = {};
|
||||
const used = new Set<string>();
|
||||
|
||||
headerRow.eachCell((cell, col) => {
|
||||
const header = cellStr(cell.value);
|
||||
if (!header) return;
|
||||
const samples: string[] = [];
|
||||
const last = Math.min(sheet.rowCount, 6);
|
||||
for (let r = 2; r <= last; r += 1) {
|
||||
const value = cellStr(sheet.getRow(r).getCell(col).value);
|
||||
if (value) samples.push(value);
|
||||
if (samples.length >= 3) break;
|
||||
}
|
||||
columns.push({ header, index: col, samples });
|
||||
const key = resolveFieldKey(header, aliases);
|
||||
if (key && !used.has(key)) {
|
||||
suggested[header] = key;
|
||||
used.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
if (!columns.length) {
|
||||
throw new BadRequestException('ستون عنوان در ردیف اول فایل پیدا نشد');
|
||||
}
|
||||
|
||||
const required = fields.filter((f) => f.required).map((f) => f.key);
|
||||
const mapped = new Set(Object.values(suggested));
|
||||
const missingRequired = required.some((key) => !mapped.has(key));
|
||||
const unrecognized = columns.some((col) => !suggested[col.header]);
|
||||
return {
|
||||
columns,
|
||||
fields,
|
||||
suggested,
|
||||
needsMapping: unrecognized || missingRequired || !mapped.size,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readMappedRows(
|
||||
file: Express.Multer.File,
|
||||
aliases: Record<string, string>,
|
||||
mapping?: Record<string, string>,
|
||||
): Promise<Record<string, string>[]> {
|
||||
const sheet = await workbookFromUpload(file);
|
||||
const headerRow = sheet.getRow(1);
|
||||
@@ -198,13 +310,12 @@ export async function readMappedRows(
|
||||
headerRow.eachCell((cell, col) => {
|
||||
const raw = cellStr(cell.value);
|
||||
if (!raw) return;
|
||||
const compact = normalizeHeader(raw);
|
||||
const key = aliases[compact] ?? aliases[raw.trim()] ?? aliases[normalizeHeader(raw)];
|
||||
const key = resolveFieldKey(raw, aliases, mapping);
|
||||
if (key) map.set(col, key);
|
||||
});
|
||||
if (!map.size) {
|
||||
throw new BadRequestException(
|
||||
'ستونهای فایل شناسایی نشد. از قالب خروجی همین سیستم یا خروجی دیدار استفاده کنید.',
|
||||
'ستونهای فایل شناسایی نشد. ستونهای فایل را با فیلدهای سیستم تطبیق دهید.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -56,13 +56,20 @@ export class CompaniesController {
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post('import/preview')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
previewImport(@UploadedFile() file: Express.Multer.File) {
|
||||
return this.excelService.previewCompanies(file);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
import(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('mapping') mapping?: string,
|
||||
) {
|
||||
return this.excelService.importCompanies(user, file);
|
||||
return this.excelService.importCompanies(user, file, mapping);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -6,8 +6,11 @@ import type { Actor } from '../common/record-scope';
|
||||
import { ownerWhere } from '../common/record-scope';
|
||||
import {
|
||||
formatJalaliDate,
|
||||
parseExcelMapping,
|
||||
previewExcel,
|
||||
readMappedRows,
|
||||
writeXlsx,
|
||||
type ExcelPreviewResult,
|
||||
} from '../common/excel';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { PeopleService } from './people.service';
|
||||
@@ -107,6 +110,19 @@ const PERSON_ALIASES: Record<string, string> = {
|
||||
ستاره: 'star',
|
||||
};
|
||||
|
||||
const PERSON_FIELDS = [
|
||||
{ key: 'firstName', label: 'نام' },
|
||||
{ key: 'lastName', label: 'نام خانوادگی' },
|
||||
{ key: 'title', label: 'نام کامل / عنوان' },
|
||||
{ key: 'mobile', label: 'موبایل' },
|
||||
{ key: 'phone', label: 'تلفن' },
|
||||
{ key: 'email', label: 'ایمیل' },
|
||||
{ key: 'position', label: 'سمت' },
|
||||
{ key: 'companyName', label: 'شرکت' },
|
||||
{ key: 'notes', label: 'یادداشت' },
|
||||
{ key: 'mood', label: 'وضعیت مشتری' },
|
||||
];
|
||||
|
||||
const COMPANY_ALIASES: Record<string, string> = {
|
||||
name: 'name',
|
||||
نام: 'name',
|
||||
@@ -126,6 +142,16 @@ const COMPANY_ALIASES: Record<string, string> = {
|
||||
یادداشت: 'notes',
|
||||
};
|
||||
|
||||
const COMPANY_FIELDS = [
|
||||
{ key: 'name', label: 'نام شرکت', required: true },
|
||||
{ key: 'nationalId', label: 'شناسه ملی' },
|
||||
{ key: 'phone', label: 'تلفن' },
|
||||
{ key: 'email', label: 'ایمیل' },
|
||||
{ key: 'website', label: 'وبسایت' },
|
||||
{ key: 'address', label: 'آدرس' },
|
||||
{ key: 'notes', label: 'یادداشت' },
|
||||
];
|
||||
|
||||
const CONTACT_COLUMNS = [
|
||||
{ header: '_type', key: '_type', width: 12 },
|
||||
{ header: 'کد دیدار مشتری', key: 'didarId', width: 16 },
|
||||
@@ -294,11 +320,20 @@ export class ContactsExcelService {
|
||||
);
|
||||
}
|
||||
|
||||
previewPeople(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||
return previewExcel(file, PERSON_ALIASES, PERSON_FIELDS);
|
||||
}
|
||||
|
||||
previewCompanies(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||
return previewExcel(file, COMPANY_ALIASES, COMPANY_FIELDS);
|
||||
}
|
||||
|
||||
async importPeople(
|
||||
actor: Actor,
|
||||
file: Express.Multer.File,
|
||||
mappingRaw?: string,
|
||||
): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, PERSON_ALIASES);
|
||||
const rows = await readMappedRows(file, PERSON_ALIASES, parseExcelMapping(mappingRaw));
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
@@ -307,9 +342,18 @@ export class ContactsExcelService {
|
||||
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'contact') {
|
||||
continue;
|
||||
}
|
||||
const names = splitDidarName(row.firstName ?? '', row.lastName ?? '', row.title);
|
||||
if (!names) {
|
||||
result.errors.push({ row: rowNum, message: 'نام مشتری الزامی است' });
|
||||
const names = splitDidarName(row.firstName ?? '', row.lastName ?? '', row.title) ?? {
|
||||
firstName: (row.firstName ?? '').trim().slice(0, 80),
|
||||
lastName: (row.lastName ?? '').trim().slice(0, 80),
|
||||
};
|
||||
if (
|
||||
!names.firstName &&
|
||||
!names.lastName &&
|
||||
!row.mobile?.trim() &&
|
||||
!row.email?.trim() &&
|
||||
!row.phone?.trim()
|
||||
) {
|
||||
result.errors.push({ row: rowNum, message: 'حداقل نام، موبایل، تلفن یا ایمیل لازم است' });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -357,8 +401,9 @@ export class ContactsExcelService {
|
||||
async importCompanies(
|
||||
actor: Actor,
|
||||
file: Express.Multer.File,
|
||||
mappingRaw?: string,
|
||||
): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, COMPANY_ALIASES);
|
||||
const rows = await readMappedRows(file, COMPANY_ALIASES, parseExcelMapping(mappingRaw));
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
|
||||
@@ -135,15 +135,15 @@ export class UpdateCompanyDto {
|
||||
}
|
||||
|
||||
export class CreatePersonDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
firstName!: string;
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
lastName!: string;
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@@ -192,13 +192,11 @@ export class CreatePersonDto {
|
||||
export class UpdatePersonDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
lastName?: string;
|
||||
|
||||
|
||||
@@ -18,13 +18,16 @@ import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { CreatePersonDto, ListQueryDto, UpdatePersonDto } from './dto';
|
||||
import { PeopleService } from './people.service';
|
||||
import { ContactsExcelService } from './contacts-excel.service';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
@Controller('people')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('customers')
|
||||
export class PeopleController {
|
||||
constructor(
|
||||
private readonly peopleService: PeopleService,
|
||||
@@ -57,13 +60,22 @@ export class PeopleController {
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post('import/preview')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
previewImport(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.excelService.previewPeople(file);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
import(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('mapping') mapping?: string,
|
||||
) {
|
||||
return this.excelService.importPeople(user, file);
|
||||
return this.excelService.importPeople(user, file, mapping);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@@ -73,7 +85,11 @@ export class PeopleController {
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: Actor, @Body() dto: CreatePersonDto) {
|
||||
return this.peopleService.create(user, dto);
|
||||
return this.peopleService.create(user, {
|
||||
...dto,
|
||||
firstName: dto.firstName ?? '',
|
||||
lastName: dto.lastName ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
|
||||
@@ -134,8 +134,8 @@ export class PeopleService {
|
||||
|
||||
const data: Prisma.PersonUncheckedCreateInput = {
|
||||
organizationId: actor.organizationId,
|
||||
firstName: dto.firstName.trim(),
|
||||
lastName: dto.lastName.trim(),
|
||||
firstName: (dto.firstName ?? '').trim(),
|
||||
lastName: (dto.lastName ?? '').trim(),
|
||||
email: normalizeEmail(dto.email),
|
||||
phone: normalizePhone(dto.phone),
|
||||
mobile: normalizePhone(dto.mobile),
|
||||
|
||||
@@ -20,7 +20,7 @@ export class MembershipsService {
|
||||
|
||||
async listActive(userId: string) {
|
||||
return this.prisma.membership.findMany({
|
||||
where: { userId, isActive: true },
|
||||
where: { userId, isActive: true, organization: { isActive: true } },
|
||||
include: {
|
||||
organization: { select: { id: true, name: true } },
|
||||
orgRole: { select: { name: true } },
|
||||
@@ -35,12 +35,24 @@ export class MembershipsService {
|
||||
): Promise<MembershipActor> {
|
||||
const row = await this.prisma.membership.findFirst({
|
||||
where: { userId, organizationId, isActive: true },
|
||||
select: { userId: true, organizationId: true, role: true },
|
||||
select: {
|
||||
userId: true,
|
||||
organizationId: true,
|
||||
role: true,
|
||||
organization: { select: { isActive: true } },
|
||||
},
|
||||
});
|
||||
if (!row) {
|
||||
throw new ForbiddenException('به این محیط دسترسی ندارید');
|
||||
}
|
||||
return row;
|
||||
if (row.organization.isActive === false) {
|
||||
throw new UnauthorizedException('این محیط غیرفعال است');
|
||||
}
|
||||
return {
|
||||
userId: row.userId,
|
||||
organizationId: row.organizationId,
|
||||
role: row.role,
|
||||
};
|
||||
}
|
||||
|
||||
toChoices(
|
||||
|
||||
@@ -12,6 +12,8 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import {
|
||||
CreateMessageAutomationRuleDto,
|
||||
UpdateMessageAutomationRuleDto,
|
||||
@@ -19,7 +21,8 @@ import {
|
||||
import { MessageAutomationsService } from './message-automations.service';
|
||||
|
||||
@Controller('message-automations')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('message_automations')
|
||||
@Roles('owner', 'admin')
|
||||
export class MessageAutomationsController {
|
||||
constructor(private readonly messages: MessageAutomationsService) {}
|
||||
|
||||
@@ -3,11 +3,18 @@ import { AuthModule } from '../auth/auth.module';
|
||||
import { MembershipsModule } from '../memberships/memberships.module';
|
||||
import { OrgRolesModule } from '../org-roles/org-roles.module';
|
||||
import { PipelinesModule } from '../pipelines/pipelines.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { OrganizationsController } from './organizations.controller';
|
||||
import { OrganizationsService } from './organizations.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, MembershipsModule, OrgRolesModule, PipelinesModule],
|
||||
imports: [
|
||||
AuthModule,
|
||||
MembershipsModule,
|
||||
OrgRolesModule,
|
||||
PipelinesModule,
|
||||
BillingModule,
|
||||
],
|
||||
controllers: [OrganizationsController],
|
||||
providers: [OrganizationsService],
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import { isKnownCurrency } from '../common/currency';
|
||||
import { MembershipsService } from '../memberships/memberships.service';
|
||||
import { OrgRolesService } from '../org-roles/org-roles.service';
|
||||
import { PipelinesService } from '../pipelines/pipelines.service';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
|
||||
function slugify(input: string): string {
|
||||
return (
|
||||
@@ -34,6 +35,7 @@ export class OrganizationsService {
|
||||
private readonly memberships: MembershipsService,
|
||||
private readonly orgRoles: OrgRolesService,
|
||||
private readonly pipelines: PipelinesService,
|
||||
private readonly billing: BillingService,
|
||||
) {}
|
||||
|
||||
async createForUser(
|
||||
@@ -57,6 +59,8 @@ export class OrganizationsService {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
await this.billing.assertCanCreateOrganization(userId);
|
||||
|
||||
const baseSlug = slugify(name);
|
||||
let slug = baseSlug;
|
||||
let i = 1;
|
||||
@@ -69,6 +73,7 @@ export class OrganizationsService {
|
||||
name,
|
||||
slug,
|
||||
businessField,
|
||||
enabledModules: this.billing.defaultModulesForNewOrg(),
|
||||
},
|
||||
});
|
||||
await this.memberships.createMembership({
|
||||
@@ -76,6 +81,7 @@ export class OrganizationsService {
|
||||
organizationId: organization.id,
|
||||
role: 'owner',
|
||||
});
|
||||
await this.billing.ensureTrialForOwner(userId);
|
||||
await this.orgRoles.ensureDefaultRoles(organization.id);
|
||||
await this.pipelines.ensureDefaultPipeline(organization.id);
|
||||
return { id: organization.id };
|
||||
@@ -219,6 +225,7 @@ export class OrganizationsService {
|
||||
businessField: string | null;
|
||||
baseCurrency: string;
|
||||
enabledCurrencies: string[];
|
||||
enabledModules?: string[];
|
||||
exchangeRates: Array<{
|
||||
currency: string;
|
||||
rateToBase: Prisma.Decimal;
|
||||
@@ -235,6 +242,7 @@ export class OrganizationsService {
|
||||
: null,
|
||||
baseCurrency: org.baseCurrency,
|
||||
enabledCurrencies: org.enabledCurrencies,
|
||||
enabledModules: this.billing.normalizeModules(org.enabledModules),
|
||||
exchangeRates: org.exchangeRates.map((r) => ({
|
||||
currency: r.currency,
|
||||
rateToBase: Number(r.rateToBase),
|
||||
|
||||
@@ -7,11 +7,15 @@ import { ownerWhere, resolveOwnerId } from '../common/record-scope';
|
||||
import { toJsonCustomFields } from '../common/custom-fields';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
formatJalaliDate,
|
||||
parseAmount,
|
||||
parseExcelMapping,
|
||||
parseJalaliDateTime,
|
||||
previewExcel,
|
||||
readMappedRows,
|
||||
writeXlsx,
|
||||
type ExcelPreviewResult,
|
||||
} from '../common/excel';
|
||||
import { PipelinesService } from './pipelines.service';
|
||||
|
||||
@@ -144,8 +148,16 @@ export class DealsExcelService {
|
||||
);
|
||||
}
|
||||
|
||||
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, DEAL_ALIASES);
|
||||
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||
return previewExcel(file, DEAL_ALIASES, excelFieldsFromColumns(DEAL_COLUMNS, ['title']));
|
||||
}
|
||||
|
||||
async import(
|
||||
actor: Actor,
|
||||
file: Express.Multer.File,
|
||||
mappingRaw?: string,
|
||||
): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, DEAL_ALIASES, parseExcelMapping(mappingRaw));
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
const affected = new Map<string, string>();
|
||||
|
||||
|
||||
@@ -18,13 +18,16 @@ import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { CreateDealDto, MoveDealDto, UpdateDealDto } from './dto';
|
||||
import { DealsService } from './deals.service';
|
||||
import { DealsExcelService } from './deals-excel.service';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
@Controller('deals')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('pipeline')
|
||||
export class DealsController {
|
||||
constructor(
|
||||
private readonly dealsService: DealsService,
|
||||
@@ -45,6 +48,19 @@ export class DealsController {
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post('import/preview')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 15_000_000 },
|
||||
}),
|
||||
)
|
||||
importPreview(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.excelService.preview(file);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
@@ -55,8 +71,9 @@ export class DealsController {
|
||||
import(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('mapping') mapping?: string,
|
||||
) {
|
||||
return this.excelService.import(user, file);
|
||||
return this.excelService.import(user, file, mapping);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -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 { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
parseAmount,
|
||||
parseExcelMapping,
|
||||
previewExcel,
|
||||
readMappedRows,
|
||||
writeXlsx,
|
||||
type ExcelPreviewResult,
|
||||
} from '../common/excel';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
@@ -106,11 +110,16 @@ export class ProductsExcelService {
|
||||
);
|
||||
}
|
||||
|
||||
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||
return previewExcel(file, PRODUCT_ALIASES, excelFieldsFromColumns(PRODUCT_COLUMNS, ['name']));
|
||||
}
|
||||
|
||||
async import(
|
||||
organizationId: string,
|
||||
file: Express.Multer.File,
|
||||
mappingRaw?: string,
|
||||
): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, PRODUCT_ALIASES);
|
||||
const rows = await readMappedRows(file, PRODUCT_ALIASES, parseExcelMapping(mappingRaw));
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
|
||||
@@ -62,6 +62,18 @@ export class ProductsController {
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post('import/preview')
|
||||
@Roles('owner', 'admin')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 15_000_000 },
|
||||
}),
|
||||
)
|
||||
importPreview(@UploadedFile() file: Express.Multer.File) {
|
||||
return this.excelService.preview(file);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@Roles('owner', 'admin')
|
||||
@UseInterceptors(
|
||||
@@ -73,8 +85,9 @@ export class ProductsController {
|
||||
import(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('mapping') mapping?: string,
|
||||
) {
|
||||
return this.excelService.import(user.organizationId, file);
|
||||
return this.excelService.import(user.organizationId, file, mapping);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -128,6 +128,17 @@ export class ProjectBoardsController {
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post('project-cards/import/preview')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 15_000_000 },
|
||||
}),
|
||||
)
|
||||
previewCards(@UploadedFile() file: Express.Multer.File) {
|
||||
return this.excelService.preview(file);
|
||||
}
|
||||
|
||||
@Post('project-cards/import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
@@ -138,8 +149,9 @@ export class ProjectBoardsController {
|
||||
importCards(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('mapping') mapping?: string,
|
||||
) {
|
||||
return this.excelService.import(user, file);
|
||||
return this.excelService.import(user, file, mapping);
|
||||
}
|
||||
|
||||
@Get('project-cards/:id')
|
||||
|
||||
@@ -5,11 +5,15 @@ import type { Actor } from '../common/record-scope';
|
||||
import { ownerWhere } from '../common/record-scope';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
formatJalaliDate,
|
||||
parseExcelMapping,
|
||||
parseJalaliDateTime,
|
||||
previewExcel,
|
||||
readMappedRows,
|
||||
stripHtml,
|
||||
writeXlsx,
|
||||
type ExcelPreviewResult,
|
||||
} from '../common/excel';
|
||||
import { ProjectBoardsService } from './project-boards.service';
|
||||
|
||||
@@ -105,8 +109,16 @@ export class ProjectCardsExcelService {
|
||||
);
|
||||
}
|
||||
|
||||
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, CASE_ALIASES);
|
||||
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||
return previewExcel(file, CASE_ALIASES, excelFieldsFromColumns(CASE_COLUMNS, ['title']));
|
||||
}
|
||||
|
||||
async import(
|
||||
actor: Actor,
|
||||
file: Express.Multer.File,
|
||||
mappingRaw?: string,
|
||||
): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, CASE_ALIASES, parseExcelMapping(mappingRaw));
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
const affected = new Map<string, string>();
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 596 KiB |
@@ -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 dragOffsetY = 0;
|
||||
|
||||
function mobileTabbarPx(): number {
|
||||
if (typeof window === 'undefined' || viewport.value.w > 900) return 0;
|
||||
const bar = document.querySelector('.mobile-tabbar');
|
||||
if (bar instanceof HTMLElement) {
|
||||
const h = bar.getBoundingClientRect().height;
|
||||
if (h > 0) return h;
|
||||
}
|
||||
return 64;
|
||||
}
|
||||
|
||||
function defaultPos(): FabPos {
|
||||
const extra = viewport.value.w <= 900 ? 64 : 0;
|
||||
const extra = mobileTabbarPx();
|
||||
return {
|
||||
left: FAB_MARGIN_LEFT,
|
||||
top: Math.max(8, viewport.value.h - FAB_MARGIN_BOTTOM - extra - FAB_SIZE),
|
||||
@@ -88,7 +98,7 @@ function defaultPos(): FabPos {
|
||||
|
||||
function clampPos(pos: FabPos): FabPos {
|
||||
const margin = 8;
|
||||
const bottomGap = viewport.value.w <= 900 ? 64 : 0;
|
||||
const bottomGap = mobileTabbarPx();
|
||||
return {
|
||||
left: Math.min(Math.max(margin, pos.left), viewport.value.w - FAB_SIZE - margin),
|
||||
top: Math.min(
|
||||
|
||||
@@ -55,6 +55,8 @@ defineProps<{
|
||||
| 'grid'
|
||||
| 'list'
|
||||
| 'eye'
|
||||
| 'eye-off'
|
||||
| 'lock'
|
||||
| 'truck'
|
||||
| 'grip'
|
||||
| 'apps-plus'
|
||||
@@ -339,6 +341,15 @@ defineProps<{
|
||||
<path d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</template>
|
||||
<template v-else-if="name === 'eye-off'">
|
||||
<path d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M4 20 20 4" />
|
||||
</template>
|
||||
<template v-else-if="name === 'lock'">
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
||||
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
|
||||
</template>
|
||||
<template v-else-if="name === 'truck'">
|
||||
<path d="M3 7.5h11V16H3z" />
|
||||
<path d="M14 10h4.2l2.3 3.2V16H14z" />
|
||||
|
||||
@@ -10,44 +10,37 @@ withDefaults(
|
||||
}>(),
|
||||
{
|
||||
headline: 'همه ارتباطات، یکجا مدیریت میشوند',
|
||||
description: `با ${brand.nameFa} فروش، پشتیبانی و ارتباط با مشتری را در یک پنل یکپارچه مدیریت کنید و رشد پایدار بسازید.`,
|
||||
description: `${brand.nameFa} به شما کمک می کند فروش بیشتر داشته باشید،\nمشتریان را بهتر بشناسید و کسب و کارتان را رشد دهید.`,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="brand-panel" aria-hidden="false">
|
||||
<div class="brand-head">
|
||||
<MentoLogo :size="44" :show-text="false" />
|
||||
<div class="brand-copy">
|
||||
<BrandName />
|
||||
<span>{{ brand.taglineFa }}</span>
|
||||
<div class="brand-main">
|
||||
<div class="brand-head">
|
||||
<MentoLogo :size="70" :show-text="false" />
|
||||
<div class="brand-copy">
|
||||
<BrandName />
|
||||
<span class="tagline">{{ brand.taglineFa }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="brand-body">
|
||||
<h2>{{ headline }}</h2>
|
||||
<p>{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="illustration" aria-hidden="true">
|
||||
<img src="/brand/lagin_pic.png" alt="" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="brand-body">
|
||||
<h2>{{ headline }}</h2>
|
||||
<p>{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="illustration" aria-hidden="true">
|
||||
<div class="dash">
|
||||
<div class="dash-top">
|
||||
<span class="avatar" />
|
||||
<span class="bar short" />
|
||||
<span class="bar" />
|
||||
</div>
|
||||
<div class="chart-line" />
|
||||
<div class="chart-bars">
|
||||
<i /><i /><i /><i /><i />
|
||||
</div>
|
||||
</div>
|
||||
<span class="float chat" />
|
||||
<span class="float people" />
|
||||
<span class="float check" />
|
||||
<span class="float ring" />
|
||||
<span class="plant" />
|
||||
</div>
|
||||
<nav class="brand-foot">
|
||||
<a href="#">شرایط و قوانین</a>
|
||||
<span>|</span>
|
||||
<a href="#">تماس با ما</a>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -56,184 +49,110 @@ withDefaults(
|
||||
position: relative;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: clamp(1.4rem, 3vw, 2rem);
|
||||
background:
|
||||
radial-gradient(circle at 20% 10%, rgba(45, 212, 191, 0.18), transparent 42%),
|
||||
linear-gradient(165deg, #f0fdfa 0%, #e6fffa 48%, #f8fafc 100%);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: 0;
|
||||
padding: 2.6rem clamp(1.4rem, 3vw, 2.4rem);
|
||||
background-color: #f5fbfb;
|
||||
background-image:
|
||||
radial-gradient(ellipse 72% 48% at 50% 58%, rgba(45, 212, 191, 0.14), transparent 70%),
|
||||
radial-gradient(circle at 12% 10%, rgba(20, 184, 166, 0.08), transparent 28%),
|
||||
radial-gradient(rgba(13, 148, 136, 0.16) 1.05px, transparent 1.2px);
|
||||
background-size: auto, auto, 20px 20px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.brand-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.35rem;
|
||||
}
|
||||
|
||||
.brand-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.brand-copy strong {
|
||||
font-size: 1.2rem;
|
||||
.brand-copy :deep(.brand-name) {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.brand-copy span {
|
||||
font-size: 0.78rem;
|
||||
.brand-copy .tagline {
|
||||
font-size: 0.88rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.brand-body {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.brand-body h2 {
|
||||
margin: 0 0 0.55rem;
|
||||
font-size: clamp(1.35rem, 2.2vw, 1.75rem);
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: clamp(1.22rem, 1.95vw, 1.52rem);
|
||||
line-height: 1.45;
|
||||
color: #0f172a;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand-body p {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
color: #64748b;
|
||||
line-height: 1.75;
|
||||
font-size: 0.95rem;
|
||||
max-width: 28ch;
|
||||
line-height: 1.7;
|
||||
font-size: 1.08rem;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.illustration {
|
||||
position: relative;
|
||||
margin-top: auto;
|
||||
min-height: 220px;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.dash {
|
||||
.illustration img {
|
||||
width: min(100%, 460px);
|
||||
height: auto;
|
||||
max-height: 360px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-foot {
|
||||
position: absolute;
|
||||
inset-inline-start: 12%;
|
||||
bottom: 18%;
|
||||
width: min(78%, 280px);
|
||||
aspect-ratio: 1.35;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.1);
|
||||
padding: 1rem;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.dash-top {
|
||||
inset-inline: 0;
|
||||
bottom: 1.15rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(145deg, #5eead4, #0d9488);
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bar.short {
|
||||
flex: 0.55;
|
||||
}
|
||||
|
||||
.chart-line {
|
||||
height: 54px;
|
||||
border-radius: var(--radius);
|
||||
background:
|
||||
linear-gradient(180deg, transparent 55%, rgba(13, 148, 136, 0.12) 55%),
|
||||
radial-gradient(circle at 20% 60%, #14b8a6 3px, transparent 4px),
|
||||
radial-gradient(circle at 45% 35%, #14b8a6 3px, transparent 4px),
|
||||
radial-gradient(circle at 70% 50%, #14b8a6 3px, transparent 4px),
|
||||
radial-gradient(circle at 88% 28%, #14b8a6 3px, transparent 4px);
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.chart-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
height: 48px;
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.chart-bars i {
|
||||
flex: 1;
|
||||
border-radius: 6px 6px 2px 2px;
|
||||
background: linear-gradient(180deg, #5eead4, #0f766e);
|
||||
opacity: 0.85;
|
||||
.brand-foot a {
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chart-bars i:nth-child(1) { height: 45%; }
|
||||
.chart-bars i:nth-child(2) { height: 70%; }
|
||||
.chart-bars i:nth-child(3) { height: 55%; }
|
||||
.chart-bars i:nth-child(4) { height: 88%; }
|
||||
.chart-bars i:nth-child(5) { height: 62%; }
|
||||
|
||||
.float {
|
||||
position: absolute;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.float.chat {
|
||||
top: 18%;
|
||||
inset-inline-end: 8%;
|
||||
background:
|
||||
radial-gradient(circle at 35% 40%, #0d9488 0 28%, transparent 29%),
|
||||
linear-gradient(145deg, #ecfdf5, #fff);
|
||||
}
|
||||
|
||||
.float.people {
|
||||
top: 42%;
|
||||
inset-inline-start: 4%;
|
||||
background:
|
||||
radial-gradient(circle at 50% 38%, #14b8a6 0 22%, transparent 23%),
|
||||
linear-gradient(145deg, #f0fdfa, #fff);
|
||||
}
|
||||
|
||||
.float.check {
|
||||
bottom: 28%;
|
||||
inset-inline-end: 6%;
|
||||
background:
|
||||
linear-gradient(135deg, transparent 42%, #10b981 42% 58%, transparent 58%),
|
||||
linear-gradient(45deg, transparent 48%, #10b981 48% 62%, transparent 62%),
|
||||
#ecfdf5;
|
||||
}
|
||||
|
||||
.float.ring {
|
||||
top: 8%;
|
||||
inset-inline-start: 18%;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
conic-gradient(#0d9488 0 70%, #e2e8f0 70% 100%);
|
||||
mask: radial-gradient(farthest-side, transparent calc(100% - 7px), #000 calc(100% - 6px));
|
||||
}
|
||||
|
||||
.plant {
|
||||
position: absolute;
|
||||
bottom: 8%;
|
||||
inset-inline-start: 8%;
|
||||
width: 28px;
|
||||
height: 36px;
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 20%, #34d399 0 35%, transparent 36%),
|
||||
linear-gradient(180deg, #0d9488 0 55%, #134e4a 55%);
|
||||
border-radius: 40% 40% 8px 8px;
|
||||
opacity: 0.9;
|
||||
.brand-foot a:hover {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
|
||||
@@ -96,7 +96,7 @@ const model = defineModel<string | number>({ default: '' });
|
||||
}
|
||||
|
||||
.ui-input::placeholder {
|
||||
color: var(--muted-light);
|
||||
color: #c0c8d4;
|
||||
}
|
||||
|
||||
.ui-input-wrapper:focus-within {
|
||||
|
||||
@@ -67,7 +67,8 @@ function onBackdrop(event: MouseEvent) {
|
||||
|
||||
.dialog {
|
||||
width: min(880px, 100%);
|
||||
max-height: min(88vh, 880px);
|
||||
max-height: min(88dvh, 88vh, 880px);
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
@@ -119,9 +120,10 @@ function onBackdrop(event: MouseEvent) {
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -130,10 +132,8 @@ function onBackdrop(event: MouseEvent) {
|
||||
.body :deep(.page.embedded),
|
||||
.body :deep(.product-form-page.embedded),
|
||||
.body :deep(.company-form-page.embedded) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 100%;
|
||||
min-height: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.body :deep(.page.embedded > .form),
|
||||
@@ -141,7 +141,6 @@ function onBackdrop(event: MouseEvent) {
|
||||
.body :deep(.crm-page.embedded > .form),
|
||||
.body :deep(.crm-page.embedded > form),
|
||||
.body :deep(.crm-page.embedded > .crm-form) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -209,6 +208,7 @@ function onBackdrop(event: MouseEvent) {
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1 1 0%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ const model = defineModel<string>({ default: '' });
|
||||
}
|
||||
|
||||
.ui-textarea::placeholder {
|
||||
color: var(--muted-light);
|
||||
color: #c0c8d4;
|
||||
}
|
||||
|
||||
.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,
|
||||
);
|
||||
|
||||
const enabledModules = computed(() => auth.user?.enabledModules ?? null);
|
||||
const subscription = computed(() => auth.user?.subscription ?? null);
|
||||
|
||||
function moduleAllowed(module?: string) {
|
||||
if (!module) return true;
|
||||
if (!enabledModules.value) return true;
|
||||
return enabledModules.value.includes(module as never);
|
||||
}
|
||||
|
||||
const openHub = computed<NavHubDef | null>(() => {
|
||||
if (!openHubKey.value) return null;
|
||||
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
|
||||
if (!hub) return null;
|
||||
return {
|
||||
...hub,
|
||||
cards: hub.cards.filter((card) => !card.admin || canSettings.value),
|
||||
shortcuts: hub.shortcuts.filter((item) => !item.admin || canSettings.value),
|
||||
cards: hub.cards.filter(
|
||||
(card) =>
|
||||
(!card.admin || canSettings.value) && moduleAllowed(card.module),
|
||||
),
|
||||
shortcuts: hub.shortcuts.filter(
|
||||
(item) =>
|
||||
(!item.admin || canSettings.value) && moduleAllowed(item.module),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const hubVisible = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
for (const hub of NAV_HUBS) {
|
||||
map[hub.key] = hub.cards.some(
|
||||
(card) =>
|
||||
(!card.admin || canSettings.value) && moduleAllowed(card.module),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
function givenName(fullName: string): string {
|
||||
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length <= 1) return fullName.trim();
|
||||
@@ -368,7 +394,7 @@ onUnmounted(() => {
|
||||
<span>داشبورد</span>
|
||||
</RouterLink>
|
||||
|
||||
<div class="nav-fold" :class="{ active: customersActive || openHubKey === 'customers' }">
|
||||
<div class="nav-fold" v-if="hubVisible.customers" :class="{ active: customersActive || openHubKey === 'customers' }">
|
||||
<button
|
||||
class="nav-item nav-fold-btn"
|
||||
type="button"
|
||||
@@ -383,7 +409,7 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-fold" :class="{ active: salesActive || openHubKey === 'sales' }">
|
||||
<div class="nav-fold" v-if="hubVisible.sales" :class="{ active: salesActive || openHubKey === 'sales' }">
|
||||
<button
|
||||
class="nav-item nav-fold-btn"
|
||||
type="button"
|
||||
@@ -398,7 +424,7 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-fold" :class="{ active: activitiesActive || openHubKey === 'activities' }">
|
||||
<div class="nav-fold" v-if="hubVisible.activities" :class="{ active: activitiesActive || openHubKey === 'activities' }">
|
||||
<button
|
||||
class="nav-item nav-fold-btn"
|
||||
type="button"
|
||||
@@ -600,6 +626,17 @@ onUnmounted(() => {
|
||||
<RouterLink class="round-btn" :to="{ name: 'tickets' }" title="پیامها">
|
||||
<UiIcon name="mail" :size="18" />
|
||||
</RouterLink>
|
||||
<div
|
||||
v-if="subscription"
|
||||
class="sub-chip"
|
||||
:class="{ warn: subscription.warn }"
|
||||
:title="`انقضا: ${subscription.expiresAt}`"
|
||||
>
|
||||
<UiIcon name="clock" :size="15" />
|
||||
<span>
|
||||
{{ toFaDigits(subscription.daysRemaining) }} روز باقیمانده
|
||||
</span>
|
||||
</div>
|
||||
<div class="date-chip">
|
||||
<UiIcon name="calendar" :size="16" />
|
||||
<span>{{ todayLabel }}</span>
|
||||
@@ -1601,6 +1638,24 @@ onUnmounted(() => {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.sub-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sub-chip.warn {
|
||||
background: color-mix(in srgb, var(--danger) 12%, white);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.date-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -26,7 +26,7 @@ api.interceptors.response.use(
|
||||
(error) => {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
const path = window.location.pathname;
|
||||
if (path.startsWith('/p/')) {
|
||||
if (path.startsWith('/p/') || path.startsWith('/adm')) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
localStorage.removeItem('mento_token');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { OrgModuleKey } from '@mento/shared';
|
||||
|
||||
export type NavHubKey =
|
||||
| 'customers'
|
||||
| 'sales'
|
||||
@@ -43,6 +45,7 @@ export type NavHubCard = {
|
||||
icon: NavIconName;
|
||||
tint: NavHubTint;
|
||||
admin?: boolean;
|
||||
module?: OrgModuleKey;
|
||||
};
|
||||
|
||||
export type NavHubShortcut = {
|
||||
@@ -50,6 +53,7 @@ export type NavHubShortcut = {
|
||||
title: string;
|
||||
icon: NavIconName;
|
||||
admin?: boolean;
|
||||
module?: OrgModuleKey;
|
||||
};
|
||||
|
||||
export type NavHubDef = {
|
||||
@@ -70,8 +74,8 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
icon: 'users',
|
||||
tint: 'mint',
|
||||
shortcuts: [
|
||||
{ to: '/contacts/people/new', title: 'شخص جدید', icon: 'user-plus' },
|
||||
{ to: '/contacts/companies/new', title: 'شرکت جدید', icon: 'building' },
|
||||
{ to: '/contacts/people/new', title: 'شخص جدید', icon: 'user-plus', module: 'customers' },
|
||||
{ to: '/contacts/companies/new', title: 'شرکت جدید', icon: 'building', module: 'customers' },
|
||||
],
|
||||
cards: [
|
||||
{
|
||||
@@ -80,6 +84,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'مخاطبان حقیقی و پروندههای مشتری',
|
||||
icon: 'user',
|
||||
tint: 'mint',
|
||||
module: 'customers',
|
||||
},
|
||||
{
|
||||
to: '/contacts?tab=companies',
|
||||
@@ -87,6 +92,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'سازمانها و مشتریان حقوقی',
|
||||
icon: 'building',
|
||||
tint: 'violet',
|
||||
module: 'customers',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -97,8 +103,8 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
icon: 'briefcase',
|
||||
tint: 'mint',
|
||||
shortcuts: [
|
||||
{ to: '/deals/new', title: 'معامله جدید', icon: 'plus' },
|
||||
{ to: '/quotes/new', title: 'پیشفاکتور جدید', icon: 'document' },
|
||||
{ to: '/deals/new', title: 'معامله جدید', icon: 'plus', module: 'pipeline' },
|
||||
{ to: '/quotes/new', title: 'پیشفاکتور جدید', icon: 'document', module: 'quotes' },
|
||||
],
|
||||
cards: [
|
||||
{
|
||||
@@ -107,6 +113,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'سرنخها، معاملات و مراحل فروش',
|
||||
icon: 'filter',
|
||||
tint: 'mint',
|
||||
module: 'pipeline',
|
||||
},
|
||||
{
|
||||
to: '/settings/products',
|
||||
@@ -115,6 +122,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
icon: 'box',
|
||||
tint: 'peach',
|
||||
admin: true,
|
||||
module: 'products',
|
||||
},
|
||||
{
|
||||
to: '/invoices',
|
||||
@@ -122,6 +130,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'صدور فاکتور و پیگیری پرداختها',
|
||||
icon: 'mail',
|
||||
tint: 'sky',
|
||||
module: 'invoices',
|
||||
},
|
||||
{
|
||||
to: '/quotes',
|
||||
@@ -129,6 +138,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'پیشنهاد قیمت و پیگیری تایید',
|
||||
icon: 'document',
|
||||
tint: 'amber',
|
||||
module: 'quotes',
|
||||
},
|
||||
{
|
||||
to: '/commissions',
|
||||
@@ -136,6 +146,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'سهم فروشندگان از فاکتورهای صادرشده',
|
||||
icon: 'dollar',
|
||||
tint: 'rose',
|
||||
module: 'commissions',
|
||||
},
|
||||
{
|
||||
to: '/sales/forecast',
|
||||
@@ -143,6 +154,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'تعیین اهداف و تحلیل عملکرد فروش',
|
||||
icon: 'target',
|
||||
tint: 'mint',
|
||||
module: 'sales_forecast',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -153,8 +165,8 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
icon: 'check',
|
||||
tint: 'sky',
|
||||
shortcuts: [
|
||||
{ to: '/activities/new', title: 'فعالیت جدید', icon: 'plus' },
|
||||
{ to: '/activities/calendar', title: 'تقویم', icon: 'calendar' },
|
||||
{ to: '/activities/new', title: 'فعالیت جدید', icon: 'plus', module: 'activities' },
|
||||
{ to: '/activities/calendar', title: 'تقویم', icon: 'calendar', module: 'activities' },
|
||||
],
|
||||
cards: [
|
||||
{
|
||||
@@ -163,6 +175,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'کارها، تماسها و پیگیریهای جاری',
|
||||
icon: 'check',
|
||||
tint: 'mint',
|
||||
module: 'activities',
|
||||
},
|
||||
{
|
||||
to: '/activities/calendar',
|
||||
@@ -170,6 +183,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'نمای روز، هفته و زمانبندی کارها',
|
||||
icon: 'calendar',
|
||||
tint: 'violet',
|
||||
module: 'activities',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -180,8 +194,8 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
icon: 'chat',
|
||||
tint: 'peach',
|
||||
shortcuts: [
|
||||
{ to: '/tickets/new', title: 'تیکت جدید', icon: 'plus' },
|
||||
{ to: '/tickets', title: 'تیکتها', icon: 'chat' },
|
||||
{ to: '/tickets/new', title: 'تیکت جدید', icon: 'plus', module: 'tickets' },
|
||||
{ to: '/tickets', title: 'تیکتها', icon: 'chat', module: 'tickets' },
|
||||
],
|
||||
cards: [
|
||||
{
|
||||
@@ -190,6 +204,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'درخواستها و پیامهای پشتیبانی',
|
||||
icon: 'chat',
|
||||
tint: 'peach',
|
||||
module: 'tickets',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -200,8 +215,18 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
icon: 'sparkle',
|
||||
tint: 'violet',
|
||||
shortcuts: [
|
||||
{ to: '/settings/automations', title: 'قانون گردش کار', icon: 'layers' },
|
||||
{ to: '/settings/message-automations', title: 'قانون پیامک', icon: 'send' },
|
||||
{
|
||||
to: '/settings/automations',
|
||||
title: 'قانون گردش کار',
|
||||
icon: 'layers',
|
||||
module: 'workflow_automations',
|
||||
},
|
||||
{
|
||||
to: '/settings/message-automations',
|
||||
title: 'قانون پیامک',
|
||||
icon: 'send',
|
||||
module: 'message_automations',
|
||||
},
|
||||
],
|
||||
cards: [
|
||||
{
|
||||
@@ -211,6 +236,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
icon: 'layers',
|
||||
tint: 'violet',
|
||||
admin: true,
|
||||
module: 'workflow_automations',
|
||||
},
|
||||
{
|
||||
to: '/settings/message-automations',
|
||||
@@ -219,6 +245,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
icon: 'send',
|
||||
tint: 'sky',
|
||||
admin: true,
|
||||
module: 'message_automations',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -229,8 +256,8 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
icon: 'apps-plus',
|
||||
tint: 'violet',
|
||||
shortcuts: [
|
||||
{ to: '/project-cards/new', title: 'کارت پروژه جدید', icon: 'plus' },
|
||||
{ to: '/cheques', title: 'ثبت چک', icon: 'document' },
|
||||
{ to: '/project-cards/new', title: 'کارت پروژه جدید', icon: 'plus', module: 'project_boards' },
|
||||
{ to: '/cheques', title: 'ثبت چک', icon: 'document', module: 'cheques' },
|
||||
],
|
||||
cards: [
|
||||
{
|
||||
@@ -240,6 +267,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
icon: 'layers',
|
||||
tint: 'mint',
|
||||
admin: true,
|
||||
module: 'lead_forms',
|
||||
},
|
||||
{
|
||||
to: '/projects',
|
||||
@@ -247,6 +275,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'کارتها و مراحل اجرای کار',
|
||||
icon: 'flag',
|
||||
tint: 'violet',
|
||||
module: 'project_boards',
|
||||
},
|
||||
{
|
||||
to: '/inventory',
|
||||
@@ -254,6 +283,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'موجودی کالا و گردش داخلی',
|
||||
icon: 'box',
|
||||
tint: 'peach',
|
||||
module: 'inventory',
|
||||
},
|
||||
{
|
||||
to: '/cheques',
|
||||
@@ -261,6 +291,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'چکهای دریافتی و پرداختی',
|
||||
icon: 'document',
|
||||
tint: 'sky',
|
||||
module: 'cheques',
|
||||
},
|
||||
{
|
||||
to: '/attachments',
|
||||
@@ -268,6 +299,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'فایلهای متصل به یادداشتها',
|
||||
icon: 'document',
|
||||
tint: 'amber',
|
||||
module: 'attachments',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -278,8 +310,13 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
icon: 'settings',
|
||||
tint: 'sky',
|
||||
shortcuts: [
|
||||
{ to: '/settings/team#settings-team', title: 'تیم و نقشها', icon: 'users' },
|
||||
{ to: '/settings/tahesab', title: 'تهحساب', icon: 'chart' },
|
||||
{
|
||||
to: '/settings/team#settings-team',
|
||||
title: 'تیم و نقشها',
|
||||
icon: 'users',
|
||||
module: 'team_settings',
|
||||
},
|
||||
{ to: '/settings/tahesab', title: 'تهحساب', icon: 'chart', module: 'tahesab' },
|
||||
],
|
||||
cards: [
|
||||
{
|
||||
@@ -288,6 +325,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'نام سازمان و تنظیمات پایه',
|
||||
icon: 'building',
|
||||
tint: 'mint',
|
||||
module: 'team_settings',
|
||||
},
|
||||
{
|
||||
to: '/settings/team#settings-team',
|
||||
@@ -295,6 +333,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'اعضا، دسترسی و نقشها',
|
||||
icon: 'users',
|
||||
tint: 'violet',
|
||||
module: 'team_settings',
|
||||
},
|
||||
{
|
||||
to: '/settings/custom-fields',
|
||||
@@ -302,6 +341,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'فیلدهای اضافه روی پروندهها',
|
||||
icon: 'pencil',
|
||||
tint: 'peach',
|
||||
module: 'custom_fields',
|
||||
},
|
||||
{
|
||||
to: '/settings/currencies',
|
||||
@@ -309,6 +349,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'ارز پایه و نرخ تبدیل',
|
||||
icon: 'dollar',
|
||||
tint: 'sky',
|
||||
module: 'currencies',
|
||||
},
|
||||
{
|
||||
to: '/settings/tahesab',
|
||||
@@ -316,6 +357,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'اتصال مالی و تنظیمات حسابداری',
|
||||
icon: 'chart',
|
||||
tint: 'amber',
|
||||
module: 'tahesab',
|
||||
},
|
||||
{
|
||||
to: '/settings/faq',
|
||||
@@ -323,6 +365,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'پرسشهای پرتکرار سازمان',
|
||||
icon: 'lightbulb',
|
||||
tint: 'rose',
|
||||
module: 'faq',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 type { UserRole } from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { usePlatformAuthStore } from '@/stores/platform-auth';
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -29,6 +30,39 @@ export const router = createRouter({
|
||||
component: () => import('@/views/PortalView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/adm',
|
||||
name: 'adm-login',
|
||||
component: () => import('@/views/adm/AdmLoginView.vue'),
|
||||
meta: { public: true, platformPublic: true },
|
||||
},
|
||||
{
|
||||
path: '/adm',
|
||||
component: () => import('@/layouts/AdmLayout.vue'),
|
||||
meta: { public: true, platform: true },
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'adm-dashboard',
|
||||
component: () => import('@/views/adm/AdmDashboardView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'organizations',
|
||||
name: 'adm-organizations',
|
||||
component: () => import('@/views/adm/AdmOrganizationsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'adm-users',
|
||||
component: () => import('@/views/adm/AdmUsersView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'admins',
|
||||
name: 'adm-admins',
|
||||
component: () => import('@/views/adm/AdmAdminsView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/AppLayout.vue'),
|
||||
@@ -291,6 +325,16 @@ export const router = createRouter({
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.path === '/adm' || to.path.startsWith('/adm/')) {
|
||||
const platform = usePlatformAuthStore();
|
||||
if (to.meta.platformPublic) {
|
||||
if (platform.isAuthenticated) return { name: 'adm-dashboard' };
|
||||
return true;
|
||||
}
|
||||
if (!platform.isAuthenticated) return { name: 'adm-login' };
|
||||
return true;
|
||||
}
|
||||
|
||||
const auth = useAuthStore();
|
||||
if (!to.meta.public && !auth.isAuthenticated) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } };
|
||||
|
||||
@@ -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";
|
||||
|
||||
:root {
|
||||
--mobile-tabbar-h: 0px;
|
||||
|
||||
/* Surface & Background */
|
||||
--bg: #f8fafc;
|
||||
--surface: #ffffff;
|
||||
@@ -245,12 +247,21 @@ textarea {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: #c0c8d4;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
:root {
|
||||
--mobile-tabbar-h: calc(3.65rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.35rem, 5vw, 1.75rem);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
UiPagination,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import ActivityFormView from '@/views/ActivityFormView.vue';
|
||||
import type {
|
||||
ActivityDto,
|
||||
@@ -78,7 +79,6 @@ const activityModal = ref<{
|
||||
const menuId = ref<string | null>(null);
|
||||
const busy = ref(false);
|
||||
const importMsg = ref('');
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const counts = ref<Record<ActivityFilter, number>>({
|
||||
today: 0,
|
||||
overdue: 0,
|
||||
@@ -286,44 +286,31 @@ async function exportExcel() {
|
||||
}
|
||||
}
|
||||
|
||||
function pickImportFile() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onImportFile(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
function onImportStart() {
|
||||
busy.value = true;
|
||||
importMsg.value = '';
|
||||
error.value = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const { data } = await api.post<ImportContactsResult>('/activities/import', form, {
|
||||
headers: { 'Content-Type': undefined },
|
||||
});
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
await Promise.all([load(), loadCounts()]);
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ایمپورت ناموفق بود');
|
||||
} else {
|
||||
error.value = 'ایمپورت ناموفق بود';
|
||||
}
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function onImportDone(data: ImportContactsResult) {
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
await Promise.all([load(), loadCounts()]);
|
||||
}
|
||||
|
||||
function onImportFail(message: string) {
|
||||
error.value = message;
|
||||
}
|
||||
|
||||
function onImportSettled() {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -357,17 +344,20 @@ onMounted(() => {
|
||||
<span class="btn-full">خروجی اکسل</span>
|
||||
<span class="btn-short">خروجی</span>
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" type="button" :disabled="busy" @click.stop="pickImportFile">
|
||||
<span class="btn-full">ورود از اکسل</span>
|
||||
<span class="btn-short">ورود</span>
|
||||
</UiButton>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
hidden
|
||||
@change="onImportFile"
|
||||
/>
|
||||
<ExcelImportFlow
|
||||
preview-path="/activities/import/preview"
|
||||
import-path="/activities/import"
|
||||
:disabled="busy"
|
||||
@start="onImportStart"
|
||||
@done="onImportDone"
|
||||
@fail="onImportFail"
|
||||
@settled="onImportSettled"
|
||||
>
|
||||
<UiButton variant="ghost" type="button" :disabled="busy">
|
||||
<span class="btn-full">ورود از اکسل</span>
|
||||
<span class="btn-short">ورود</span>
|
||||
</UiButton>
|
||||
</ExcelImportFlow>
|
||||
<UiButton variant="primary" @click="activityModal = {}">
|
||||
<UiIcon name="plus" :size="16" />
|
||||
فعالیت جدید
|
||||
|
||||
@@ -319,6 +319,7 @@ onMounted(load);
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
padding-bottom: 5.5rem;
|
||||
}
|
||||
|
||||
.nav {
|
||||
@@ -572,7 +573,7 @@ dd {
|
||||
.dock {
|
||||
position: fixed;
|
||||
inset-inline: 0.85rem;
|
||||
bottom: calc(0.55rem + env(safe-area-inset-bottom));
|
||||
bottom: calc(0.55rem + var(--mobile-tabbar-h));
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
@@ -607,6 +608,10 @@ dd {
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.page {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.dock {
|
||||
position: sticky;
|
||||
inset-inline: auto;
|
||||
|
||||
@@ -661,6 +661,10 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.crm-page:not(.embedded) {
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
|
||||
.crm-page.embedded {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
@@ -1142,7 +1146,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
.mobile-save {
|
||||
position: fixed;
|
||||
inset-inline: 0.85rem;
|
||||
bottom: calc(0.55rem + env(safe-area-inset-bottom));
|
||||
bottom: calc(0.55rem + var(--mobile-tabbar-h));
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
@@ -1154,6 +1158,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.crm-page:not(.embedded),
|
||||
.page {
|
||||
padding-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
|
||||
@@ -5,6 +5,7 @@ import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import {
|
||||
UiButton,
|
||||
UiModal,
|
||||
@@ -52,7 +53,6 @@ const error = ref('');
|
||||
const importMsg = ref('');
|
||||
const people = ref<PaginatedResponse<PersonDto> | null>(null);
|
||||
const companies = ref<PaginatedResponse<CompanyDto> | null>(null);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const menuId = ref<string | null>(null);
|
||||
const sortOpen = ref(false);
|
||||
const filterOpen = ref(false);
|
||||
@@ -187,46 +187,38 @@ async function exportExcel() {
|
||||
}
|
||||
}
|
||||
|
||||
function pickImportFile() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
const importPreviewPath = computed(() =>
|
||||
tab.value === 'people' ? '/people/import/preview' : '/companies/import/preview',
|
||||
);
|
||||
const importPath = computed(() =>
|
||||
tab.value === 'people' ? '/people/import' : '/companies/import',
|
||||
);
|
||||
|
||||
async function onImportFile(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
function onImportStart() {
|
||||
busy.value = true;
|
||||
importMsg.value = '';
|
||||
error.value = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const path = tab.value === 'people' ? '/people/import' : '/companies/import';
|
||||
const { data } = await api.post<ImportContactsResult>(path, form, {
|
||||
headers: { 'Content-Type': undefined },
|
||||
});
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}`;
|
||||
if (data.errors.length) {
|
||||
const sample = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
error.value = sample;
|
||||
}
|
||||
await load();
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ایمپورت ناموفق بود');
|
||||
} else {
|
||||
error.value = 'ایمپورت ناموفق بود';
|
||||
}
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function onImportDone(data: ImportContactsResult) {
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
function onImportFail(message: string) {
|
||||
error.value = message;
|
||||
}
|
||||
|
||||
function onImportSettled() {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -251,17 +243,20 @@ onMounted(load);
|
||||
<span class="btn-full">خروجی اکسل</span>
|
||||
<span class="btn-short">خروجی</span>
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" size="sm" :disabled="busy" @click.stop="pickImportFile">
|
||||
<span class="btn-full">ورود از اکسل</span>
|
||||
<span class="btn-short">ورود</span>
|
||||
</UiButton>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
hidden
|
||||
@change="onImportFile"
|
||||
/>
|
||||
<ExcelImportFlow
|
||||
:preview-path="importPreviewPath"
|
||||
:import-path="importPath"
|
||||
:disabled="busy"
|
||||
@start="onImportStart"
|
||||
@done="onImportDone"
|
||||
@fail="onImportFail"
|
||||
@settled="onImportSettled"
|
||||
>
|
||||
<UiButton variant="ghost" size="sm" :disabled="busy">
|
||||
<span class="btn-full">ورود از اکسل</span>
|
||||
<span class="btn-short">ورود</span>
|
||||
</UiButton>
|
||||
</ExcelImportFlow>
|
||||
<UiButton
|
||||
variant="primary"
|
||||
@click="contactModal = tab === 'companies' ? 'company' : 'person'"
|
||||
|
||||
@@ -791,6 +791,7 @@ onMounted(load);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
|
||||
.page.embedded {
|
||||
@@ -1391,7 +1392,7 @@ h1 {
|
||||
.mobile-save {
|
||||
position: fixed;
|
||||
inset-inline: 0.85rem;
|
||||
bottom: calc(0.55rem + env(safe-area-inset-bottom));
|
||||
bottom: calc(0.55rem + var(--mobile-tabbar-h));
|
||||
z-index: 8;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.2fr;
|
||||
|
||||
@@ -2362,7 +2362,7 @@ h1 {
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
position: sticky;
|
||||
bottom: 0.5rem;
|
||||
bottom: calc(0.5rem + var(--mobile-tabbar-h));
|
||||
padding: 0.35rem 0;
|
||||
background: linear-gradient(to top, #f8fafc 70%, transparent);
|
||||
}
|
||||
|
||||
@@ -857,7 +857,7 @@ tbody tr:nth-child(even) { background: #fcfdff; }
|
||||
.page-btn.on { background: var(--teal); color: #fff; border-color: transparent; }
|
||||
|
||||
.fab {
|
||||
display: none; position: fixed; left: 1rem; right: 1rem; bottom: 1rem; z-index: 40;
|
||||
display: none; position: fixed; left: 1rem; right: 1rem; bottom: calc(1rem + var(--mobile-tabbar-h)); z-index: 40;
|
||||
background: var(--teal); color: #fff; text-align: center; text-decoration: none;
|
||||
border-radius: var(--radius); padding: 0.9rem; font-weight: 600;
|
||||
box-shadow: 0 12px 28px rgba(13,148,136,.35);
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { LoginChallengeResponse, LoginOrganizationChoiceResponse } from '@m
|
||||
import { USER_ROLE_LABELS } from '@/lib/roles';
|
||||
import { brand } from '@mento/shared';
|
||||
import BrandName from '@/components/brand/BrandName.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
@@ -18,8 +19,8 @@ const route = useRoute();
|
||||
const mode = ref<'password' | 'otp'>('password');
|
||||
const otpStep = ref<'mobile' | 'code'>('mobile');
|
||||
|
||||
const identifier = ref('owner@demo.local');
|
||||
const password = ref('demo1234');
|
||||
const identifier = ref('');
|
||||
const password = ref('');
|
||||
const mobile = ref('');
|
||||
const showPassword = ref(false);
|
||||
const remember = ref(true);
|
||||
@@ -209,15 +210,17 @@ function cancelOrgChoice() {
|
||||
required
|
||||
autocomplete="username"
|
||||
placeholder="ایمیل یا شماره موبایل خود را وارد کنید"
|
||||
dir="ltr"
|
||||
class="ltr-value"
|
||||
/>
|
||||
<span class="ico user" aria-hidden="true" />
|
||||
<span class="field-ico" aria-hidden="true">
|
||||
<UiIcon name="user" :size="18" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">رمز عبور</label>
|
||||
<div class="input-wrap">
|
||||
<div class="input-wrap has-eye">
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
@@ -225,16 +228,18 @@ function cancelOrgChoice() {
|
||||
required
|
||||
autocomplete="current-password"
|
||||
placeholder="رمز عبور خود را وارد کنید"
|
||||
dir="ltr"
|
||||
class="ltr-value"
|
||||
/>
|
||||
<span class="ico lock" aria-hidden="true" />
|
||||
<span class="field-ico" aria-hidden="true">
|
||||
<UiIcon name="lock" :size="18" />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="eye"
|
||||
:aria-label="showPassword ? 'مخفی کردن رمز' : 'نمایش رمز'"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
{{ showPassword ? 'مخفی' : 'نمایش' }}
|
||||
<UiIcon :name="showPassword ? 'eye-off' : 'eye'" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,9 +271,11 @@ function cancelOrgChoice() {
|
||||
required
|
||||
autocomplete="tel"
|
||||
placeholder="۰۹۱۲۱۲۳۴۵۶۷"
|
||||
dir="ltr"
|
||||
class="ltr-value"
|
||||
/>
|
||||
<span class="ico user" aria-hidden="true" />
|
||||
<span class="field-ico" aria-hidden="true">
|
||||
<UiIcon name="phone" :size="18" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
@@ -297,7 +304,7 @@ function cancelOrgChoice() {
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
placeholder="کد را وارد کنید"
|
||||
dir="ltr"
|
||||
class="ltr-value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -339,21 +346,22 @@ function cancelOrgChoice() {
|
||||
padding: 1rem;
|
||||
padding-bottom: max(1rem, env(safe-area-inset-bottom));
|
||||
background:
|
||||
radial-gradient(circle at 90% 0%, rgba(13, 148, 136, 0.08), transparent 36%),
|
||||
var(--bg);
|
||||
radial-gradient(ellipse 70% 45% at 50% 0%, rgba(45, 212, 191, 0.12), transparent 55%),
|
||||
radial-gradient(rgba(13, 148, 136, 0.12) 1px, transparent 1.2px),
|
||||
#f5fbfb;
|
||||
background-size: auto, 20px 20px, auto;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
flex: 1;
|
||||
width: min(1040px, 100%);
|
||||
width: min(1100px, 100%);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
min-height: min(640px, calc(100dvh - 5.5rem));
|
||||
min-height: min(680px, calc(100dvh - 5.5rem));
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
@@ -361,6 +369,9 @@ function cancelOrgChoice() {
|
||||
flex-direction: column;
|
||||
padding: clamp(1.2rem, 3vw, 2.4rem);
|
||||
min-width: 0;
|
||||
background: #fff;
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.mobile-brand {
|
||||
@@ -434,6 +445,22 @@ h1 {
|
||||
gap: 0.95rem;
|
||||
}
|
||||
|
||||
.form input {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.form input::placeholder {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
color: #cdd4de;
|
||||
}
|
||||
|
||||
.form input.ltr-value:not(:placeholder-shown) {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
position: relative;
|
||||
}
|
||||
@@ -446,48 +473,42 @@ h1 {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.input-wrap.has-eye input {
|
||||
padding-left: 2.7rem;
|
||||
}
|
||||
|
||||
.input-wrap input:focus {
|
||||
outline: 2px solid var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ico {
|
||||
.field-ico {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
inset-inline-end: 0.85rem;
|
||||
right: 0.75rem;
|
||||
left: auto;
|
||||
transform: translateY(-50%);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
opacity: 0.45;
|
||||
color: #94a3b8;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ico.user {
|
||||
background:
|
||||
radial-gradient(circle at 50% 35%, currentColor 0 28%, transparent 29%),
|
||||
radial-gradient(circle at 50% 85%, currentColor 0 38%, transparent 39%);
|
||||
color: #64748b;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.ico.lock {
|
||||
background:
|
||||
linear-gradient(currentColor, currentColor) center 62%/10px 8px no-repeat,
|
||||
radial-gradient(circle at 50% 38%, transparent 34%, currentColor 35% 55%, transparent 56%);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.eye {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
inset-inline-start: 0.55rem;
|
||||
left: 0.45rem;
|
||||
right: auto;
|
||||
transform: translateY(-50%);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.4rem;
|
||||
padding: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
@@ -612,8 +633,34 @@ h1 {
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.auth-page {
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
grid-template-columns: minmax(280px, 0.9fr) minmax(340px, 1.1fr);
|
||||
width: 100%;
|
||||
min-height: 100dvh;
|
||||
grid-template-columns: minmax(420px, 1fr) minmax(440px, 1.05fr);
|
||||
grid-template-areas: "form brand";
|
||||
background: #fff;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
grid-area: form;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: #fff;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-shell :deep(.brand-panel) {
|
||||
grid-area: brand;
|
||||
height: 100dvh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.mobile-brand {
|
||||
@@ -621,8 +668,12 @@ h1 {
|
||||
}
|
||||
|
||||
.form-inner {
|
||||
margin: 0;
|
||||
margin: 0 auto;
|
||||
width: min(440px, 100%);
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2482,7 +2482,7 @@ textarea {
|
||||
|
||||
.m-wiz-foot {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
bottom: var(--mobile-tabbar-h);
|
||||
display: grid;
|
||||
grid-template-columns: 1.25fr 1fr;
|
||||
gap: 0.65rem;
|
||||
|
||||
@@ -1710,7 +1710,7 @@ onBeforeUnmount(() => {
|
||||
.fab {
|
||||
position: fixed;
|
||||
inset-inline-end: 1.1rem;
|
||||
bottom: 1.25rem;
|
||||
bottom: calc(1.25rem + var(--mobile-tabbar-h));
|
||||
z-index: 40;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
@@ -2147,6 +2147,10 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.notes-page {
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -196,12 +196,12 @@ onMounted(load);
|
||||
<form v-else class="crm-form" @submit.prevent="submit">
|
||||
<UiCard>
|
||||
<div class="crm-form-grid-2">
|
||||
<UiField label="نام" required for-id="firstName">
|
||||
<UiInput id="firstName" v-model="firstName" required />
|
||||
<UiField label="نام" for-id="firstName">
|
||||
<UiInput id="firstName" v-model="firstName" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="نام خانوادگی" required for-id="lastName">
|
||||
<UiInput id="lastName" v-model="lastName" required />
|
||||
<UiField label="نام خانوادگی" for-id="lastName">
|
||||
<UiInput id="lastName" v-model="lastName" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="موبایل" for-id="mobile">
|
||||
|
||||
@@ -1658,7 +1658,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
.dock {
|
||||
position: fixed;
|
||||
inset-inline: 1.1rem;
|
||||
bottom: calc(0.7rem + env(safe-area-inset-bottom));
|
||||
bottom: calc(0.7rem + var(--mobile-tabbar-h));
|
||||
z-index: 12;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1709,6 +1709,12 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 959px) {
|
||||
.page {
|
||||
padding-bottom: 6.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.page {
|
||||
padding-bottom: 1.2rem;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
moveStageToIndex,
|
||||
updateDealStage,
|
||||
} from '@/lib/pipeline-board';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import UiButton from '@/components/ui/UiButton.vue';
|
||||
import UiInput from '@/components/ui/UiInput.vue';
|
||||
import UiModal from '@/components/ui/UiModal.vue';
|
||||
@@ -47,7 +48,6 @@ const deleteStageId = ref<string | null>(null);
|
||||
const stageBusy = ref(false);
|
||||
const busy = ref(false);
|
||||
const importMsg = ref('');
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const draggingId = ref<string | null>(null);
|
||||
const draggingColumnId = ref<string | null>(null);
|
||||
@@ -390,43 +390,35 @@ async function exportExcel() {
|
||||
}
|
||||
}
|
||||
|
||||
function pickImportFile() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onImportFile(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
function onImportStart() {
|
||||
busy.value = true;
|
||||
importMsg.value = '';
|
||||
error.value = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const { data } = await api.post<ImportContactsResult>('/deals/import', form, {
|
||||
headers: { 'Content-Type': undefined },
|
||||
});
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}${data.boards?.length ? ` · کاریز: ${data.boards.join('، ')}` : ''}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
if (data.boardId && data.boardId !== selectedPipelineId.value) {
|
||||
await router.replace({ name: 'pipeline', query: { pipelineId: data.boardId } });
|
||||
} else {
|
||||
await load();
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'ایمپورت ناموفق بود');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function onImportDone(data: ImportContactsResult) {
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}${data.boards?.length ? ` · کاریز: ${data.boards.join('، ')}` : ''}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
if (data.boardId && data.boardId !== selectedPipelineId.value) {
|
||||
await router.replace({ name: 'pipeline', query: { pipelineId: data.boardId } });
|
||||
} else {
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
function onImportFail(message: string) {
|
||||
error.value = message;
|
||||
}
|
||||
|
||||
function onImportSettled() {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
function onMenuDeal(dealId: string) {
|
||||
@@ -472,16 +464,19 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
|
||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
|
||||
خروجی اکسل
|
||||
</button>
|
||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="pickImportFile">
|
||||
ورود از اکسل
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
hidden
|
||||
@change="onImportFile"
|
||||
/>
|
||||
<ExcelImportFlow
|
||||
preview-path="/deals/import/preview"
|
||||
import-path="/deals/import"
|
||||
:disabled="loading || busy"
|
||||
@start="onImportStart"
|
||||
@done="onImportDone"
|
||||
@fail="onImportFail"
|
||||
@settled="onImportSettled"
|
||||
>
|
||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy">
|
||||
ورود از اکسل
|
||||
</button>
|
||||
</ExcelImportFlow>
|
||||
</template>
|
||||
</PipelineToolbar>
|
||||
|
||||
@@ -658,7 +653,7 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
|
||||
.menu-pop {
|
||||
position: fixed;
|
||||
inset-inline-start: 1rem;
|
||||
bottom: 5.5rem;
|
||||
bottom: calc(var(--mobile-tabbar-h) + 1.75rem);
|
||||
z-index: 40;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
UiPagination,
|
||||
UiSelect,
|
||||
} from '@/components/ui';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import ProductFormView from '@/views/ProductFormView.vue';
|
||||
import type { ImportContactsResult, ProductDto } from '@mento/shared';
|
||||
@@ -41,7 +42,6 @@ const moreOpen = ref(false);
|
||||
const productModal = ref(false);
|
||||
const busy = ref(false);
|
||||
const importMsg = ref('');
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const categories = computed(() => {
|
||||
const set = new Set<string>();
|
||||
@@ -187,45 +187,32 @@ async function exportExcel() {
|
||||
}
|
||||
}
|
||||
|
||||
function pickImportFile() {
|
||||
function onImportStart() {
|
||||
closeMenus();
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onImportFile(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
busy.value = true;
|
||||
importMsg.value = '';
|
||||
error.value = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const { data } = await api.post<ImportContactsResult>('/products/import', form, {
|
||||
headers: { 'Content-Type': undefined },
|
||||
});
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String(
|
||||
(e.response?.data as { message?: string | string[] })?.message ??
|
||||
'ایمپورت ناموفق بود',
|
||||
)
|
||||
: 'ایمپورت ناموفق بود';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function onImportDone(data: ImportContactsResult) {
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
function onImportFail(message: string) {
|
||||
error.value = message;
|
||||
}
|
||||
|
||||
function onImportSettled() {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function toggleActive(product: ProductDto) {
|
||||
@@ -270,16 +257,17 @@ onMounted(load);
|
||||
<UiButton variant="ghost" type="button" :disabled="busy" @click="exportExcel">
|
||||
خروجی اکسل
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" type="button" :disabled="busy" @click="pickImportFile">
|
||||
ورود از اکسل
|
||||
</UiButton>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
hidden
|
||||
@change="onImportFile"
|
||||
/>
|
||||
<ExcelImportFlow
|
||||
preview-path="/products/import/preview"
|
||||
import-path="/products/import"
|
||||
:disabled="busy"
|
||||
@start="onImportStart"
|
||||
@done="onImportDone"
|
||||
@fail="onImportFail"
|
||||
@settled="onImportSettled"
|
||||
>
|
||||
<UiButton variant="ghost" type="button" :disabled="busy">ورود از اکسل</UiButton>
|
||||
</ExcelImportFlow>
|
||||
<UiButton variant="primary" @click="productModal = true">
|
||||
<UiIcon name="plus" :size="16" />
|
||||
محصول جدید
|
||||
@@ -743,7 +731,7 @@ tr:last-child td {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 1.1rem;
|
||||
bottom: 1.1rem;
|
||||
bottom: calc(1.1rem + var(--mobile-tabbar-h));
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 999px;
|
||||
|
||||
@@ -1420,7 +1420,7 @@ h1 {
|
||||
.dock {
|
||||
position: fixed;
|
||||
inset-inline: 0.85rem;
|
||||
bottom: calc(0.55rem + env(safe-area-inset-bottom));
|
||||
bottom: calc(0.55rem + var(--mobile-tabbar-h));
|
||||
width: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
moveStageToIndex,
|
||||
updateDealStage,
|
||||
} from '@/lib/pipeline-board';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import UiButton from '@/components/ui/UiButton.vue';
|
||||
import UiInput from '@/components/ui/UiInput.vue';
|
||||
import UiModal from '@/components/ui/UiModal.vue';
|
||||
@@ -50,7 +51,6 @@ const deleteStageId = ref<string | null>(null);
|
||||
const stageBusy = ref(false);
|
||||
const busy = ref(false);
|
||||
const importMsg = ref('');
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const draggingId = ref<string | null>(null);
|
||||
const draggingColumnId = ref<string | null>(null);
|
||||
@@ -387,45 +387,35 @@ async function exportExcel() {
|
||||
}
|
||||
}
|
||||
|
||||
function pickImportFile() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onImportFile(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
function onImportStart() {
|
||||
busy.value = true;
|
||||
importMsg.value = '';
|
||||
error.value = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const { data } = await api.post<ImportContactsResult>(
|
||||
'/project-cards/import',
|
||||
form,
|
||||
{ headers: { 'Content-Type': undefined } },
|
||||
);
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}${data.boards?.length ? ` · برد: ${data.boards.join('، ')}` : ''}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
if (data.boardId && data.boardId !== selectedBoardId.value) {
|
||||
await router.replace({ name: 'projects', query: { boardId: data.boardId } });
|
||||
} else {
|
||||
await load();
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'ایمپورت ناموفق بود');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function onImportDone(data: ImportContactsResult) {
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}${data.boards?.length ? ` · برد: ${data.boards.join('، ')}` : ''}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
if (data.boardId && data.boardId !== selectedBoardId.value) {
|
||||
await router.replace({ name: 'projects', query: { boardId: data.boardId } });
|
||||
} else {
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
function onImportFail(message: string) {
|
||||
error.value = message;
|
||||
}
|
||||
|
||||
function onImportSettled() {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
function onMenuCard(cardId: string) {
|
||||
@@ -470,16 +460,19 @@ onBeforeUnmount(() => {
|
||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
|
||||
خروجی اکسل
|
||||
</button>
|
||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="pickImportFile">
|
||||
ورود از اکسل
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
hidden
|
||||
@change="onImportFile"
|
||||
/>
|
||||
<ExcelImportFlow
|
||||
preview-path="/project-cards/import/preview"
|
||||
import-path="/project-cards/import"
|
||||
:disabled="loading || busy"
|
||||
@start="onImportStart"
|
||||
@done="onImportDone"
|
||||
@fail="onImportFail"
|
||||
@settled="onImportSettled"
|
||||
>
|
||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy">
|
||||
ورود از اکسل
|
||||
</button>
|
||||
</ExcelImportFlow>
|
||||
</template>
|
||||
</PipelineToolbar>
|
||||
|
||||
@@ -671,7 +664,7 @@ onBeforeUnmount(() => {
|
||||
.menu-pop {
|
||||
position: fixed;
|
||||
inset-inline-start: 1rem;
|
||||
bottom: 5.5rem;
|
||||
bottom: calc(var(--mobile-tabbar-h) + 1.75rem);
|
||||
z-index: 40;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
|
||||
@@ -8,6 +8,7 @@ import MentoLogo from '@/components/brand/MentoLogo.vue';
|
||||
import axios from 'axios';
|
||||
import { brand, type RegisterChallengeResponse } from '@mento/shared';
|
||||
import BrandName from '@/components/brand/BrandName.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
@@ -105,10 +106,7 @@ function backToForm() {
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-shell">
|
||||
<AuthBrandPanel
|
||||
headline="همه ارتباطات، یکجا مدیریت میشوند"
|
||||
:description="`با ساخت حساب ${brand.nameFa}، سازمان خود را راهاندازی کنید و فروش و پشتیبانی را از یکجا پیش ببرید.`"
|
||||
/>
|
||||
<AuthBrandPanel />
|
||||
|
||||
<section class="form-panel">
|
||||
<header class="mobile-brand">
|
||||
@@ -134,6 +132,9 @@ function backToForm() {
|
||||
<label for="org">نام سازمان <em>*</em></label>
|
||||
<div class="input-wrap">
|
||||
<input id="org" v-model="organizationName" required placeholder="نام سازمان" />
|
||||
<span class="field-ico" aria-hidden="true">
|
||||
<UiIcon name="building" :size="18" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -157,7 +158,9 @@ function backToForm() {
|
||||
required
|
||||
placeholder="نام و نام خانوادگی خود را وارد کنید"
|
||||
/>
|
||||
<span class="ico user" aria-hidden="true" />
|
||||
<span class="field-ico" aria-hidden="true">
|
||||
<UiIcon name="user" :size="18" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -169,9 +172,11 @@ function backToForm() {
|
||||
type="email"
|
||||
required
|
||||
placeholder="ایمیل خود را وارد کنید"
|
||||
dir="ltr"
|
||||
class="ltr-value"
|
||||
/>
|
||||
<span class="ico mail" aria-hidden="true" />
|
||||
<span class="field-ico" aria-hidden="true">
|
||||
<UiIcon name="mail" :size="18" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,14 +192,16 @@ function backToForm() {
|
||||
type="tel"
|
||||
required
|
||||
placeholder="۹۱۲۱۲۳۴۵۶۷"
|
||||
dir="ltr"
|
||||
class="ltr-value"
|
||||
/>
|
||||
<span class="ico phone" aria-hidden="true" />
|
||||
<span class="field-ico" aria-hidden="true">
|
||||
<UiIcon name="phone" :size="18" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">رمز عبور <em>*</em></label>
|
||||
<div class="input-wrap">
|
||||
<div class="input-wrap has-eye">
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
@@ -202,15 +209,18 @@ function backToForm() {
|
||||
minlength="6"
|
||||
required
|
||||
placeholder="رمز عبور خود را وارد کنید"
|
||||
dir="ltr"
|
||||
class="ltr-value"
|
||||
/>
|
||||
<span class="ico lock" aria-hidden="true" />
|
||||
<span class="field-ico" aria-hidden="true">
|
||||
<UiIcon name="lock" :size="18" />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="eye"
|
||||
:aria-label="showPassword ? 'مخفی کردن رمز' : 'نمایش رمز'"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
{{ showPassword ? 'مخفی' : 'نمایش' }}
|
||||
<UiIcon :name="showPassword ? 'eye-off' : 'eye'" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,7 +229,7 @@ function backToForm() {
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="passwordConfirm">تکرار رمز عبور <em>*</em></label>
|
||||
<div class="input-wrap">
|
||||
<div class="input-wrap has-eye">
|
||||
<input
|
||||
id="passwordConfirm"
|
||||
v-model="passwordConfirm"
|
||||
@@ -227,15 +237,18 @@ function backToForm() {
|
||||
minlength="6"
|
||||
required
|
||||
placeholder="رمز عبور خود را تکرار کنید"
|
||||
dir="ltr"
|
||||
class="ltr-value"
|
||||
/>
|
||||
<span class="ico lock" aria-hidden="true" />
|
||||
<span class="field-ico" aria-hidden="true">
|
||||
<UiIcon name="lock" :size="18" />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="eye"
|
||||
:aria-label="showPasswordConfirm ? 'مخفی کردن رمز' : 'نمایش رمز'"
|
||||
@click="showPasswordConfirm = !showPasswordConfirm"
|
||||
>
|
||||
{{ showPasswordConfirm ? 'مخفی' : 'نمایش' }}
|
||||
<UiIcon :name="showPasswordConfirm ? 'eye-off' : 'eye'" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,7 +260,9 @@ function backToForm() {
|
||||
v-model="referralCode"
|
||||
placeholder="اگر کد معرف دارید وارد کنید"
|
||||
/>
|
||||
<span class="ico gift" aria-hidden="true" />
|
||||
<span class="field-ico" aria-hidden="true">
|
||||
<UiIcon name="gift" :size="18" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -297,7 +312,7 @@ function backToForm() {
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
required
|
||||
dir="ltr"
|
||||
class="ltr-value"
|
||||
placeholder="کد ایمیل"
|
||||
/>
|
||||
</div>
|
||||
@@ -309,7 +324,7 @@ function backToForm() {
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
required
|
||||
dir="ltr"
|
||||
class="ltr-value"
|
||||
placeholder="کد موبایل"
|
||||
/>
|
||||
</div>
|
||||
@@ -349,8 +364,10 @@ function backToForm() {
|
||||
padding: 1rem;
|
||||
padding-bottom: max(1rem, env(safe-area-inset-bottom));
|
||||
background:
|
||||
radial-gradient(circle at 8% 0%, rgba(13, 148, 136, 0.07), transparent 34%),
|
||||
var(--bg);
|
||||
radial-gradient(ellipse 70% 45% at 50% 0%, rgba(45, 212, 191, 0.12), transparent 55%),
|
||||
radial-gradient(rgba(13, 148, 136, 0.12) 1px, transparent 1.2px),
|
||||
#f5fbfb;
|
||||
background-size: auto, 20px 20px, auto;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
@@ -358,16 +375,18 @@ function backToForm() {
|
||||
width: min(1100px, 100%);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
padding: clamp(1rem, 2.5vw, 2rem);
|
||||
min-width: 0;
|
||||
background: #fff;
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.mobile-brand {
|
||||
@@ -466,6 +485,23 @@ h1 {
|
||||
padding: 0.8rem 0.95rem;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.field select {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.form input::placeholder {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
color: #cdd4de;
|
||||
}
|
||||
|
||||
.form input.ltr-value:not(:placeholder-shown) {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
@@ -473,16 +509,21 @@ h1 {
|
||||
}
|
||||
|
||||
.input-wrap input {
|
||||
padding-inline-end: 2.5rem;
|
||||
padding-right: 2.6rem;
|
||||
}
|
||||
|
||||
.input-wrap.has-eye input {
|
||||
padding-left: 2.7rem;
|
||||
}
|
||||
|
||||
.mobile-wrap input {
|
||||
padding-inline-start: 3.4rem;
|
||||
padding-left: 3.4rem;
|
||||
}
|
||||
|
||||
.cc {
|
||||
position: absolute;
|
||||
inset-inline-start: 0.7rem;
|
||||
left: 0.7rem;
|
||||
right: auto;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--muted);
|
||||
@@ -490,49 +531,33 @@ h1 {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ico {
|
||||
.field-ico {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
inset-inline-end: 0.8rem;
|
||||
right: 0.7rem;
|
||||
left: auto;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
opacity: 0.45;
|
||||
color: #94a3b8;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
background: #64748b;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.ico.user {
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 35%, #64748b 0 28%, transparent 29%),
|
||||
radial-gradient(circle at 50% 85%, #64748b 0 38%, transparent 39%);
|
||||
}
|
||||
|
||||
.ico.mail {
|
||||
background: linear-gradient(#64748b, #64748b);
|
||||
mask: linear-gradient(#000 0 0);
|
||||
opacity: 0.4;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.ico.lock,
|
||||
.ico.phone,
|
||||
.ico.gift {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.eye {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
inset-inline-start: 0.45rem;
|
||||
left: 0.35rem;
|
||||
right: auto;
|
||||
transform: translateY(-50%);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.terms {
|
||||
@@ -618,12 +643,48 @@ h1 {
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.auth-page {
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
grid-template-columns: minmax(260px, 0.85fr) minmax(380px, 1.15fr);
|
||||
width: 100%;
|
||||
min-height: 100dvh;
|
||||
grid-template-columns: minmax(420px, 1fr) minmax(440px, 1.05fr);
|
||||
grid-template-areas: "form brand";
|
||||
background: #fff;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
grid-area: form;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-shell :deep(.brand-panel) {
|
||||
grid-area: brand;
|
||||
height: 100dvh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.mobile-brand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-inner {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -921,9 +921,9 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap;
|
||||
.sum-row.payable { color: var(--teal); font-weight: 600; border-bottom: 0; }
|
||||
.forecast-summary { padding: 1rem; }
|
||||
.mob-bottom {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
|
||||
position: fixed; left: 0; right: 0; bottom: var(--mobile-tabbar-h); z-index: 40;
|
||||
display: none; grid-template-columns: auto 1fr auto; gap: 0.5rem; align-items: center;
|
||||
padding: 0.65rem 0.85rem calc(0.65rem + env(safe-area-inset-bottom));
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: #fff; border-top: 1px solid #eef2f7; box-shadow: 0 -8px 24px rgba(15,23,42,.06);
|
||||
}
|
||||
.fab {
|
||||
@@ -948,6 +948,7 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap;
|
||||
.kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.page { padding-bottom: 5.5rem; }
|
||||
.desk-only { display: none !important; }
|
||||
.mob-only, .mob-tabs, .mob-goals, .mob-forecast, .mob-bottom { display: flex; }
|
||||
.mob-goals, .mob-forecast, .mob-form-screen { display: grid; }
|
||||
|
||||
@@ -1745,7 +1745,7 @@ textarea {
|
||||
min-height: 0;
|
||||
max-height: min(78dvh, 640px);
|
||||
border-radius: 22px 22px 0 0;
|
||||
padding-bottom: calc(0.85rem + env(safe-area-inset-bottom));
|
||||
padding-bottom: 0.85rem;
|
||||
animation: sheet-up 0.22s ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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 (۶۴ کاراکتر)
|
||||
INTEGRATIONS_ENCRYPTION_KEY=change-me-64-hex-chars-use-openssl-rand-hex-32
|
||||
|
||||
# سوپرادمین پلتفرم — crm.montzar.ir/adm (ورود با موبایل + رمز)
|
||||
# بعد از تنظیم، از apps/api: npx prisma db seed
|
||||
PLATFORM_ADMIN_MOBILE=09121234567
|
||||
PLATFORM_ADMIN_PASSWORD=change-me-strong-platform-password
|
||||
PLATFORM_ADMIN_NAME=سوپرادمین
|
||||
# PLATFORM_ADMIN_EMAIL=
|
||||
|
||||
+33
-1
@@ -87,6 +87,7 @@ nano apps/api/.env
|
||||
| `SMS_*` | همان ترِز مونتایم |
|
||||
| `PORTAL_SHOW_OTP` | در production حتماً `false` |
|
||||
| `INTEGRATIONS_ENCRYPTION_KEY` | ۶۴ کاراکتر hex برای رمز توکن تهحساب (`openssl rand -hex 32`) |
|
||||
| `PLATFORM_ADMIN_MOBILE` / `PASSWORD` / `NAME` | حساب اولیه سوپرادمین برای `/adm` (با `npx prisma db seed`) |
|
||||
|
||||
نمونهٔ اتصال:
|
||||
|
||||
@@ -185,7 +186,38 @@ sudo nginx -t && sudo systemctl reload nginx
|
||||
1. `https://crm.montzar.ir` → ورود پنل
|
||||
2. دمو: `owner@demo.local` / `demo1234` (اگر seed زده باشید)
|
||||
3. پرتال: `https://crm.montzar.ir/p/demo-mento` → OTP باید پیامک شود
|
||||
4. منوها: گزارشات، کاریز پروژه، باشگاه مشتریان، اتوماسیون (مالک/مدیر)
|
||||
4. منوها: گزارشات، کاریز پروژه، باشگاه مشتریان، اتوماسیون (مالک/مدیر)
|
||||
5. سوپرادمین: `https://crm.montzar.ir/adm` (بعد از seed با `PLATFORM_ADMIN_*`)
|
||||
|
||||
---
|
||||
|
||||
## پنل سوپرادمین (`/adm`)
|
||||
|
||||
مسیر جدا روی nginx لازم نیست؛ همان SPA (`crm.montzar.ir`) روت `/adm` را سرو میکند. ورود با **موبایل + رمز** است.
|
||||
|
||||
1. در `apps/api/.env` این کلیدها را بگذارید (نمونه در `deploy/.env.production.example`):
|
||||
|
||||
```text
|
||||
PLATFORM_ADMIN_MOBILE=09121234567
|
||||
PLATFORM_ADMIN_PASSWORD=change-me-strong-platform-password
|
||||
PLATFORM_ADMIN_NAME=سوپرادمین
|
||||
```
|
||||
|
||||
2. بعد از `prisma migrate deploy` یکبار seed بزنید تا حساب اولیه ساخته شود:
|
||||
|
||||
```bash
|
||||
cd /var/www/crm/apps/api
|
||||
npx prisma db seed
|
||||
```
|
||||
|
||||
اگر حساب با همان موبایل از قبل باشد، seed رمز را هم از `.env` بهروز میکند. ادمینهای بعدی را از داخل پنل (`/adm/admins`) اضافه کنید.
|
||||
|
||||
### اشتراک و ماژولها
|
||||
|
||||
بعد از این نسخه، `prisma migrate deploy` جدول `BillingAccount` و ستون `enabledModules` را میسازد و برای مالکان فعلی trial **۱۵ روزه** میسازد. از `/adm/users` پلن را تمدید کنید و از `/adm/organizations` ماژول هر محیط را تنظیم کنید.
|
||||
|
||||
3. ورود: `https://crm.montzar.ir/adm` → داشبورد `/adm/dashboard`
|
||||
توکن سوپرادمین جدا از JWT کاربران سازمانی است.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# BillingAccount + Modules Implementation Plan
|
||||
|
||||
> **For agentic workers:** Execute task-by-task. Checkboxes track progress.
|
||||
|
||||
**Goal:** Owner-level `BillingAccount` (plans/expiry/max orgs), per-org `enabledModules`, CRM enforcement + remaining-days UI, `/adm` controls and polish.
|
||||
|
||||
**Architecture:** Shared module catalog; Prisma `BillingAccount` + `Organization.enabledModules`; billing checks on login/org-create; module guards on API + filtered nav; platform admin APIs/UI for plans and modules.
|
||||
|
||||
**Tech Stack:** NestJS, Prisma, Vue 3, `@mento/shared`, existing CRM CSS tokens.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Manual activation only (no payment gateway).
|
||||
- Existing owners: trial 15 days; `maxOrganizations >=` current owned org count.
|
||||
- Expiry: block CRM login; header days remaining; warn ≤3 days.
|
||||
- Vue + CRM CSS only for `/adm`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Shared catalog + DTOs
|
||||
|
||||
- [ ] Add `ORG_MODULE_KEYS`, labels, plan presets, billing DTOs to `packages/shared/src/index.ts`
|
||||
- [ ] Extend org/login DTOs with `enabledModules` + `subscription` summary
|
||||
|
||||
## Task 2: Prisma
|
||||
|
||||
- [ ] `BillingAccount` model; `Organization.enabledModules String[]`
|
||||
- [ ] Migration SQL + data backfill (trial 15d, full modules)
|
||||
|
||||
## Task 3: Billing service + auth/org enforcement
|
||||
|
||||
- [ ] `BillingService` resolve/require active for org; apply plan; ensure account
|
||||
- [ ] Hook login / switch org / create org
|
||||
- [ ] Module require helper for key controllers (or lightweight guard)
|
||||
|
||||
## Task 4: Platform API + /adm UI
|
||||
|
||||
- [ ] Endpoints: get/apply billing, patch org modules
|
||||
- [ ] Adm users: billing card; adm orgs: module checklist; light shell polish
|
||||
|
||||
## Task 5: Web CRM
|
||||
|
||||
- [ ] Filter nav hubs by modules
|
||||
- [ ] Header subscription chip + warn
|
||||
- [ ] Router guard for disabled modules
|
||||
@@ -0,0 +1,202 @@
|
||||
# Design: BillingAccount + per-org modules + /adm polish
|
||||
|
||||
**Date:** 2026-09-20
|
||||
**Status:** Approved for planning
|
||||
**Scope:** Superadmin (`/adm`) subscription control, per-organization module entitlements, CRM enforcement, and visual polish of the platform admin UI.
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Subscription on account owner** via a dedicated `BillingAccount` (not raw fields on `Organization`).
|
||||
2. **Modules per environment** — every CRM menu surface can be enabled/disabled per org from `/adm`.
|
||||
3. **Manual plan activation now**; structure ready for a future landing-page payment gateway (no gateway in this phase).
|
||||
4. **Expiry UX:** show remaining days in the CRM app header; warn from 3 days before; after expiry **block login** with a clear “اشتراک منقضی شده” message.
|
||||
5. **Migrate existing accounts:** create a `BillingAccount` with plan `trial` and **15 days from migration time** so current customers keep working until extended.
|
||||
6. **Polish `/adm` UI** to feel more app-like (same Vue + CRM CSS tokens; no separate Ant Design/Tailwind app).
|
||||
|
||||
## Non-goals (later)
|
||||
|
||||
- Online payment gateway / landing checkout
|
||||
- Impersonate user login
|
||||
- Complex multi-tier platform-admin roles
|
||||
- Automatic invoice generation for Mentekt SaaS itself
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
| Topic | Choice |
|
||||
|---|---|
|
||||
| Account vs org | Both: subscription on owner (`BillingAccount`); modules on each `Organization` |
|
||||
| Toggleable modules | Entire menu surface (not only add-ons) |
|
||||
| Payment | Manual in `/adm` now; `planCode` + optional payment ref reserved for gateway |
|
||||
| On expiry | Block CRM login; header shows remaining days; warn ≤3 days |
|
||||
| Existing orgs/users | Backfill trial **15 days** from migrate/seed time |
|
||||
| Data model style | Dedicated `BillingAccount` entity (approach 2) |
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
### `BillingAccount`
|
||||
|
||||
One billing account per **owner user** (the SaaS customer).
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | cuid | PK |
|
||||
| `ownerUserId` | string unique | FK → `User` — the account holder |
|
||||
| `planCode` | string | `trial` \| `monthly` \| `quarterly` \| `semiannual` \| `yearly` \| `custom` |
|
||||
| `status` | string | `active` \| `expired` \| `suspended` |
|
||||
| `startsAt` | DateTime | |
|
||||
| `expiresAt` | DateTime | |
|
||||
| `maxOrganizations` | int | e.g. trial demo = 1 |
|
||||
| `externalPaymentRef` | string? | Reserved for future gateway / landing |
|
||||
| `notes` | string? | Superadmin internal note |
|
||||
| `createdAt` / `updatedAt` | DateTime | |
|
||||
|
||||
Indexes: `ownerUserId` unique; `expiresAt`; `status`.
|
||||
|
||||
**Ownership rule:** The billing owner is the user who first registered / is treated as account holder. Membership in other orgs does not create extra billing accounts. Creating a new org as owner still counts against `maxOrganizations` of that user’s `BillingAccount`.
|
||||
|
||||
### `Organization.enabledModules`
|
||||
|
||||
- `enabledModules String[]` on `Organization` (Prisma).
|
||||
- Canonical keys live in `@mento/shared` (see Module catalog).
|
||||
- Empty array after migration for existing orgs means **all modules enabled** (backward compatible), OR we backfill the full catalog — prefer **backfill full catalog** so “empty” can later mean “nothing enabled” if needed.
|
||||
**Locked choice:** migration sets `enabledModules` to the **full default catalog** for existing orgs. New orgs get a default catalog from the plan template (trial = core set configurable in code; superadmin can change immediately).
|
||||
|
||||
### Plan presets (manual buttons in `/adm`)
|
||||
|
||||
| planCode | Default duration | Default maxOrgs |
|
||||
|---|---|---|
|
||||
| `trial` | 15 days | 1 |
|
||||
| `monthly` | 30 days | configurable (default 3) |
|
||||
| `quarterly` | 90 days | configurable (default 5) |
|
||||
| `semiannual` | 180 days | configurable (default 10) |
|
||||
| `yearly` | 365 days | configurable (default 20) |
|
||||
| `custom` | superadmin picks `expiresAt` + `maxOrganizations` | as set |
|
||||
|
||||
Extending an active plan from `/adm` sets `startsAt` (if missing), `expiresAt` = now + duration (or custom date), `status = active`, updates `planCode`.
|
||||
|
||||
### Backfill migration
|
||||
|
||||
1. For every `User` who has at least one `Membership` with `role = owner` (or is the sole owner of an org), ensure one `BillingAccount`:
|
||||
- Prefer: one account per distinct owner user who owns ≥1 org.
|
||||
- `planCode = trial`, `status = active`, `startsAt = now`, `expiresAt = now + 15 days`, `maxOrganizations = max(1, current org count they own)` so nobody is suddenly blocked for having multiple orgs already.
|
||||
2. Set `Organization.enabledModules` to full catalog for all existing orgs.
|
||||
|
||||
---
|
||||
|
||||
## Module catalog
|
||||
|
||||
Keys align with nav hubs/cards (and API surfaces). Shared constant e.g. `ORG_MODULE_KEYS` / labels in `@mento/shared`.
|
||||
|
||||
Suggested keys (final list in implementation may split cards 1:1 with nav):
|
||||
|
||||
- `customers` (people + companies)
|
||||
- `pipeline` (sales pipeline / deals)
|
||||
- `products`
|
||||
- `invoices`
|
||||
- `quotes`
|
||||
- `commissions`
|
||||
- `sales_forecast`
|
||||
- `activities`
|
||||
- `tickets`
|
||||
- `workflow_automations`
|
||||
- `message_automations`
|
||||
- `lead_forms`
|
||||
- `project_boards`
|
||||
- `inventory`
|
||||
- `cheques`
|
||||
- `attachments`
|
||||
- `loyalty` (if present in nav/settings)
|
||||
- `reports` (if in nav)
|
||||
- `notes` / smart notes if separately gated
|
||||
- `tahesab`
|
||||
- `custom_fields`
|
||||
- `currencies`
|
||||
- `team_settings` (keep always-on for owners? **Locked:** still toggleable, but warn in UI that disabling team settings locks self-service; login/org switcher remain available)
|
||||
|
||||
Settings hub items are individually toggleable. Core shell (home, profile, logout, org switcher) stays available when subscription is active.
|
||||
|
||||
---
|
||||
|
||||
## Enforcement
|
||||
|
||||
### Login / session
|
||||
|
||||
On CRM password login and org switch / token refresh:
|
||||
|
||||
1. Resolve `BillingAccount` for the user (by `ownerUserId`).
|
||||
- If user is **not** a billing owner (invited member only): use the **org owner’s** billing account for the active organization (subscription is per owner account that owns the env).
|
||||
- **Locked rule:** Entitlement for entering an org is the billing account of that org’s **active owner membership** (any active owner). If multiple owners, any one’s active billing is enough; prefer the earliest owner membership’s user.
|
||||
2. If `status !== active` OR `expiresAt < now` → reject with message اشتراک منقضی / معلق؛ mark status `expired` lazily when detected.
|
||||
3. Members of an expired owner cannot log into that org either.
|
||||
|
||||
### Create organization
|
||||
|
||||
- Only if caller has `BillingAccount` with `status = active` and `expiresAt >= now`.
|
||||
- Count orgs where user has `role = owner` membership; must be `< maxOrganizations`.
|
||||
|
||||
### Modules
|
||||
|
||||
- **Web:** filter `NAV_HUBS` cards/shortcuts and router guards by `organization.enabledModules` (loaded with org/session payload).
|
||||
- **API:** guard controllers/services for module-gated routes; return 403 with clear Persian message if module disabled.
|
||||
- Role permissions (`RolePermissionKey`) remain orthogonal: module must be on **and** role must allow.
|
||||
|
||||
### Remaining days UI
|
||||
|
||||
- Include `subscription: { planCode, status, expiresAt, daysRemaining, warn }` in login/me/org bootstrap DTO.
|
||||
- App header chip: «N روز باقیمانده».
|
||||
- If `daysRemaining <= 3` and `> 0`: warning style banner/chip.
|
||||
- If expired: user never reaches shell (login blocked).
|
||||
|
||||
---
|
||||
|
||||
## Platform admin (`/adm`) API
|
||||
|
||||
Authenticated with existing platform-admin JWT.
|
||||
|
||||
- `GET/PATCH` billing for a user: plan, dates, maxOrgs, status, notes.
|
||||
- Shortcuts: `POST .../billing/apply-plan` with `{ planCode, maxOrganizations?, expiresAt? }`.
|
||||
- `PATCH /platform/organizations/:id/modules` with `{ enabledModules: string[] }`.
|
||||
- List users/orgs already exists; extend DTOs with billing summary + module flags.
|
||||
|
||||
---
|
||||
|
||||
## Platform admin UI polish
|
||||
|
||||
Keep Vue 3 + existing CSS variables (`--accent`, `--line`, cards). Improvements:
|
||||
|
||||
- Denserv app-like shell: clearer hierarchy, sticky subheaders, soft gradients consistent with CRM auth pages.
|
||||
- User detail: billing card with plan chips + date picker (Jalali) + max orgs.
|
||||
- Org detail: module checklist grouped by hub (مشتریان / فروش / …) with select-all per group.
|
||||
- Dashboard KPIs: optional counts of expiring ≤3 days / expired (nice-to-have in same phase if cheap).
|
||||
|
||||
No Ant Design / Tailwind.
|
||||
|
||||
---
|
||||
|
||||
## Future gateway hook
|
||||
|
||||
Landing page later will:
|
||||
|
||||
1. Create/find `BillingAccount` by owner.
|
||||
2. After payment success, set `planCode`, `expiresAt`, `externalPaymentRef`, `status = active`.
|
||||
|
||||
No payment code in this phase; do not invent webhook handlers yet.
|
||||
|
||||
---
|
||||
|
||||
## Risks & edge cases
|
||||
|
||||
- Users with multiple owned orgs today: backfill `maxOrganizations` ≥ current owned count.
|
||||
- Invited users: gated by org owner’s subscription.
|
||||
- Disabling a module mid-use: existing data kept; API/UI blocked until re-enabled.
|
||||
- Superadmin `/adm` login unaffected by BillingAccount.
|
||||
|
||||
## Testing (manual)
|
||||
|
||||
1. Fresh seed/migrate → existing owner has 15-day trial; can log in; header shows days.
|
||||
2. Set expiresAt to yesterday → login fails with expiry message.
|
||||
3. Disable `message_automations` on an org → menu gone + API 403.
|
||||
4. Trial maxOrgs=1 with one owned org → create second org rejected.
|
||||
5. Apply `monthly` from `/adm` → expiresAt ~+30d, status active.
|
||||
@@ -37,6 +37,223 @@ export interface AuthUserDto {
|
||||
organizationId: string;
|
||||
organizationName?: string;
|
||||
hasAvatar?: boolean;
|
||||
/** مالک محیط فعلی — اشتراک متعلق به مالک است نه عضو زیرمجموعه */
|
||||
isOrgOwner?: boolean;
|
||||
enabledModules?: OrgModuleKey[];
|
||||
/** خلاصه اشتراک مالک محیط (برای اعضا فقط جهت نمایش/گیت ورود) */
|
||||
subscription?: SubscriptionSummaryDto | null;
|
||||
}
|
||||
|
||||
export type BillingPlanCode =
|
||||
| "trial"
|
||||
| "monthly"
|
||||
| "quarterly"
|
||||
| "semiannual"
|
||||
| "yearly"
|
||||
| "custom";
|
||||
|
||||
export type BillingAccountStatus = "active" | "expired" | "suspended";
|
||||
|
||||
export const BILLING_PLAN_CODES: BillingPlanCode[] = [
|
||||
"trial",
|
||||
"monthly",
|
||||
"quarterly",
|
||||
"semiannual",
|
||||
"yearly",
|
||||
"custom",
|
||||
];
|
||||
|
||||
export const BILLING_PLAN_LABELS: Record<BillingPlanCode, string> = {
|
||||
trial: "دمو ۱۵ روزه",
|
||||
monthly: "ماهانه",
|
||||
quarterly: "سهماهه",
|
||||
semiannual: "ششماهه",
|
||||
yearly: "یکساله",
|
||||
custom: "سفارشی",
|
||||
};
|
||||
|
||||
/** مدت پیشفرض پلنها (روز) — custom ندارد */
|
||||
export const BILLING_PLAN_DAYS: Record<Exclude<BillingPlanCode, "custom">, number> = {
|
||||
trial: 15,
|
||||
monthly: 30,
|
||||
quarterly: 90,
|
||||
semiannual: 180,
|
||||
yearly: 365,
|
||||
};
|
||||
|
||||
export const BILLING_PLAN_DEFAULT_MAX_ORGS: Record<
|
||||
Exclude<BillingPlanCode, "custom">,
|
||||
number
|
||||
> = {
|
||||
trial: 1,
|
||||
monthly: 3,
|
||||
quarterly: 5,
|
||||
semiannual: 10,
|
||||
yearly: 20,
|
||||
};
|
||||
|
||||
export type OrgModuleKey =
|
||||
| "customers"
|
||||
| "pipeline"
|
||||
| "products"
|
||||
| "invoices"
|
||||
| "quotes"
|
||||
| "commissions"
|
||||
| "sales_forecast"
|
||||
| "activities"
|
||||
| "tickets"
|
||||
| "workflow_automations"
|
||||
| "message_automations"
|
||||
| "lead_forms"
|
||||
| "project_boards"
|
||||
| "inventory"
|
||||
| "cheques"
|
||||
| "attachments"
|
||||
| "loyalty"
|
||||
| "reports"
|
||||
| "notes"
|
||||
| "tahesab"
|
||||
| "custom_fields"
|
||||
| "currencies"
|
||||
| "team_settings"
|
||||
| "faq";
|
||||
|
||||
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
||||
"customers",
|
||||
"pipeline",
|
||||
"products",
|
||||
"invoices",
|
||||
"quotes",
|
||||
"commissions",
|
||||
"sales_forecast",
|
||||
"activities",
|
||||
"tickets",
|
||||
"workflow_automations",
|
||||
"message_automations",
|
||||
"lead_forms",
|
||||
"project_boards",
|
||||
"inventory",
|
||||
"cheques",
|
||||
"attachments",
|
||||
"loyalty",
|
||||
"reports",
|
||||
"notes",
|
||||
"tahesab",
|
||||
"custom_fields",
|
||||
"currencies",
|
||||
"team_settings",
|
||||
"faq",
|
||||
];
|
||||
|
||||
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
||||
customers: "مشتریان",
|
||||
pipeline: "کاریز فروش",
|
||||
products: "محصولات",
|
||||
invoices: "فاکتورها",
|
||||
quotes: "پیشفاکتورها",
|
||||
commissions: "پورسانت",
|
||||
sales_forecast: "هدف و پیشبینی",
|
||||
activities: "فعالیتها",
|
||||
tickets: "تیکتها",
|
||||
workflow_automations: "اتوماسیون گردش کار",
|
||||
message_automations: "اتوماسیون پیامک",
|
||||
lead_forms: "فرمساز سرنخ",
|
||||
project_boards: "کاریز پروژه",
|
||||
inventory: "انبار",
|
||||
cheques: "چکها",
|
||||
attachments: "پیوستها",
|
||||
loyalty: "باشگاه مشتریان",
|
||||
reports: "گزارشها",
|
||||
notes: "یادداشتها",
|
||||
tahesab: "تهحساب",
|
||||
custom_fields: "فیلدهای سفارشی",
|
||||
currencies: "ارزها",
|
||||
team_settings: "تیم و تنظیمات سازمان",
|
||||
faq: "FAQ",
|
||||
};
|
||||
|
||||
export const ORG_MODULE_GROUPS: Array<{
|
||||
title: string;
|
||||
keys: OrgModuleKey[];
|
||||
}> = [
|
||||
{ title: "مشتریان", keys: ["customers"] },
|
||||
{
|
||||
title: "فروش",
|
||||
keys: [
|
||||
"pipeline",
|
||||
"products",
|
||||
"invoices",
|
||||
"quotes",
|
||||
"commissions",
|
||||
"sales_forecast",
|
||||
],
|
||||
},
|
||||
{ title: "فعالیتها", keys: ["activities", "notes"] },
|
||||
{ title: "پشتیبانی", keys: ["tickets", "faq"] },
|
||||
{
|
||||
title: "اتوماسیون",
|
||||
keys: ["workflow_automations", "message_automations"],
|
||||
},
|
||||
{
|
||||
title: "ابزارها",
|
||||
keys: [
|
||||
"lead_forms",
|
||||
"project_boards",
|
||||
"inventory",
|
||||
"cheques",
|
||||
"attachments",
|
||||
"loyalty",
|
||||
"reports",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "تنظیمات",
|
||||
keys: ["team_settings", "custom_fields", "currencies", "tahesab"],
|
||||
},
|
||||
];
|
||||
|
||||
export function isOrgModuleKey(value: string): value is OrgModuleKey {
|
||||
return (ORG_MODULE_KEYS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function defaultEnabledModules(): OrgModuleKey[] {
|
||||
return [...ORG_MODULE_KEYS];
|
||||
}
|
||||
|
||||
export interface SubscriptionSummaryDto {
|
||||
planCode: BillingPlanCode;
|
||||
status: BillingAccountStatus;
|
||||
startsAt: string;
|
||||
expiresAt: string;
|
||||
maxOrganizations: number;
|
||||
daysRemaining: number;
|
||||
warn: boolean;
|
||||
}
|
||||
|
||||
export interface BillingAccountDto {
|
||||
id: string;
|
||||
ownerUserId: string;
|
||||
planCode: BillingPlanCode;
|
||||
status: BillingAccountStatus;
|
||||
startsAt: string;
|
||||
expiresAt: string;
|
||||
maxOrganizations: number;
|
||||
externalPaymentRef?: string | null;
|
||||
notes?: string | null;
|
||||
daysRemaining: number;
|
||||
warn: boolean;
|
||||
}
|
||||
|
||||
export interface ApplyBillingPlanRequest {
|
||||
planCode: BillingPlanCode;
|
||||
maxOrganizations?: number;
|
||||
expiresAt?: string;
|
||||
notes?: string | null;
|
||||
status?: BillingAccountStatus;
|
||||
}
|
||||
|
||||
export interface UpdateOrgModulesRequest {
|
||||
enabledModules: OrgModuleKey[];
|
||||
}
|
||||
|
||||
export interface OrgUserDto {
|
||||
@@ -245,6 +462,7 @@ export interface OrganizationDto {
|
||||
baseCurrency: string;
|
||||
enabledCurrencies: string[];
|
||||
exchangeRates: ExchangeRateDto[];
|
||||
enabledModules: OrgModuleKey[];
|
||||
}
|
||||
|
||||
export interface UpdateOrganizationRequest {
|
||||
@@ -423,8 +641,8 @@ export interface PersonDto {
|
||||
}
|
||||
|
||||
export interface CreatePersonRequest {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
mobile?: string;
|
||||
@@ -850,6 +1068,25 @@ export interface ImportContactsResult {
|
||||
boards?: string[];
|
||||
}
|
||||
|
||||
export interface ExcelFieldOption {
|
||||
key: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface ExcelColumnPreview {
|
||||
header: string;
|
||||
index: number;
|
||||
samples: string[];
|
||||
}
|
||||
|
||||
export interface ExcelPreviewResult {
|
||||
columns: ExcelColumnPreview[];
|
||||
fields: ExcelFieldOption[];
|
||||
suggested: Record<string, string>;
|
||||
needsMapping: boolean;
|
||||
}
|
||||
|
||||
export interface ProductDto {
|
||||
id: string;
|
||||
kind: "product" | "service";
|
||||
@@ -2496,6 +2733,123 @@ export interface CreateMessageAutomationRuleRequest {
|
||||
export type UpdateMessageAutomationRuleRequest =
|
||||
Partial<CreateMessageAutomationRuleRequest>;
|
||||
|
||||
export interface PlatformAdminDto {
|
||||
id: string;
|
||||
mobile?: string | null;
|
||||
email?: string | null;
|
||||
fullName: string;
|
||||
isActive?: boolean;
|
||||
lastLoginAt?: string | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface PlatformLoginRequest {
|
||||
mobile: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface PlatformLoginResponse {
|
||||
accessToken: string;
|
||||
admin: PlatformAdminDto;
|
||||
}
|
||||
|
||||
export interface CreatePlatformAdminRequest {
|
||||
fullName: string;
|
||||
mobile: string;
|
||||
password: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface ResetPlatformAdminPasswordRequest {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface PlatformStatsDto {
|
||||
organizationCount: number;
|
||||
activeOrganizationCount: number;
|
||||
userCount: number;
|
||||
activeUserCount: number;
|
||||
membershipCount: number;
|
||||
expiringSoonCount?: number;
|
||||
expiredBillingCount?: number;
|
||||
}
|
||||
|
||||
export interface PlatformOrgMemberDto {
|
||||
membershipId: string;
|
||||
userId: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
mobile?: string | null;
|
||||
role: UserRole;
|
||||
isActive: boolean;
|
||||
userIsActive: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformOrganizationDto {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
businessField?: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
memberCount: number;
|
||||
personCount: number;
|
||||
dealCount: number;
|
||||
enabledModules?: OrgModuleKey[];
|
||||
members?: PlatformOrgMemberDto[];
|
||||
}
|
||||
|
||||
export interface PlatformUserMembershipDto {
|
||||
membershipId: string;
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
organizationIsActive: boolean;
|
||||
role: UserRole;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformUserDto {
|
||||
id: string;
|
||||
email: string;
|
||||
mobile?: string | null;
|
||||
fullName: string;
|
||||
isActive: boolean;
|
||||
lastLoginAt?: string | null;
|
||||
createdAt: string;
|
||||
memberships: PlatformUserMembershipDto[];
|
||||
/** فقط اگر حداقل یک عضویت مالک فعال دارد */
|
||||
isBillingOwner?: boolean;
|
||||
billing?: BillingAccountDto | null;
|
||||
}
|
||||
|
||||
export interface UpdatePlatformStatusRequest {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface CreatePlatformOrganizationRequest {
|
||||
name: string;
|
||||
businessField?: string;
|
||||
ownerFullName: string;
|
||||
ownerEmail: string;
|
||||
ownerPassword: string;
|
||||
ownerMobile?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePlatformOrganizationRequest {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
businessField?: string | null;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdatePlatformMembershipRoleRequest {
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
export interface ResetPlatformUserPasswordRequest {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export { brand, APP_NAME, APP_NAME_FA, brandSuggestionFa } from "./brand";
|
||||
export type { Brand } from "./brand";
|
||||
export const API_PREFIX = "api";
|
||||
|
||||
Reference in New Issue
Block a user