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

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

After

Width:  |  Height:  |  Size: 596 KiB

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